diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..5592118f --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +xtask = "run -p xtask --" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..91e0f863 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,109 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + inputs: + fuzz: + description: "Run the fuzz job" + type: boolean + default: false + fuzz_seconds: + description: "Seconds per fuzz target" + default: "600" + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + + - name: Install Linux system dependencies (egui/winit/rfd) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \ + libgl1-mesa-dev libgtk-3-dev + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - uses: Swatinem/rust-cache@v2 + + - name: Format + if: runner.os == 'Linux' + run: cargo fmt --all -- --check + + - name: Test + run: cargo test --workspace + + - name: Clippy + run: cargo clippy --workspace --all-targets -- -D warnings + + - name: Dependency layering + run: cargo xtask layers + + wasm: + name: wasm32 check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + with: + key: wasm32 + - name: Check wasm-safe crates + run: cargo xtask wasm + + fuzz: + name: fuzz (${{ matrix.target.crate }}/${{ matrix.target.name }}) + # Placeholder: manual trigger only until a nightly schedule is enabled. + if: github.event_name == 'workflow_dispatch' && inputs.fuzz + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: + - { crate: psd, name: parse } + - { crate: psd, name: descriptor } + - { crate: codecs, name: decode_any } + - { crate: codecs, name: decode_png } + - { crate: codecs, name: decode_jpeg } + - { crate: codecs, name: decode_tiff } + - { crate: codecs, name: decode_exr } + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@nightly + - uses: Swatinem/rust-cache@v2 + with: + workspaces: crates/${{ matrix.target.crate }}/fuzz + - name: Install cargo-fuzz + run: cargo install cargo-fuzz --locked + - name: Fuzz + working-directory: crates/${{ matrix.target.crate }} + run: cargo fuzz run ${{ matrix.target.name }} -- -max_total_time=${{ inputs.fuzz_seconds }} + - name: Upload crash artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fuzz-${{ matrix.target.crate }}-${{ matrix.target.name }} + path: crates/${{ matrix.target.crate }}/fuzz/artifacts diff --git a/.gitignore b/.gitignore index b25c15b8..34089cab 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ *~ +plan/ +/target +/dist +/corpus +/log diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..bc11f706 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,62 @@ +# 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). Read this file first, then `docs/`. + +## 1. Orientation (5 minutes) + +| Read | Why | +|---|---| +| `docs/architecture.md` | Crate map, dependency layers, the engine/UI seam, document model | +| `docs/development.md` | Build, test, run, drive the app programmatically, debug tricks | +| `docs/contributing.md` | Rules: clean-room, tests, layering, style, commits | +| `docs/control-protocol.md` | JSON control channel: how agents drive and screenshot the running app | +| `docs/ui-design.md` | Design tokens, themes, widgets, and how to match Photoshop's look | +| `crates//README.md` (where present) | Public API of that crate | + +## 2. Workspace map + +```text +crates/ + geom color raster L0 foundation (runtime pixel formats, COW tiles, blend math) + psd codecs L0 standalone format crates (no workspace deps; publishable) + doc L1 document model (layers, masks, adjustments, effects: pure data) + ops paint L2 history/undo, brush engine + compose L3 CPU reference compositor (the oracle for GPU work) + io L4 document <-> PSD / flat formats + engine L5 Session + command registry (every action is a command) + ui-egui L6 egui shell (thin: all actions go through the engine) + testkit test helpers +apps/ + photocraft desktop app (eframe/wgpu), TCP control server + photocraft-cli headless CLI +xtask/ cargo xtask layers | wasm | ci | stats | corpus +``` + +**Layering is enforced** by `cargo xtask layers`. A crate may depend only on lower layers. `psd` and `codecs` depend on nothing in the workspace. Nothing below `ui-egui` may use egui, eframe, winit or rfd. + +## 3. Golden rules + +1. **Everything is a command.** New user-visible behaviour = a command in `crates/engine/src/commands.rs` (id, label, menu path, shortcut, params doc, `enabled`, `run`) plus tests in `crates/engine/src/tests.rs`. The UI, CLI, control channel and future MCP all dispatch commands by id. +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. Test at several depths. +3. **Clean-room.** We studied Photon Studio (proprietary) and Photoshop for *behaviour and look only*. Never copy their code, shaders or assets. Implement from public specs (Adobe PSD spec, ISO 32000 blend modes, papers) and observation. Third-party assets must be permissively licensed and recorded (see `assets/`). +4. **Tests are the gate.** Every change comes with tests. Format crates use round-trip, synthetic-generator, oracle and fuzz tests. Keep the real-file PSD corpus results 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.** Launch the app with `--control`, drive it, take `ui.screenshot`, and look at the PNG. See `docs/development.md`. +7. **Never break wasm.** L0–L6 must `cargo check --target wasm32-unknown-unknown` (run `cargo xtask wasm`). + +## 4. Before you finish a task + +```sh +cargo test -p +cargo clippy -p --all-targets -- -D warnings +cargo xtask layers +cargo xtask wasm # if you touched L0–L6 +``` + +Parallel agents: use your own target dir (`CARGO_TARGET_DIR=target/agent-`) to avoid the Cargo build lock, and edit only the crates you own. Keep every `Cargo.toml` valid at all times: the `crates/*` glob means one broken manifest breaks everyone's build. + +## 5. Where things are tracked + +- `plan/` (local, gitignored): research, parity plan, execution plan, estimates. +- `docs/roadmap.md`: milestones M0–M12 and their status. +- `log/` (local, gitignored): the dev log. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..3e0e3a1a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,5245 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +dependencies = [ + "accesskit", + "accesskit_consumer 0.36.0", + "atspi-common", + "phf", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit", + "accesskit_consumer 0.38.0", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", + "hashbrown 0.16.1", + "static_assertions", + "windows", + "windows-core", +] + +[[package]] +name = "accesskit_winit" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.2", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.21", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.5", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.5", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.5", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.21", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec 0.9.1", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.2", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.13.2", + "polling", + "rustix 1.1.5", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.4", + "rustix 1.1.5", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "dify" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc" +dependencies = [ + "anyhow", + "colored", + "getopts", + "image", + "rayon", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "ecolor" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "eframe" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "pollster 1.0.1", + "profiling", + "raw-window-handle", + "static_assertions", + "wasm-bindgen", + "web-sys", + "web-time", + "wgpu", + "windows-sys 0.61.2", + "winit", +] + +[[package]] +name = "egui" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +dependencies = [ + "accesskit", + "ahash", + "bitflags 2.13.2", + "emath", + "epaint", + "itertools 0.15.0", + "log", + "nohash-hasher", + "profiling", + "smallvec", + "unicode-segmentation", + "web-sys", +] + +[[package]] +name = "egui-wgpu" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "profiling", + "thiserror 2.0.21", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +dependencies = [ + "accesskit_winit", + "arboard", + "bytemuck", + "egui", + "log", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "profiling", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "winit", +] + +[[package]] +name = "egui_extras" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf" +dependencies = [ + "ahash", + "egui", + "enum-map", + "itertools 0.15.0", + "log", + "profiling", + "resvg", +] + +[[package]] +name = "egui_glow" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +dependencies = [ + "bytemuck", + "egui", + "glow", + "log", + "profiling", + "winit", +] + +[[package]] +name = "egui_kittest" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +dependencies = [ + "dify", + "eframe", + "egui", + "egui-wgpu", + "image", + "kittest", + "log", + "open", + "pollster 1.0.1", + "serde", + "tempfile", + "toml", + "wgpu", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "emath" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enum-map" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +dependencies = [ + "enum-map-derive", +] + +[[package]] +name = "enum-map-derive" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "epaint" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +dependencies = [ + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "font-types", + "harfrust", + "log", + "nohash-hasher", + "parking_lot", + "profiling", + "self_cell", + "skrifa", + "smallvec", + "unicode-general-category", + "unicode-segmentation", + "vello_cpu", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.74.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide 0.8.9", + "num-complex", + "pulp", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.5", + "windows-link", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glifo" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +dependencies = [ + "bytemuck", + "foldhash", + "hashbrown 0.17.1", + "log", + "peniko", + "skrifa", + "smallvec", + "vello_common", +] + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.2", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.21", + "windows", +] + +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "read-fonts", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "moxcms", + "num-traits", + "png 0.18.1", + "qoi", + "ravif", + "rgb", + "tiff 0.11.3", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error 2.0.1", +] + +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + +[[package]] +name = "imgref" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "jpeg-encoder" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b454d911ac55068f53495488d8ccd0646eaa540c033a28ee15b07838afafb01f" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kittest" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", +] + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "plain", + "redox_syscall 0.9.4", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set 0.10.0", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.21", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "thiserror 2.0.21", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo 0.13.1", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "photocraft" +version = "0.1.0" +dependencies = [ + "eframe", + "egui", + "log", + "photocraft-codecs", + "photocraft-color", + "photocraft-compose", + "photocraft-doc", + "photocraft-engine", + "photocraft-geom", + "photocraft-io", + "photocraft-raster", + "photocraft-ui-egui", + "rfd", + "serde_json", +] + +[[package]] +name = "photocraft-algo" +version = "0.1.0" +dependencies = [ + "photocraft-color", + "photocraft-geom", + "photocraft-raster", + "rayon", + "serde", + "serde_json", +] + +[[package]] +name = "photocraft-cli" +version = "0.1.0" + +[[package]] +name = "photocraft-codecs" +version = "0.1.0" +dependencies = [ + "exr", + "flate2", + "half", + "image", + "image-webp", + "jpeg-encoder", + "png 0.18.1", + "proptest", + "thiserror 2.0.21", + "tiff 0.10.3", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "photocraft-color" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "photocraft-compose" +version = "0.1.0" +dependencies = [ + "photocraft-color", + "photocraft-doc", + "photocraft-geom", + "photocraft-raster", + "proptest", +] + +[[package]] +name = "photocraft-doc" +version = "0.1.0" +dependencies = [ + "photocraft-color", + "photocraft-geom", + "photocraft-raster", + "serde", + "serde_json", +] + +[[package]] +name = "photocraft-engine" +version = "0.1.0" +dependencies = [ + "photocraft-algo", + "photocraft-color", + "photocraft-compose", + "photocraft-doc", + "photocraft-geom", + "photocraft-ops", + "photocraft-paint", + "photocraft-raster", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "photocraft-geom" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "photocraft-io" +version = "0.1.0" +dependencies = [ + "photocraft-codecs", + "photocraft-color", + "photocraft-compose", + "photocraft-doc", + "photocraft-geom", + "photocraft-psd", + "photocraft-raster", + "proptest", + "thiserror 2.0.21", +] + +[[package]] +name = "photocraft-ops" +version = "0.1.0" +dependencies = [ + "photocraft-doc", +] + +[[package]] +name = "photocraft-paint" +version = "0.1.0" +dependencies = [ + "photocraft-color", + "photocraft-geom", + "photocraft-raster", + "serde", +] + +[[package]] +name = "photocraft-psd" +version = "0.1.0" +dependencies = [ + "flate2", + "photocraft-psd", + "proptest", + "serde", + "thiserror 2.0.21", +] + +[[package]] +name = "photocraft-raster" +version = "0.1.0" +dependencies = [ + "photocraft-color", + "photocraft-geom", + "proptest", + "serde", +] + +[[package]] +name = "photocraft-testkit" +version = "0.1.0" +dependencies = [ + "photocraft-color", + "photocraft-geom", + "photocraft-raster", +] + +[[package]] +name = "photocraft-ui-egui" +version = "0.1.0" +dependencies = [ + "eframe", + "egui", + "egui_extras", + "egui_kittest", + "log", + "photocraft-color", + "photocraft-compose", + "photocraft-doc", + "photocraft-engine", + "photocraft-geom", + "photocraft-raster", + "serde", + "serde_json", +] + +[[package]] +name = "photocraft-web" +version = "0.1.0" +dependencies = [ + "eframe", + "egui", + "js-sys", + "log", + "photocraft-codecs", + "photocraft-doc", + "photocraft-engine", + "photocraft-io", + "photocraft-ui-egui", + "rfd", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "pollster" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336" + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.13.2", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand", + "rand_chacha", + "simd_helpers", + "thiserror 2.0.21", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error 2.0.1", + "rav1e", + "rgb", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "resvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +dependencies = [ + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", +] + +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "percent-encoding", + "pollster 0.4.0", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error 1.2.3", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.5", + "thiserror 2.0.21", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo 0.11.3", + "siphasher", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "flate2", + "half", + "quick-error 2.0.1", + "weezl", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error 2.0.1", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.17.16", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64", + "data-url", + "flate2", + "imagesize", + "kurbo 0.11.3", + "log", + "pico-args", + "roxmltree", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "xmlwriter", +] + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "vello_common" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "log", + "peniko", + "smallvec", + "thiserror 2.0.21", +] + +[[package]] +name = "vello_cpu" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "vello_common", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.5", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix 1.1.5", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.2", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.5", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" +dependencies = [ + "arrayvec", + "bit-set 0.10.0", + "bit-vec 0.9.1", + "bitflags 2.13.2", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "indexmap", + "log", + "naga", + "naga-types", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.21", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-wasm", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-wasm" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d260ca2ca8adc654f513a869bf3cac0b47f5b8d3f87803d6942015f8de597aae" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set 0.10.0", + "bitflags 2.13.2", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "hashbrown 0.17.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "naga", + "naga-types", + "ndk-sys", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "static_assertions", + "thiserror 2.0.21", + "wasm-bindgen", + "wayland-sys", + "web-sys", + "wgpu-naga-bridge", + "wgpu-types", + "windows", + "windows-core", + "windows-result", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "js-sys", + "log", + "naga-types", + "raw-window-handle", + "static_assertions", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.5", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "xtask" +version = "0.1.0" +dependencies = [ + "serde_json", +] + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.5", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" +dependencies = [ + "serde", + "winnow", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..c0f30ad8 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,60 @@ +[workspace] +resolver = "3" +members = ["crates/*", "apps/*", "xtask"] +default-members = ["crates/*", "apps/photocraft-cli", "xtask"] + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "MIT OR Apache-2.0" +rust-version = "1.90" + +[workspace.dependencies] +# internal +photocraft-geom = { path = "crates/geom" } +photocraft-color = { path = "crates/color" } +photocraft-raster = { path = "crates/raster" } +photocraft-doc = { path = "crates/doc" } +photocraft-ops = { path = "crates/ops" } +photocraft-psd = { path = "crates/psd" } +photocraft-codecs = { path = "crates/codecs" } +photocraft-compose = { path = "crates/compose" } +photocraft-paint = { path = "crates/paint" } +photocraft-algo = { path = "crates/algo" } +photocraft-io = { path = "crates/io" } +photocraft-engine = { path = "crates/engine" } +photocraft-ui-egui = { path = "crates/ui-egui" } +photocraft-testkit = { path = "crates/testkit" } + +# external +thiserror = "2" +serde = { version = "1", features = ["derive", "rc"] } +serde_json = "1" +arc-swap = "1" +bytemuck = { version = "1", features = ["derive"] } +flate2 = { version = "1", default-features = false, features = ["rust_backend"] } +image = { version = "0.25", default-features = false } +proptest = "1" +eframe = { version = "0.36", default-features = false } +egui = "0.36" +egui_kittest = "0.36" +rfd = "0.17" +log = "0.4" +rayon = "1" + +[workspace.lints.rust] +unsafe_code = "deny" + +[workspace.lints.clippy] +dbg_macro = "warn" +todo = "warn" + +[profile.dev.package."*"] +opt-level = 2 + +[profile.dev] +opt-level = 1 + +[profile.release] +lto = "thin" +codegen-units = 1 diff --git a/README.md b/README.md index 8d295553..7a73e591 100644 --- a/README.md +++ b/README.md @@ -2,3 +2,16 @@ photocraft =========== By the artcraft team + +An open-source, native image editor written in Rust, aiming for Photoshop-level capability and ergonomics. + +- **Native and portable:** egui on wgpu (Metal/Vulkan/DX12/WebGPU), for macOS, Windows, Linux and the web. Mobile shells come later. +- **Photoshop-grade formats:** a standalone PSD/PSB crate (byte-exact round trips on a real-file corpus), plus 13 raster formats with symmetric read/write at 8/16/32-bit. +- **Engine first:** every action is a command, so the UI, CLI and automation (a JSON control channel, MCP next) all drive the same core. + +```sh +cargo run --release -p photocraft -- image.psd +cargo test --workspace +``` + +Start with [`AGENTS.md`](AGENTS.md), then [`docs/`](docs/). diff --git a/apps/photocraft-cli/Cargo.toml b/apps/photocraft-cli/Cargo.toml new file mode 100644 index 00000000..9da00eb4 --- /dev/null +++ b/apps/photocraft-cli/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "photocraft-cli" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[[bin]] +name = "photocraft" +path = "src/main.rs" + +[dependencies] diff --git a/apps/photocraft-cli/src/main.rs b/apps/photocraft-cli/src/main.rs new file mode 100644 index 00000000..f328e4d9 --- /dev/null +++ b/apps/photocraft-cli/src/main.rs @@ -0,0 +1 @@ +fn main() {} diff --git a/apps/photocraft-web/Cargo.toml b/apps/photocraft-web/Cargo.toml new file mode 100644 index 00000000..67518152 --- /dev/null +++ b/apps/photocraft-web/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "photocraft-web" +description = "Photocraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[lints] +workspace = true + +[[bin]] +name = "photocraft-web" +path = "src/main.rs" + +[dependencies] +photocraft-engine = { workspace = true } +photocraft-io = { workspace = true } +photocraft-codecs = { workspace = true } +photocraft-doc = { workspace = true } +photocraft-ui-egui = { workspace = true } +egui = { workspace = true } +log = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback. +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } +rfd = { workspace = true } +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] } diff --git a/apps/photocraft-web/Trunk.toml b/apps/photocraft-web/Trunk.toml new file mode 100644 index 00000000..b5c21eca --- /dev/null +++ b/apps/photocraft-web/Trunk.toml @@ -0,0 +1,10 @@ +# Build with: trunk build --release (from apps/photocraft-web) +[build] +target = "index.html" +dist = "../../dist/web" +public_url = "./" +filehash = true + +[serve] +addresses = ["127.0.0.1"] +port = 8765 diff --git a/apps/photocraft-web/index.html b/apps/photocraft-web/index.html new file mode 100644 index 00000000..f97088ec --- /dev/null +++ b/apps/photocraft-web/index.html @@ -0,0 +1,19 @@ + + + + + + Photocraft + + + + + +
Loading Photocraft…
+ + diff --git a/apps/photocraft-web/src/main.rs b/apps/photocraft-web/src/main.rs new file mode 100644 index 00000000..34264020 --- /dev/null +++ b/apps/photocraft-web/src/main.rs @@ -0,0 +1,30 @@ +//! Photocraft in the browser. +//! +//! Runs the same [`photocraft_ui_egui::PhotocraftApp`] as the desktop app through eframe's web +//! runner (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` +//! from this directory; see `docs/development.md` ("Web build"). +//! +//! Differences from the desktop app: +//! - no TCP control server (browsers can't listen on sockets); +//! - File → Open uses the browser file picker; bytes arrive asynchronously through +//! `Services::inbox`; +//! - saving/exporting triggers a browser download; +//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox. +//! +//! URL query flags: `?cpu` forces the CPU canvas path (same as `PHOTOCRAFT_CPU_CANVAS=1`); +//! `?webgl` forces the WebGL2 backend instead of WebGPU. + +#[cfg(target_arch = "wasm32")] +mod web; + +#[cfg(target_arch = "wasm32")] +fn main() { + web::start(); +} + +#[cfg(not(target_arch = "wasm32"))] +fn main() { + eprintln!( + "photocraft-web only runs in the browser: build it with `trunk build --release` in apps/photocraft-web" + ); +} diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs new file mode 100644 index 00000000..d5c018c6 --- /dev/null +++ b/apps/photocraft-web/src/web.rs @@ -0,0 +1,229 @@ +//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner. + +use std::sync::{Arc, Mutex}; + +use photocraft_codecs::{ChannelLayout, EncodeOptions, Image}; +use photocraft_doc::Document; +use photocraft_engine::Session; +use photocraft_ui_egui::theme::ThemeKind; +use photocraft_ui_egui::{PhotocraftApp, Services}; +use wasm_bindgen::JsCast as _; + +type Inbox = Arc)>>>; + +const IMAGE_EXTS: &[&str] = &[ + "psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", + "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", +]; +const CANVAS_ID: &str = "photocraft_canvas"; + +pub fn start() { + eframe::WebLogger::init(log::LevelFilter::Info).ok(); + wasm_bindgen_futures::spawn_local(async { + let Some(document) = web_sys::window().and_then(|w| w.document()) else { + log::error!("no document"); + return; + }; + let Some(canvas) = document + .get_element_by_id(CANVAS_ID) + .and_then(|e| e.dyn_into::().ok()) + else { + log::error!("missing "); + return; + }; + let q = query(); + let force_cpu = q.contains("cpu"); + let mut options = eframe::WebOptions::default(); + if q.contains("webgl") + && let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = + &mut options.wgpu_options.wgpu_setup + { + create.instance_descriptor.backends = eframe::wgpu::Backends::GL; + } + let result = eframe::WebRunner::new() + .start( + canvas, + options, + Box::new(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro); + let inbox: Inbox = Arc::default(); + let mut app = PhotocraftApp::new( + Session::new(), + services(inbox.clone(), cc.egui_ctx.clone()), + ); + app.set_theme(&cc.egui_ctx, ThemeKind::Pro); + if let Some(rs) = cc.wgpu_render_state.clone() + && !force_cpu + { + log::info!( + "photocraft-web: wgpu backend {:?}", + rs.adapter.get_info().backend + ); + app.set_wgpu(rs); + } + Ok(Box::new(WebShell { app, inbox })) + }), + ) + .await; + if let Some(el) = document.get_element_by_id("photocraft_loading") { + match result { + Ok(()) => el.remove(), + Err(e) => el.set_inner_html(&format!("

Photocraft failed to start: {e:?}

A browser with WebGPU or WebGL2 is required.

")), + } + } + }); +} + +fn query() -> String { + web_sys::window() + .and_then(|w| w.location().search().ok()) + .unwrap_or_default() +} + +/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously, +/// so the app's own drop path can't handle them) and feed them through the inbox. +struct WebShell { + app: PhotocraftApp, + inbox: Inbox, +} + +impl eframe::App for WebShell { + fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); + for f in dropped { + let inbox = self.inbox.clone(); + let ctx = ctx.clone(); + wasm_bindgen_futures::spawn_local(async move { + let name = f + .path() + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "dropped".into()); + match f.bytes_async().await { + Ok(bytes) => { + inbox + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push((name, bytes)); + ctx.request_repaint(); + } + Err(e) => log::error!("couldn't read dropped file {name}: {e}"), + } + }); + } + self.app.logic(ctx, frame); + } + + fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + self.app.ui(ui, frame); + } +} + +fn services(inbox: Inbox, ctx: egui::Context) -> Services { + let open_inbox = inbox.clone(); + Services { + import: Some(Box::new(|name: &str, bytes: &[u8]| { + photocraft_io::import(name, bytes) + .map(|r| r.document) + .map_err(|e| e.to_string()) + })), + export: Some(Box::new(|doc: &Document, path: &str| { + photocraft_io::export(doc, path, &Default::default()) + .map(|r| r.bytes) + .map_err(|e| e.to_string()) + })), + pick_open: Some(Box::new(move || { + let inbox = open_inbox.clone(); + let ctx = ctx.clone(); + wasm_bindgen_futures::spawn_local(async move { + let Some(file) = rfd::AsyncFileDialog::new() + .add_filter("Images", IMAGE_EXTS) + .pick_file() + .await + else { + return; + }; + let bytes = file.read().await; + inbox + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push((file.file_name(), bytes)); + ctx.request_repaint(); + }); + None + })), + // No save dialog on the web: the suggested name becomes the download name. + pick_save: Some(Box::new(|suggested: &str| { + let name = std::path::Path::new(suggested) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| suggested.to_string()); + Some(name) + })), + write: Some(Box::new(|path: &str, bytes: &[u8]| download(path, bytes))), + encode_png: Some(Box::new(|w, h, rgba| { + let img = Image::from_u8(w, h, ChannelLayout::Rgba, rgba.to_vec()) + .map_err(|e| e.to_string())?; + photocraft_codecs::encode( + &img, + photocraft_codecs::Format::Png, + &EncodeOptions::default(), + ) + .map_err(|e| e.to_string()) + })), + inbox: Some(inbox), + } +} + +/// Trigger a browser download of `bytes` named after the last component of `path`. +fn download(path: &str, bytes: &[u8]) -> Result<(), String> { + let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); + let name = std::path::Path::new(path) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "photocraft".into()); + let window = web_sys::window().ok_or("no window")?; + let document = window.document().ok_or("no document")?; + let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes)); + let opts = web_sys::BlobPropertyBag::new(); + opts.set_type(mime_for(&name)); + let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?; + let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?; + let a: web_sys::HtmlAnchorElement = document + .create_element("a") + .map_err(js)? + .dyn_into() + .map_err(|_| "not an anchor")?; + a.set_href(&url); + a.set_download(&name); + a.style().set_property("display", "none").map_err(js)?; + let body = document.body().ok_or("no body")?; + body.append_child(&a).map_err(js)?; + a.click(); + a.remove(); + // Revoke after the click has been dispatched; the download keeps its own reference. + let revoke = wasm_bindgen::closure::Closure::once_into_js(move || { + web_sys::Url::revoke_object_url(&url).ok(); + }); + window + .set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000) + .map_err(js)?; + Ok(()) +} + +fn mime_for(name: &str) -> &'static str { + match name + .rsplit('.') + .next() + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("png") => "image/png", + Some("jpg" | "jpeg") => "image/jpeg", + Some("tif" | "tiff") => "image/tiff", + Some("webp") => "image/webp", + Some("gif") => "image/gif", + Some("psd" | "psb") => "image/vnd.adobe.photoshop", + _ => "application/octet-stream", + } +} diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml new file mode 100644 index 00000000..28d31ad1 --- /dev/null +++ b/apps/photocraft/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "photocraft" +version.workspace = true +edition.workspace = true +license.workspace = true +default-run = "photocraft" + +[lints] +workspace = true + +[dependencies] +photocraft-doc = { workspace = true } +photocraft-color = { workspace = true } +photocraft-raster = { workspace = true } +photocraft-geom = { workspace = true } +photocraft-compose = { workspace = true } +photocraft-engine = { workspace = true } +photocraft-codecs = { workspace = true } +photocraft-io = { workspace = true } +photocraft-ui-egui = { workspace = true } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] } +egui = { workspace = true } +serde_json = { workspace = true } +log = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +rfd = { workspace = true } diff --git a/apps/photocraft/src/control_server.rs b/apps/photocraft/src/control_server.rs new file mode 100644 index 00000000..946e2718 --- /dev/null +++ b/apps/photocraft/src/control_server.rs @@ -0,0 +1,62 @@ +//! Localhost JSON-lines control server (one request per line, one reply per line). +//! Loopback only. This is the transport the MCP server will wrap. + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::mpsc::{Receiver, Sender, channel}; +use std::time::Duration; + +use photocraft_ui_egui::ControlRequest; +use serde_json::{Value, json}; + +pub fn start(port: u16, ctx: egui::Context) -> Receiver { + let (tx, rx) = channel::(); + 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; + } + }; + eprintln!("photocraft: control server listening on 127.0.0.1:{port}"); + std::thread::spawn(move || { + for stream in listener.incoming().flatten() { + let tx = tx.clone(); + let ctx = ctx.clone(); + std::thread::spawn(move || serve(stream, tx, ctx)); + } + }); + rx +} + +fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) { + let Ok(read) = stream.try_clone() else { return }; + let mut out = stream; + for line in BufReader::new(read).lines() { + let Ok(line) = line else { break }; + if line.trim().is_empty() { + continue; + } + let reply = match serde_json::from_str::(&line) { + Ok(msg) => { + let id = msg.get("id").cloned().unwrap_or(Value::Null); + let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string(); + let params = msg.get("params").cloned().unwrap_or(json!({})); + let (req, rrx) = ControlRequest::new(method, params); + if tx.send(req).is_err() { + break; + } + ctx.request_repaint(); + let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})); + if let Some(o) = r.as_object_mut() { + o.insert("id".into(), id); + } + r + } + Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}), + }; + if writeln!(out, "{reply}").is_err() { + break; + } + } +} diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs new file mode 100644 index 00000000..da3dfbcc --- /dev/null +++ b/apps/photocraft/src/main.rs @@ -0,0 +1,65 @@ +//! Photocraft desktop app. +//! +//! Usage: `photocraft [--control ] [files…]` +//! +//! `--control ` (or `PHOTOCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server. +//! Each line `{"id":1,"method":"ui.inspect","params":{}}` gets a reply line +//! `{"id":1,"ok":true,"result":…}`. See `photocraft_ui_egui::control` for the methods. + +mod control_server; +mod services; + +use photocraft_engine::Session; +use photocraft_ui_egui::PhotocraftApp; + +fn main() -> eframe::Result { + let mut control_port: Option = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); + let mut files = Vec::new(); + let mut args = std::env::args().skip(1); + while let Some(a) = args.next() { + match a.as_str() { + "--control" => control_port = args.next().and_then(|p| p.parse().ok()), + "--version" => { + println!("photocraft {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + _ => files.push(a), + } + } + + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default().with_title("Photocraft").with_inner_size([1440.0, 900.0]).with_min_inner_size([760.0, 480.0]).with_drag_and_drop(true).with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false), + ..Default::default() + }; + eframe::run_native( + "Photocraft", + options, + Box::new(move |cc| { + let mut app = PhotocraftApp::new(Session::new(), services::native()); + app.integrated_titlebar = cfg!(target_os = "macos"); + if let Some(rs) = cc.wgpu_render_state.clone() + && std::env::var_os("PHOTOCRAFT_CPU_CANVAS").is_none() + { + app.set_wgpu(rs); + } + if let Some(port) = control_port { + let rx = control_server::start(port, cc.egui_ctx.clone()); + app = app.with_control(rx); + } + for f in files { + match std::fs::read(&f) { + Ok(bytes) => { + let name = std::path::Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(f.clone()); + if let Err(e) = app.open_bytes(&name, &bytes) { + eprintln!("photocraft: {f}: {e}"); + } else if let Some(st) = app.session.active_mut() { + st.path = Some(f.clone()); + } + } + Err(e) => eprintln!("photocraft: {f}: {e}"), + } + } + Ok(Box::new(app)) + }), + ) +} diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs new file mode 100644 index 00000000..a86083d6 --- /dev/null +++ b/apps/photocraft/src/services.rs @@ -0,0 +1,91 @@ +//! Native platform services: file dialogs (rfd), filesystem, codecs. + +use photocraft_codecs::{ChannelLayout, EncodeOptions, Image, SampleType as CS}; +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::{Document, Layer, Size}; +use photocraft_geom::Rect; +use photocraft_ui_egui::Services; + +const IMAGE_EXTS: &[&str] = &["psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm"]; + +pub fn native() -> Services { + Services { + import: Some(Box::new(|name: &str, bytes: &[u8]| photocraft_io::import(name, bytes).map(|r| r.document).map_err(|e| e.to_string()))), + export: Some(Box::new(|doc: &Document, path: &str| photocraft_io::export(doc, path, &Default::default()).map(|r| r.bytes).map_err(|e| e.to_string()))), + pick_open: Some(Box::new(|| { + let path = rfd::FileDialog::new().add_filter("Images", IMAGE_EXTS).pick_file()?; + let bytes = std::fs::read(&path).ok()?; + Some((path.to_string_lossy().to_string(), bytes)) + })), + pick_save: Some(Box::new(|suggested: &str| { + let p = std::path::Path::new(suggested); + let mut d = rfd::FileDialog::new().add_filter("Photoshop", &["psd", "psb"]).add_filter("PNG", &["png"]).add_filter("JPEG", &["jpg"]).add_filter("TIFF", &["tif"]).add_filter("OpenEXR", &["exr"]); + if let Some(name) = p.file_name() { + d = d.set_file_name(name.to_string_lossy()); + } + Some(d.save_file()?.to_string_lossy().to_string()) + })), + write: Some(Box::new(|path: &str, bytes: &[u8]| std::fs::write(path, bytes).map_err(|e| e.to_string()))), + encode_png: Some(Box::new(|w, h, rgba| { + let img = Image::from_u8(w, h, ChannelLayout::Rgba, rgba.to_vec()).map_err(|e| e.to_string())?; + photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &EncodeOptions::default()).map_err(|e| e.to_string()) + })), + inbox: None, + } +} + +/// Flat-image import via photocraft-codecs (kept for reference/tests; the app uses photocraft-io). +#[allow(dead_code)] +pub fn import_flat(name: &str, bytes: &[u8]) -> Result { + let img = photocraft_codecs::decode(bytes).map_err(|e| e.to_string())?; + let (w, h) = (img.width(), img.height()); + let depth = match img.sample_type() { + CS::U8 => SampleType::U8, + CS::U16 => SampleType::U16, + _ => SampleType::F32, + }; + let gray = matches!(img.layout(), ChannelLayout::Gray | ChannelLayout::GrayA); + let cmyk = matches!(img.layout(), ChannelLayout::Cmyk | ChannelLayout::CmykA); + let mode = if gray { ColorMode::Grayscale } else if cmyk { ColorMode::Cmyk } else { ColorMode::Rgb }; + let target = match mode { + ColorMode::Grayscale => ChannelLayout::GrayA, + ColorMode::Cmyk => ChannelLayout::CmykA, + _ => ChannelLayout::Rgba, + }; + let sample = match depth { + SampleType::U8 => CS::U8, + SampleType::U16 => CS::U16, + SampleType::F32 => CS::F32, + }; + let conv = img.convert(target, sample); + let stem = std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or(name.to_string()); + let mut doc = Document::new(stem, Size::new(w, h), mode, depth); + doc.icc_profile = img.icc.clone().map(std::sync::Arc::new); + if let Some((x, _)) = img.meta.dpi { + doc.resolution_dpi = x; + } + let mut layer = Layer::raster("Background", doc.pixel_format()); + let data = conv.to_normalized(); + layer.surface_mut().unwrap().write_region(Rect::from_xywh(0, 0, w, h), &data); + doc.layers.push(layer); + Ok(doc) +} + +#[allow(dead_code)] +pub fn export_flat(doc: &Document, path: &str) -> Result, String> { + let format = photocraft_codecs::from_extension(path).ok_or_else(|| format!("unknown file type for {path}"))?; + let buf = photocraft_compose::flatten(doc); + let (w, h) = (buf.rect.width(), buf.rect.height()); + let data: Vec = buf.px.iter().flat_map(|p| *p).collect(); + let img = match doc.depth { + SampleType::U8 => Image::from_u8(w, h, ChannelLayout::Rgba, buf.to_rgba8().pixels), + SampleType::U16 => Image::from_u16(w, h, ChannelLayout::Rgba, &data.iter().map(|v| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).collect::>()), + SampleType::F32 => Image::from_f32(w, h, ChannelLayout::Rgba, &data), + } + .map_err(|e| e.to_string())?; + let img = match &doc.icc_profile { + Some(icc) if doc.mode == ColorMode::Rgb => img.with_icc(Some((**icc).clone())), + _ => img, + }; + photocraft_codecs::encode(&img, format, &EncodeOptions::default()).map_err(|e| e.to_string()) +} diff --git a/assets/fonts/Inter-Medium.ttf b/assets/fonts/Inter-Medium.ttf new file mode 100644 index 00000000..458cd060 Binary files /dev/null and b/assets/fonts/Inter-Medium.ttf differ diff --git a/assets/fonts/Inter-Regular.ttf b/assets/fonts/Inter-Regular.ttf new file mode 100644 index 00000000..b7aaca8d Binary files /dev/null and b/assets/fonts/Inter-Regular.ttf differ diff --git a/assets/fonts/Inter-SemiBold.ttf b/assets/fonts/Inter-SemiBold.ttf new file mode 100644 index 00000000..47f8ab1d Binary files /dev/null and b/assets/fonts/Inter-SemiBold.ttf differ diff --git a/assets/fonts/JetBrainsMono-Regular.ttf b/assets/fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 00000000..711830ed Binary files /dev/null and b/assets/fonts/JetBrainsMono-Regular.ttf differ diff --git a/assets/fonts/OFL-Inter.txt b/assets/fonts/OFL-Inter.txt new file mode 100644 index 00000000..9b2ca37b --- /dev/null +++ b/assets/fonts/OFL-Inter.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/OFL-JetBrainsMono.txt b/assets/fonts/OFL-JetBrainsMono.txt new file mode 100644 index 00000000..5ceee002 --- /dev/null +++ b/assets/fonts/OFL-JetBrainsMono.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/icons/LICENSE-lucide.txt b/assets/icons/LICENSE-lucide.txt new file mode 100644 index 00000000..718bb3f0 --- /dev/null +++ b/assets/icons/LICENSE-lucide.txt @@ -0,0 +1,43 @@ +ISC License + +Copyright (c) 2026 Lucide Icons and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--- + +The following Lucide icons are derived from the Feather project: + +airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out + +The MIT License (MIT) (for the icons listed above) + +Copyright (c) 2013-present Cole Bemis + +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. diff --git a/assets/icons/app-window.svg b/assets/icons/app-window.svg new file mode 100644 index 00000000..815548bc --- /dev/null +++ b/assets/icons/app-window.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/arrow-left-right.svg b/assets/icons/arrow-left-right.svg new file mode 100644 index 00000000..acf2cb39 --- /dev/null +++ b/assets/icons/arrow-left-right.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/bandage.svg b/assets/icons/bandage.svg new file mode 100644 index 00000000..8241041a --- /dev/null +++ b/assets/icons/bandage.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/assets/icons/blend.svg b/assets/icons/blend.svg new file mode 100644 index 00000000..3ee5832c --- /dev/null +++ b/assets/icons/blend.svg @@ -0,0 +1,14 @@ + + + + diff --git a/assets/icons/brush-cleaning.svg b/assets/icons/brush-cleaning.svg new file mode 100644 index 00000000..68885262 --- /dev/null +++ b/assets/icons/brush-cleaning.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/brush.svg b/assets/icons/brush.svg new file mode 100644 index 00000000..772cc289 --- /dev/null +++ b/assets/icons/brush.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/assets/icons/check.svg b/assets/icons/check.svg new file mode 100644 index 00000000..5abe1527 --- /dev/null +++ b/assets/icons/check.svg @@ -0,0 +1,13 @@ + + + diff --git a/assets/icons/chevron-down.svg b/assets/icons/chevron-down.svg new file mode 100644 index 00000000..e70d30b8 --- /dev/null +++ b/assets/icons/chevron-down.svg @@ -0,0 +1,13 @@ + + + diff --git a/assets/icons/chevron-right.svg b/assets/icons/chevron-right.svg new file mode 100644 index 00000000..db62a0c2 --- /dev/null +++ b/assets/icons/chevron-right.svg @@ -0,0 +1,13 @@ + + + diff --git a/assets/icons/chevrons-left.svg b/assets/icons/chevrons-left.svg new file mode 100644 index 00000000..94b37487 --- /dev/null +++ b/assets/icons/chevrons-left.svg @@ -0,0 +1,14 @@ + + + + diff --git a/assets/icons/chevrons-right.svg b/assets/icons/chevrons-right.svg new file mode 100644 index 00000000..0d2d8ec9 --- /dev/null +++ b/assets/icons/chevrons-right.svg @@ -0,0 +1,14 @@ + + + + diff --git a/assets/icons/circle-dashed.svg b/assets/icons/circle-dashed.svg new file mode 100644 index 00000000..4ae53eed --- /dev/null +++ b/assets/icons/circle-dashed.svg @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/assets/icons/circle-dot.svg b/assets/icons/circle-dot.svg new file mode 100644 index 00000000..880029db --- /dev/null +++ b/assets/icons/circle-dot.svg @@ -0,0 +1,14 @@ + + + + diff --git a/assets/icons/clock.svg b/assets/icons/clock.svg new file mode 100644 index 00000000..b6ae2834 --- /dev/null +++ b/assets/icons/clock.svg @@ -0,0 +1,14 @@ + + + + diff --git a/assets/icons/compass.svg b/assets/icons/compass.svg new file mode 100644 index 00000000..1a4e8c7d --- /dev/null +++ b/assets/icons/compass.svg @@ -0,0 +1,14 @@ + + + + diff --git a/assets/icons/contrast.svg b/assets/icons/contrast.svg new file mode 100644 index 00000000..334f5281 --- /dev/null +++ b/assets/icons/contrast.svg @@ -0,0 +1,14 @@ + + + + diff --git a/assets/icons/copy.svg b/assets/icons/copy.svg new file mode 100644 index 00000000..f62ce99c --- /dev/null +++ b/assets/icons/copy.svg @@ -0,0 +1,14 @@ + + + + diff --git a/assets/icons/crop.svg b/assets/icons/crop.svg new file mode 100644 index 00000000..4ca1e6fa --- /dev/null +++ b/assets/icons/crop.svg @@ -0,0 +1,14 @@ + + + + diff --git a/assets/icons/droplet.svg b/assets/icons/droplet.svg new file mode 100644 index 00000000..36aa334f --- /dev/null +++ b/assets/icons/droplet.svg @@ -0,0 +1,13 @@ + + + diff --git a/assets/icons/ellipsis.svg b/assets/icons/ellipsis.svg new file mode 100644 index 00000000..68456c39 --- /dev/null +++ b/assets/icons/ellipsis.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/assets/icons/eraser.svg b/assets/icons/eraser.svg new file mode 100644 index 00000000..6abb6fdd --- /dev/null +++ b/assets/icons/eraser.svg @@ -0,0 +1,14 @@ + + + + diff --git a/assets/icons/eye-off.svg b/assets/icons/eye-off.svg new file mode 100644 index 00000000..0083aaa8 --- /dev/null +++ b/assets/icons/eye-off.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/eye.svg b/assets/icons/eye.svg new file mode 100644 index 00000000..e5e78706 --- /dev/null +++ b/assets/icons/eye.svg @@ -0,0 +1,14 @@ + + + + diff --git a/assets/icons/file-plus.svg b/assets/icons/file-plus.svg new file mode 100644 index 00000000..fe4715be --- /dev/null +++ b/assets/icons/file-plus.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/folder-open.svg b/assets/icons/folder-open.svg new file mode 100644 index 00000000..b01f60fc --- /dev/null +++ b/assets/icons/folder-open.svg @@ -0,0 +1,13 @@ + + + diff --git a/assets/icons/folder-plus.svg b/assets/icons/folder-plus.svg new file mode 100644 index 00000000..661ebf4d --- /dev/null +++ b/assets/icons/folder-plus.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/assets/icons/folder.svg b/assets/icons/folder.svg new file mode 100644 index 00000000..ef69b345 --- /dev/null +++ b/assets/icons/folder.svg @@ -0,0 +1,13 @@ + + + diff --git a/assets/icons/gauge.svg b/assets/icons/gauge.svg new file mode 100644 index 00000000..340b9327 --- /dev/null +++ b/assets/icons/gauge.svg @@ -0,0 +1,14 @@ + + + + diff --git a/assets/icons/grid-3x3.svg b/assets/icons/grid-3x3.svg new file mode 100644 index 00000000..6c714352 --- /dev/null +++ b/assets/icons/grid-3x3.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/assets/icons/hand.svg b/assets/icons/hand.svg new file mode 100644 index 00000000..30af9046 --- /dev/null +++ b/assets/icons/hand.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/image.svg b/assets/icons/image.svg new file mode 100644 index 00000000..3241134c --- /dev/null +++ b/assets/icons/image.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/assets/icons/info.svg b/assets/icons/info.svg new file mode 100644 index 00000000..2a46eac2 --- /dev/null +++ b/assets/icons/info.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/assets/icons/lasso.svg b/assets/icons/lasso.svg new file mode 100644 index 00000000..f2c6d7f8 --- /dev/null +++ b/assets/icons/lasso.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/assets/icons/layers.svg b/assets/icons/layers.svg new file mode 100644 index 00000000..a3b69ae3 --- /dev/null +++ b/assets/icons/layers.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/assets/icons/link.svg b/assets/icons/link.svg new file mode 100644 index 00000000..645e746a --- /dev/null +++ b/assets/icons/link.svg @@ -0,0 +1,14 @@ + + + + diff --git a/assets/icons/lock-open.svg b/assets/icons/lock-open.svg new file mode 100644 index 00000000..b2367128 --- /dev/null +++ b/assets/icons/lock-open.svg @@ -0,0 +1,14 @@ + + + + diff --git a/assets/icons/lock.svg b/assets/icons/lock.svg new file mode 100644 index 00000000..b2b32d12 --- /dev/null +++ b/assets/icons/lock.svg @@ -0,0 +1,14 @@ + + + + diff --git a/assets/icons/maximize-2.svg b/assets/icons/maximize-2.svg new file mode 100644 index 00000000..790d2538 --- /dev/null +++ b/assets/icons/maximize-2.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/message-square.svg b/assets/icons/message-square.svg new file mode 100644 index 00000000..5044bc7e --- /dev/null +++ b/assets/icons/message-square.svg @@ -0,0 +1,13 @@ + + + diff --git a/assets/icons/minus.svg b/assets/icons/minus.svg new file mode 100644 index 00000000..75093e27 --- /dev/null +++ b/assets/icons/minus.svg @@ -0,0 +1,13 @@ + + + diff --git a/assets/icons/moon.svg b/assets/icons/moon.svg new file mode 100644 index 00000000..3c4bc311 --- /dev/null +++ b/assets/icons/moon.svg @@ -0,0 +1,13 @@ + + + diff --git a/assets/icons/mouse-pointer-2.svg b/assets/icons/mouse-pointer-2.svg new file mode 100644 index 00000000..b881f795 --- /dev/null +++ b/assets/icons/mouse-pointer-2.svg @@ -0,0 +1,13 @@ + + + diff --git a/assets/icons/move.svg b/assets/icons/move.svg new file mode 100644 index 00000000..d78876f7 --- /dev/null +++ b/assets/icons/move.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/assets/icons/navigation.svg b/assets/icons/navigation.svg new file mode 100644 index 00000000..b2be022d --- /dev/null +++ b/assets/icons/navigation.svg @@ -0,0 +1,13 @@ + + + diff --git a/assets/icons/paint-bucket.svg b/assets/icons/paint-bucket.svg new file mode 100644 index 00000000..f7997334 --- /dev/null +++ b/assets/icons/paint-bucket.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/palette.svg b/assets/icons/palette.svg new file mode 100644 index 00000000..25fb773a --- /dev/null +++ b/assets/icons/palette.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/assets/icons/panel-right.svg b/assets/icons/panel-right.svg new file mode 100644 index 00000000..53ba1e61 --- /dev/null +++ b/assets/icons/panel-right.svg @@ -0,0 +1,14 @@ + + + + diff --git a/assets/icons/panels-top-left.svg b/assets/icons/panels-top-left.svg new file mode 100644 index 00000000..574e4dea --- /dev/null +++ b/assets/icons/panels-top-left.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/assets/icons/pen-tool.svg b/assets/icons/pen-tool.svg new file mode 100644 index 00000000..738c6fec --- /dev/null +++ b/assets/icons/pen-tool.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/pipette.svg b/assets/icons/pipette.svg new file mode 100644 index 00000000..2a5e1363 --- /dev/null +++ b/assets/icons/pipette.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/assets/icons/plus.svg b/assets/icons/plus.svg new file mode 100644 index 00000000..41f92bb9 --- /dev/null +++ b/assets/icons/plus.svg @@ -0,0 +1,14 @@ + + + + diff --git a/assets/icons/redo-2.svg b/assets/icons/redo-2.svg new file mode 100644 index 00000000..72371c8d --- /dev/null +++ b/assets/icons/redo-2.svg @@ -0,0 +1,14 @@ + + + + diff --git a/assets/icons/scan.svg b/assets/icons/scan.svg new file mode 100644 index 00000000..33d067b2 --- /dev/null +++ b/assets/icons/scan.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/scissors.svg b/assets/icons/scissors.svg new file mode 100644 index 00000000..f87bf20b --- /dev/null +++ b/assets/icons/scissors.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/assets/icons/search.svg b/assets/icons/search.svg new file mode 100644 index 00000000..03e442a5 --- /dev/null +++ b/assets/icons/search.svg @@ -0,0 +1,14 @@ + + + + diff --git a/assets/icons/settings.svg b/assets/icons/settings.svg new file mode 100644 index 00000000..4c6263af --- /dev/null +++ b/assets/icons/settings.svg @@ -0,0 +1,14 @@ + + + + diff --git a/assets/icons/sliders-horizontal.svg b/assets/icons/sliders-horizontal.svg new file mode 100644 index 00000000..819f80c1 --- /dev/null +++ b/assets/icons/sliders-horizontal.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + diff --git a/assets/icons/sparkles.svg b/assets/icons/sparkles.svg new file mode 100644 index 00000000..2d749398 --- /dev/null +++ b/assets/icons/sparkles.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/square-dashed.svg b/assets/icons/square-dashed.svg new file mode 100644 index 00000000..f0822a34 --- /dev/null +++ b/assets/icons/square-dashed.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + diff --git a/assets/icons/square.svg b/assets/icons/square.svg new file mode 100644 index 00000000..78627335 --- /dev/null +++ b/assets/icons/square.svg @@ -0,0 +1,13 @@ + + + diff --git a/assets/icons/squares-subtract.svg b/assets/icons/squares-subtract.svg new file mode 100644 index 00000000..8dbd9db1 --- /dev/null +++ b/assets/icons/squares-subtract.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/assets/icons/stamp.svg b/assets/icons/stamp.svg new file mode 100644 index 00000000..5eb5c081 --- /dev/null +++ b/assets/icons/stamp.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/assets/icons/sun.svg b/assets/icons/sun.svg new file mode 100644 index 00000000..3ecadd68 --- /dev/null +++ b/assets/icons/sun.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + diff --git a/assets/icons/swatch-book.svg b/assets/icons/swatch-book.svg new file mode 100644 index 00000000..5daba1ed --- /dev/null +++ b/assets/icons/swatch-book.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/trash.svg b/assets/icons/trash.svg new file mode 100644 index 00000000..e8cb0edf --- /dev/null +++ b/assets/icons/trash.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/assets/icons/type.svg b/assets/icons/type.svg new file mode 100644 index 00000000..8f269e72 --- /dev/null +++ b/assets/icons/type.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/assets/icons/undo-2.svg b/assets/icons/undo-2.svg new file mode 100644 index 00000000..3e93594c --- /dev/null +++ b/assets/icons/undo-2.svg @@ -0,0 +1,14 @@ + + + + diff --git a/assets/icons/wand-sparkles.svg b/assets/icons/wand-sparkles.svg new file mode 100644 index 00000000..fd60bf0e --- /dev/null +++ b/assets/icons/wand-sparkles.svg @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/assets/icons/x.svg b/assets/icons/x.svg new file mode 100644 index 00000000..e3179434 --- /dev/null +++ b/assets/icons/x.svg @@ -0,0 +1,14 @@ + + + + diff --git a/assets/icons/zoom-in.svg b/assets/icons/zoom-in.svg new file mode 100644 index 00000000..11173c0e --- /dev/null +++ b/assets/icons/zoom-in.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/crates/algo/Cargo.toml b/crates/algo/Cargo.toml new file mode 100644 index 00000000..533ec0ba --- /dev/null +++ b/crates/algo/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "photocraft-algo" +description = "Depth-agnostic, tile-aware CPU image filters (blur, sharpen, noise, distort, stylize)" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +photocraft-geom = { workspace = true } +photocraft-color = { workspace = true } +photocraft-raster = { workspace = true } +serde = { workspace = true } + +# Parallel tiles on native; wasm builds stay single-threaded. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +rayon = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/crates/algo/examples/bench_gaussian.rs b/crates/algo/examples/bench_gaussian.rs new file mode 100644 index 00000000..c0efa985 --- /dev/null +++ b/crates/algo/examples/bench_gaussian.rs @@ -0,0 +1,22 @@ +//! Timing: Gaussian Blur radius 10 on a 6016×6016 RGBA8 layer. +//! `cargo run --release -p photocraft-algo --example bench_gaussian` +use photocraft_algo::{FilterParams, apply, output_area}; +use photocraft_color::PixelFormat; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +fn main() { + let n: i32 = std::env::args().nth(1).and_then(|v| v.parse().ok()).unwrap_or(6016); + let radius: f32 = std::env::args().nth(2).and_then(|v| v.parse().ok()).unwrap_or(10.0); + let r = Rect::new(0, 0, n, n); + let mut s = Surface::new(PixelFormat::RGBA8); + let row: Vec = (0..n).flat_map(|x| [(x % 256) as u8, (x / 7 % 256) as u8, 128, 255]).collect(); + let bytes: Vec = (0..n).flat_map(|_| row.iter().copied()).collect(); + s.write_interleaved(r, &bytes); + let p = FilterParams::GaussianBlur { radius }; + let area = output_area(&p, s.content_bounds(), r, None).intersect(&r); + let t = std::time::Instant::now(); + let out = apply(&s, &p, area, r, None); + let dt = t.elapsed(); + println!("gaussian radius {radius} on {n}x{n} RGBA8: {:.2?} ({} tiles)", dt, out.tile_count()); +} diff --git a/crates/algo/src/blur.rs b/crates/algo/src/blur.rs new file mode 100644 index 00000000..ff8b5639 --- /dev/null +++ b/crates/algo/src/blur.rs @@ -0,0 +1,195 @@ +//! Blurs: Gaussian, box, motion, radial, surface. + +use photocraft_geom::Rect; + +use crate::image::{Edge, Image, premultiply, unpremultiply}; +use crate::{Ctx, RadialMethod}; + +/// Normalized Gaussian kernel with standard deviation `sigma` (radius 3σ). +pub(crate) fn gaussian_kernel(sigma: f32) -> Vec { + if sigma < 0.05 { + return vec![1.0]; + } + let r = (sigma * 3.0).ceil() as i32; + let k: Vec = (-r..=r).map(|i| (-(i * i) as f32 / (2.0 * sigma * sigma)).exp()).collect(); + let s: f32 = k.iter().sum(); + k.into_iter().map(|v| v / s).collect() +} + +/// 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 { + let n = src.ch; + let (rx, ry) = ((kx.len() / 2) as i32, (ky.len() / 2) as i32); + let (ow, oh) = (out.width() as usize, out.height() as usize); + let th = oh + 2 * ry as usize; + // 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]; + 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; + for c in 0..n { + p[o + c] = src.get(x, y, c); + } + } + } + premultiply(&mut p, n, alpha); + // Horizontal pass: (ow × th). + let mut tmp = vec![0.0f32; ow * th * n]; + 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]; + for ox in 0..ow { + let d = &mut dst[ox * n..(ox + 1) * n]; + for (i, kv) in kx.iter().enumerate() { + let s = &row[(ox + i) * n..(ox + i + 1) * n]; + for c in 0..n { + d[c] += s[c] * kv; + } + } + } + } + // Vertical pass. + let mut res = vec![0.0f32; ow * oh * n]; + for oy in 0..oh { + let d = &mut res[oy * ow * n..(oy + 1) * ow * n]; + for (i, kv) in ky.iter().enumerate() { + let s = &tmp[(oy + i) * ow * n..(oy + i + 1) * ow * n]; + for (dv, sv) in d.iter_mut().zip(s) { + *dv += sv * kv; + } + } + } + unpremultiply(&mut res, n, alpha); + res +} + +pub(crate) fn gaussian(src: &Image, out: Rect, ctx: &Ctx, radius: f32) -> Vec { + let k = gaussian_kernel(radius); + conv_sep(src, out, &k, &k, ctx.alpha) +} + +pub(crate) fn boxed(src: &Image, out: Rect, ctx: &Ctx, radius: f32) -> Vec { + let r = radius.max(0.0).round() as usize; + let k = vec![1.0 / (2 * r + 1) as f32; 2 * r + 1]; + conv_sep(src, out, &k, &k, ctx.alpha) +} + +fn average_samples(src: &Image, out: Rect, ctx: &Ctx, mut offsets: impl FnMut(f32, f32, &mut Vec<(f32, f32)>)) -> Vec { + let n = src.ch; + let mut res = Vec::with_capacity(out.width() as usize * out.height() as usize * n); + let mut pts = Vec::new(); + let mut tmp = vec![0.0f32; n]; + let mut acc = vec![0.0f32; n]; + for y in out.y0..out.y1 { + for x in out.x0..out.x1 { + let (cx, cy) = (x as f32 + 0.5, y as f32 + 0.5); + pts.clear(); + offsets(cx, cy, &mut pts); + for a in acc.iter_mut() { + *a = 0.0; + } + for &(sx, sy) in &pts { + src.sample(sx, sy, Edge::Transparent, src.rect, ctx.alpha, &mut tmp); + // Accumulate premultiplied. + let a = if ctx.alpha { tmp[n - 1] } else { 1.0 }; + for c in 0..n { + acc[c] += if ctx.alpha && c < n - 1 { tmp[c] * a } else { tmp[c] }; + } + } + let k = 1.0 / pts.len().max(1) as f32; + for a in acc.iter_mut() { + *a *= k; + } + if ctx.alpha { + let a = acc[n - 1]; + for v in acc.iter_mut().take(n - 1) { + *v = if a > 1e-7 { *v / a } else { 0.0 }; + } + } + res.extend_from_slice(&acc); + } + } + res +} + +pub(crate) fn motion(src: &Image, out: Rect, ctx: &Ctx, angle: f32, distance: f32) -> Vec { + let d = distance.abs(); + if d < 0.5 { + return src.crop(out); + } + let (s, c) = angle.to_radians().sin_cos(); + let steps = d.ceil() as i32; + average_samples(src, out, ctx, |x, y, pts| { + for i in 0..=steps { + let t = i as f32 / steps as f32 - 0.5; + pts.push((x + c * d * t, y - s * d * t)); + } + }) +} + +pub(crate) fn radial(src: &Image, out: Rect, ctx: &Ctx, amount: f32, method: RadialMethod, center: (f32, f32)) -> Vec { + let b = ctx.bounds; + let (cx, cy) = (b.x0 as f32 + b.width() as f32 * center.0, b.y0 as f32 + b.height() as f32 * center.1); + let amount = amount.clamp(0.0, 100.0); + average_samples(src, out, ctx, |x, y, pts| { + let (dx, dy) = (x - cx, y - cy); + let r = (dx * dx + dy * dy).sqrt(); + match method { + RadialMethod::Spin => { + // Arc of `amount` degrees centred on the pixel. + let arc = amount.to_radians(); + let n = ((arc * r).ceil() as i32).clamp(1, 64); + for i in 0..=n { + let t = (i as f32 / n as f32 - 0.5) * arc; + let (s, c) = t.sin_cos(); + pts.push((cx + dx * c - dy * s, cy + dx * s + dy * c)); + } + } + RadialMethod::Zoom => { + // Samples along the ray, up to amount/2 % closer to the centre. + let span = amount / 200.0; + let n = ((span * r).ceil() as i32).clamp(1, 64); + for i in 0..=n { + let k = 1.0 - span * i as f32 / n as f32; + pts.push((cx + dx * k, cy + dy * k)); + } + } + } + }) +} + +pub(crate) fn surface(src: &Image, out: Rect, ctx: &Ctx, radius: f32, threshold: f32) -> Vec { + let n = src.ch; + let cc = if ctx.alpha { n - 1 } else { n }; + let r = radius.max(0.0).round() as i32; + let t = (threshold.max(1.0) / 255.0) * 2.5; + let mut res = Vec::with_capacity(out.width() as usize * out.height() as usize * n); + for y in out.y0..out.y1 { + for x in out.x0..out.x1 { + let p = src.px(x, y); + for c in 0..cc { + let v0 = p[c]; + let (mut acc, mut wsum) = (0.0, 0.0); + for yy in y - r..=y + r { + for xx in x - r..=x + r { + if ctx.alpha && src.get(xx, yy, n - 1) <= 0.0 { + continue; + } + let v = src.get(xx, yy, c); + let w = (1.0 - (v - v0).abs() / t).max(0.0); + acc += v * w; + wsum += w; + } + } + res.push(if wsum > 0.0 { acc / wsum } else { v0 }); + } + if ctx.alpha { + res.push(p[n - 1]); + } + } + } + res +} diff --git a/crates/algo/src/distort.rs b/crates/algo/src/distort.rs new file mode 100644 index 00000000..5f1c0ffe --- /dev/null +++ b/crates/algo/src/distort.rs @@ -0,0 +1,178 @@ +//! Distortions (inverse mapping + bilinear resampling): Twirl, Pinch, +//! Spherize, Wave, Ripple, Polar Coordinates. All are centred on the +//! reference bounds. + +use photocraft_geom::Rect; + +use crate::image::{Edge, Image}; +use crate::noise::hash01; +use crate::other::edge_of; +use crate::{Ctx, PolarMode, RippleSize, SpherizeMode, UndefinedAreas, WaveType}; + +fn remap(src: &Image, out: Rect, ctx: &Ctx, edge: Edge, f: impl Fn(f32, f32) -> (f32, f32)) -> Vec { + let n = src.ch; + let mut res = Vec::with_capacity(out.width() as usize * out.height() as usize * n); + let mut tmp = vec![0.0f32; n]; + for y in out.y0..out.y1 { + for x in out.x0..out.x1 { + let (sx, sy) = f(x as f32 + 0.5, y as f32 + 0.5); + src.sample(sx, sy, edge, ctx.bounds, ctx.alpha, &mut tmp); + res.extend_from_slice(&tmp); + } + } + res +} + +fn centre(b: Rect) -> (f32, f32, f32) { + let (w, h) = (b.width() as f32, b.height() as f32); + (b.x0 as f32 + w / 2.0, b.y0 as f32 + h / 2.0, w.min(h) / 2.0) +} + +pub(crate) fn twirl(src: &Image, out: Rect, ctx: &Ctx, angle: f32) -> Vec { + let (cx, cy, rr) = centre(ctx.bounds); + let a = angle.to_radians(); + remap(src, out, ctx, Edge::Repeat, |x, y| { + let (dx, dy) = (x - cx, y - cy); + let r = (dx * dx + dy * dy).sqrt(); + if r >= rr || rr <= 0.0 { + return (x, y); + } + let t = a * (1.0 - r / rr); + let (s, c) = t.sin_cos(); + (cx + dx * c - dy * s, cy + dx * s + dy * c) + }) +} + +pub(crate) fn pinch(src: &Image, out: Rect, ctx: &Ctx, amount: f32) -> Vec { + let (cx, cy, rr) = centre(ctx.bounds); + let a = (amount / 100.0).clamp(-1.0, 1.0); + remap(src, out, ctx, Edge::Repeat, |x, y| { + let (dx, dy) = (x - cx, y - cy); + let r = (dx * dx + dy * dy).sqrt(); + if r >= rr || r <= 0.0 { + return (x, y); + } + let rs = rr * (r / rr).powf(1.0 - a * 0.5); + (cx + dx * rs / r, cy + dy * rs / r) + }) +} + +fn sphere_1d(rn: f32, a: f32) -> f32 { + // rn in [0,1): positive amount magnifies the centre (samples closer in). + let target = if a >= 0.0 { rn.asin() * 2.0 / std::f32::consts::PI } else { (rn * std::f32::consts::FRAC_PI_2).sin() }; + rn + a.abs() * (target - rn) +} + +pub(crate) fn spherize(src: &Image, out: Rect, ctx: &Ctx, amount: f32, mode: SpherizeMode) -> Vec { + let b = ctx.bounds; + let (cx, cy, rr) = centre(b); + let (hx, hy) = (b.width() as f32 / 2.0, b.height() as f32 / 2.0); + let a = (amount / 100.0).clamp(-1.0, 1.0); + remap(src, out, ctx, Edge::Repeat, |x, y| { + let (dx, dy) = (x - cx, y - cy); + match mode { + SpherizeMode::Normal => { + let r = (dx * dx + dy * dy).sqrt(); + if r >= rr || r <= 0.0 { + return (x, y); + } + let rs = rr * sphere_1d(r / rr, a); + (cx + dx * rs / r, cy + dy * rs / r) + } + SpherizeMode::HorizontalOnly => { + if dx.abs() >= hx { + return (x, y); + } + (cx + dx.signum() * hx * sphere_1d(dx.abs() / hx, a), y) + } + SpherizeMode::VerticalOnly => { + if dy.abs() >= hy { + return (x, y); + } + (x, cy + dy.signum() * hy * sphere_1d(dy.abs() / hy, a)) + } + } + }) +} + +/// Wave generator settings. +pub(crate) struct WaveSpec { + pub generators: u32, + pub wavelength: (f32, f32), + pub amplitude: (f32, f32), + pub wave_type: WaveType, + pub undefined: UndefinedAreas, + pub seed: u32, +} + +fn shape(t: WaveType, phase: f32) -> f32 { + let p = phase.rem_euclid(std::f32::consts::TAU) / std::f32::consts::TAU; + match t { + WaveType::Sine => (phase).sin(), + WaveType::Triangle => 1.0 - 4.0 * (p - 0.5).abs(), + WaveType::Square => { + if p < 0.5 { + 1.0 + } else { + -1.0 + } + } + } +} + +pub(crate) fn wave(src: &Image, out: Rect, ctx: &Ctx, w: WaveSpec) -> Vec { + let n = w.generators.clamp(1, 64); + let lerp = |(a, b): (f32, f32), t: f32| a + (b - a).max(0.0) * t; + let gens: Vec<(f32, f32, f32, f32)> = (0..n) + .map(|i| { + let r = |k: u32| hash01(i as i32, k as i32, 7, w.seed); + (lerp(w.wavelength, r(1)).max(1.0), lerp(w.amplitude, r(2)), r(3) * std::f32::consts::TAU, r(4) * std::f32::consts::TAU) + }) + .collect(); + let b = ctx.bounds; + remap(src, out, ctx, edge_of(w.undefined), |x, y| { + let (lx, ly) = (x - b.x0 as f32, y - b.y0 as f32); + let (mut dx, mut dy) = (0.0, 0.0); + for &(len, amp, p1, p2) in &gens { + dx += amp * shape(w.wave_type, std::f32::consts::TAU * ly / len + p1); + dy += amp * shape(w.wave_type, std::f32::consts::TAU * lx / len + p2); + } + (x + dx / n as f32, y + dy / n as f32) + }) +} + +pub(crate) fn ripple(src: &Image, out: Rect, ctx: &Ctx, amount: f32, size: RippleSize) -> Vec { + let len = match size { + RippleSize::Small => 8.0, + RippleSize::Medium => 16.0, + RippleSize::Large => 32.0, + }; + let amp = amount / 100.0 * len / 8.0; + let b = ctx.bounds; + remap(src, out, ctx, Edge::Repeat, |x, y| { + let (lx, ly) = (x - b.x0 as f32, y - b.y0 as f32); + (x + amp * (std::f32::consts::TAU * ly / len).sin(), y + amp * (std::f32::consts::TAU * lx / len).sin()) + }) +} + +pub(crate) fn polar(src: &Image, out: Rect, ctx: &Ctx, mode: PolarMode) -> Vec { + let b = ctx.bounds; + let (w, h) = (b.width().max(1) as f32, b.height().max(1) as f32); + let (cx, cy) = (b.x0 as f32 + w / 2.0, b.y0 as f32 + h / 2.0); + let rmax = w.min(h) / 2.0; + remap(src, out, ctx, Edge::Transparent, |x, y| match mode { + PolarMode::RectangularToPolar => { + // Output is polar: angle from 12 o'clock clockwise → source x, + // radius → source y. + let (dx, dy) = (x - cx, y - cy); + let theta = dx.atan2(-dy).rem_euclid(std::f32::consts::TAU); + let r = (dx * dx + dy * dy).sqrt(); + (b.x0 as f32 + theta / std::f32::consts::TAU * w, b.y0 as f32 + r / rmax * h) + } + PolarMode::PolarToRectangular => { + let theta = (x - b.x0 as f32) / w * std::f32::consts::TAU; + let r = (y - b.y0 as f32) / h * rmax; + (cx + r * theta.sin(), cy - r * theta.cos()) + } + }) +} diff --git a/crates/algo/src/image.rs b/crates/algo/src/image.rs new file mode 100644 index 00000000..27374340 --- /dev/null +++ b/crates/algo/src/image.rs @@ -0,0 +1,146 @@ +//! Interleaved `f32` image regions and sampling helpers. + +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +/// How samples outside an image are read. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Edge { + /// Zero (transparent). + Transparent, + /// Clamp to the nearest edge pixel. + Repeat, + /// Wrap around. + Wrap, +} + +/// A rectangle of interleaved, normalized samples. +#[derive(Clone, Debug, PartialEq)] +pub struct Image { + pub rect: Rect, + /// Channels per pixel (alpha last when the surface has alpha). + pub ch: usize, + pub data: Vec, +} + +impl Image { + /// Reads `rect` from a surface. + pub fn read(s: &Surface, rect: Rect) -> Self { + Image { rect, ch: s.channels(), data: s.read_region(rect) } + } + + /// 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] } + } + + #[inline] + pub fn w(&self) -> i32 { + self.rect.width() as i32 + } + + #[inline] + fn index(&self, x: i32, y: i32) -> usize { + ((y - self.rect.y0) as usize * self.rect.width() as usize + (x - self.rect.x0) as usize) * self.ch + } + + /// Sample at integer coordinates; 0 outside. + #[inline] + pub fn get(&self, x: i32, y: i32, c: usize) -> f32 { + if self.rect.contains(x, y) { self.data[self.index(x, y) + c] } else { 0.0 } + } + + /// Pixel slice at `(x, y)` (must be inside). + #[inline] + pub fn px(&self, x: i32, y: i32) -> &[f32] { + let i = self.index(x, y); + &self.data[i..i + self.ch] + } + + /// Sample with an edge policy relative to `area`. + #[inline] + pub fn get_edge(&self, x: i32, y: i32, c: usize, edge: Edge, area: Rect) -> f32 { + if area.contains(x, y) { + return self.get(x, y, c); + } + match edge { + Edge::Transparent => 0.0, + Edge::Repeat => self.get(x.clamp(area.x0, area.x1 - 1), y.clamp(area.y0, area.y1 - 1), c), + Edge::Wrap => { + let (w, h) = (area.width().max(1) as i32, area.height().max(1) as i32); + self.get(area.x0 + (x - area.x0).rem_euclid(w), area.y0 + (y - area.y0).rem_euclid(h), c) + } + } + } + + /// Bilinear sample of the premultiplied pixel at `(x, y)` (pixel centres + /// at `.5`), written to `out` (straight colour, alpha last). + pub fn sample(&self, x: f32, y: f32, edge: Edge, area: Rect, alpha: bool, out: &mut [f32]) { + let (fx, fy) = (x - 0.5, y - 0.5); + let (x0, y0) = (fx.floor(), fy.floor()); + let (ax, ay) = (fx - x0, fy - y0); + let (x0, y0) = (x0 as i32, y0 as i32); + let n = self.ch; + for v in out.iter_mut() { + *v = 0.0; + } + let mut acc_a = 0.0; + for (dx, dy, wgt) in [(0, 0, (1.0 - ax) * (1.0 - ay)), (1, 0, ax * (1.0 - ay)), (0, 1, (1.0 - ax) * ay), (1, 1, ax * ay)] { + if wgt <= 0.0 { + continue; + } + let a = if alpha { self.get_edge(x0 + dx, y0 + dy, n - 1, edge, area) } else { 1.0 }; + for (c, o) in out.iter_mut().enumerate().take(if alpha { n - 1 } else { n }) { + *o += self.get_edge(x0 + dx, y0 + dy, c, edge, area) * a * wgt; + } + acc_a += a * wgt; + } + if alpha { + if acc_a > 0.0 { + for o in out.iter_mut().take(n - 1) { + *o /= acc_a; + } + } + out[n - 1] = acc_a; + } + } + + /// Copy of the samples inside `out` (for identity passes). + pub fn crop(&self, out: Rect) -> Vec { + let mut v = Vec::with_capacity(out.width() as usize * out.height() as usize * self.ch); + for y in out.y0..out.y1 { + for x in out.x0..out.x1 { + for c in 0..self.ch { + v.push(self.get(x, y, c)); + } + } + } + v + } +} + +/// Premultiplies colour channels by alpha (in place). No-op without alpha. +pub fn premultiply(data: &mut [f32], ch: usize, alpha: bool) { + if !alpha { + return; + } + for px in data.chunks_exact_mut(ch) { + let a = px[ch - 1]; + for v in px.iter_mut().take(ch - 1) { + *v *= a; + } + } +} + +/// Inverse of [`premultiply`]. +pub fn unpremultiply(data: &mut [f32], ch: usize, alpha: bool) { + if !alpha { + return; + } + for px in data.chunks_exact_mut(ch) { + let a = px[ch - 1]; + for v in px.iter_mut().take(ch - 1) { + *v = if a > 1e-7 { *v / a } else { 0.0 }; + } + } +} diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs new file mode 100644 index 00000000..67e87d5a --- /dev/null +++ b/crates/algo/src/lib.rs @@ -0,0 +1,383 @@ +//! # photocraft-algo +//! +//! CPU image filters (Photoshop's Filter menu): blur, sharpen, noise, +//! pixelate, stylize and distort. Every filter is +//! +//! * **depth-agnostic**: works on normalized `f32` samples read from any +//! [`Surface`] (8/16-bit integer or 32-bit float) in the surface's own +//! colour model; +//! * **tile/region aware**: [`FilterParams::halo`] declares how far outside an +//! output tile the filter reads, so results are identical whatever the +//! tiling (distortions declare [`Halo::Bounds`] and read the whole +//! reference area); +//! * **selection aware**: output is mixed with the original by the +//! selection's coverage. +//! +//! [`apply`] runs a filter over an area, tile by tile (in parallel with +//! rayon on native targets; single-threaded on wasm). +#![forbid(unsafe_code)] + +mod blur; +mod distort; +mod image; +mod noise; +mod other; +mod sharpen; +mod stylize; + +pub use image::{Edge, Image}; + +use photocraft_color::ColorMode; +use photocraft_geom::Rect; +use photocraft_raster::Surface; +use serde::{Deserialize, Serialize}; + +/// How far outside an output tile a filter reads. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Halo { + /// A margin of this many pixels. + Radius(i32), + /// The whole reference bounds (distortions, radial blur). + Bounds, +} + +/// Radial blur method. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum RadialMethod { + #[default] + Spin, + Zoom, +} + +/// Noise distribution. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum Distribution { + #[default] + Uniform, + Gaussian, +} + +/// How pixels uncovered by a displacement are filled. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum UndefinedAreas { + #[default] + Wrap, + Repeat, + Transparent, +} + +/// Spherize mode. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum SpherizeMode { + #[default] + Normal, + HorizontalOnly, + VerticalOnly, +} + +/// Wave shape. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum WaveType { + #[default] + Sine, + Triangle, + Square, +} + +/// Ripple size. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum RippleSize { + Small, + #[default] + Medium, + Large, +} + +/// Polar coordinates direction. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum PolarMode { + #[default] + RectangularToPolar, + PolarToRectangular, +} + +/// A filter and its parameters, in Photoshop's dialog units (pixels, +/// percent, degrees, levels 0–255). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "filter", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum FilterParams { + /// Radius = Gaussian standard deviation in pixels (0.1–1000). + GaussianBlur { radius: f32 }, + /// Box of `2·radius + 1` pixels. + BoxBlur { radius: f32 }, + MotionBlur { angle: f32, distance: f32 }, + /// Amount 1–100; centre as a fraction of the bounds (0.5, 0.5 = centre). + RadialBlur { amount: f32, method: RadialMethod, center_x: f32, center_y: f32 }, + /// Radius px, threshold in levels. + SurfaceBlur { radius: f32, threshold: f32 }, + /// Amount %, radius px, threshold levels. + UnsharpMask { amount: f32, radius: f32, threshold: f32 }, + /// Amount %, radius px, noise reduction % (basic: Gaussian unsharp). + SmartSharpen { amount: f32, radius: f32, reduce_noise: f32 }, + HighPass { radius: f32 }, + /// Amount 0–400 %. + AddNoise { amount: f32, distribution: Distribution, monochromatic: bool, seed: u32 }, + Median { radius: f32 }, + DustAndScratches { radius: f32, threshold: f32 }, + Minimum { radius: f32 }, + Maximum { radius: f32 }, + Offset { horizontal: i32, vertical: i32, undefined: UndefinedAreas }, + Mosaic { cell_size: f32 }, + /// Angle degrees, height px, amount %. + Emboss { angle: f32, height: f32, amount: f32 }, + FindEdges, + Solarize, + Invert, + Desaturate, + /// Angle −999…999 degrees. + Twirl { angle: f32 }, + /// Amount −100…100 %. + Pinch { amount: f32 }, + /// Amount −100…100 %. + Spherize { amount: f32, mode: SpherizeMode }, + Wave { + generators: u32, + wavelength_min: f32, + wavelength_max: f32, + amplitude_min: f32, + amplitude_max: f32, + wave_type: WaveType, + undefined: UndefinedAreas, + seed: u32, + }, + /// Amount −999…999 %. + Ripple { amount: f32, size: RippleSize }, + PolarCoordinates { mode: PolarMode }, +} + +impl FilterParams { + /// Pixels read outside an output tile. + pub fn halo(&self) -> Halo { + let g = |sigma: f32| Halo::Radius((sigma.max(0.0) * 3.0).ceil() as i32 + 1); + match self { + FilterParams::GaussianBlur { radius } | FilterParams::HighPass { radius } => g(*radius), + FilterParams::UnsharpMask { radius, .. } | FilterParams::SmartSharpen { radius, .. } => g(*radius), + FilterParams::BoxBlur { radius } + | FilterParams::SurfaceBlur { radius, .. } + | FilterParams::Median { radius } + | FilterParams::DustAndScratches { radius, .. } + | FilterParams::Minimum { radius } + | FilterParams::Maximum { radius } => Halo::Radius(radius.max(0.0).ceil() as i32 + 1), + FilterParams::MotionBlur { distance, .. } => Halo::Radius((distance.abs() / 2.0).ceil() as i32 + 2), + FilterParams::Mosaic { cell_size } => Halo::Radius(cell_size.max(1.0).ceil() as i32 + 1), + FilterParams::Emboss { height, .. } => Halo::Radius(height.abs().ceil() as i32 + 2), + FilterParams::FindEdges => Halo::Radius(1), + FilterParams::AddNoise { .. } | FilterParams::Solarize | FilterParams::Invert | FilterParams::Desaturate => Halo::Radius(0), + FilterParams::RadialBlur { .. } + | FilterParams::Offset { .. } + | FilterParams::Twirl { .. } + | FilterParams::Pinch { .. } + | FilterParams::Spherize { .. } + | FilterParams::Wave { .. } + | FilterParams::Ripple { .. } + | FilterParams::PolarCoordinates { .. } => Halo::Bounds, + } + } + + /// Whether the filter moves pixels around the reference bounds (its + /// output area is the bounds, not the layer's content grown by the halo). + pub fn is_global(&self) -> bool { + self.halo() == Halo::Bounds + } + + /// Human-readable name. + pub fn label(&self) -> &'static str { + match self { + FilterParams::GaussianBlur { .. } => "Gaussian Blur", + FilterParams::BoxBlur { .. } => "Box Blur", + FilterParams::MotionBlur { .. } => "Motion Blur", + FilterParams::RadialBlur { .. } => "Radial Blur", + FilterParams::SurfaceBlur { .. } => "Surface Blur", + FilterParams::UnsharpMask { .. } => "Unsharp Mask", + FilterParams::SmartSharpen { .. } => "Smart Sharpen", + FilterParams::HighPass { .. } => "High Pass", + FilterParams::AddNoise { .. } => "Add Noise", + FilterParams::Median { .. } => "Median", + FilterParams::DustAndScratches { .. } => "Dust & Scratches", + FilterParams::Minimum { .. } => "Minimum", + FilterParams::Maximum { .. } => "Maximum", + FilterParams::Offset { .. } => "Offset", + FilterParams::Mosaic { .. } => "Mosaic", + FilterParams::Emboss { .. } => "Emboss", + FilterParams::FindEdges => "Find Edges", + FilterParams::Solarize => "Solarize", + FilterParams::Invert => "Invert", + FilterParams::Desaturate => "Desaturate", + FilterParams::Twirl { .. } => "Twirl", + FilterParams::Pinch { .. } => "Pinch", + FilterParams::Spherize { .. } => "Spherize", + FilterParams::Wave { .. } => "Wave", + FilterParams::Ripple { .. } => "Ripple", + FilterParams::PolarCoordinates { .. } => "Polar Coordinates", + } + } +} + +/// Context a filter runs in. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Ctx { + /// Reference area: distortions are centred on it and sample within it + /// (Photoshop uses the selection bounds, else the canvas). + pub bounds: Rect, + /// Colour model of the samples (the last channel is alpha if `alpha`). + pub mode: ColorMode, + /// Whether the last channel is alpha. + pub alpha: bool, +} + +/// Runs the filter kernel for `out` from `src` (which covers `out` grown by +/// the halo, or the bounds for global filters). Returns interleaved samples. +pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec { + match params { + FilterParams::GaussianBlur { radius } => blur::gaussian(src, out, ctx, *radius), + FilterParams::BoxBlur { radius } => blur::boxed(src, out, ctx, *radius), + FilterParams::MotionBlur { angle, distance } => blur::motion(src, out, ctx, *angle, *distance), + FilterParams::RadialBlur { amount, method, center_x, center_y } => blur::radial(src, out, ctx, *amount, *method, (*center_x, *center_y)), + FilterParams::SurfaceBlur { radius, threshold } => blur::surface(src, out, ctx, *radius, *threshold), + FilterParams::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(src, out, ctx, *amount, *radius, *threshold), + FilterParams::SmartSharpen { amount, radius, reduce_noise } => sharpen::smart(src, out, ctx, *amount, *radius, *reduce_noise), + FilterParams::HighPass { radius } => sharpen::high_pass(src, out, ctx, *radius), + FilterParams::AddNoise { amount, distribution, monochromatic, seed } => noise::add(src, out, ctx, *amount, *distribution, *monochromatic, *seed), + FilterParams::Median { radius } => noise::median(src, out, *radius, None), + FilterParams::DustAndScratches { radius, threshold } => noise::median(src, out, *radius, Some(*threshold)), + FilterParams::Minimum { radius } => other::min_max(src, out, *radius, false), + FilterParams::Maximum { radius } => other::min_max(src, out, *radius, true), + FilterParams::Offset { horizontal, vertical, undefined } => other::offset(src, out, ctx, *horizontal, *vertical, *undefined), + FilterParams::Mosaic { cell_size } => stylize::mosaic(src, out, ctx, *cell_size), + FilterParams::Emboss { angle, height, amount } => stylize::emboss(src, out, ctx, *angle, *height, *amount), + FilterParams::FindEdges => stylize::find_edges(src, out, ctx), + FilterParams::Solarize => stylize::per_pixel(src, out, ctx, |v| if v > 0.5 { 1.0 - v } else { v }), + FilterParams::Invert => stylize::per_pixel(src, out, ctx, |v| 1.0 - v), + FilterParams::Desaturate => stylize::desaturate(src, out, ctx), + FilterParams::Twirl { angle } => distort::twirl(src, out, ctx, *angle), + FilterParams::Pinch { amount } => distort::pinch(src, out, ctx, *amount), + FilterParams::Spherize { amount, mode } => distort::spherize(src, out, ctx, *amount, *mode), + FilterParams::Wave { generators, wavelength_min, wavelength_max, amplitude_min, amplitude_max, wave_type, undefined, seed } => distort::wave( + src, + out, + ctx, + distort::WaveSpec { + generators: *generators, + wavelength: (*wavelength_min, *wavelength_max), + amplitude: (*amplitude_min, *amplitude_max), + wave_type: *wave_type, + undefined: *undefined, + seed: *seed, + }, + ), + FilterParams::Ripple { amount, size } => distort::ripple(src, out, ctx, *amount, *size), + FilterParams::PolarCoordinates { mode } => distort::polar(src, out, ctx, *mode), + } +} + +/// Output tile size used by [`apply`]. +pub const TILE: i32 = 256; + +/// The area a filter writes for a layer whose pixels cover `content`: +/// global filters write the reference bounds; others the content grown by +/// the halo (blurs spread into transparent areas). Clipped to the +/// selection's bounds when there is one. +pub fn output_area(params: &FilterParams, content: Rect, bounds: Rect, selection_bounds: Option) -> Rect { + let mut area = match params.halo() { + Halo::Bounds => bounds, + Halo::Radius(r) => { + if content.is_empty() { + Rect::EMPTY + } else { + content.inflate(r) + } + } + }; + if let Some(sb) = selection_bounds { + area = area.intersect(&sb); + } + area +} + +/// Applies a filter to `area` of `surface`, mixing with the original by the +/// selection coverage (channel 0 of `selection`). Returns the new surface. +pub fn apply(surface: &Surface, params: &FilterParams, area: Rect, bounds: Rect, selection: Option<&Surface>) -> Surface { + apply_tiled(surface, params, area, bounds, selection, TILE) +} + +/// [`apply`] with an explicit tile size (tests use it to check tile independence). +pub fn apply_tiled(surface: &Surface, params: &FilterParams, area: Rect, bounds: Rect, selection: Option<&Surface>, tile: i32) -> Surface { + let mut out = surface.clone(); + if area.is_empty() { + return out; + } + let fmt = surface.format(); + let ctx = Ctx { bounds, mode: fmt.mode, alpha: fmt.alpha }; + let halo = params.halo(); + let shared = (halo == Halo::Bounds).then(|| Image::read(surface, bounds.union(&area))); + let mut tiles = Vec::new(); + let mut y = area.y0; + while y < area.y1 { + let mut x = area.x0; + while x < area.x1 { + tiles.push(Rect::new(x, y, (x + tile).min(area.x1), (y + tile).min(area.y1))); + x += tile; + } + y += tile; + } + let run = |t: &Rect| -> (Rect, Vec) { + let owned; + let src = match (&shared, halo) { + (Some(s), _) => s, + (None, Halo::Radius(r)) => { + owned = Image::read(surface, t.inflate(r)); + &owned + } + (None, Halo::Bounds) => unreachable!("shared image exists for global filters"), + }; + let mut data = kernel(params, src, *t, &ctx); + if let Some(sel) = selection { + let n = src.ch; + let w = t.width() as usize; + for (i, px) in data.chunks_exact_mut(n).enumerate() { + let (xx, yy) = (t.x0 + (i % w) as i32, t.y0 + (i / w) as i32); + let k = sel.pixel(xx, yy)[0].clamp(0.0, 1.0); + if k >= 1.0 { + continue; + } + for (c, v) in px.iter_mut().enumerate() { + let o = src.get(xx, yy, c); + *v = o + (*v - o) * k; + } + } + } + (*t, data) + }; + #[cfg(not(target_arch = "wasm32"))] + let results: Vec<(Rect, Vec)> = { + use rayon::prelude::*; + tiles.par_iter().map(run).collect() + }; + #[cfg(target_arch = "wasm32")] + let results: Vec<(Rect, Vec)> = tiles.iter().map(run).collect(); + for (t, data) in results { + out.write_region(t, &data); + } + out.prune(); + out +} + +#[cfg(test)] +mod tests; diff --git a/crates/algo/src/noise.rs b/crates/algo/src/noise.rs new file mode 100644 index 00000000..c80b0599 --- /dev/null +++ b/crates/algo/src/noise.rs @@ -0,0 +1,81 @@ +//! Add Noise, Median, Dust & Scratches. + +use photocraft_geom::Rect; + +use crate::image::Image; +use crate::{Ctx, Distribution}; + +/// Deterministic hash → `[0, 1)` from document coordinates, so results do not +/// depend on tiling. +#[inline] +pub(crate) fn hash01(x: i32, y: i32, c: u32, seed: u32) -> f32 { + let mut h = (x as u32).wrapping_mul(0x8da6_b343) ^ (y as u32).wrapping_mul(0xd816_3841) ^ c.wrapping_mul(0xcb1a_b31f) ^ seed.wrapping_mul(0x9e37_79b9); + h ^= h >> 16; + h = h.wrapping_mul(0x7feb_352d); + h ^= h >> 15; + h = h.wrapping_mul(0x846c_a68b); + h ^= h >> 16; + (h >> 8) as f32 / (1u32 << 24) as f32 +} + +/// Amount 100 % spans ±50 % of the range (uniform); Gaussian uses the same +/// amount as ~2σ. +#[allow(clippy::too_many_arguments)] +pub(crate) fn add(src: &Image, out: Rect, ctx: &Ctx, amount: f32, dist: Distribution, mono: bool, seed: u32) -> Vec { + let n = src.ch; + let cc = if ctx.alpha { n - 1 } else { n }; + let a = amount.max(0.0) / 100.0 * 0.5; + let mut res = src.crop(out); + let w = out.width() as usize; + for (i, px) in res.chunks_exact_mut(n).enumerate() { + if ctx.alpha && px[n - 1] <= 0.0 { + continue; + } + let (x, y) = (out.x0 + (i % w) as i32, out.y0 + (i / w) as i32); + for c in 0..cc { + let ch = if mono { 0 } else { c as u32 }; + let v = match dist { + Distribution::Uniform => (hash01(x, y, ch, seed) - 0.5) * 2.0 * a, + Distribution::Gaussian => { + let u1 = hash01(x, y, ch * 2 + 101, seed).max(1e-7); + let u2 = hash01(x, y, ch * 2 + 102, seed); + (-2.0 * u1.ln()).sqrt() * (std::f32::consts::TAU * u2).cos() * a * 0.5 + } + }; + // Integer storage clamps on write; float surfaces keep the value. + px[c] += v; + } + } + res +} + +/// Median over a disc of `radius`; with `threshold`, a pixel is replaced only +/// when it differs from the median by more than `threshold` levels. +pub(crate) fn median(src: &Image, out: Rect, radius: f32, threshold: Option) -> Vec { + let n = src.ch; + let r = radius.max(0.0).round() as i32; + if r == 0 { + return src.crop(out); + } + let offs: Vec<(i32, i32)> = (-r..=r).flat_map(|dy| (-r..=r).map(move |dx| (dx, dy))).filter(|(dx, dy)| dx * dx + dy * dy <= r * r + r).collect(); + let t = threshold.map(|t| t / 255.0); + let mut vals = Vec::with_capacity(offs.len()); + let mut res = Vec::with_capacity(out.width() as usize * out.height() as usize * n); + for y in out.y0..out.y1 { + for x in out.x0..out.x1 { + for c in 0..n { + vals.clear(); + vals.extend(offs.iter().map(|(dx, dy)| src.get(x + dx, y + dy, c))); + let mid = vals.len() / 2; + let (_, m, _) = vals.select_nth_unstable_by(mid, |a, b| a.total_cmp(b)); + let m = *m; + let o = src.get(x, y, c); + res.push(match t { + Some(t) if (o - m).abs() <= t => o, + _ => m, + }); + } + } + } + res +} diff --git a/crates/algo/src/other.rs b/crates/algo/src/other.rs new file mode 100644 index 00000000..aff546cc --- /dev/null +++ b/crates/algo/src/other.rs @@ -0,0 +1,68 @@ +//! Minimum, Maximum, Offset. + +use photocraft_geom::Rect; + +use crate::image::{Edge, Image}; +use crate::{Ctx, UndefinedAreas}; + +/// Minimum (spreads dark/transparent areas) or Maximum over a square. +pub(crate) fn min_max(src: &Image, out: Rect, radius: f32, max: bool) -> Vec { + let n = src.ch; + let r = radius.max(0.0).round() as i32; + if r == 0 { + return src.crop(out); + } + // Separable: rows then columns. + let win = Rect::new(out.x0, out.y0 - r, out.x1, out.y1 + r); + let ww = win.width() as usize; + let mut tmp = vec![0.0f32; ww * win.height() as usize * n]; + for y in win.y0..win.y1 { + for x in win.x0..win.x1 { + for c in 0..n { + let mut m = if max { f32::MIN } else { f32::MAX }; + for xx in x - r..=x + r { + let v = src.get(xx, y, c); + m = if max { m.max(v) } else { m.min(v) }; + } + tmp[((y - win.y0) as usize * ww + (x - win.x0) as usize) * n + c] = m; + } + } + } + let mut res = Vec::with_capacity(out.width() as usize * out.height() as usize * n); + for y in out.y0..out.y1 { + for x in out.x0..out.x1 { + for c in 0..n { + let mut m = if max { f32::MIN } else { f32::MAX }; + for yy in y - r..=y + r { + let v = tmp[((yy - win.y0) as usize * ww + (x - win.x0) as usize) * n + c]; + m = if max { m.max(v) } else { m.min(v) }; + } + res.push(m); + } + } + } + res +} + +pub(crate) fn edge_of(u: UndefinedAreas) -> Edge { + match u { + UndefinedAreas::Wrap => Edge::Wrap, + UndefinedAreas::Repeat => Edge::Repeat, + UndefinedAreas::Transparent => Edge::Transparent, + } +} + +/// Shifts the bounds' content by whole pixels. +pub(crate) fn offset(src: &Image, out: Rect, ctx: &Ctx, dx: i32, dy: i32, undefined: UndefinedAreas) -> Vec { + let n = src.ch; + let edge = edge_of(undefined); + let mut res = Vec::with_capacity(out.width() as usize * out.height() as usize * n); + for y in out.y0..out.y1 { + for x in out.x0..out.x1 { + for c in 0..n { + res.push(src.get_edge(x - dx, y - dy, c, edge, ctx.bounds)); + } + } + } + res +} diff --git a/crates/algo/src/sharpen.rs b/crates/algo/src/sharpen.rs new file mode 100644 index 00000000..175de2b3 --- /dev/null +++ b/crates/algo/src/sharpen.rs @@ -0,0 +1,48 @@ +//! Unsharp Mask, Smart Sharpen (basic), High Pass. + +use photocraft_geom::Rect; + +use crate::Ctx; +use crate::blur::{conv_sep, gaussian_kernel}; +use crate::image::Image; + +fn with_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, mut f: impl FnMut(f32, f32) -> f32) -> Vec { + let k = gaussian_kernel(radius); + let blurred = conv_sep(src, out, &k, &k, ctx.alpha); + let mut res = src.crop(out); + let n = src.ch; + let cc = if ctx.alpha { n - 1 } else { n }; + for (px, bl) in res.chunks_exact_mut(n).zip(blurred.chunks_exact(n)) { + if ctx.alpha && px[n - 1] <= 0.0 { + continue; + } + for c in 0..cc { + px[c] = f(px[c], bl[c]); + } + } + res +} + +pub(crate) fn unsharp(src: &Image, out: Rect, ctx: &Ctx, amount: f32, radius: f32, threshold: f32) -> Vec { + let k = amount / 100.0; + let t = threshold / 255.0; + with_blur(src, out, ctx, radius, |o, b| { + let d = o - b; + if d.abs() + 1e-6 >= t { o + k * d } else { o } + }) +} + +pub(crate) fn smart(src: &Image, out: Rect, ctx: &Ctx, amount: f32, radius: f32, reduce_noise: f32) -> Vec { + let k = amount / 100.0; + let nr = (reduce_noise / 100.0).clamp(0.0, 1.0); + with_blur(src, out, ctx, radius, |o, b| { + let d = o - b; + // Suppress small (noise-level) differences. + let keep = 1.0 - nr * (-(d.abs() * 40.0)).exp(); + o + k * d * keep + }) +} + +pub(crate) fn high_pass(src: &Image, out: Rect, ctx: &Ctx, radius: f32) -> Vec { + with_blur(src, out, ctx, radius, |o, b| 0.5 + (o - b)) +} diff --git a/crates/algo/src/stylize.rs b/crates/algo/src/stylize.rs new file mode 100644 index 00000000..b8e9f916 --- /dev/null +++ b/crates/algo/src/stylize.rs @@ -0,0 +1,133 @@ +//! Mosaic, Emboss, Find Edges, Solarize/Invert (per pixel), Desaturate. + +use photocraft_color::PixelFormat; +use photocraft_geom::Rect; +use photocraft_raster::{from_rgba, to_rgba}; + +use crate::Ctx; +use crate::image::Image; + +fn fmt(ctx: &Ctx) -> PixelFormat { + PixelFormat::new(ctx.mode, photocraft_color::SampleType::F32, ctx.alpha) +} + +fn luma(ctx: &Ctx, px: &[f32]) -> f32 { + let c = to_rgba(&fmt(ctx), px); + 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2] +} + +/// Replaces the colour with gray `g` in the pixel's own model (alpha kept). +fn set_gray(ctx: &Ctx, px: &mut [f32], g: f32) { + let a = if ctx.alpha { px[px.len() - 1] } else { 1.0 }; + let v = from_rgba(&fmt(ctx), [g, g, g, a]); + px.copy_from_slice(&v[..px.len()]); +} + +/// Colour channels through `f` (alpha untouched). +pub(crate) fn per_pixel(src: &Image, out: Rect, ctx: &Ctx, f: impl Fn(f32) -> f32) -> Vec { + let n = src.ch; + let cc = if ctx.alpha { n - 1 } else { n }; + let mut res = src.crop(out); + for px in res.chunks_exact_mut(n) { + for v in px.iter_mut().take(cc) { + *v = f(*v); + } + } + res +} + +pub(crate) fn desaturate(src: &Image, out: Rect, ctx: &Ctx) -> Vec { + let n = src.ch; + let mut res = src.crop(out); + for px in res.chunks_exact_mut(n) { + let g = luma(ctx, px); + set_gray(ctx, px, g); + } + res +} + +/// Mosaic cells anchored at the bounds' origin; each cell becomes its +/// (premultiplied) average over the part inside the bounds. +pub(crate) fn mosaic(src: &Image, out: Rect, ctx: &Ctx, cell: f32) -> Vec { + let n = src.ch; + let cs = cell.max(1.0).round() as i32; + let b = ctx.bounds; + let mut res = Vec::with_capacity(out.width() as usize * out.height() as usize * n); + let mut cache: Option<(i32, i32, Vec)> = None; + for y in out.y0..out.y1 { + for x in out.x0..out.x1 { + let (cx, cy) = ((x - b.x0).div_euclid(cs), (y - b.y0).div_euclid(cs)); + if cache.as_ref().is_none_or(|(a, bb, _)| (*a, *bb) != (cx, cy)) { + let r = Rect::new(b.x0 + cx * cs, b.y0 + cy * cs, b.x0 + (cx + 1) * cs, b.y0 + (cy + 1) * cs).intersect(&b); + let mut acc = vec![0.0f32; n]; + let mut count = 0.0; + for yy in r.y0..r.y1 { + for xx in r.x0..r.x1 { + let a = if ctx.alpha { src.get(xx, yy, n - 1) } else { 1.0 }; + for (c, v) in acc.iter_mut().enumerate() { + let s = src.get(xx, yy, c); + *v += if ctx.alpha && c < n - 1 { s * a } else { s }; + } + count += 1.0; + } + } + if count > 0.0 { + for v in acc.iter_mut() { + *v /= count; + } + } + if ctx.alpha { + let a = acc[n - 1]; + for v in acc.iter_mut().take(n - 1) { + *v = if a > 1e-7 { *v / a } else { 0.0 }; + } + } + cache = Some((cx, cy, acc)); + } + res.extend_from_slice(&cache.as_ref().map(|c| c.2.clone()).unwrap_or_default()); + } + } + res +} + +/// Emboss: gray relief from luminance differences along the light angle. +pub(crate) fn emboss(src: &Image, out: Rect, ctx: &Ctx, angle: f32, height: f32, amount: f32) -> Vec { + let n = src.ch; + let (s, c) = angle.to_radians().sin_cos(); + let h = height.max(1.0); + let (dx, dy) = ((c * h).round() as i32, (-s * h).round() as i32); + let k = amount / 100.0; + let mut res = src.crop(out); + let mut tmp = vec![0.0f32; n]; + let lum_at = |x: i32, y: i32, tmp: &mut Vec| { + for (ci, t) in tmp.iter_mut().enumerate() { + *t = src.get(x, y, ci); + } + luma(ctx, tmp) + }; + let w = out.width() as usize; + for (i, px) in res.chunks_exact_mut(n).enumerate() { + let (x, y) = (out.x0 + (i % w) as i32, out.y0 + (i / w) as i32); + let g = 0.5 + k * (lum_at(x + dx, y + dy, &mut tmp) - lum_at(x - dx, y - dy, &mut tmp)); + set_gray(ctx, px, g.clamp(0.0, 1.0)); + } + res +} + +/// Find Edges: Sobel magnitude per colour channel, dark edges on white. +pub(crate) fn find_edges(src: &Image, out: Rect, ctx: &Ctx) -> Vec { + let n = src.ch; + let cc = if ctx.alpha { n - 1 } else { n }; + let mut res = src.crop(out); + let w = out.width() as usize; + for (i, px) in res.chunks_exact_mut(n).enumerate() { + let (x, y) = (out.x0 + (i % w) as i32, out.y0 + (i / w) as i32); + for (c, v) in px.iter_mut().enumerate().take(cc) { + let g = |dx: i32, dy: i32| src.get(x + dx, y + dy, c); + let gx = (g(1, -1) + 2.0 * g(1, 0) + g(1, 1)) - (g(-1, -1) + 2.0 * g(-1, 0) + g(-1, 1)); + let gy = (g(-1, 1) + 2.0 * g(0, 1) + g(1, 1)) - (g(-1, -1) + 2.0 * g(0, -1) + g(1, -1)); + *v = (1.0 - (gx * gx + gy * gy).sqrt() / 4.0).clamp(0.0, 1.0); + } + } + res +} diff --git a/crates/algo/src/tests.rs b/crates/algo/src/tests.rs new file mode 100644 index 00000000..389ef41a --- /dev/null +++ b/crates/algo/src/tests.rs @@ -0,0 +1,336 @@ +use super::*; +use photocraft_color::{PixelFormat, SampleType}; + +fn fmt(s: SampleType) -> PixelFormat { + PixelFormat::new(ColorMode::Rgb, s, true) +} + +/// Deterministic test image over `r`. +fn pattern(s: SampleType, r: Rect) -> Surface { + let mut surf = Surface::new(fmt(s)); + let mut v = Vec::new(); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + v.extend_from_slice(&[((x * 7 + y * 3) % 64) as f32 / 63.0, ((x * x + y) % 50) as f32 / 49.0, (y % 9) as f32 / 8.0, 1.0]); + } + } + surf.write_region(r, &v); + surf +} + +fn flat(s: SampleType, r: Rect, px: [f32; 4]) -> Surface { + let mut surf = Surface::new(fmt(s)); + surf.fill_rect(r, &px); + surf +} + +const R: Rect = Rect { x0: 0, y0: 0, x1: 40, y1: 30 }; + +fn run(s: &Surface, p: &FilterParams) -> Surface { + let area = output_area(p, s.content_bounds(), R, None); + apply(s, p, area, R, None) +} + +fn all_filters() -> Vec { + vec![ + FilterParams::GaussianBlur { radius: 2.5 }, + FilterParams::BoxBlur { radius: 2.0 }, + FilterParams::MotionBlur { angle: 30.0, distance: 7.0 }, + FilterParams::RadialBlur { amount: 20.0, method: RadialMethod::Spin, center_x: 0.5, center_y: 0.5 }, + FilterParams::RadialBlur { amount: 30.0, method: RadialMethod::Zoom, center_x: 0.3, center_y: 0.6 }, + FilterParams::SurfaceBlur { radius: 3.0, threshold: 20.0 }, + FilterParams::UnsharpMask { amount: 150.0, radius: 1.5, threshold: 2.0 }, + FilterParams::SmartSharpen { amount: 100.0, radius: 1.0, reduce_noise: 20.0 }, + FilterParams::HighPass { radius: 3.0 }, + FilterParams::AddNoise { amount: 25.0, distribution: Distribution::Gaussian, monochromatic: false, seed: 3 }, + FilterParams::Median { radius: 2.0 }, + FilterParams::DustAndScratches { radius: 2.0, threshold: 10.0 }, + FilterParams::Minimum { radius: 1.0 }, + FilterParams::Maximum { radius: 2.0 }, + FilterParams::Offset { horizontal: 7, vertical: -3, undefined: UndefinedAreas::Wrap }, + FilterParams::Mosaic { cell_size: 6.0 }, + FilterParams::Emboss { angle: 135.0, height: 2.0, amount: 100.0 }, + FilterParams::FindEdges, + FilterParams::Solarize, + FilterParams::Invert, + FilterParams::Desaturate, + FilterParams::Twirl { angle: 90.0 }, + FilterParams::Pinch { amount: 50.0 }, + FilterParams::Spherize { amount: -60.0, mode: SpherizeMode::HorizontalOnly }, + FilterParams::Wave { + generators: 3, + wavelength_min: 5.0, + wavelength_max: 20.0, + amplitude_min: 1.0, + amplitude_max: 4.0, + wave_type: WaveType::Triangle, + undefined: UndefinedAreas::Repeat, + seed: 9, + }, + FilterParams::Ripple { amount: 200.0, size: RippleSize::Small }, + FilterParams::PolarCoordinates { mode: PolarMode::RectangularToPolar }, + FilterParams::PolarCoordinates { mode: PolarMode::PolarToRectangular }, + ] +} + +#[test] +fn every_filter_is_tile_independent() { + let s = pattern(SampleType::U8, R); + for p in all_filters() { + let area = output_area(&p, s.content_bounds(), R, None); + let a = apply_tiled(&s, &p, area, R, None, 256); + let b = apply_tiled(&s, &p, area, R, None, 7); + let big = area.union(&R); + assert!(a.to_interleaved(big) == b.to_interleaved(big), "{} differs between tile sizes", p.label()); + } +} + +#[test] +fn every_filter_changes_something_and_respects_zero_selection() { + let s = pattern(SampleType::U16, R); + let none = Surface::new(PixelFormat::GRAY8); + for p in all_filters() { + let out = run(&s, &p); + assert!(out != s, "{} did nothing", p.label()); + let area = output_area(&p, s.content_bounds(), R, None); + let masked = apply(&s, &p, area, R, Some(&none)); + assert_eq!(masked.read_region(area), s.read_region(area), "{} ignored an empty selection", p.label()); + } +} + +#[test] +fn half_selection_mixes_halfway() { + let s = flat(SampleType::F32, R, [0.2, 0.4, 0.6, 1.0]); + let sel = Surface::with_default(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false), &[0.5]); + let out = apply(&s, &FilterParams::Invert, R, R, Some(&sel)); + let p = out.pixel(5, 5); + assert!((p[0] - 0.5).abs() < 1e-5 && (p[2] - 0.5).abs() < 1e-5, "{p:?}"); +} + +#[test] +fn gaussian_identity_at_tiny_radius() { + let s = pattern(SampleType::U8, R); + let out = run(&s, &FilterParams::GaussianBlur { radius: 0.01 }); + assert_eq!(out.to_interleaved(R), s.to_interleaved(R)); +} + +#[test] +fn blur_preserves_flat_colour_and_mass() { + let s = flat(SampleType::F32, R, [0.3, 0.6, 0.9, 1.0]); + let out = run(&s, &FilterParams::GaussianBlur { radius: 3.0 }); + let p = out.pixel(20, 15); + assert!((p[0] - 0.3).abs() < 1e-5 && (p[3] - 1.0).abs() < 1e-5); + // Transparency mass (sum of alpha) is preserved when blurring into the margin. + let small = flat(SampleType::F32, Rect::new(10, 10, 20, 20), [1.0, 0.0, 0.0, 1.0]); + for p in [FilterParams::GaussianBlur { radius: 2.0 }, FilterParams::BoxBlur { radius: 3.0 }] { + let out = run(&small, &p); + let big = Rect::new(-10, -10, 40, 40); + let sum: f32 = out.read_region(big).chunks_exact(4).map(|px| px[3]).sum(); + assert!((sum - 100.0).abs() < 0.01, "{}: {sum}", p.label()); + // No colour bleeding from transparent pixels. + let edge = out.pixel(9, 15); + assert!(edge[3] > 0.0 && (edge[0] - 1.0).abs() < 1e-4, "{edge:?}"); + } +} + +#[test] +fn blur_keeps_16bit_precision() { + // A shallow 16-bit ramp (1/65535 per pixel) must stay a ramp after blurring. + let mut s = Surface::new(fmt(SampleType::U16)); + let r = Rect::new(0, 0, 200, 1); + let v: Vec = (0..200).flat_map(|x| [x as f32 / 65535.0, 0.0, 0.0, 1.0]).collect(); + s.write_region(r, &v); + let out = apply(&s, &FilterParams::GaussianBlur { radius: 2.0 }, r, r, None); + for x in 20..180 { + let got = (out.pixel(x, 0)[0] * 65535.0).round() as i32; + assert_eq!(got, x, "x={x}"); + } +} + +#[test] +fn float_surfaces_keep_hdr_values() { + let s = flat(SampleType::F32, R, [4.0, 2.0, 0.5, 1.0]); + let out = run(&s, &FilterParams::GaussianBlur { radius: 2.0 }); + assert!((out.pixel(20, 15)[0] - 4.0).abs() < 1e-4); +} + +#[test] +fn gaussian_sigma_matches_radius() { + // A single bright pixel blurs into a Gaussian with sigma = radius. + let mut s = Surface::new(fmt(SampleType::F32)); + s.write_pixel(20, 15, &[1.0, 1.0, 1.0, 1.0]); + let out = apply(&s, &FilterParams::GaussianBlur { radius: 2.0 }, R, R, None); + let a0 = out.pixel(20, 15)[3]; + let a2 = out.pixel(22, 15)[3]; + // exp(-d²/2σ²) at d = σ = 2 → e^-0.5 + assert!((a2 / a0 - (-0.5f32).exp()).abs() < 1e-3, "{}", a2 / a0); +} + +#[test] +fn unsharp_threshold_and_contrast() { + let s = pattern(SampleType::F32, R); + let none = run(&s, &FilterParams::UnsharpMask { amount: 200.0, radius: 2.0, threshold: 255.0 }); + assert_eq!(none.read_region(R), s.read_region(R), "threshold 255 disables sharpening"); + let sharp = run(&s, &FilterParams::UnsharpMask { amount: 200.0, radius: 2.0, threshold: 0.0 }); + let var = |surf: &Surface| { + let v = surf.read_region(Rect::new(5, 5, 35, 25)); + let reds: Vec = v.chunks_exact(4).map(|p| p[0]).collect(); + let m = reds.iter().sum::() / reds.len() as f32; + reds.iter().map(|r| (r - m).powi(2)).sum::() + }; + assert!(var(&sharp) > var(&s)); +} + +#[test] +fn high_pass_of_flat_is_mid_gray() { + let s = flat(SampleType::F32, R, [0.9, 0.1, 0.4, 1.0]); + let p = run(&s, &FilterParams::HighPass { radius: 4.0 }).pixel(20, 15); + assert!((p[0] - 0.5).abs() < 1e-5 && (p[1] - 0.5).abs() < 1e-5); +} + +#[test] +fn noise_is_deterministic_monochrome_and_centred() { + let s = flat(SampleType::F32, R, [0.5, 0.5, 0.5, 1.0]); + let p = FilterParams::AddNoise { amount: 50.0, distribution: Distribution::Uniform, monochromatic: true, seed: 1 }; + let a = run(&s, &p); + assert_eq!(a, run(&s, &p)); + let v = a.read_region(R); + let mut mean = 0.0; + for px in v.chunks_exact(4) { + assert_eq!(px[0], px[1]); + assert_eq!(px[1], px[2]); + assert!((px[0] - 0.5).abs() <= 0.25 + 1e-6); + mean += px[0]; + } + mean /= (v.len() / 4) as f32; + assert!((mean - 0.5).abs() < 0.03, "{mean}"); + let other_seed = run(&s, &FilterParams::AddNoise { amount: 50.0, distribution: Distribution::Uniform, monochromatic: true, seed: 2 }); + assert_ne!(a, other_seed); +} + +#[test] +fn median_and_dust_remove_a_speck() { + let mut s = flat(SampleType::F32, R, [0.2, 0.2, 0.2, 1.0]); + s.write_pixel(10, 10, &[1.0, 1.0, 1.0, 1.0]); + let m = run(&s, &FilterParams::Median { radius: 1.0 }); + assert!((m.pixel(10, 10)[0] - 0.2).abs() < 1e-6); + let keep = run(&s, &FilterParams::DustAndScratches { radius: 1.0, threshold: 255.0 }); + assert!((keep.pixel(10, 10)[0] - 1.0).abs() < 1e-6); + let remove = run(&s, &FilterParams::DustAndScratches { radius: 1.0, threshold: 50.0 }); + assert!((remove.pixel(10, 10)[0] - 0.2).abs() < 1e-6); +} + +#[test] +fn minimum_maximum_spread() { + let mut s = flat(SampleType::F32, R, [0.5, 0.5, 0.5, 1.0]); + s.write_pixel(10, 10, &[1.0, 1.0, 1.0, 1.0]); + let mx = run(&s, &FilterParams::Maximum { radius: 1.0 }); + assert!((mx.pixel(11, 11)[0] - 1.0).abs() < 1e-6); + assert!((mx.pixel(12, 10)[0] - 0.5).abs() < 1e-6); + let mn = run(&s, &FilterParams::Minimum { radius: 1.0 }); + assert!((mn.pixel(10, 10)[0] - 0.5).abs() < 1e-6); +} + +#[test] +fn offset_wrap_repeat_transparent() { + let s = pattern(SampleType::U8, R); + let full = run(&s, &FilterParams::Offset { horizontal: 40, vertical: 30, undefined: UndefinedAreas::Wrap }); + assert_eq!(full.to_interleaved(R), s.to_interleaved(R)); + let t = run(&s, &FilterParams::Offset { horizontal: 5, vertical: 0, undefined: UndefinedAreas::Transparent }); + assert_eq!(t.pixel(2, 3)[3], 0.0); + assert_eq!(t.pixel(15, 3), s.pixel(10, 3)); + let rp = run(&s, &FilterParams::Offset { horizontal: 5, vertical: 0, undefined: UndefinedAreas::Repeat }); + assert_eq!(rp.pixel(2, 3), s.pixel(0, 3)); +} + +#[test] +fn mosaic_cells_are_uniform() { + let s = pattern(SampleType::F32, R); + let m = run(&s, &FilterParams::Mosaic { cell_size: 5.0 }); + assert_eq!(m.pixel(0, 0), m.pixel(4, 4)); + assert_ne!(m.pixel(4, 4), m.pixel(5, 4)); +} + +#[test] +fn stylize_basics() { + let s = flat(SampleType::F32, R, [0.8, 0.3, 0.1, 1.0]); + let e = run(&s, &FilterParams::Emboss { angle: 45.0, height: 3.0, amount: 100.0 }).pixel(20, 15); + assert!((e[0] - 0.5).abs() < 1e-5 && (e[1] - 0.5).abs() < 1e-5, "{e:?}"); + let f = run(&s, &FilterParams::FindEdges).pixel(20, 15); + assert!((f[0] - 1.0).abs() < 1e-6); + let so = run(&s, &FilterParams::Solarize).pixel(3, 3); + assert!((so[0] - 0.2).abs() < 1e-6 && (so[1] - 0.3).abs() < 1e-6); + let d = run(&s, &FilterParams::Desaturate).pixel(3, 3); + assert!((d[0] - d[1]).abs() < 1e-6 && (d[1] - d[2]).abs() < 1e-6); +} + +#[test] +fn distortions_are_identity_at_zero() { + let s = pattern(SampleType::U8, R); + for p in [ + FilterParams::Twirl { angle: 0.0 }, + FilterParams::Pinch { amount: 0.0 }, + FilterParams::Spherize { amount: 0.0, mode: SpherizeMode::Normal }, + FilterParams::Ripple { amount: 0.0, size: RippleSize::Large }, + ] { + let out = run(&s, &p); + assert_eq!(out.to_interleaved(R), s.to_interleaved(R), "{}", p.label()); + } +} + +#[test] +fn twirl_keeps_centre_and_outside() { + let s = pattern(SampleType::F32, Rect::new(0, 0, 40, 40)); + let b = Rect::new(0, 0, 40, 40); + let out = apply(&s, &FilterParams::Twirl { angle: 180.0 }, b, b, None); + assert_eq!(out.pixel(0, 0), s.pixel(0, 0), "corner is outside the twirl circle"); + assert_ne!(out.pixel(20, 12), s.pixel(20, 12)); +} + +#[test] +fn motion_blur_spreads_along_angle_only() { + let mut s = flat(SampleType::F32, R, [0.0, 0.0, 0.0, 1.0]); + for y in 0..30 { + s.write_pixel(20, y, &[1.0, 1.0, 1.0, 1.0]); + } + let h = run(&s, &FilterParams::MotionBlur { angle: 0.0, distance: 6.0 }); + assert!(h.pixel(22, 15)[0] > 0.05); + let v = run(&s, &FilterParams::MotionBlur { angle: 90.0, distance: 6.0 }); + assert!(v.pixel(22, 15)[0] < 1e-4); +} + +#[test] +fn params_serde_roundtrip() { + for p in all_filters() { + let j = serde_json::to_string(&p).unwrap(); + let back: FilterParams = serde_json::from_str(&j).unwrap(); + assert_eq!(back, p, "{j}"); + } + let p: FilterParams = serde_json::from_str(r#"{"filter":"unsharpMask","amount":50,"radius":1,"threshold":0}"#).unwrap(); + assert_eq!(p, FilterParams::UnsharpMask { amount: 50.0, radius: 1.0, threshold: 0.0 }); +} + +#[test] +fn halos_and_output_area() { + assert_eq!(FilterParams::GaussianBlur { radius: 2.0 }.halo(), Halo::Radius(7)); + assert_eq!(FilterParams::Twirl { angle: 1.0 }.halo(), Halo::Bounds); + let content = Rect::new(10, 10, 20, 20); + assert_eq!(output_area(&FilterParams::Invert, content, R, None), content.inflate(0)); + assert_eq!(output_area(&FilterParams::Twirl { angle: 1.0 }, content, R, None), R); + assert_eq!(output_area(&FilterParams::BoxBlur { radius: 1.0 }, content, R, Some(Rect::new(0, 0, 12, 12))), Rect::new(8, 8, 12, 12)); + assert!(output_area(&FilterParams::Invert, Rect::EMPTY, R, None).is_empty()); +} + +#[test] +fn other_colour_modes_work() { + for mode in [ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] { + let f = PixelFormat::new(mode, SampleType::U8, true); + let mut s = Surface::new(f); + let px: Vec = vec![0.4; f.channels() - 1].into_iter().chain([1.0]).collect(); + s.fill_rect(R, &px); + for p in [FilterParams::GaussianBlur { radius: 1.0 }, FilterParams::Desaturate, FilterParams::Emboss { angle: 0.0, height: 1.0, amount: 100.0 }, FilterParams::Twirl { angle: 50.0 }] { + let out = run(&s, &p); + assert_eq!(out.format(), f); + } + } +} diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml new file mode 100644 index 00000000..4b690b96 --- /dev/null +++ b/crates/codecs/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "photocraft-codecs" +description = "Symmetric, depth- and metadata-preserving codecs for flat raster image formats" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[features] +default = [] +# AVIF *encode* via ravif (pure Rust). Decoding AVIF needs dav1d (C), so AVIF +# stays read-unsupported and therefore asymmetric; it is off by default. +avif = ["image/avif"] + +[dependencies] +thiserror.workspace = true +image = { workspace = true, features = ["gif", "bmp", "tga", "ico", "qoi", "hdr"] } +png = "0.18" +tiff = { version = "0.10", default-features = false, features = ["deflate", "lzw"] } +exr = { version = "1.73", default-features = false } +half = "2" +flate2.workspace = true +image-webp = "0.2" +zune-jpeg = "0.5" +zune-core = "0.5" +jpeg-encoder = "0.6" + +[dev-dependencies] +proptest.workspace = true +# Oracle decoders for cross-checking in tests only. +image = { workspace = true, features = ["png", "jpeg", "tiff"] } + +[lints] +workspace = true diff --git a/crates/codecs/README.md b/crates/codecs/README.md new file mode 100644 index 00000000..88af8a54 --- /dev/null +++ b/crates/codecs/README.md @@ -0,0 +1,121 @@ +# photocraft-codecs + +Codecs for flat raster image formats, written in pure Rust. The crate is standalone: it depends on no other +workspace crate and defines its own `Image` type. It also builds for `wasm32-unknown-unknown` and +does no I/O: decoders take `&[u8]` and encoders return `Vec`. + +It follows the "avoid GIMP's hole" rules in `plan/architecture.md` §1.1: + +* **Symmetric.** Every format we write, we can also read. The only exceptions are listed in + `ASYMMETRIC_EXCEPTIONS`, each with a reason. The test suite checks this. +* **Depth-preserving.** An image can hold U8, U16, F16 or F32 samples, and each format keeps + what it can hold. +* **Metadata-preserving.** ICC, EXIF, XMP, DPI and text are kept wherever the format supports + them. +* **Honest.** `caps(format)` says what each format holds, and + `fidelity_warnings(&image, format)` lists what an export will lose before you write it. Both + functions use the same encode plan as `encode`, so a warning appears exactly when the file + changes. + +```rust +use photocraft_codecs::*; + +let img = decode(&bytes)?; // detect + decode (default Limits) +for w in fidelity_warnings(&img, Format::Jpeg) { // e.g. "16-bit will be reduced to 8-bit" + println!("{w}"); +} +let out = encode(&img, Format::Tiff, &EncodeOptions::default())?; +``` + +## Capability matrix (default build) + +| Format | Read | Write | Depths (native) | Layouts (native) | Alpha | ICC | EXIF | XMP | DPI | Text | Lossy write | Backend | +|---|---|---|---|---|---|---|---|---|---|---|---|---| +| PNG | yes | yes | U8, U16 | Gray, GrayA, RGB, RGBA | yes | yes (iCCP) | yes (eXIf) | yes (iTXt `XML:com.adobe.xmp`) | yes (pHYs) | yes (tEXt/zTXt/iTXt) | no | `png` | +| JPEG | yes | yes | U8 | Gray, RGB, CMYK | no | yes (multi-segment APP2) | yes (APP1) | yes (APP1) | yes (JFIF) | no | yes | `zune-jpeg` / `jpeg-encoder` | +| TIFF | yes | yes | U8, U16, F32 | all six (CMYK, CMYK+A included) | yes | yes (tag 34675) | no | yes (tag 700) | yes | yes (Description, Make, Model, Software, DateTime, Artist, Copyright) | no | `tiff` | +| WebP | yes | yes (lossless only) | U8 | RGB, RGBA | yes | yes | yes | yes | no | no | no | `image-webp` | +| GIF | yes | yes | U8 | RGBA | 1-bit | no | no | no | no | no | yes (256-colour palette) | `image` | +| BMP | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` | +| TGA | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | no | `image` | +| ICO | yes | yes | U8 | RGBA (at most 256×256) | yes | no | no | no | no | no | no | `image` | +| Netpbm (PBM/PGM/PPM/PAM/PFM) | yes | yes | U8, U16, F32 (float: PFM gray/RGB only) | all six (PAM `CMYK`/`CMYK_ALPHA`) | yes (PAM) | no | no | no | no | no | no | built in | +| QOI | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` | +| OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` | +| Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` | +| AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` | + +"Native" means the data is stored and read back without conversion. Anything else is converted by +the encode plan, and `fidelity_warnings` reports the conversion when it loses information: + +* A float image written to an integer format goes to U16 where the format supports it. Values + outside 0..1 raise `RangeClipped`. +* Integer data written to a float-only format (EXR, HDR) becomes F32 without loss. +* CMYK is converted to RGB with a naive formula that is not colour-managed. `CmykConverted` is + raised and the ICC profile is dropped, because a CMYK profile does not describe RGB data. +* Gray written to an RGB-only format is expanded to RGB without loss, so no warning is raised. + +`Image::convert` applies the same ICC rule: it keeps the profile only when the colour model stays +the same. + +## Documented asymmetries and limitations + +* **AVIF (the only asymmetric format).** Encoding uses `ravif`, which is pure Rust. Decoding + needs `dav1d`, which is C. AVIF is therefore read-unsupported, and write support is gated + behind the non-default `avif` feature. In a default build it is neither readable nor writable, + so the symmetric guarantee holds. It is listed in `ASYMMETRIC_EXCEPTIONS`. +* **Lossy WebP.** There is no pure-Rust lossy WebP encoder. We always write lossless WebP, and + `webp_lossless: false` returns `CodecError::Unsupported`. We can read both lossy and lossless + files. +* **Animation and multi-page files** (APNG, animated GIF/WebP, multi-page TIFF): only the first + frame or page is decoded, and a single frame is written. `FormatCaps::animation` marks + containers that can hold more frames. +* **EXIF in TIFF** is stored as a sub-IFD rather than a blob, so it is not preserved yet. + `caps.exif = false` for TIFF, and a warning is raised. +* **EXIF orientation** is preserved as data but not applied to the pixels. +* **JPEG** + * 8-bit only. Neither decoder backend supports 12-bit. + * CMYK is always written 4:4:4 (subsampled CMYK is not portable) as Adobe-inverted CMYK with an + APP14 marker. + * Text is not written because there is no COM-segment support. + * EXIF and XMP must each fit in one 64 KiB APP1 segment, otherwise the encoder returns an error. + Extended XMP is not written. +* **TIFF decode** + * Supports 1, 2 and 4-bit gray (best effort, not yet covered by tests), 8/16/32-bit integers (32-bit is reduced to U16), F16, F32 and + F64 (converted to F32), and WhiteIsZero. + * Palette and YCbCr TIFFs, and compressions that the `tiff` crate lacks (CCITT, JPEG-in-TIFF), + return `Unsupported`. +* **EXR** + * Reads the first valid layer at full resolution, from its data window. + * Channel names are matched by suffix, so `layer.R` counts as `R`. + * Subsampled channels and deep data are unsupported. + * Only lossless compressions are offered for writing: None, RLE, ZIP1, ZIP16 and PIZ. +* **Netpbm writing** picks the subtype from the image: float gives PFM, gray gives P5, RGB gives + P6, and anything with alpha or CMYK gives P7 (PAM). Writing a `.pbm` file therefore produces + whichever of these fits the data. +* **Colour conversions** (CMYK↔RGB, RGB→gray) are naive. Colour-managed conversion belongs in + `photocraft-color`. + +## Limits (decompression bombs) + +`DecodeOptions { limits: Limits { max_width, max_height, max_pixels, max_alloc } }` guards +decoding. Header dimensions are checked before the pixel buffer is allocated, and the budget is +also passed to the underlying decoders. A violation returns `CodecError::LimitExceeded`. The +defaults are 262144 px per side, 2^28 pixels and 2 GiB. + +## Tests + +`cargo test -p photocraft-codecs` covers: + +* the full round-trip matrix (every writable format × 4 sample types × 6 layouts), exact for + lossless formats and above a PSNR threshold for lossy ones; +* metadata preservation per format, checked against `caps`; +* magic-number detection, including negatives; +* fidelity warnings, and a cross-check that the warnings match the actual round-trip result; +* the symmetric-capability guarantee; +* malformed input: truncation, bit flips and proptest random bytes; +* Adam7 PNG against progressive PNG and against the `image` crate as an oracle; +* limit enforcement. + +If `corpus/pngsuite/*.png` exists at the repo root, every file in it is compared against the +`image` crate's decoder. Without it, that test is skipped. diff --git a/crates/codecs/fuzz/.gitignore b/crates/codecs/fuzz/.gitignore new file mode 100644 index 00000000..ab0eaa1a --- /dev/null +++ b/crates/codecs/fuzz/.gitignore @@ -0,0 +1,5 @@ +target/ +corpus/ +artifacts/ +coverage/ +Cargo.lock diff --git a/crates/codecs/fuzz/Cargo.toml b/crates/codecs/fuzz/Cargo.toml new file mode 100644 index 00000000..83b5a738 --- /dev/null +++ b/crates/codecs/fuzz/Cargo.toml @@ -0,0 +1,54 @@ +[package] +name = "photocraft-codecs-fuzz" +version = "0.0.0" +publish = false +edition = "2024" + +[package.metadata] +cargo-fuzz = true + +# Standalone workspace so the main workspace's `crates/*` glob and lints do +# not apply (cargo-fuzz needs nightly + sanitizer flags). +[workspace] + +[dependencies] +libfuzzer-sys = "0.4" +photocraft-codecs = { path = ".." } + +[profile.release] +debug = 1 + +[[bin]] +name = "decode_any" +path = "fuzz_targets/decode_any.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "decode_png" +path = "fuzz_targets/decode_png.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "decode_jpeg" +path = "fuzz_targets/decode_jpeg.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "decode_tiff" +path = "fuzz_targets/decode_tiff.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "decode_exr" +path = "fuzz_targets/decode_exr.rs" +test = false +doc = false +bench = false diff --git a/crates/codecs/fuzz/fuzz_targets/decode_any.rs b/crates/codecs/fuzz/fuzz_targets/decode_any.rs new file mode 100644 index 00000000..18408efc --- /dev/null +++ b/crates/codecs/fuzz/fuzz_targets/decode_any.rs @@ -0,0 +1,17 @@ +#![no_main] +//! Detect + decode arbitrary bytes, then re-encode what decoded (symmetry +//! path) — neither may panic. + +use libfuzzer_sys::fuzz_target; +use photocraft_codecs::{decode_with, encode, DecodeOptions, EncodeOptions, Limits}; + +fuzz_target!(|data: &[u8]| { + let opts = DecodeOptions { + limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 }, + }; + if let Ok(img) = decode_with(data, &opts) + && let Some(f) = photocraft_codecs::detect(data) + { + let _ = encode(&img, f, &EncodeOptions::default()); + } +}); diff --git a/crates/codecs/fuzz/fuzz_targets/decode_exr.rs b/crates/codecs/fuzz/fuzz_targets/decode_exr.rs new file mode 100644 index 00000000..3f4ae617 --- /dev/null +++ b/crates/codecs/fuzz/fuzz_targets/decode_exr.rs @@ -0,0 +1,11 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use photocraft_codecs::{decode_as_with, DecodeOptions, Format, Limits}; + +fuzz_target!(|data: &[u8]| { + let opts = DecodeOptions { + limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 }, + }; + let _ = decode_as_with(Format::OpenExr, data, &opts); +}); diff --git a/crates/codecs/fuzz/fuzz_targets/decode_jpeg.rs b/crates/codecs/fuzz/fuzz_targets/decode_jpeg.rs new file mode 100644 index 00000000..b08017ee --- /dev/null +++ b/crates/codecs/fuzz/fuzz_targets/decode_jpeg.rs @@ -0,0 +1,11 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use photocraft_codecs::{decode_as_with, DecodeOptions, Format, Limits}; + +fuzz_target!(|data: &[u8]| { + let opts = DecodeOptions { + limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 }, + }; + let _ = decode_as_with(Format::Jpeg, data, &opts); +}); diff --git a/crates/codecs/fuzz/fuzz_targets/decode_png.rs b/crates/codecs/fuzz/fuzz_targets/decode_png.rs new file mode 100644 index 00000000..5451640f --- /dev/null +++ b/crates/codecs/fuzz/fuzz_targets/decode_png.rs @@ -0,0 +1,11 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use photocraft_codecs::{decode_as_with, DecodeOptions, Format, Limits}; + +fuzz_target!(|data: &[u8]| { + let opts = DecodeOptions { + limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 }, + }; + let _ = decode_as_with(Format::Png, data, &opts); +}); diff --git a/crates/codecs/fuzz/fuzz_targets/decode_tiff.rs b/crates/codecs/fuzz/fuzz_targets/decode_tiff.rs new file mode 100644 index 00000000..943502a0 --- /dev/null +++ b/crates/codecs/fuzz/fuzz_targets/decode_tiff.rs @@ -0,0 +1,11 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use photocraft_codecs::{decode_as_with, DecodeOptions, Format, Limits}; + +fuzz_target!(|data: &[u8]| { + let opts = DecodeOptions { + limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 }, + }; + let _ = decode_as_with(Format::Tiff, data, &opts); +}); diff --git a/crates/codecs/src/codecs/exr.rs b/crates/codecs/src/codecs/exr.rs new file mode 100644 index 00000000..d3281022 --- /dev/null +++ b/crates/codecs/src/codecs/exr.rs @@ -0,0 +1,163 @@ +//! OpenEXR via the pure-Rust `exr` crate: F16/F32, Y/YA/RGB/RGBA channels, +//! lossless compression. Reads the first valid layer at full resolution; +//! pixel data is taken from the data window. + +use std::io::Cursor; + +use exr::prelude::{ + AnyChannel, AnyChannels, Blocks, Compression, Encoding, FlatSamples, Layer, LayerAttributes, + LineOrder, ReadChannels, ReadLayers, WritableImage, read, +}; +use half::f16; + +use crate::Format; +use crate::error::CodecError; +use crate::fidelity::Plan; +use crate::image::{ChannelLayout, Image, SampleType}; +use crate::options::{EncodeOptions, ExrCompression, Limits}; + +const F: Format = Format::OpenExr; + +fn err(e: impl std::fmt::Display) -> CodecError { + CodecError::malformed(F, e) +} + +/// Strip an optional `layer.` prefix from a channel name. +fn base_name(name: &str) -> &str { + name.rsplit('.').next().unwrap_or(name) +} + +pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { + // Check declared sizes before any pixel allocation. + let meta = exr::meta::MetaData::read_from_buffered(Cursor::new(bytes), false).map_err(err)?; + for header in meta.headers.iter() { + let size = header.layer_size; + let (w, h) = ( + u32::try_from(size.0).unwrap_or(u32::MAX), + u32::try_from(size.1).unwrap_or(u32::MAX), + ); + let bpp = header.channels.list.len().max(1) as u64 * 4; + limits.check_bytes(w, h, bpp)?; + } + + let image = read() + .no_deep_data() + .largest_resolution_level() + .all_channels() + .first_valid_layer() + .all_attributes() + .from_buffered(Cursor::new(bytes)) + .map_err(err)?; + let layer = &image.layer_data; + let (w, h) = (layer.size.0, layer.size.1); + let channels = &layer.channel_data.list; + let find = |n: &str| { + channels + .iter() + .find(|c| base_name(c.name.to_string().as_str()) == n) + }; + let (color, layout): (Vec<_>, ChannelLayout) = + if let (Some(r), Some(g), Some(b)) = (find("R"), find("G"), find("B")) { + match find("A") { + Some(a) => (vec![r, g, b, a], ChannelLayout::Rgba), + None => (vec![r, g, b], ChannelLayout::Rgb), + } + } else if let Some(y) = find("Y").or_else(|| (channels.len() == 1).then(|| &channels[0])) { + match find("A") { + Some(a) => (vec![y, a], ChannelLayout::GrayA), + None => (vec![y], ChannelLayout::Gray), + } + } else { + return Err(CodecError::unsupported(F, "no R/G/B or Y channels")); + }; + let all_f16 = color + .iter() + .all(|c| matches!(c.sample_data, FlatSamples::F16(_))); + let sample = if all_f16 { + SampleType::F16 + } else { + SampleType::F32 + }; + let npx = w * h; + if color.iter().any(|c| c.sample_data.len() != npx) { + return Err(err( + "channel sample count mismatch (subsampled channels are unsupported)", + )); + } + let nc = color.len(); + let (w32, h32) = (w as u32, h as u32); + let img = if all_f16 { + let mut out = vec![f16::ZERO; npx * nc]; + for (ci, c) in color.iter().enumerate() { + if let FlatSamples::F16(v) = &c.sample_data { + for (i, s) in v.iter().enumerate() { + out[i * nc + ci] = *s; + } + } + } + Image::from_f16(w32, h32, layout, &out)? + } else { + let mut out = vec![0f32; npx * nc]; + for (ci, c) in color.iter().enumerate() { + for (i, s) in c.sample_data.values_as_f32().enumerate() { + out[i * nc + ci] = s; + } + } + Image::from_f32(w32, h32, layout, &out)? + }; + debug_assert_eq!(img.sample_type(), sample); + Ok(img) +} + +pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { + let img = src.convert(plan.layout, plan.sample); + let (w, h) = (img.width() as usize, img.height() as usize); + let layout = img.layout(); + let names: &[&str] = match layout { + ChannelLayout::Gray => &["Y"], + ChannelLayout::GrayA => &["Y", "A"], + ChannelLayout::Rgb => &["R", "G", "B"], + ChannelLayout::Rgba => &["R", "G", "B", "A"], + l => return Err(CodecError::encode(F, format!("unsupported layout {l:?}"))), + }; + let nc = names.len(); + let mut list: Vec> = Vec::with_capacity(nc); + match img.sample_type() { + SampleType::F16 => { + let s = img.to_f16_samples().unwrap_or_default(); + for (ci, n) in names.iter().enumerate() { + let v: Vec = s.iter().skip(ci).step_by(nc).copied().collect(); + list.push(AnyChannel::new(*n, FlatSamples::F16(v))); + } + } + SampleType::F32 => { + let s = img.to_f32_samples().unwrap_or_default(); + for (ci, n) in names.iter().enumerate() { + let v: Vec = s.iter().skip(ci).step_by(nc).copied().collect(); + list.push(AnyChannel::new(*n, FlatSamples::F32(v))); + } + } + s => return Err(CodecError::encode(F, format!("unsupported sample {s:?}"))), + } + let compression = match opts.exr_compression { + ExrCompression::None => Compression::Uncompressed, + ExrCompression::Rle => Compression::RLE, + ExrCompression::Zip1 => Compression::ZIP1, + ExrCompression::Zip16 => Compression::ZIP16, + ExrCompression::Piz => Compression::PIZ, + }; + let encoding = Encoding { + compression, + blocks: Blocks::ScanLines, + line_order: LineOrder::Increasing, + }; + let channels = AnyChannels::sort(list.into_iter().collect()); + let layer = Layer::new((w, h), LayerAttributes::default(), encoding, channels); + let image = exr::image::Image::from_layer(layer); + let mut cursor = Cursor::new(Vec::new()); + image + .write() + .to_buffered(&mut cursor) + .map_err(|e| CodecError::encode(F, e))?; + Ok(cursor.into_inner()) +} diff --git a/crates/codecs/src/codecs/jpeg.rs b/crates/codecs/src/codecs/jpeg.rs new file mode 100644 index 00000000..5e378354 --- /dev/null +++ b/crates/codecs/src/codecs/jpeg.rs @@ -0,0 +1,228 @@ +//! JPEG: decode via `zune-jpeg` (gray, YCbCr, CMYK, YCCK), encode via +//! `jpeg-encoder` (gray, RGB, CMYK). Metadata (APP0 JFIF density, APP1 +//! EXIF/XMP, APP2 multi-segment ICC, APP14 Adobe) is parsed by our own marker +//! scanner so behaviour does not depend on decoder internals. + +use zune_core::bytestream::ZCursor; +use zune_core::colorspace::ColorSpace; +use zune_core::options::DecoderOptions; + +use crate::Format; +use crate::error::CodecError; +use crate::fidelity::Plan; +use crate::image::{ChannelLayout, Image, Metadata, SampleType}; +use crate::options::{EncodeOptions, Limits}; + +const F: Format = Format::Jpeg; +const EXIF_HEADER: &[u8] = b"Exif\0\0"; +const XMP_HEADER: &[u8] = b"http://ns.adobe.com/xap/1.0/\0"; +const ICC_HEADER: &[u8] = b"ICC_PROFILE\0"; + +#[derive(Default, Debug)] +pub(crate) struct JpegMeta { + pub icc: Option>, + pub exif: Option>, + pub xmp: Option, + pub dpi: Option<(f32, f32)>, + pub adobe_transform: Option, + /// (width, height, components) from the first SOFn. + pub frame: Option<(u32, u32, u8)>, +} + +/// Walk the marker segments up to SOS and collect metadata. +pub(crate) fn scan_metadata(b: &[u8]) -> JpegMeta { + let mut m = JpegMeta::default(); + let mut icc_parts: Vec<(u8, u8, &[u8])> = Vec::new(); + let mut i = 2; + while i + 4 <= b.len() { + if b[i] != 0xFF { + break; + } + let marker = b[i + 1]; + if marker == 0xFF { + i += 1; + continue; + } + if marker == 0xD8 || (0xD0..=0xD7).contains(&marker) || marker == 0x01 { + i += 2; + continue; + } + if marker == 0xDA || marker == 0xD9 { + break; + } + let len = u16::from_be_bytes([b[i + 2], b[i + 3]]) as usize; + if len < 2 || i + 2 + len > b.len() { + break; + } + let seg = &b[i + 4..i + 2 + len]; + match marker { + 0xE0 if seg.len() >= 12 && seg.starts_with(b"JFIF\0") => { + let unit = seg[7]; + let x = u16::from_be_bytes([seg[8], seg[9]]) as f32; + let y = u16::from_be_bytes([seg[10], seg[11]]) as f32; + if x > 0.0 && y > 0.0 { + m.dpi = match unit { + 1 => Some((x, y)), + 2 => Some((x * 2.54, y * 2.54)), + _ => None, + }; + } + } + 0xE1 if seg.starts_with(EXIF_HEADER) && m.exif.is_none() => { + m.exif = Some(seg[EXIF_HEADER.len()..].to_vec()); + } + 0xE1 if seg.starts_with(XMP_HEADER) && m.xmp.is_none() => { + m.xmp = std::str::from_utf8(&seg[XMP_HEADER.len()..]) + .ok() + .map(|s| s.trim_end_matches('\0').to_owned()); + } + 0xE2 if seg.len() >= 14 && seg.starts_with(ICC_HEADER) => { + icc_parts.push((seg[12], seg[13], &seg[14..])); + } + 0xC0..=0xCF + if !matches!(marker, 0xC4 | 0xC8 | 0xCC) && seg.len() >= 6 && m.frame.is_none() => + { + let h = u16::from_be_bytes([seg[1], seg[2]]) as u32; + let w = u16::from_be_bytes([seg[3], seg[4]]) as u32; + m.frame = Some((w, h, seg[5])); + } + 0xEE if seg.len() >= 12 && seg.starts_with(b"Adobe") => { + m.adobe_transform = Some(seg[11]); + } + _ => {} + } + i += 2 + len; + } + if !icc_parts.is_empty() { + icc_parts.sort_by_key(|p| p.0); + let total = icc_parts[0].1 as usize; + let seqs_ok = icc_parts.len() == total + && icc_parts + .iter() + .enumerate() + .all(|(k, p)| p.0 as usize == k + 1 && p.1 as usize == total); + if seqs_ok || icc_parts.len() == 1 { + m.icc = Some(icc_parts.iter().flat_map(|p| p.2.iter().copied()).collect()); + } + } + m +} + +fn err(e: impl std::fmt::Display) -> CodecError { + CodecError::malformed(F, e) +} + +pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { + let meta = scan_metadata(bytes); + if let Some((w, h, nc)) = meta.frame { + limits.check_bytes(w, h, u64::from(nc.max(1)))?; + } + let options = DecoderOptions::default() + .set_strict_mode(false) + .set_max_width(65535) + .set_max_height(65535); + let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(bytes), options); + dec.decode_headers().map_err(err)?; + let (w, h) = dec.dimensions().ok_or_else(|| err("no dimensions"))?; + let (w, h) = (w as u32, h as u32); + let input = dec.input_colorspace().ok_or_else(|| err("no colorspace"))?; + let (out_cs, layout) = match input { + ColorSpace::Luma | ColorSpace::LumaA => (ColorSpace::Luma, ChannelLayout::Gray), + ColorSpace::CMYK => (ColorSpace::CMYK, ChannelLayout::Cmyk), + ColorSpace::YCCK => (ColorSpace::YCCK, ChannelLayout::Cmyk), + _ => (ColorSpace::RGB, ChannelLayout::Rgb), + }; + limits.check(w, h, layout, SampleType::U8)?; + dec.set_options(dec.options().jpeg_set_out_colorspace(out_cs)); + let mut px = dec.decode().map_err(err)?; + let expected = w as usize * h as usize * layout.channels(); + if px.len() < expected { + return Err(err("short pixel buffer")); + } + px.truncate(expected); + match input { + // Adobe-style CMYK is stored inverted (255 = no ink). + ColorSpace::CMYK => px.iter_mut().for_each(|v| *v = 255 - *v), + ColorSpace::YCCK => { + for p in px.chunks_exact_mut(4) { + let (y, cb, cr) = (p[0] as f32, p[1] as f32 - 128.0, p[2] as f32 - 128.0); + let c = y + 1.402 * cr; + let m = y - 0.344_136 * cb - 0.714_136 * cr; + let yy = y + 1.772 * cb; + p[0] = c.round().clamp(0.0, 255.0) as u8; + p[1] = m.round().clamp(0.0, 255.0) as u8; + p[2] = yy.round().clamp(0.0, 255.0) as u8; + p[3] = 255 - p[3]; + } + } + _ => {} + } + let mut img = Image::from_raw(w, h, layout, SampleType::U8, px)?; + img.icc = meta.icc; + img.meta = Metadata { + exif: meta.exif, + xmp: meta.xmp, + dpi: meta.dpi, + text: Vec::new(), + }; + Ok(img) +} + +pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { + let img = src.convert(plan.layout, plan.sample); + let (w, h) = img.dimensions(); + let (w16, h16) = match (u16::try_from(w), u16::try_from(h)) { + (Ok(a), Ok(b)) => (a, b), + _ => { + return Err(CodecError::encode( + F, + "JPEG dimensions are limited to 65535", + )); + } + }; + let ct = match img.layout() { + ChannelLayout::Gray => jpeg_encoder::ColorType::Luma, + ChannelLayout::Rgb => jpeg_encoder::ColorType::Rgb, + ChannelLayout::Cmyk => jpeg_encoder::ColorType::Cmyk, + l => return Err(CodecError::encode(F, format!("unsupported layout {l:?}"))), + }; + let mut out = Vec::new(); + let mut enc = jpeg_encoder::Encoder::new(&mut out, opts.jpeg_quality.clamp(1, 100)); + // CMYK is always written 4:4:4: subsampled 4-component JPEGs are not + // decoded consistently across implementations. + let sampling = if opts.jpeg_chroma_subsampling && img.layout() == ChannelLayout::Rgb { + jpeg_encoder::SamplingFactor::R_4_2_0 + } else { + jpeg_encoder::SamplingFactor::R_4_4_4 + }; + enc.set_sampling_factor(sampling); + let e = |e: jpeg_encoder::EncodingError| CodecError::encode(F, e); + if opts.embed_metadata { + if let Some((x, y)) = img.meta.dpi + && x >= 1.0 + && y >= 1.0 + { + enc.set_density(jpeg_encoder::Density::Inch { + x: x.round().min(65535.0) as u16, + y: y.round().min(65535.0) as u16, + }); + } + if let Some(exif) = &img.meta.exif { + let mut seg = EXIF_HEADER.to_vec(); + seg.extend_from_slice(exif); + enc.add_app_segment(1, &seg).map_err(e)?; + } + if let Some(xmp) = &img.meta.xmp { + let mut seg = XMP_HEADER.to_vec(); + seg.extend_from_slice(xmp.as_bytes()); + enc.add_app_segment(1, &seg).map_err(e)?; + } + } + if opts.embed_icc + && let Some(icc) = &img.icc + { + enc.add_icc_profile(icc).map_err(e)?; + } + enc.encode(img.data(), w16, h16, ct).map_err(e)?; + Ok(out) +} diff --git a/crates/codecs/src/codecs/mod.rs b/crates/codecs/src/codecs/mod.rs new file mode 100644 index 00000000..f6f4987e --- /dev/null +++ b/crates/codecs/src/codecs/mod.rs @@ -0,0 +1,7 @@ +pub(crate) mod exr; +pub(crate) mod jpeg; +pub(crate) mod png; +pub(crate) mod pnm; +pub(crate) mod tiff; +pub(crate) mod via_image; +pub(crate) mod webp; diff --git a/crates/codecs/src/codecs/png.rs b/crates/codecs/src/codecs/png.rs new file mode 100644 index 00000000..517b58da --- /dev/null +++ b/crates/codecs/src/codecs/png.rs @@ -0,0 +1,257 @@ +//! PNG via the `png` crate directly: 8/16-bit, Adam7 read (and write), +//! iCCP, eXIf, tEXt/zTXt/iTXt (XMP in `XML:com.adobe.xmp`), pHYs. + +use std::io::{Cursor, Write}; + +use crate::Format; +use crate::error::CodecError; +use crate::fidelity::Plan; +use crate::image::{ChannelLayout, Image, Metadata, SampleType}; +use crate::options::{EncodeOptions, Limits, PngCompression}; + +const F: Format = Format::Png; +pub(crate) const XMP_KEYWORD: &str = "XML:com.adobe.xmp"; +const METERS_PER_INCH: f32 = 0.0254; + +fn err(e: impl std::fmt::Display) -> CodecError { + CodecError::malformed(F, e) +} + +pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { + let mut decoder = png::Decoder::new_with_limits( + Cursor::new(bytes), + png::Limits { + bytes: limits.alloc_usize(), + }, + ); + decoder.set_transformations(png::Transformations::EXPAND); + { + let info = decoder.read_header_info().map_err(map_png_err)?; + let (w, h) = info.size(); + // Worst case output: 4 channels x 16 bit. + limits.check_bytes(w, h, 8)?; + } + let mut reader = decoder.read_info().map_err(map_png_err)?; + let (color, depth) = reader.output_color_type(); + let layout = match color { + png::ColorType::Grayscale => ChannelLayout::Gray, + png::ColorType::GrayscaleAlpha => ChannelLayout::GrayA, + png::ColorType::Rgb => ChannelLayout::Rgb, + png::ColorType::Rgba => ChannelLayout::Rgba, + png::ColorType::Indexed => return Err(err("palette was not expanded")), + }; + let sample = match depth { + png::BitDepth::Eight => SampleType::U8, + png::BitDepth::Sixteen => SampleType::U16, + d => return Err(err(format!("unexpected output bit depth {d:?}"))), + }; + let size = reader + .output_buffer_size() + .ok_or_else(|| err("image too large"))?; + let mut buf = vec![0u8; size]; + let out = reader.next_frame(&mut buf).map_err(map_png_err)?; + buf.truncate(out.buffer_size()); + // Collect chunks after IDAT (text may live there). Errors here are + // tolerated: pixel data is already complete. + let _ = reader.finish(); + if sample == SampleType::U16 { + for c in buf.chunks_exact_mut(2) { + let v = u16::from_be_bytes([c[0], c[1]]); + c.copy_from_slice(&v.to_ne_bytes()); + } + } + let info = reader.info(); + let mut img = Image::from_raw(out.width, out.height, layout, sample, buf)?; + img.icc = info.icc_profile.as_ref().map(|c| c.to_vec()); + let mut meta = Metadata { + exif: info.exif_metadata.as_ref().map(|c| c.to_vec()), + ..Default::default() + }; + if let Some(d) = info.pixel_dims + && d.unit == png::Unit::Meter + && d.xppu > 0 + && d.yppu > 0 + { + meta.dpi = Some(( + d.xppu as f32 * METERS_PER_INCH, + d.yppu as f32 * METERS_PER_INCH, + )); + } + for t in &info.uncompressed_latin1_text { + meta.text.push((t.keyword.clone(), t.text.clone())); + } + for t in &info.compressed_latin1_text { + if let Ok(s) = t.get_text() { + meta.text.push((t.keyword.clone(), s)); + } + } + for t in &info.utf8_text { + if let Ok(s) = t.get_text() { + if t.keyword == XMP_KEYWORD { + meta.xmp = Some(s); + } else { + meta.text.push((t.keyword.clone(), s)); + } + } + } + img.meta = meta; + Ok(img) +} + +fn map_png_err(e: png::DecodingError) -> CodecError { + match e { + png::DecodingError::LimitsExceeded => { + CodecError::LimitExceeded("PNG decoder memory limit".into()) + } + e => err(e), + } +} + +fn is_latin1_keyword(k: &str) -> bool { + !k.is_empty() + && k.len() <= 79 + && k.chars() + .all(|c| (' '..='~').contains(&c) || ('\u{a1}'..='\u{ff}').contains(&c)) +} + +pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { + let img = src.convert(plan.layout, plan.sample); + let (w, h) = img.dimensions(); + let color = match img.layout() { + ChannelLayout::Gray => png::ColorType::Grayscale, + ChannelLayout::GrayA => png::ColorType::GrayscaleAlpha, + ChannelLayout::Rgb => png::ColorType::Rgb, + ChannelLayout::Rgba => png::ColorType::Rgba, + l => return Err(CodecError::encode(F, format!("unsupported layout {l:?}"))), + }; + let depth = match img.sample_type() { + SampleType::U8 => png::BitDepth::Eight, + SampleType::U16 => png::BitDepth::Sixteen, + s => return Err(CodecError::encode(F, format!("unsupported sample {s:?}"))), + }; + // Big-endian sample bytes. + let mut data = img.data().to_vec(); + if depth == png::BitDepth::Sixteen { + for c in data.chunks_exact_mut(2) { + let v = u16::from_ne_bytes([c[0], c[1]]); + c.copy_from_slice(&v.to_be_bytes()); + } + } + + let mut info = png::Info::with_size(w, h); + info.color_type = color; + info.bit_depth = depth; + info.interlaced = opts.png_interlaced; + if opts.embed_icc + && let Some(icc) = &img.icc + { + info.icc_profile = Some(icc.clone().into()); + } + if opts.embed_metadata { + if let Some(exif) = &img.meta.exif { + info.exif_metadata = Some(exif.clone().into()); + } + if let Some((x, y)) = img.meta.dpi + && x > 0.0 + && y > 0.0 + { + info.pixel_dims = Some(png::PixelDimensions { + xppu: (x / METERS_PER_INCH).round() as u32, + yppu: (y / METERS_PER_INCH).round() as u32, + unit: png::Unit::Meter, + }); + } + } + + let mut out = Vec::new(); + { + let mut encoder = + png::Encoder::with_info(&mut out, info).map_err(|e| CodecError::encode(F, e))?; + encoder.set_compression(match opts.png_compression { + PngCompression::None => png::Compression::NoCompression, + PngCompression::Fast => png::Compression::Fast, + PngCompression::Default => png::Compression::Balanced, + PngCompression::Best => png::Compression::High, + }); + if opts.embed_metadata { + for (k, v) in &img.meta.text { + let latin1_text = v.chars().all(|c| (c as u32) < 256); + let res = if is_latin1_keyword(k) && latin1_text { + encoder.add_text_chunk(k.clone(), v.clone()) + } else if is_latin1_keyword(k) { + encoder.add_itxt_chunk(k.clone(), v.clone()) + } else { + continue; + }; + res.map_err(|e| CodecError::encode(F, e))?; + } + if let Some(xmp) = &img.meta.xmp { + encoder + .add_itxt_chunk(XMP_KEYWORD.into(), xmp.clone()) + .map_err(|e| CodecError::encode(F, e))?; + } + } + let mut writer = encoder + .write_header() + .map_err(|e| CodecError::encode(F, e))?; + if opts.png_interlaced { + let bpp = img.layout().channels() * img.sample_type().bytes(); + let idat = adam7_idat(&data, w as usize, h as usize, bpp, opts.png_compression)?; + writer + .write_chunk(png::chunk::IDAT, &idat) + .map_err(|e| CodecError::encode(F, e))?; + } else { + writer + .write_image_data(&data) + .map_err(|e| CodecError::encode(F, e))?; + } + writer.finish().map_err(|e| CodecError::encode(F, e))?; + } + Ok(out) +} + +/// Build the zlib stream for an Adam7-interlaced image (filter type 0). +fn adam7_idat( + data: &[u8], + w: usize, + h: usize, + bpp: usize, + level: PngCompression, +) -> Result, CodecError> { + // (x0, y0, dx, dy) per pass. + const PASSES: [(usize, usize, usize, usize); 7] = [ + (0, 0, 8, 8), + (4, 0, 8, 8), + (0, 4, 4, 8), + (2, 0, 4, 4), + (0, 2, 2, 4), + (1, 0, 2, 2), + (0, 1, 1, 2), + ]; + let mut raw = Vec::with_capacity(data.len() + h * 7); + for (x0, y0, dx, dy) in PASSES { + if x0 >= w || y0 >= h { + continue; + } + let mut y = y0; + while y < h { + raw.push(0u8); + let mut x = x0; + while x < w { + let o = (y * w + x) * bpp; + raw.extend_from_slice(&data[o..o + bpp]); + x += dx; + } + y += dy; + } + } + let lvl = match level { + PngCompression::None => flate2::Compression::none(), + PngCompression::Fast => flate2::Compression::fast(), + PngCompression::Default => flate2::Compression::default(), + PngCompression::Best => flate2::Compression::best(), + }; + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), lvl); + z.write_all(&raw).map_err(|e| CodecError::encode(F, e))?; + z.finish().map_err(|e| CodecError::encode(F, e)) +} diff --git a/crates/codecs/src/codecs/pnm.rs b/crates/codecs/src/codecs/pnm.rs new file mode 100644 index 00000000..94d99a54 --- /dev/null +++ b/crates/codecs/src/codecs/pnm.rs @@ -0,0 +1,360 @@ +//! Netpbm family, implemented here: P1–P6 (PBM/PGM/PPM, ASCII and binary), +//! P7 (PAM, including the common `CMYK`/`CMYK_ALPHA` tuple types) and PFM +//! (`PF` colour / `Pf` gray float). +//! +//! Writing picks the subtype from the image: float → PFM (gray/RGB), gray +//! → P5, RGB → P6, anything with alpha or CMYK → P7. + +use crate::Format; +use crate::error::CodecError; +use crate::fidelity::Plan; +use crate::image::{ChannelLayout, Image, SampleType}; +use crate::options::{EncodeOptions, Limits}; + +const F: Format = Format::Pnm; + +fn err(msg: impl std::fmt::Display) -> CodecError { + CodecError::malformed(F, msg) +} + +struct Reader<'a> { + b: &'a [u8], + pos: usize, +} + +impl<'a> Reader<'a> { + fn skip_ws_and_comments(&mut self) { + while self.pos < self.b.len() { + let c = self.b[self.pos]; + if c == b'#' { + while self.pos < self.b.len() + && self.b[self.pos] != b'\n' + && self.b[self.pos] != b'\r' + { + self.pos += 1; + } + } else if c.is_ascii_whitespace() { + self.pos += 1; + } else { + break; + } + } + } + + fn token(&mut self) -> Result<&'a [u8], CodecError> { + self.skip_ws_and_comments(); + let start = self.pos; + while self.pos < self.b.len() + && !self.b[self.pos].is_ascii_whitespace() + && self.b[self.pos] != b'#' + { + self.pos += 1; + } + if start == self.pos { + return Err(err("unexpected end of header")); + } + Ok(&self.b[start..self.pos]) + } + + fn uint(&mut self) -> Result { + let t = self.token()?; + std::str::from_utf8(t) + .ok() + .and_then(|s| s.parse::().ok()) + .ok_or_else(|| err("invalid integer in header")) + } + + /// Consume the single whitespace byte that separates header and raster. + fn single_ws(&mut self) -> Result<(), CodecError> { + match self.b.get(self.pos) { + Some(c) if c.is_ascii_whitespace() => { + self.pos += 1; + Ok(()) + } + _ => Err(err("missing whitespace after header")), + } + } + + fn rest(&self) -> &'a [u8] { + &self.b[self.pos.min(self.b.len())..] + } +} + +fn sample_for_maxval(maxval: u32) -> Result { + match maxval { + 1..=255 => Ok(SampleType::U8), + 256..=65535 => Ok(SampleType::U16), + _ => Err(err(format!("invalid maxval {maxval}"))), + } +} + +/// Build an image from raw integer values in `[0, maxval]`. +fn from_values( + w: u32, + h: u32, + layout: ChannelLayout, + maxval: u32, + values: Vec, +) -> Result { + let sample = sample_for_maxval(maxval)?; + if values.iter().any(|&v| v > maxval) { + return Err(err("sample exceeds maxval")); + } + match (sample, maxval) { + (SampleType::U8, 255) => { + Image::from_u8(w, h, layout, values.into_iter().map(|v| v as u8).collect()) + } + (SampleType::U16, 65535) => Image::from_u16( + w, + h, + layout, + &values.into_iter().map(|v| v as u16).collect::>(), + ), + (SampleType::U8, m) => Image::from_u8( + w, + h, + layout, + values + .into_iter() + .map(|v| ((v * 255 + m / 2) / m) as u8) + .collect(), + ), + (_, m) => Image::from_u16( + w, + h, + layout, + &values + .into_iter() + .map(|v| ((v as u64 * 65535 + m as u64 / 2) / m as u64) as u16) + .collect::>(), + ), + } +} + +fn read_binary(data: &[u8], n: usize, maxval: u32) -> Result, CodecError> { + if maxval < 256 { + if data.len() < n { + return Err(err("truncated raster")); + } + Ok(data[..n].iter().map(|&v| v as u32).collect()) + } else { + if data.len() < n * 2 { + return Err(err("truncated raster")); + } + Ok(data[..n * 2] + .chunks_exact(2) + .map(|c| u16::from_be_bytes([c[0], c[1]]) as u32) + .collect()) + } +} + +pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { + if bytes.len() < 2 || bytes[0] != b'P' { + return Err(err("not a Netpbm file")); + } + let kind = bytes[1]; + let mut r = Reader { b: bytes, pos: 2 }; + match kind { + b'1' | b'4' => { + let (w, h) = (r.uint()?, r.uint()?); + limits.check(w, h, ChannelLayout::Gray, SampleType::U8)?; + let n = w as usize * h as usize; + let mut px = Vec::with_capacity(n); + if kind == b'4' { + r.single_ws()?; + let row = (w as usize).div_ceil(8); + let data = r.rest(); + if data.len() < row * h as usize { + return Err(err("truncated raster")); + } + for y in 0..h as usize { + for x in 0..w as usize { + let bit = (data[y * row + x / 8] >> (7 - x % 8)) & 1; + px.push(if bit == 1 { 0 } else { 255 }); + } + } + } else { + while px.len() < n { + r.skip_ws_and_comments(); + match r.b.get(r.pos) { + Some(b'0') => px.push(255), + Some(b'1') => px.push(0), + Some(_) => return Err(err("invalid PBM digit")), + None => return Err(err("truncated raster")), + } + r.pos += 1; + } + } + Image::from_u8(w, h, ChannelLayout::Gray, px) + } + b'2' | b'3' | b'5' | b'6' => { + let (w, h, maxval) = (r.uint()?, r.uint()?, r.uint()?); + let layout = if matches!(kind, b'2' | b'5') { + ChannelLayout::Gray + } else { + ChannelLayout::Rgb + }; + limits.check(w, h, layout, sample_for_maxval(maxval)?)?; + let n = w as usize * h as usize * layout.channels(); + let values = if matches!(kind, b'5' | b'6') { + r.single_ws()?; + read_binary(r.rest(), n, maxval)? + } else { + let mut v = Vec::with_capacity(n); + for _ in 0..n { + v.push(r.uint()?); + } + v + }; + from_values(w, h, layout, maxval, values) + } + b'7' => decode_pam(&mut r, limits), + b'F' | b'f' => { + let layout = if kind == b'F' { + ChannelLayout::Rgb + } else { + ChannelLayout::Gray + }; + let (w, h) = (r.uint()?, r.uint()?); + let scale: f32 = std::str::from_utf8(r.token()?) + .ok() + .and_then(|s| s.parse().ok()) + .filter(|s: &f32| s.is_finite() && *s != 0.0) + .ok_or_else(|| err("invalid PFM scale"))?; + r.single_ws()?; + limits.check(w, h, layout, SampleType::F32)?; + let nc = layout.channels(); + let row = w as usize * nc; + let data = r.rest(); + if data.len() < row * h as usize * 4 { + return Err(err("truncated raster")); + } + let le = scale < 0.0; + let mut out = vec![0f32; row * h as usize]; + for y in 0..h as usize { + // PFM rows are stored bottom-to-top. + let dst = (h as usize - 1 - y) * row; + for i in 0..row { + let o = (y * row + i) * 4; + let b4 = [data[o], data[o + 1], data[o + 2], data[o + 3]]; + out[dst + i] = if le { + f32::from_le_bytes(b4) + } else { + f32::from_be_bytes(b4) + }; + } + } + Image::from_f32(w, h, layout, &out) + } + _ => Err(err("unknown Netpbm subtype")), + } +} + +fn decode_pam(r: &mut Reader<'_>, limits: &Limits) -> Result { + let (mut w, mut h, mut depth, mut maxval) = (None, None, None, None); + let mut tupltype = String::new(); + loop { + let tok = r.token()?; + match tok { + b"WIDTH" => w = Some(r.uint()?), + b"HEIGHT" => h = Some(r.uint()?), + b"DEPTH" => depth = Some(r.uint()?), + b"MAXVAL" => maxval = Some(r.uint()?), + b"TUPLTYPE" => { + let t = r.token()?; + if !tupltype.is_empty() { + tupltype.push(' '); + } + tupltype.push_str(std::str::from_utf8(t).map_err(|_| err("bad TUPLTYPE"))?); + } + b"ENDHDR" => break, + _ => return Err(err("unknown PAM header field")), + } + } + r.single_ws()?; + let (w, h, depth, maxval) = match (w, h, depth, maxval) { + (Some(a), Some(b), Some(c), Some(d)) => (a, b, c, d), + _ => return Err(err("incomplete PAM header")), + }; + let layout = match (tupltype.as_str(), depth) { + ("BLACKANDWHITE" | "GRAYSCALE", 1) | ("", 1) => ChannelLayout::Gray, + ("BLACKANDWHITE_ALPHA" | "GRAYSCALE_ALPHA", 2) | ("", 2) => ChannelLayout::GrayA, + ("RGB", 3) | ("", 3) => ChannelLayout::Rgb, + ("RGB_ALPHA", 4) | ("", 4) => ChannelLayout::Rgba, + ("CMYK", 4) => ChannelLayout::Cmyk, + ("CMYK_ALPHA", 5) => ChannelLayout::CmykA, + (t, d) => { + return Err(CodecError::unsupported( + F, + format!("PAM tuple type {t:?} with depth {d}"), + )); + } + }; + limits.check(w, h, layout, sample_for_maxval(maxval)?)?; + let n = w as usize * h as usize * layout.channels(); + let values = read_binary(r.rest(), n, maxval)?; + from_values(w, h, layout, maxval, values) +} + +pub(crate) fn encode( + src: &Image, + plan: Plan, + _opts: &EncodeOptions, +) -> Result, CodecError> { + let img = src.convert(plan.layout, plan.sample); + let (w, h) = img.dimensions(); + let layout = img.layout(); + let mut out = Vec::new(); + match img.sample_type() { + SampleType::F32 => { + let tag = match layout { + ChannelLayout::Gray => "Pf", + ChannelLayout::Rgb => "PF", + l => return Err(CodecError::encode(F, format!("PFM cannot store {l:?}"))), + }; + out.extend_from_slice(format!("{tag}\n{w} {h}\n-1.0\n").as_bytes()); + let s = img.to_f32_samples().unwrap_or_default(); + let row = w as usize * layout.channels(); + for y in (0..h as usize).rev() { + for v in &s[y * row..(y + 1) * row] { + out.extend_from_slice(&v.to_le_bytes()); + } + } + } + sample @ (SampleType::U8 | SampleType::U16) => { + let maxval = if sample == SampleType::U8 { 255 } else { 65535 }; + match layout { + ChannelLayout::Gray => { + out.extend_from_slice(format!("P5\n{w} {h}\n{maxval}\n").as_bytes()) + } + ChannelLayout::Rgb => { + out.extend_from_slice(format!("P6\n{w} {h}\n{maxval}\n").as_bytes()) + } + l => { + let t = match l { + ChannelLayout::GrayA => "GRAYSCALE_ALPHA", + ChannelLayout::Rgba => "RGB_ALPHA", + ChannelLayout::Cmyk => "CMYK", + _ => "CMYK_ALPHA", + }; + out.extend_from_slice( + format!( + "P7\nWIDTH {w}\nHEIGHT {h}\nDEPTH {}\nMAXVAL {maxval}\nTUPLTYPE {t}\nENDHDR\n", + l.channels() + ) + .as_bytes(), + ); + } + } + if sample == SampleType::U8 { + out.extend_from_slice(img.data()); + } else { + for v in img.to_u16_samples().unwrap_or_default() { + out.extend_from_slice(&v.to_be_bytes()); + } + } + } + s => return Err(CodecError::encode(F, format!("unsupported sample {s:?}"))), + } + Ok(out) +} diff --git a/crates/codecs/src/codecs/tiff.rs b/crates/codecs/src/codecs/tiff.rs new file mode 100644 index 00000000..7fa78e36 --- /dev/null +++ b/crates/codecs/src/codecs/tiff.rs @@ -0,0 +1,463 @@ +//! TIFF via the `tiff` crate: 8/16-bit integer and 32-bit float, gray/RGB/ +//! CMYK with optional (unassociated) alpha, ICC (34675), XMP (700), DPI and +//! a few ASCII text tags. Only the first IFD is read. + +use std::borrow::Cow; +use std::io::Cursor; + +use tiff::decoder::{Decoder, DecodingResult}; +use tiff::encoder::colortype::{self, ColorType as TiffColorType}; +use tiff::encoder::{Rational, TiffEncoder, TiffValue}; +use tiff::tags::{PhotometricInterpretation, ResolutionUnit, SampleFormat, Tag, Type}; + +use crate::Format; +use crate::error::CodecError; +use crate::fidelity::Plan; +use crate::image::{ChannelLayout, Image, Metadata, SampleType}; +use crate::options::{EncodeOptions, Limits, TiffCompression}; + +const F: Format = Format::Tiff; +const TAG_XMP: u16 = 700; + +/// ASCII tags mapped to `Metadata::text` keys. +const TEXT_TAGS: &[(Tag, &str)] = &[ + (Tag::ImageDescription, "Description"), + (Tag::Make, "Make"), + (Tag::Model, "Model"), + (Tag::Software, "Software"), + (Tag::DateTime, "DateTime"), + (Tag::Artist, "Artist"), + (Tag::Copyright, "Copyright"), +]; + +fn err(e: impl std::fmt::Display) -> CodecError { + CodecError::malformed(F, e) +} + +fn map_err(e: tiff::TiffError) -> CodecError { + match e { + tiff::TiffError::LimitsExceeded => CodecError::LimitExceeded("TIFF decoder limit".into()), + tiff::TiffError::UnsupportedError(u) => CodecError::unsupported(F, u.to_string()), + e => err(e), + } +} + +fn value_bytes(v: tiff::decoder::ifd::Value) -> Option> { + use tiff::decoder::ifd::Value; + match v { + Value::Byte(b) => Some(vec![b]), + Value::Ascii(s) => Some(s.into_bytes()), + Value::List(l) => l + .into_iter() + .map(|v| match v { + Value::Byte(b) => Some(b), + _ => None, + }) + .collect(), + _ => None, + } +} + +pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { + let tl = { + let mut l = tiff::decoder::Limits::default(); + l.decoding_buffer_size = limits.alloc_usize(); + l.intermediate_buffer_size = limits.alloc_usize().min(1 << 30); + l + }; + let mut dec = Decoder::new(Cursor::new(bytes)) + .map_err(map_err)? + .with_limits(tl); + let (w, h) = dec.dimensions().map_err(map_err)?; + let ct = dec.colortype().map_err(map_err)?; + let (layout, bits) = match ct { + tiff::ColorType::Gray(b) => (ChannelLayout::Gray, b), + tiff::ColorType::GrayA(b) => (ChannelLayout::GrayA, b), + tiff::ColorType::RGB(b) => (ChannelLayout::Rgb, b), + tiff::ColorType::RGBA(b) => (ChannelLayout::Rgba, b), + tiff::ColorType::CMYK(b) => (ChannelLayout::Cmyk, b), + tiff::ColorType::CMYKA(b) => (ChannelLayout::CmykA, b), + // The decoder reports gray+alpha as multiband (it ignores ExtraSamples). + tiff::ColorType::Multiband { + bit_depth, + num_samples: 2, + } => (ChannelLayout::GrayA, bit_depth), + other => return Err(CodecError::unsupported(F, format!("colour type {other:?}"))), + }; + let bytes_per_sample = u64::from(bits.max(8) / 8); + limits.check_bytes(w, h, layout.channels() as u64 * bytes_per_sample.max(1))?; + let white_is_zero = dec + .find_tag_unsigned::(Tag::PhotometricInterpretation) + .ok() + .flatten() + == Some(PhotometricInterpretation::WhiteIsZero.to_u16()); + + let result = dec.read_image().map_err(map_err)?; + let n = w as usize * h as usize * layout.channels(); + let mut img = match result { + DecodingResult::U8(v) if bits == 8 => Image::from_u8(w, h, layout, v)?, + DecodingResult::U8(v) if bits < 8 && layout == ChannelLayout::Gray => { + Image::from_u8(w, h, layout, unpack_bits(&v, w as usize, h as usize, bits)?)? + } + DecodingResult::U16(v) => Image::from_u16(w, h, layout, &v)?, + DecodingResult::U32(v) => { + let v: Vec = v.iter().map(|&x| (x >> 16) as u16).collect(); + Image::from_u16(w, h, layout, &v)? + } + DecodingResult::F16(v) => Image::from_f16(w, h, layout, &v)?, + DecodingResult::F32(v) => Image::from_f32(w, h, layout, &v)?, + DecodingResult::F64(v) => { + let v: Vec = v.iter().map(|&x| x as f32).collect(); + Image::from_f32(w, h, layout, &v)? + } + _ => { + return Err(CodecError::unsupported( + F, + format!("sample format for {ct:?}"), + )); + } + }; + if img.sample_count() != n { + return Err(err("sample count mismatch")); + } + if white_is_zero && layout.is_gray() { + let v: Vec = img + .to_normalized() + .chunks(layout.channels()) + .flat_map(|p| { + let mut p = p.to_vec(); + p[0] = 1.0 - p[0]; + p + }) + .collect(); + img = Image::from_normalized(w, h, layout, img.sample_type(), &v)?; + } + + img.icc = dec + .find_tag(Tag::IccProfile) + .ok() + .flatten() + .and_then(value_bytes); + let mut meta = Metadata { + xmp: dec + .find_tag(Tag::Unknown(TAG_XMP)) + .ok() + .flatten() + .and_then(value_bytes) + .and_then(|b| String::from_utf8(b).ok()) + .map(|s| s.trim_end_matches('\0').to_owned()), + ..Default::default() + }; + let unit = dec + .find_tag_unsigned::(Tag::ResolutionUnit) + .ok() + .flatten() + .unwrap_or(2); + let xr = dec + .find_tag(Tag::XResolution) + .ok() + .flatten() + .and_then(rational_f64); + let yr = dec + .find_tag(Tag::YResolution) + .ok() + .flatten() + .and_then(rational_f64); + if let (Some(x), Some(y)) = (xr, yr) + && x > 0.0 + && y > 0.0 + { + meta.dpi = match unit { + 2 => Some((x as f32, y as f32)), + 3 => Some(((x * 2.54) as f32, (y * 2.54) as f32)), + _ => None, + }; + } + for (tag, key) in TEXT_TAGS { + if let Some(s) = dec + .find_tag(*tag) + .ok() + .flatten() + .and_then(|v| v.into_string().ok()) + { + let s = s.trim_end_matches('\0').to_owned(); + if !s.is_empty() { + meta.text.push(((*key).to_owned(), s)); + } + } + } + img.meta = meta; + Ok(img) +} + +fn rational_f64(v: tiff::decoder::ifd::Value) -> Option { + use tiff::decoder::ifd::Value; + match v { + Value::Rational(n, d) if d != 0 => Some(n as f64 / d as f64), + Value::RationalBig(n, d) if d != 0 => Some(n as f64 / d as f64), + Value::Float(f) => Some(f as f64), + Value::Double(f) => Some(f), + Value::Unsigned(u) => Some(u as f64), + Value::Short(u) => Some(u as f64), + Value::List(mut l) if l.len() == 1 => rational_f64(l.remove(0)), + _ => None, + } +} + +fn unpack_bits(packed: &[u8], w: usize, h: usize, bits: u8) -> Result, CodecError> { + let bits = bits as usize; + if !matches!(bits, 1 | 2 | 4) { + return Err(CodecError::unsupported(F, format!("{bits}-bit gray"))); + } + let row_bytes = (w * bits).div_ceil(8); + if packed.len() < row_bytes * h { + return Err(err("short bilevel data")); + } + let max = (1u32 << bits) - 1; + let mut out = Vec::with_capacity(w * h); + for y in 0..h { + let row = &packed[y * row_bytes..(y + 1) * row_bytes]; + for x in 0..w { + let bit = x * bits; + let v = (row[bit / 8] >> (8 - bits - bit % 8)) as u32 & max; + out.push((v * 255 / max) as u8); + } + } + Ok(out) +} + +// --------------------------------------------------------------------------- +// Encoding +// --------------------------------------------------------------------------- + +/// Bytes written with TIFF type UNDEFINED (7), as required for ICC. +struct Undefined<'a>(&'a [u8]); + +impl TiffValue for Undefined<'_> { + const BYTE_LEN: u8 = 1; + const FIELD_TYPE: Type = Type::UNDEFINED; + fn count(&self) -> usize { + self.0.len() + } + fn data(&self) -> Cow<'_, [u8]> { + Cow::Borrowed(self.0) + } +} + +macro_rules! custom_colortype { + ($name:ident, $inner:ty, $photo:expr, $bits:expr, $fmt:expr, int) => { + struct $name; + impl TiffColorType for $name { + type Inner = $inner; + const TIFF_VALUE: PhotometricInterpretation = $photo; + const BITS_PER_SAMPLE: &'static [u16] = $bits; + const SAMPLE_FORMAT: &'static [SampleFormat] = $fmt; + fn horizontal_predict(row: &[Self::Inner], result: &mut Vec) { + let n = Self::SAMPLE_FORMAT.len(); + let n = n.min(row.len()); + result.extend_from_slice(&row[..n]); + result.extend(row.iter().zip(&row[n..]).map(|(p, c)| c.wrapping_sub(*p))); + } + } + }; + ($name:ident, $inner:ty, $photo:expr, $bits:expr, $fmt:expr, float) => { + struct $name; + impl TiffColorType for $name { + type Inner = $inner; + const TIFF_VALUE: PhotometricInterpretation = $photo; + const BITS_PER_SAMPLE: &'static [u16] = $bits; + const SAMPLE_FORMAT: &'static [SampleFormat] = $fmt; + fn horizontal_predict(row: &[Self::Inner], result: &mut Vec) { + result.extend_from_slice(row); + } + } + }; +} + +use PhotometricInterpretation as PI; +custom_colortype!( + GrayA8, + u8, + PI::BlackIsZero, + &[8, 8], + &[SampleFormat::Uint; 2], + int +); +custom_colortype!( + GrayA16, + u16, + PI::BlackIsZero, + &[16, 16], + &[SampleFormat::Uint; 2], + int +); +custom_colortype!( + GrayA32F, + f32, + PI::BlackIsZero, + &[32, 32], + &[SampleFormat::IEEEFP; 2], + float +); +custom_colortype!( + CmykA16, + u16, + PI::CMYK, + &[16; 5], + &[SampleFormat::Uint; 5], + int +); +custom_colortype!( + CmykA32F, + f32, + PI::CMYK, + &[32; 5], + &[SampleFormat::IEEEFP; 5], + float +); + +struct TagSet<'a> { + icc: Option<&'a [u8]>, + xmp: Option<&'a [u8]>, + dpi: Option<(f32, f32)>, + text: Vec<(Tag, &'a str)>, + alpha: bool, +} + +fn write_one( + enc: &mut TiffEncoder<&mut Cursor>>, + w: u32, + h: u32, + data: &[C::Inner], + tags: &TagSet<'_>, +) -> tiff::TiffResult<()> +where + C: TiffColorType, + [C::Inner]: TiffValue, +{ + let mut im = enc.new_image::(w, h)?; + if let Some((x, y)) = tags.dpi { + im.resolution_unit(ResolutionUnit::Inch); + im.x_resolution(Rational { + n: (x * 1000.0).round() as u32, + d: 1000, + }); + im.y_resolution(Rational { + n: (y * 1000.0).round() as u32, + d: 1000, + }); + } + let d = im.encoder(); + if tags.alpha { + // 2 = unassociated alpha + d.write_tag(Tag::ExtraSamples, &[2u16][..])?; + } + if let Some(icc) = tags.icc { + d.write_tag(Tag::IccProfile, Undefined(icc))?; + } + if let Some(xmp) = tags.xmp { + d.write_tag(Tag::Unknown(TAG_XMP), xmp)?; + } + for (tag, s) in &tags.text { + d.write_tag(*tag, *s)?; + } + im.write_data(data) +} + +pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { + let img = src.convert(plan.layout, plan.sample); + let (w, h) = img.dimensions(); + let compression = match opts.tiff_compression { + TiffCompression::None => tiff::encoder::Compression::Uncompressed, + TiffCompression::Lzw => tiff::encoder::Compression::Lzw, + TiffCompression::Deflate => { + tiff::encoder::Compression::Deflate(tiff::encoder::DeflateLevel::Balanced) + } + TiffCompression::PackBits => tiff::encoder::Compression::Packbits, + }; + let predictor = if !img.sample_type().is_float() + && matches!( + opts.tiff_compression, + TiffCompression::Lzw | TiffCompression::Deflate + ) { + tiff::encoder::Predictor::Horizontal + } else { + tiff::encoder::Predictor::None + }; + let text: Vec<(Tag, &str)> = if opts.embed_metadata { + img.meta + .text + .iter() + .filter_map(|(k, v)| { + TEXT_TAGS + .iter() + .find(|(_, key)| key.eq_ignore_ascii_case(k)) + .map(|(t, _)| (*t, v.as_str())) + }) + .filter(|(_, v)| v.is_ascii() && !v.contains('\0')) + .collect() + } else { + Vec::new() + }; + let tags = TagSet { + icc: if opts.embed_icc { + img.icc.as_deref() + } else { + None + }, + xmp: if opts.embed_metadata { + img.meta.xmp.as_deref().map(str::as_bytes) + } else { + None + }, + dpi: if opts.embed_metadata { + img.meta.dpi.filter(|d| d.0 > 0.0 && d.1 > 0.0) + } else { + None + }, + text, + alpha: img.layout().has_alpha(), + }; + + let mut cursor = Cursor::new(Vec::new()); + { + let mut enc = TiffEncoder::new(&mut cursor) + .map_err(|e| CodecError::encode(F, e))? + .with_compression(compression) + .with_predictor(predictor); + use ChannelLayout as L; + use SampleType as S; + let u16s = || img.to_u16_samples().unwrap_or_default(); + let f32s = || img.to_f32_samples().unwrap_or_default(); + let d8 = img.data(); + let res = match (img.layout(), img.sample_type()) { + (L::Gray, S::U8) => write_one::(&mut enc, w, h, d8, &tags), + (L::Gray, S::U16) => write_one::(&mut enc, w, h, &u16s(), &tags), + (L::Gray, S::F32) => { + write_one::(&mut enc, w, h, &f32s(), &tags) + } + (L::GrayA, S::U8) => write_one::(&mut enc, w, h, d8, &tags), + (L::GrayA, S::U16) => write_one::(&mut enc, w, h, &u16s(), &tags), + (L::GrayA, S::F32) => write_one::(&mut enc, w, h, &f32s(), &tags), + (L::Rgb, S::U8) => write_one::(&mut enc, w, h, d8, &tags), + (L::Rgb, S::U16) => write_one::(&mut enc, w, h, &u16s(), &tags), + (L::Rgb, S::F32) => write_one::(&mut enc, w, h, &f32s(), &tags), + (L::Rgba, S::U8) => write_one::(&mut enc, w, h, d8, &tags), + (L::Rgba, S::U16) => write_one::(&mut enc, w, h, &u16s(), &tags), + (L::Rgba, S::F32) => { + write_one::(&mut enc, w, h, &f32s(), &tags) + } + (L::Cmyk, S::U8) => write_one::(&mut enc, w, h, d8, &tags), + (L::Cmyk, S::U16) => write_one::(&mut enc, w, h, &u16s(), &tags), + (L::Cmyk, S::F32) => { + write_one::(&mut enc, w, h, &f32s(), &tags) + } + (L::CmykA, S::U8) => write_one::(&mut enc, w, h, d8, &tags), + (L::CmykA, S::U16) => write_one::(&mut enc, w, h, &u16s(), &tags), + (L::CmykA, S::F32) => write_one::(&mut enc, w, h, &f32s(), &tags), + (l, s) => return Err(CodecError::encode(F, format!("unsupported {l:?} {s:?}"))), + }; + res.map_err(|e| CodecError::encode(F, e))?; + } + Ok(cursor.into_inner()) +} diff --git a/crates/codecs/src/codecs/via_image.rs b/crates/codecs/src/codecs/via_image.rs new file mode 100644 index 00000000..318a7576 --- /dev/null +++ b/crates/codecs/src/codecs/via_image.rs @@ -0,0 +1,150 @@ +//! Formats delegated to the `image` crate (pure-Rust features only): +//! GIF, BMP, TGA, ICO, QOI, Radiance HDR, and AVIF encode (feature `avif`). + +use std::io::Cursor; + +use image::{DynamicImage, ImageDecoder, ImageFormat}; + +use crate::Format; +use crate::error::CodecError; +use crate::fidelity::Plan; +use crate::image::{ChannelLayout, Image, SampleType}; +use crate::options::{EncodeOptions, Limits}; + +fn image_format(f: Format) -> Option { + Some(match f { + Format::Gif => ImageFormat::Gif, + Format::Bmp => ImageFormat::Bmp, + Format::Tga => ImageFormat::Tga, + Format::Ico => ImageFormat::Ico, + Format::Qoi => ImageFormat::Qoi, + Format::Hdr => ImageFormat::Hdr, + Format::Avif => ImageFormat::Avif, + _ => return None, + }) +} + +fn map_err(f: Format, e: image::ImageError) -> CodecError { + match e { + image::ImageError::Limits(l) => CodecError::LimitExceeded(l.to_string()), + image::ImageError::Unsupported(u) => CodecError::unsupported(f, u.to_string()), + e => CodecError::malformed(f, e), + } +} + +pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result { + let fmt = image_format(f) + .ok_or_else(|| CodecError::unsupported(f, "not handled by image backend"))?; + let mut reader = image::ImageReader::with_format(Cursor::new(bytes), fmt); + let mut il = image::Limits::default(); + il.max_image_width = Some(limits.max_width); + il.max_image_height = Some(limits.max_height); + il.max_alloc = Some(limits.max_alloc); + reader.limits(il); + let mut dec = reader.into_decoder().map_err(|e| map_err(f, e))?; + let (w, h) = dec.dimensions(); + limits.check_bytes(w, h, dec.color_type().bytes_per_pixel() as u64)?; + let icc = dec.icc_profile().ok().flatten(); + let exif = dec.exif_metadata().ok().flatten(); + let dynimg = DynamicImage::from_decoder(dec).map_err(|e| map_err(f, e))?; + let mut img = from_dynamic(f, dynimg)?; + img.icc = icc; + img.meta.exif = exif; + Ok(img) +} + +pub(crate) fn from_dynamic(f: Format, d: DynamicImage) -> Result { + let (w, h) = (d.width(), d.height()); + Ok(match d { + DynamicImage::ImageLuma8(b) => Image::from_u8(w, h, ChannelLayout::Gray, b.into_raw())?, + DynamicImage::ImageLumaA8(b) => Image::from_u8(w, h, ChannelLayout::GrayA, b.into_raw())?, + DynamicImage::ImageRgb8(b) => Image::from_u8(w, h, ChannelLayout::Rgb, b.into_raw())?, + DynamicImage::ImageRgba8(b) => Image::from_u8(w, h, ChannelLayout::Rgba, b.into_raw())?, + DynamicImage::ImageLuma16(b) => Image::from_u16(w, h, ChannelLayout::Gray, &b.into_raw())?, + DynamicImage::ImageLumaA16(b) => { + Image::from_u16(w, h, ChannelLayout::GrayA, &b.into_raw())? + } + DynamicImage::ImageRgb16(b) => Image::from_u16(w, h, ChannelLayout::Rgb, &b.into_raw())?, + DynamicImage::ImageRgba16(b) => Image::from_u16(w, h, ChannelLayout::Rgba, &b.into_raw())?, + DynamicImage::ImageRgb32F(b) => Image::from_f32(w, h, ChannelLayout::Rgb, &b.into_raw())?, + DynamicImage::ImageRgba32F(b) => Image::from_f32(w, h, ChannelLayout::Rgba, &b.into_raw())?, + other => { + let b = other.into_rgba32f(); + Image::from_f32(w, h, ChannelLayout::Rgba, &b.into_raw()) + .map_err(|e| CodecError::malformed(f, e))? + } + }) +} + +fn to_dynamic(img: &Image) -> Result { + let (w, h) = img.dimensions(); + let bad = || CodecError::InvalidImage("buffer size mismatch".into()); + Ok(match (img.layout(), img.sample_type()) { + (ChannelLayout::Gray, SampleType::U8) => DynamicImage::ImageLuma8( + image::GrayImage::from_raw(w, h, img.data().to_vec()).ok_or_else(bad)?, + ), + (ChannelLayout::GrayA, SampleType::U8) => DynamicImage::ImageLumaA8( + image::GrayAlphaImage::from_raw(w, h, img.data().to_vec()).ok_or_else(bad)?, + ), + (ChannelLayout::Rgb, SampleType::U8) => DynamicImage::ImageRgb8( + image::RgbImage::from_raw(w, h, img.data().to_vec()).ok_or_else(bad)?, + ), + (ChannelLayout::Rgba, SampleType::U8) => DynamicImage::ImageRgba8( + image::RgbaImage::from_raw(w, h, img.data().to_vec()).ok_or_else(bad)?, + ), + (ChannelLayout::Rgb, SampleType::F32) => DynamicImage::ImageRgb32F( + image::Rgb32FImage::from_raw(w, h, img.to_f32_samples().unwrap_or_default()) + .ok_or_else(bad)?, + ), + (ChannelLayout::Rgba, SampleType::F32) => DynamicImage::ImageRgba32F( + image::Rgba32FImage::from_raw(w, h, img.to_f32_samples().unwrap_or_default()) + .ok_or_else(bad)?, + ), + (l, s) => { + return Err(CodecError::InvalidImage(format!( + "no image-crate mapping for {l:?} {s:?}" + ))); + } + }) +} + +pub(crate) fn encode( + f: Format, + src: &Image, + plan: Plan, + opts: &EncodeOptions, +) -> Result, CodecError> { + let fmt = image_format(f) + .ok_or_else(|| CodecError::unsupported(f, "not handled by image backend"))?; + if let Some((mw, mh)) = f.max_dimensions() + && (src.width() > mw || src.height() > mh) + { + return Err(CodecError::encode( + f, + format!("dimensions exceed {mw}x{mh}"), + )); + } + let img = src.convert(plan.layout, plan.sample); + let dynimg = to_dynamic(&img)?; + let mut cursor = Cursor::new(Vec::new()); + match f { + #[cfg(feature = "avif")] + Format::Avif => { + let enc = image::codecs::avif::AvifEncoder::new_with_speed_quality( + &mut cursor, + 8, + opts.jpeg_quality.clamp(1, 100), + ); + dynimg + .write_with_encoder(enc) + .map_err(|e| CodecError::encode(f, e))?; + } + _ => { + let _ = opts; + dynimg + .write_to(&mut cursor, fmt) + .map_err(|e| CodecError::encode(f, e))?; + } + } + Ok(cursor.into_inner()) +} diff --git a/crates/codecs/src/codecs/webp.rs b/crates/codecs/src/codecs/webp.rs new file mode 100644 index 00000000..c62daccc --- /dev/null +++ b/crates/codecs/src/codecs/webp.rs @@ -0,0 +1,83 @@ +//! WebP via `image-webp`: decode lossy/lossless (first frame), encode +//! lossless only (no pure-Rust lossy encoder exists). ICC/EXIF/XMP both ways. + +use std::io::Cursor; + +use crate::Format; +use crate::error::CodecError; +use crate::fidelity::Plan; +use crate::image::{ChannelLayout, Image, Metadata, SampleType}; +use crate::options::{EncodeOptions, Limits}; + +const F: Format = Format::WebP; + +fn err(e: impl std::fmt::Display) -> CodecError { + CodecError::malformed(F, e) +} + +pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { + let mut dec = image_webp::WebPDecoder::new(Cursor::new(bytes)).map_err(err)?; + dec.set_memory_limit(limits.alloc_usize()); + let (w, h) = dec.dimensions(); + let layout = if dec.has_alpha() { + ChannelLayout::Rgba + } else { + ChannelLayout::Rgb + }; + limits.check(w, h, layout, SampleType::U8)?; + let size = dec + .output_buffer_size() + .ok_or_else(|| err("image too large"))?; + let mut buf = vec![0u8; size]; + dec.read_image(&mut buf).map_err(err)?; + let icc = dec.icc_profile().ok().flatten(); + let exif = dec.exif_metadata().ok().flatten(); + let xmp = dec + .xmp_metadata() + .ok() + .flatten() + .and_then(|b| String::from_utf8(b).ok()); + let mut img = Image::from_u8(w, h, layout, buf)?; + img.icc = icc; + img.meta = Metadata { + exif, + xmp, + ..Default::default() + }; + Ok(img) +} + +pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { + if !opts.webp_lossless { + return Err(CodecError::unsupported( + F, + "lossy WebP encoding needs libwebp (C); only lossless is available", + )); + } + let img = src.convert(plan.layout, plan.sample); + let ct = match img.layout() { + ChannelLayout::Gray => image_webp::ColorType::L8, + ChannelLayout::GrayA => image_webp::ColorType::La8, + ChannelLayout::Rgb => image_webp::ColorType::Rgb8, + ChannelLayout::Rgba => image_webp::ColorType::Rgba8, + l => return Err(CodecError::encode(F, format!("unsupported layout {l:?}"))), + }; + let mut out = Vec::new(); + let mut enc = image_webp::WebPEncoder::new(&mut out); + if opts.embed_icc + && let Some(icc) = &img.icc + { + enc.set_icc_profile(icc.clone()); + } + if opts.embed_metadata { + if let Some(exif) = &img.meta.exif { + enc.set_exif_metadata(exif.clone()); + } + if let Some(xmp) = &img.meta.xmp { + enc.set_xmp_metadata(xmp.as_bytes().to_vec()); + } + } + enc.encode(img.data(), img.width(), img.height(), ct) + .map_err(|e| CodecError::encode(F, e))?; + Ok(out) +} diff --git a/crates/codecs/src/error.rs b/crates/codecs/src/error.rs new file mode 100644 index 00000000..d1bc6ff1 --- /dev/null +++ b/crates/codecs/src/error.rs @@ -0,0 +1,40 @@ +use crate::format::Format; + +/// Errors produced by this crate. Decoders never panic on malformed input; +/// they return [`CodecError::Malformed`] instead. +#[derive(Debug, thiserror::Error)] +pub enum CodecError { + #[error("unrecognized image format")] + UnknownFormat, + #[error("{format:?}: {reason}")] + Unsupported { format: Format, reason: String }, + #[error("{format:?}: malformed data: {message}")] + Malformed { format: Format, message: String }, + #[error("limit exceeded: {0}")] + LimitExceeded(String), + #[error("{format:?}: encode failed: {message}")] + Encode { format: Format, message: String }, + #[error("invalid image: {0}")] + InvalidImage(String), +} + +impl CodecError { + pub(crate) fn malformed(format: Format, e: impl std::fmt::Display) -> Self { + CodecError::Malformed { + format, + message: e.to_string(), + } + } + pub(crate) fn encode(format: Format, e: impl std::fmt::Display) -> Self { + CodecError::Encode { + format, + message: e.to_string(), + } + } + pub(crate) fn unsupported(format: Format, reason: impl Into) -> Self { + CodecError::Unsupported { + format, + reason: reason.into(), + } + } +} diff --git a/crates/codecs/src/fidelity.rs b/crates/codecs/src/fidelity.rs new file mode 100644 index 00000000..0e819628 --- /dev/null +++ b/crates/codecs/src/fidelity.rs @@ -0,0 +1,267 @@ +//! Encode planning and fidelity warnings. `encode` and `fidelity_warnings` +//! share [`plan`], so a warning is emitted exactly when the encoder will +//! actually transform or drop something. + +use std::fmt; + +use crate::format::{Format, caps}; +use crate::image::{ChannelLayout, Image, SampleType}; +use crate::options::EncodeOptions; + +/// Something that will be lost or altered when writing an image to a format. +#[derive(Debug, Clone, PartialEq)] +pub enum FidelityWarning { + /// The format cannot be written in this build. + WriteUnsupported { + format: Format, + }, + /// Bit depth / precision reduced, e.g. 16-bit → 8-bit or f32 → f16. + DepthReduced { + from: SampleType, + to: SampleType, + }, + /// Float samples outside `[0, 1]` will be clipped by integer storage. + RangeClipped, + /// Non-opaque alpha will be discarded. + AlphaDiscarded, + /// Alpha will be reduced to fully transparent / fully opaque (GIF). + AlphaBinarized, + /// CMYK data will be converted (naively, not colour-managed) to another model. + CmykConverted { + to: ChannelLayout, + }, + /// Colour data will be reduced to grayscale. + ColorToGray, + /// The image will be quantized to a 256-colour palette. + PaletteQuantized, + /// The format's encoder is lossy. + LossyCompression, + IccDropped, + ExifDropped, + XmpDropped, + DpiDropped, + TextDropped, + /// Image exceeds the format's maximum size; encoding will fail. + DimensionsExceeded { + max_width: u32, + max_height: u32, + }, +} + +impl FidelityWarning { + /// `true` for warnings that make encoding fail outright. + pub fn is_fatal(&self) -> bool { + matches!( + self, + FidelityWarning::WriteUnsupported { .. } | FidelityWarning::DimensionsExceeded { .. } + ) + } +} + +fn depth_name(s: SampleType) -> &'static str { + match s { + SampleType::U8 => "8-bit", + SampleType::U16 => "16-bit", + SampleType::F16 => "16-bit float", + SampleType::F32 => "32-bit float", + } +} + +impl fmt::Display for FidelityWarning { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + use FidelityWarning::*; + match self { + WriteUnsupported { format } => { + write!(f, "{} cannot be written in this build", format.name()) + } + DepthReduced { from, to } => write!( + f, + "{} will be reduced to {}", + depth_name(*from), + depth_name(*to) + ), + RangeClipped => write!(f, "HDR values outside 0..1 will be clipped"), + AlphaDiscarded => write!(f, "alpha will be discarded"), + AlphaBinarized => write!(f, "alpha will be reduced to on/off transparency"), + CmykConverted { to } => { + write!(f, "CMYK will be converted to {to:?} (not colour-managed)") + } + ColorToGray => write!(f, "colour will be converted to grayscale"), + PaletteQuantized => write!(f, "colours will be quantized to a 256-colour palette"), + LossyCompression => write!(f, "lossy compression"), + IccDropped => write!(f, "ICC profile not supported; it will be dropped"), + ExifDropped => write!(f, "EXIF not supported; it will be dropped"), + XmpDropped => write!(f, "XMP not supported; it will be dropped"), + DpiDropped => write!(f, "resolution (DPI) not supported; it will be dropped"), + TextDropped => write!(f, "text metadata not supported; it will be dropped"), + DimensionsExceeded { + max_width, + max_height, + } => { + write!( + f, + "image exceeds the format maximum of {max_width}x{max_height}" + ) + } + } + } +} + +/// Target storage chosen for an image/format pair. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Plan { + pub layout: ChannelLayout, + pub sample: SampleType, +} + +fn pick_sample(src: SampleType, depths: &[SampleType]) -> SampleType { + if depths.contains(&src) { + return src; + } + let pref: &[SampleType] = match src { + // Integers widen into float losslessly; F32 represents U8/U16 exactly. + SampleType::U8 => &[SampleType::U16, SampleType::F32, SampleType::F16], + SampleType::U16 => &[SampleType::F32, SampleType::U8, SampleType::F16], + SampleType::F16 => &[SampleType::F32, SampleType::U16, SampleType::U8], + SampleType::F32 => &[SampleType::F16, SampleType::U16, SampleType::U8], + }; + pref.iter() + .copied() + .find(|s| depths.contains(s)) + .unwrap_or(depths[0]) +} + +fn pick_layout(src: ChannelLayout, layouts: &[ChannelLayout], alpha: bool) -> ChannelLayout { + let mut cand = src; + if !alpha { + cand = cand.without_alpha(); + } + if layouts.contains(&cand) { + return cand; + } + // CMYK falls back to RGB; gray expands to RGB. + if cand.is_cmyk() || cand.is_gray() { + cand = if cand.has_alpha() { + ChannelLayout::Rgba + } else { + ChannelLayout::Rgb + }; + } + if layouts.contains(&cand) { + return cand; + } + if layouts.contains(&cand.with_alpha()) { + return cand.with_alpha(); + } + if layouts.contains(&cand.without_alpha()) { + return cand.without_alpha(); + } + layouts[0] +} + +pub(crate) fn plan(image: &Image, format: Format, _opts: &EncodeOptions) -> Plan { + let c = caps(format); + let (layout, sample) = (image.layout(), image.sample_type()); + match format { + // PNM: integer data can use any layout (PAM); float goes to PFM + // which only has gray and RGB. + Format::Pnm if sample.is_float() => Plan { + layout: pick_layout(layout, &[ChannelLayout::Gray, ChannelLayout::Rgb], false), + sample: SampleType::F32, + }, + _ => Plan { + layout: pick_layout(layout, c.layouts, c.alpha), + sample: pick_sample(sample, c.depths), + }, + } +} + +/// Warnings for writing `image` as `format` with default options. +pub fn fidelity_warnings(image: &Image, format: Format) -> Vec { + fidelity_warnings_with(image, format, &EncodeOptions::default()) +} + +/// Warnings for writing `image` as `format` with the given options. +pub fn fidelity_warnings_with( + image: &Image, + format: Format, + opts: &EncodeOptions, +) -> Vec { + use FidelityWarning as W; + let c = caps(format); + let mut w = Vec::new(); + if !c.write { + w.push(W::WriteUnsupported { format }); + return w; + } + if let Some((mw, mh)) = format.max_dimensions() + && (image.width() > mw || image.height() > mh) + { + w.push(W::DimensionsExceeded { + max_width: mw, + max_height: mh, + }); + } + let p = plan(image, format, opts); + let (sl, ss) = (image.layout(), image.sample_type()); + + // Depth + let lossy_depth = match (ss, p.sample) { + (a, b) if a == b => false, + (SampleType::U8, _) => false, + (SampleType::U16, SampleType::F32) => false, + (SampleType::F16, SampleType::F32) => false, + _ => true, + }; + if lossy_depth { + w.push(W::DepthReduced { + from: ss, + to: p.sample, + }); + } + if ss.is_float() && !p.sample.is_float() && image.has_out_of_range() { + w.push(W::RangeClipped); + } + + // Layout + if sl.has_alpha() && !p.layout.has_alpha() && image.has_translucency() { + w.push(W::AlphaDiscarded); + } + if format == Format::Gif && p.layout.has_alpha() && image.has_translucency() { + w.push(W::AlphaBinarized); + } + if sl.is_cmyk() && !p.layout.is_cmyk() { + w.push(W::CmykConverted { to: p.layout }); + } + if !sl.is_gray() && p.layout.is_gray() { + w.push(W::ColorToGray); + } + + // Compression + if format == Format::Gif { + w.push(W::PaletteQuantized); + } else if c.lossy { + w.push(W::LossyCompression); + } + + // Metadata + if image.icc.is_some() && opts.embed_icc && (!c.icc || !sl.same_model(p.layout)) { + w.push(W::IccDropped); + } + if opts.embed_metadata { + let m = &image.meta; + if m.exif.is_some() && !c.exif { + w.push(W::ExifDropped); + } + if m.xmp.is_some() && !c.xmp { + w.push(W::XmpDropped); + } + if m.dpi.is_some() && !c.dpi { + w.push(W::DpiDropped); + } + if !m.text.is_empty() && !c.text { + w.push(W::TextDropped); + } + } + w +} diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs new file mode 100644 index 00000000..b2f99a5c --- /dev/null +++ b/crates/codecs/src/format.rs @@ -0,0 +1,361 @@ +//! Format identification and capability declarations. + +use crate::image::{ChannelLayout, SampleType}; + +/// Supported (or known) raster file formats. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Format { + Png, + Jpeg, + Tiff, + WebP, + Gif, + Bmp, + Tga, + Ico, + /// Netpbm family: PBM/PGM/PPM (P1–P6), PAM (P7) and PFM (`PF`/`Pf`). + Pnm, + Qoi, + OpenExr, + /// Radiance RGBE. + Hdr, + /// Encode-only behind the `avif` feature; see [`ASYMMETRIC_EXCEPTIONS`]. + Avif, +} + +/// What a format can hold **and** what this crate reads/writes for it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FormatCaps { + /// Decoding is available in this build. + pub read: bool, + /// Encoding is available in this build. + pub write: bool, + /// Sample types stored without conversion. + pub depths: &'static [SampleType], + /// Layouts stored *and read back* without conversion. + pub layouts: &'static [ChannelLayout], + pub alpha: bool, + pub icc: bool, + pub exif: bool, + pub xmp: bool, + /// Physical resolution (DPI) is preserved. + pub dpi: bool, + /// Free-form key/value text is preserved. + pub text: bool, + /// The container can hold animation/multiple frames. This crate only + /// decodes the first frame and encodes a single frame. + pub animation: bool, + /// Our encoder is lossy (quantization or lossy compression). + pub lossy: bool, +} + +/// Formats that are enabled but intentionally not symmetric, with the reason. +/// The test-suite asserts that every other enabled format is read+write. +pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[( + Format::Avif, + "AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \ + until a pure-Rust AV1 decoder is viable. Only enabled with the non-default `avif` feature.", +)]; + +use ChannelLayout as L; +use SampleType as S; + +const RGB_GRAY: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba]; +const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::Cmyk, L::CmykA]; + +impl Format { + /// Every format known to the crate (enabled or not). + pub const ALL: [Format; 13] = [ + Format::Png, + Format::Jpeg, + Format::Tiff, + Format::WebP, + Format::Gif, + Format::Bmp, + Format::Tga, + Format::Ico, + Format::Pnm, + Format::Qoi, + Format::OpenExr, + Format::Hdr, + Format::Avif, + ]; + + pub fn caps(self) -> FormatCaps { + caps(self) + } + + /// Readable or writable in this build. + pub fn is_enabled(self) -> bool { + let c = caps(self); + c.read || c.write + } + + pub fn name(self) -> &'static str { + match self { + Format::Png => "PNG", + Format::Jpeg => "JPEG", + Format::Tiff => "TIFF", + Format::WebP => "WebP", + Format::Gif => "GIF", + Format::Bmp => "BMP", + Format::Tga => "TGA", + Format::Ico => "ICO", + Format::Pnm => "Netpbm", + Format::Qoi => "QOI", + Format::OpenExr => "OpenEXR", + Format::Hdr => "Radiance HDR", + Format::Avif => "AVIF", + } + } + + /// Recognized file extensions (lowercase, without dot); the first one is + /// the preferred extension for writing. + pub fn extensions(self) -> &'static [&'static str] { + match self { + Format::Png => &["png", "apng"], + Format::Jpeg => &["jpg", "jpeg", "jpe", "jfif"], + Format::Tiff => &["tif", "tiff"], + Format::WebP => &["webp"], + Format::Gif => &["gif"], + Format::Bmp => &["bmp", "dib"], + Format::Tga => &["tga", "icb", "vda", "vst"], + Format::Ico => &["ico"], + Format::Pnm => &["pnm", "pbm", "pgm", "ppm", "pam", "pfm"], + Format::Qoi => &["qoi"], + Format::OpenExr => &["exr"], + Format::Hdr => &["hdr"], + Format::Avif => &["avif"], + } + } + + pub fn mime_type(self) -> &'static str { + match self { + Format::Png => "image/png", + Format::Jpeg => "image/jpeg", + Format::Tiff => "image/tiff", + Format::WebP => "image/webp", + Format::Gif => "image/gif", + Format::Bmp => "image/bmp", + Format::Tga => "image/x-tga", + Format::Ico => "image/x-icon", + Format::Pnm => "image/x-portable-anymap", + Format::Qoi => "image/qoi", + Format::OpenExr => "image/x-exr", + Format::Hdr => "image/vnd.radiance", + Format::Avif => "image/avif", + } + } + + /// Maximum encodable dimensions, if the format has a hard limit. + pub fn max_dimensions(self) -> Option<(u32, u32)> { + match self { + Format::Jpeg | Format::Gif => Some((65535, 65535)), + Format::WebP => Some((16384, 16384)), + Format::Ico => Some((256, 256)), + _ => None, + } + } +} + +/// Capability table. `read`/`write` reflect the current build's features. +pub fn caps(format: Format) -> FormatCaps { + let base = FormatCaps { + read: true, + write: true, + depths: &[S::U8], + layouts: &[L::Rgb, L::Rgba], + alpha: true, + icc: false, + exif: false, + xmp: false, + dpi: false, + text: false, + animation: false, + lossy: false, + }; + match format { + Format::Png => FormatCaps { + depths: &[S::U8, S::U16], + layouts: RGB_GRAY, + icc: true, + exif: true, + xmp: true, + dpi: true, + text: true, + animation: true, + ..base + }, + Format::Jpeg => FormatCaps { + layouts: &[L::Gray, L::Rgb, L::Cmyk], + alpha: false, + icc: true, + exif: true, + xmp: true, + dpi: true, + lossy: true, + ..base + }, + Format::Tiff => FormatCaps { + depths: &[S::U8, S::U16, S::F32], + layouts: ALL_LAYOUTS, + icc: true, + xmp: true, + dpi: true, + text: true, + ..base + }, + Format::WebP => FormatCaps { + icc: true, + exif: true, + xmp: true, + animation: true, + ..base + }, + Format::Gif => FormatCaps { + layouts: &[L::Rgba], + animation: true, + lossy: true, + ..base + }, + Format::Bmp => base, + Format::Tga => FormatCaps { + layouts: RGB_GRAY, + ..base + }, + Format::Ico => FormatCaps { + layouts: &[L::Rgba], + ..base + }, + Format::Pnm => FormatCaps { + depths: &[S::U8, S::U16, S::F32], + layouts: ALL_LAYOUTS, + ..base + }, + Format::Qoi => base, + Format::OpenExr => FormatCaps { + depths: &[S::F16, S::F32], + layouts: RGB_GRAY, + ..base + }, + Format::Hdr => FormatCaps { + depths: &[S::F32], + layouts: &[L::Rgb], + alpha: false, + lossy: true, + ..base + }, + Format::Avif => FormatCaps { + read: false, + write: cfg!(feature = "avif"), + lossy: true, + ..base + }, + } +} + +/// Identify a format from its leading bytes (magic numbers). TGA has no +/// magic and is recognized last, by header plausibility. +pub fn detect(bytes: &[u8]) -> Option { + let b = bytes; + if b.starts_with(b"\x89PNG\r\n\x1a\n") { + return Some(Format::Png); + } + if b.starts_with(&[0xFF, 0xD8, 0xFF]) { + return Some(Format::Jpeg); + } + if b.starts_with(b"II*\0") + || b.starts_with(b"MM\0*") + || b.starts_with(b"II+\0") + || b.starts_with(b"MM\0+") + { + return Some(Format::Tiff); + } + if b.len() >= 12 && &b[0..4] == b"RIFF" && &b[8..12] == b"WEBP" { + return Some(Format::WebP); + } + if b.starts_with(b"GIF87a") || b.starts_with(b"GIF89a") { + return Some(Format::Gif); + } + if b.starts_with(b"qoif") { + return Some(Format::Qoi); + } + if b.starts_with(&[0x76, 0x2F, 0x31, 0x01]) { + return Some(Format::OpenExr); + } + if b.starts_with(b"#?RADIANCE") || b.starts_with(b"#?RGBE") { + return Some(Format::Hdr); + } + if b.len() >= 12 && &b[4..8] == b"ftyp" && is_avif_ftyp(b) { + return Some(Format::Avif); + } + if b.len() >= 14 && b.starts_with(b"BM") { + return Some(Format::Bmp); + } + if b.len() >= 6 && b[0..4] == [0, 0, 1, 0] && (b[4] != 0 || b[5] != 0) { + return Some(Format::Ico); + } + if b.len() >= 3 + && b[0] == b'P' + && matches!(b[1], b'1'..=b'7' | b'F' | b'f') + && b[2].is_ascii_whitespace() + { + return Some(Format::Pnm); + } + if looks_like_tga(b) { + return Some(Format::Tga); + } + None +} + +fn is_avif_ftyp(b: &[u8]) -> bool { + let size = u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize; + let end = size.clamp(12, b.len().min(64)); + let brands = &b[8..end]; + brands + .chunks_exact(4) + .enumerate() + .any(|(i, c)| i != 1 && (c == b"avif" || c == b"avis")) +} + +fn looks_like_tga(b: &[u8]) -> bool { + if b.len() < 18 { + return false; + } + if b.len() >= 26 && &b[b.len() - 18..] == b"TRUEVISION-XFILE.\0" { + return true; + } + let cmap_type = b[1]; + let img_type = b[2]; + let cmap_len = u16::from_le_bytes([b[5], b[6]]); + let cmap_depth = b[7]; + let w = u16::from_le_bytes([b[12], b[13]]); + let h = u16::from_le_bytes([b[14], b[15]]); + let depth = b[16]; + let desc = b[17]; + let type_ok = matches!(img_type, 1 | 2 | 3 | 9 | 10 | 11); + let cmap_ok = match cmap_type { + 0 => cmap_len == 0 && cmap_depth == 0 && !matches!(img_type, 1 | 9), + 1 => matches!(img_type, 1 | 9) && cmap_len > 0 && matches!(cmap_depth, 15 | 16 | 24 | 32), + _ => false, + }; + let depth_ok = match img_type { + 1 | 9 => depth == 8, + 3 | 11 => matches!(depth, 8 | 16), + _ => matches!(depth, 15 | 16 | 24 | 32), + }; + type_ok && cmap_ok && depth_ok && w > 0 && h > 0 && desc & 0xC0 == 0 +} + +/// Look up a format by file extension (case-insensitive, with or without a +/// leading dot, or a full file name/path). +pub fn from_extension(ext: &str) -> Option { + let ext = ext + .rsplit(['.', '/', '\\']) + .next() + .unwrap_or(ext) + .to_ascii_lowercase(); + Format::ALL + .into_iter() + .find(|f| f.extensions().contains(&ext.as_str())) +} diff --git a/crates/codecs/src/image.rs b/crates/codecs/src/image.rs new file mode 100644 index 00000000..451d54e8 --- /dev/null +++ b/crates/codecs/src/image.rs @@ -0,0 +1,610 @@ +//! The crate's own flat raster image type. +//! +//! Samples are stored interleaved (`RGBARGBA…`), row-major, top-to-bottom, +//! in **native endianness**. Integer samples are unsigned and normalized to +//! `[0, max]`; float samples are nominally `[0, 1]` but may exceed it (HDR). + +use crate::error::CodecError; +use half::f16; + +/// Channel layout of an [`Image`]. Alpha, when present, is always the last +/// channel and is *straight* (not premultiplied). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ChannelLayout { + Gray, + GrayA, + Rgb, + Rgba, + /// Ink amounts, `0 = no ink`, `max = full ink` (not Adobe-inverted). + Cmyk, + CmykA, +} + +impl ChannelLayout { + pub const ALL: [ChannelLayout; 6] = [ + ChannelLayout::Gray, + ChannelLayout::GrayA, + ChannelLayout::Rgb, + ChannelLayout::Rgba, + ChannelLayout::Cmyk, + ChannelLayout::CmykA, + ]; + + /// Total number of channels including alpha. + pub const fn channels(self) -> usize { + match self { + ChannelLayout::Gray => 1, + ChannelLayout::GrayA => 2, + ChannelLayout::Rgb => 3, + ChannelLayout::Rgba | ChannelLayout::Cmyk => 4, + ChannelLayout::CmykA => 5, + } + } + + /// Number of colour (non-alpha) channels. + pub const fn color_channels(self) -> usize { + self.channels() - self.has_alpha() as usize + } + + pub const fn has_alpha(self) -> bool { + matches!( + self, + ChannelLayout::GrayA | ChannelLayout::Rgba | ChannelLayout::CmykA + ) + } + + pub const fn is_gray(self) -> bool { + matches!(self, ChannelLayout::Gray | ChannelLayout::GrayA) + } + + pub const fn is_rgb(self) -> bool { + matches!(self, ChannelLayout::Rgb | ChannelLayout::Rgba) + } + + pub const fn is_cmyk(self) -> bool { + matches!(self, ChannelLayout::Cmyk | ChannelLayout::CmykA) + } + + /// The same colour model with alpha added. + pub const fn with_alpha(self) -> Self { + match self { + ChannelLayout::Gray | ChannelLayout::GrayA => ChannelLayout::GrayA, + ChannelLayout::Rgb | ChannelLayout::Rgba => ChannelLayout::Rgba, + ChannelLayout::Cmyk | ChannelLayout::CmykA => ChannelLayout::CmykA, + } + } + + /// The same colour model without alpha. + pub const fn without_alpha(self) -> Self { + match self { + ChannelLayout::Gray | ChannelLayout::GrayA => ChannelLayout::Gray, + ChannelLayout::Rgb | ChannelLayout::Rgba => ChannelLayout::Rgb, + ChannelLayout::Cmyk | ChannelLayout::CmykA => ChannelLayout::Cmyk, + } + } + + /// `true` if both layouts share a colour model (ignoring alpha). + pub const fn same_model(self, other: Self) -> bool { + (self.is_gray() && other.is_gray()) + || (self.is_rgb() && other.is_rgb()) + || (self.is_cmyk() && other.is_cmyk()) + } +} + +/// Storage type of one sample. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum SampleType { + U8, + U16, + /// IEEE 754 half float, stored as its 16 bit pattern. + F16, + F32, +} + +impl SampleType { + pub const ALL: [SampleType; 4] = [ + SampleType::U8, + SampleType::U16, + SampleType::F16, + SampleType::F32, + ]; + + pub const fn bytes(self) -> usize { + match self { + SampleType::U8 => 1, + SampleType::U16 | SampleType::F16 => 2, + SampleType::F32 => 4, + } + } + + pub const fn is_float(self) -> bool { + matches!(self, SampleType::F16 | SampleType::F32) + } +} + +/// Basic, format-independent metadata. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Metadata { + /// Raw EXIF payload in TIFF structure (starting with `II*\0`/`MM\0*`), + /// **without** the JPEG `Exif\0\0` prefix. + pub exif: Option>, + /// XMP packet as UTF-8. + pub xmp: Option, + /// Horizontal and vertical resolution in dots per inch. + pub dpi: Option<(f32, f32)>, + /// Free-form key/value text (PNG tEXt/iTXt, TIFF ASCII tags). + pub text: Vec<(String, String)>, +} + +impl Metadata { + pub fn is_empty(&self) -> bool { + self.exif.is_none() && self.xmp.is_none() && self.dpi.is_none() && self.text.is_empty() + } +} + +/// A single flat raster image. +#[derive(Debug, Clone, PartialEq)] +pub struct Image { + width: u32, + height: u32, + layout: ChannelLayout, + sample: SampleType, + data: Vec, + /// Embedded ICC profile (must describe `layout`'s colour model). + pub icc: Option>, + pub meta: Metadata, +} + +fn byte_len(width: u32, height: u32, layout: ChannelLayout, sample: SampleType) -> Option { + (width as usize) + .checked_mul(height as usize)? + .checked_mul(layout.channels())? + .checked_mul(sample.bytes()) +} + +impl Image { + /// A zero-filled image. + /// + /// # Panics + /// If the byte size overflows `usize`. + pub fn new(width: u32, height: u32, layout: ChannelLayout, sample: SampleType) -> Self { + let len = byte_len(width, height, layout, sample).expect("image size overflows usize"); + Image { + width, + height, + layout, + sample, + data: vec![0; len], + icc: None, + meta: Metadata::default(), + } + } + + /// Wrap raw interleaved, native-endian bytes. + pub fn from_raw( + width: u32, + height: u32, + layout: ChannelLayout, + sample: SampleType, + data: Vec, + ) -> Result { + let expected = byte_len(width, height, layout, sample) + .ok_or_else(|| CodecError::InvalidImage("image size overflows usize".into()))?; + if data.len() != expected { + return Err(CodecError::InvalidImage(format!( + "buffer has {} bytes, expected {expected} for {width}x{height} {layout:?} {sample:?}", + data.len() + ))); + } + Ok(Image { + width, + height, + layout, + sample, + data, + icc: None, + meta: Metadata::default(), + }) + } + + pub fn from_u8( + width: u32, + height: u32, + layout: ChannelLayout, + data: Vec, + ) -> Result { + Self::from_raw(width, height, layout, SampleType::U8, data) + } + + pub fn from_u16( + width: u32, + height: u32, + layout: ChannelLayout, + data: &[u16], + ) -> Result { + let bytes = data.iter().flat_map(|v| v.to_ne_bytes()).collect(); + Self::from_raw(width, height, layout, SampleType::U16, bytes) + } + + pub fn from_f16( + width: u32, + height: u32, + layout: ChannelLayout, + data: &[f16], + ) -> Result { + let bytes = data + .iter() + .flat_map(|v| v.to_bits().to_ne_bytes()) + .collect(); + Self::from_raw(width, height, layout, SampleType::F16, bytes) + } + + pub fn from_f32( + width: u32, + height: u32, + layout: ChannelLayout, + data: &[f32], + ) -> Result { + let bytes = data.iter().flat_map(|v| v.to_ne_bytes()).collect(); + Self::from_raw(width, height, layout, SampleType::F32, bytes) + } + + /// Build an image from normalized f32 samples (`[0,1]` for integer + /// targets; values are clamped and rounded when quantizing). + pub fn from_normalized( + width: u32, + height: u32, + layout: ChannelLayout, + sample: SampleType, + values: &[f32], + ) -> Result { + let data = quantize(values, sample); + Self::from_raw(width, height, layout, sample, data) + } + + pub fn with_icc(mut self, icc: Option>) -> Self { + self.icc = icc; + self + } + + pub fn with_meta(mut self, meta: Metadata) -> Self { + self.meta = meta; + self + } + + pub fn width(&self) -> u32 { + self.width + } + pub fn height(&self) -> u32 { + self.height + } + pub fn dimensions(&self) -> (u32, u32) { + (self.width, self.height) + } + pub fn layout(&self) -> ChannelLayout { + self.layout + } + pub fn sample_type(&self) -> SampleType { + self.sample + } + pub fn pixel_count(&self) -> usize { + self.width as usize * self.height as usize + } + /// Number of samples (pixels × channels). + pub fn sample_count(&self) -> usize { + self.pixel_count() * self.layout.channels() + } + /// Raw interleaved native-endian bytes. + pub fn data(&self) -> &[u8] { + &self.data + } + pub fn data_mut(&mut self) -> &mut [u8] { + &mut self.data + } + pub fn into_data(self) -> Vec { + self.data + } + + /// Borrow samples as `u8` (only when the sample type is `U8`). + pub fn as_u8(&self) -> Option<&[u8]> { + (self.sample == SampleType::U8).then_some(&self.data[..]) + } + + /// Copy samples out as `u16` (only when the sample type is `U16`). + pub fn to_u16_samples(&self) -> Option> { + (self.sample == SampleType::U16).then(|| { + self.data + .chunks_exact(2) + .map(|c| u16::from_ne_bytes([c[0], c[1]])) + .collect() + }) + } + + /// Copy samples out as `f16` (only when the sample type is `F16`). + pub fn to_f16_samples(&self) -> Option> { + (self.sample == SampleType::F16).then(|| { + self.data + .chunks_exact(2) + .map(|c| f16::from_bits(u16::from_ne_bytes([c[0], c[1]]))) + .collect() + }) + } + + /// Copy samples out as `f32` (only when the sample type is `F32`). + pub fn to_f32_samples(&self) -> Option> { + (self.sample == SampleType::F32).then(|| { + self.data + .chunks_exact(4) + .map(|c| f32::from_ne_bytes([c[0], c[1], c[2], c[3]])) + .collect() + }) + } + + /// Sample `index` (in samples, not bytes) as a normalized f32. + pub fn sample_normalized(&self, index: usize) -> f32 { + read_normalized(&self.data, self.sample, index) + } + + /// Normalized f32 of channel `c` at pixel `(x, y)`. + pub fn get(&self, x: u32, y: u32, c: usize) -> f32 { + let idx = (y as usize * self.width as usize + x as usize) * self.layout.channels() + c; + self.sample_normalized(idx) + } + + /// All samples as normalized f32 (integers mapped to `[0,1]`). + pub fn to_normalized(&self) -> Vec { + to_normalized(&self.data, self.sample) + } + + /// `true` when the image has alpha and at least one pixel is not fully + /// opaque. + pub fn has_translucency(&self) -> bool { + if !self.layout.has_alpha() { + return false; + } + let ch = self.layout.channels(); + (0..self.pixel_count()).any(|p| self.sample_normalized(p * ch + ch - 1) < 1.0) + } + + /// `true` when this is a float image with any sample outside `[0, 1]` + /// (or NaN). + pub fn has_out_of_range(&self) -> bool { + if !self.sample.is_float() { + return false; + } + (0..self.sample_count()).any(|i| { + let v = self.sample_normalized(i); + !(0.0..=1.0).contains(&v) + }) + } + + /// Convert to another layout and/or sample type. The ICC profile is kept + /// only if the colour model is unchanged (an RGB profile on CMYK data + /// would be wrong). Metadata is kept. + pub fn convert(&self, layout: ChannelLayout, sample: SampleType) -> Image { + if layout == self.layout && sample == self.sample { + return self.clone(); + } + let data = if layout == self.layout { + quantize(&self.to_normalized(), sample) + } else { + let src = self.to_normalized(); + let values = convert_layout(&src, self.layout, layout); + quantize(&values, sample) + }; + Image { + width: self.width, + height: self.height, + layout, + sample, + data, + icc: if layout.same_model(self.layout) { + self.icc.clone() + } else { + None + }, + meta: self.meta.clone(), + } + } + + /// Interleaved RGBA, 8 bits per sample. + pub fn to_rgba8(&self) -> Vec { + self.convert(ChannelLayout::Rgba, SampleType::U8).data + } + + /// Interleaved RGBA, 16 bits per sample. + pub fn to_rgba16(&self) -> Vec { + self.convert(ChannelLayout::Rgba, SampleType::U16) + .to_u16_samples() + .unwrap_or_default() + } + + /// Interleaved RGBA as f32 (integer inputs normalized to `[0,1]`). + pub fn to_rgba_f32(&self) -> Vec { + convert_layout(&self.to_normalized(), self.layout, ChannelLayout::Rgba) + } +} + +// --------------------------------------------------------------------------- +// Sample conversion helpers +// --------------------------------------------------------------------------- + +#[inline] +pub(crate) fn read_normalized(data: &[u8], sample: SampleType, i: usize) -> f32 { + match sample { + SampleType::U8 => data[i] as f32 / 255.0, + SampleType::U16 => u16::from_ne_bytes([data[2 * i], data[2 * i + 1]]) as f32 / 65535.0, + SampleType::F16 => { + f16::from_bits(u16::from_ne_bytes([data[2 * i], data[2 * i + 1]])).to_f32() + } + SampleType::F32 => f32::from_ne_bytes([ + data[4 * i], + data[4 * i + 1], + data[4 * i + 2], + data[4 * i + 3], + ]), + } +} + +pub(crate) fn to_normalized(data: &[u8], sample: SampleType) -> Vec { + match sample { + SampleType::U8 => data.iter().map(|&v| v as f32 / 255.0).collect(), + SampleType::U16 => data + .chunks_exact(2) + .map(|c| u16::from_ne_bytes([c[0], c[1]]) as f32 / 65535.0) + .collect(), + SampleType::F16 => data + .chunks_exact(2) + .map(|c| f16::from_bits(u16::from_ne_bytes([c[0], c[1]])).to_f32()) + .collect(), + SampleType::F32 => data + .chunks_exact(4) + .map(|c| f32::from_ne_bytes([c[0], c[1], c[2], c[3]])) + .collect(), + } +} + +#[inline] +fn clamp01(v: f32) -> f32 { + if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) } +} + +pub(crate) fn quantize(values: &[f32], sample: SampleType) -> Vec { + match sample { + SampleType::U8 => values + .iter() + .map(|&v| (clamp01(v) * 255.0).round() as u8) + .collect(), + SampleType::U16 => values + .iter() + .flat_map(|&v| ((clamp01(v) * 65535.0).round() as u16).to_ne_bytes()) + .collect(), + SampleType::F16 => values + .iter() + .flat_map(|&v| f16::from_f32(v).to_bits().to_ne_bytes()) + .collect(), + SampleType::F32 => values.iter().flat_map(|&v| v.to_ne_bytes()).collect(), + } +} + +/// Rec. 709 luma weights applied to encoded values. +const LUMA: [f32; 3] = [0.2126, 0.7152, 0.0722]; + +fn cmyk_to_rgb(c: f32, m: f32, y: f32, k: f32) -> [f32; 3] { + [ + (1.0 - c) * (1.0 - k), + (1.0 - m) * (1.0 - k), + (1.0 - y) * (1.0 - k), + ] +} + +fn rgb_to_cmyk(r: f32, g: f32, b: f32) -> [f32; 4] { + let k = 1.0 - r.max(g).max(b); + if k >= 1.0 { + return [0.0, 0.0, 0.0, 1.0]; + } + let d = 1.0 - k; + [(1.0 - r - k) / d, (1.0 - g - k) / d, (1.0 - b - k) / d, k] +} + +/// Convert normalized interleaved samples between layouts. Naive +/// (non-colour-managed) formulas; colour-managed conversion belongs in +/// `photocraft-color`. +pub(crate) fn convert_layout(src: &[f32], from: ChannelLayout, to: ChannelLayout) -> Vec { + if from == to { + return src.to_vec(); + } + let fc = from.channels(); + let tc = to.channels(); + let n = src.len() / fc; + let mut out = Vec::with_capacity(n * tc); + for px in src.chunks_exact(fc) { + let alpha = if from.has_alpha() { px[fc - 1] } else { 1.0 }; + let color = &px[..from.color_channels()]; + if from.same_model(to) { + out.extend_from_slice(color); + } else { + let rgb = match from { + ChannelLayout::Gray | ChannelLayout::GrayA => [color[0]; 3], + ChannelLayout::Rgb | ChannelLayout::Rgba => [color[0], color[1], color[2]], + ChannelLayout::Cmyk | ChannelLayout::CmykA => { + cmyk_to_rgb(color[0], color[1], color[2], color[3]) + } + }; + match to { + ChannelLayout::Gray | ChannelLayout::GrayA => { + out.push(rgb[0] * LUMA[0] + rgb[1] * LUMA[1] + rgb[2] * LUMA[2]); + } + ChannelLayout::Rgb | ChannelLayout::Rgba => out.extend_from_slice(&rgb), + ChannelLayout::Cmyk | ChannelLayout::CmykA => { + out.extend_from_slice(&rgb_to_cmyk(rgb[0], rgb[1], rgb[2])) + } + } + } + if to.has_alpha() { + out.push(alpha); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn u8_u16_roundtrip_exact() { + let v: Vec = (0..=255).collect(); + let img = Image::from_u8(256, 1, ChannelLayout::Gray, v.clone()).unwrap(); + let back = img + .convert(ChannelLayout::Gray, SampleType::U16) + .convert(ChannelLayout::Gray, SampleType::U8); + assert_eq!(back.data(), &v[..]); + } + + #[test] + fn u16_f32_roundtrip_exact() { + let v: Vec = (0..=65535u32).step_by(7).map(|x| x as u16).collect(); + let img = Image::from_u16(v.len() as u32, 1, ChannelLayout::Gray, &v).unwrap(); + let back = img + .convert(ChannelLayout::Gray, SampleType::F32) + .convert(ChannelLayout::Gray, SampleType::U16); + assert_eq!(back.to_u16_samples().unwrap(), v); + } + + #[test] + fn icc_dropped_on_model_change() { + let img = + Image::new(2, 2, ChannelLayout::Cmyk, SampleType::U8).with_icc(Some(vec![1, 2, 3])); + assert!( + img.convert(ChannelLayout::Rgb, SampleType::U8) + .icc + .is_none() + ); + assert!( + img.convert(ChannelLayout::CmykA, SampleType::U16) + .icc + .is_some() + ); + } + + #[test] + fn from_raw_rejects_bad_len() { + assert!(Image::from_raw(2, 2, ChannelLayout::Rgb, SampleType::U8, vec![0; 11]).is_err()); + } + + #[test] + fn rgba8_of_gray() { + let img = Image::from_u8(1, 1, ChannelLayout::GrayA, vec![10, 20]).unwrap(); + assert_eq!(img.to_rgba8(), vec![10, 10, 10, 20]); + } + + #[test] + fn cmyk_rgb_basic() { + let img = Image::from_u8(1, 1, ChannelLayout::Cmyk, vec![0, 0, 0, 255]).unwrap(); + assert_eq!(img.to_rgba8(), vec![0, 0, 0, 255]); + let img = Image::from_u8(1, 1, ChannelLayout::Rgb, vec![255, 0, 0]).unwrap(); + let c = img.convert(ChannelLayout::Cmyk, SampleType::U8); + assert_eq!(c.data(), &[0, 255, 255, 0]); + } +} diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs new file mode 100644 index 00000000..2a5bd44d --- /dev/null +++ b/crates/codecs/src/lib.rs @@ -0,0 +1,121 @@ +//! `photocraft-codecs`: symmetric, depth- and metadata-preserving codecs for +//! flat raster image formats. +//! +//! * Every format we **write** we can also **read** (see +//! [`ASYMMETRIC_EXCEPTIONS`] for the documented, feature-gated exception). +//! * Bit depth (8/16-bit integer, 16/32-bit float), ICC profiles and basic +//! metadata (EXIF, XMP, DPI, text) are preserved where the format allows. +//! * [`caps`] declares per-format capabilities and [`fidelity_warnings`] +//! tells the UI exactly what an export will lose. +//! * The API is I/O free (`&[u8]` in, `Vec` out) and builds for +//! `wasm32-unknown-unknown`. Decoders enforce [`Limits`] before allocating. + +#![forbid(unsafe_code)] + +mod codecs; +mod error; +mod fidelity; +mod format; +mod image; +mod options; + +pub use crate::error::CodecError; +pub use crate::fidelity::{FidelityWarning, fidelity_warnings, fidelity_warnings_with}; +pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect, from_extension}; +pub use crate::image::{ChannelLayout, Image, Metadata, SampleType}; +pub use crate::options::{ + DecodeOptions, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression, +}; +pub use half::f16; + +use crate::codecs::{exr, jpeg, png, pnm, tiff, via_image, webp}; + +/// Detect the format and decode with default [`Limits`]. +pub fn decode(bytes: &[u8]) -> Result { + decode_with(bytes, &DecodeOptions::default()) +} + +/// Detect the format and decode. +pub fn decode_with(bytes: &[u8], opts: &DecodeOptions) -> Result { + let format = detect(bytes).ok_or(CodecError::UnknownFormat)?; + decode_as_with(format, bytes, opts) +} + +/// Decode as a specific format with default [`Limits`]. +pub fn decode_as(format: Format, bytes: &[u8]) -> Result { + decode_as_with(format, bytes, &DecodeOptions::default()) +} + +/// Decode as a specific format. +pub fn decode_as_with( + format: Format, + bytes: &[u8], + opts: &DecodeOptions, +) -> Result { + if !caps(format).read { + return Err(CodecError::unsupported( + format, + "decoding is not available for this format", + )); + } + let l = &opts.limits; + let img = match format { + Format::Png => png::decode(bytes, l), + Format::Jpeg => jpeg::decode(bytes, l), + Format::Tiff => tiff::decode(bytes, l), + Format::WebP => webp::decode(bytes, l), + Format::Pnm => pnm::decode(bytes, l), + Format::OpenExr => exr::decode(bytes, l), + Format::Gif + | Format::Bmp + | Format::Tga + | Format::Ico + | Format::Qoi + | Format::Hdr + | Format::Avif => via_image::decode(format, bytes, l), + }?; + // Final guard for decoders whose header we could not pre-inspect. + l.check(img.width(), img.height(), img.layout(), img.sample_type())?; + Ok(img) +} + +/// Encode `image` as `format`. Conversions follow the same plan that +/// [`fidelity_warnings_with`] reports; fatal conditions (unsupported +/// format, oversize image) return an error. +pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result, CodecError> { + if !caps(format).write { + return Err(CodecError::unsupported( + format, + "encoding is not available in this build", + )); + } + if image.width() == 0 || image.height() == 0 { + return Err(CodecError::InvalidImage( + "cannot encode an empty image".into(), + )); + } + if let Some((mw, mh)) = format.max_dimensions() + && (image.width() > mw || image.height() > mh) + { + return Err(CodecError::encode( + format, + format!("dimensions exceed {mw}x{mh}"), + )); + } + let plan = fidelity::plan(image, format, opts); + match format { + Format::Png => png::encode(image, plan, opts), + Format::Jpeg => jpeg::encode(image, plan, opts), + Format::Tiff => tiff::encode(image, plan, opts), + Format::WebP => webp::encode(image, plan, opts), + Format::Pnm => pnm::encode(image, plan, opts), + Format::OpenExr => exr::encode(image, plan, opts), + Format::Gif + | Format::Bmp + | Format::Tga + | Format::Ico + | Format::Qoi + | Format::Hdr + | Format::Avif => via_image::encode(format, image, plan, opts), + } +} diff --git a/crates/codecs/src/options.rs b/crates/codecs/src/options.rs new file mode 100644 index 00000000..698b9edc --- /dev/null +++ b/crates/codecs/src/options.rs @@ -0,0 +1,169 @@ +//! Decode limits and encode options. + +use crate::error::CodecError; +use crate::image::{ChannelLayout, SampleType}; + +/// Decompression-bomb guards. All checks happen on header values *before* +/// the pixel buffer is allocated. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Limits { + pub max_width: u32, + pub max_height: u32, + /// Maximum `width * height`. + pub max_pixels: u64, + /// Maximum bytes for the decoded pixel buffer (and passed on as the + /// allocation budget to underlying decoders). + pub max_alloc: u64, +} + +impl Default for Limits { + fn default() -> Self { + Limits { + max_width: 1 << 18, + max_height: 1 << 18, + max_pixels: 1 << 28, + max_alloc: 2 << 30, + } + } +} + +impl Limits { + /// Effectively unlimited (still bounded by `usize` overflow checks). + pub fn none() -> Self { + Limits { + max_width: u32::MAX, + max_height: u32::MAX, + max_pixels: u64::MAX, + max_alloc: u64::MAX, + } + } + + /// Validate declared dimensions for an output buffer of the given layout + /// and sample type. + pub fn check( + &self, + width: u32, + height: u32, + layout: ChannelLayout, + sample: SampleType, + ) -> Result<(), CodecError> { + self.check_bytes(width, height, (layout.channels() * sample.bytes()) as u64) + } + + pub(crate) fn check_bytes( + &self, + width: u32, + height: u32, + bytes_per_pixel: u64, + ) -> Result<(), CodecError> { + if width == 0 || height == 0 { + return Err(CodecError::InvalidImage(format!( + "zero-sized image {width}x{height}" + ))); + } + if width > self.max_width || height > self.max_height { + return Err(CodecError::LimitExceeded(format!( + "dimensions {width}x{height} exceed max {}x{}", + self.max_width, self.max_height + ))); + } + let pixels = width as u64 * height as u64; + if pixels > self.max_pixels { + return Err(CodecError::LimitExceeded(format!( + "{pixels} pixels exceed max {}", + self.max_pixels + ))); + } + let bytes = pixels.saturating_mul(bytes_per_pixel); + if bytes > self.max_alloc { + return Err(CodecError::LimitExceeded(format!( + "{bytes} bytes exceed max_alloc {}", + self.max_alloc + ))); + } + if usize::try_from(bytes).is_err() { + return Err(CodecError::LimitExceeded(format!( + "{bytes} bytes do not fit in memory" + ))); + } + Ok(()) + } + + pub(crate) fn alloc_usize(&self) -> usize { + usize::try_from(self.max_alloc).unwrap_or(usize::MAX) + } +} + +/// Options for [`crate::decode_with`]. +#[derive(Debug, Clone, Default)] +pub struct DecodeOptions { + pub limits: Limits, +} + +/// PNG zlib effort. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PngCompression { + None, + Fast, + #[default] + Default, + Best, +} + +/// TIFF strip compression. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TiffCompression { + None, + Lzw, + #[default] + Deflate, + PackBits, +} + +/// OpenEXR compression (lossless methods only). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ExrCompression { + None, + Rle, + Zip1, + #[default] + Zip16, + Piz, +} + +/// Options for [`crate::encode`]. +#[derive(Debug, Clone)] +pub struct EncodeOptions { + /// JPEG quality 1..=100 (also AVIF quality when enabled). + pub jpeg_quality: u8, + /// JPEG 4:2:0 chroma subsampling (`false` = 4:4:4). + pub jpeg_chroma_subsampling: bool, + pub png_compression: PngCompression, + /// Write Adam7-interlaced PNG. + pub png_interlaced: bool, + /// WebP: only lossless encoding is available in pure Rust; `false` + /// yields [`CodecError::Unsupported`]. + pub webp_lossless: bool, + pub tiff_compression: TiffCompression, + pub exr_compression: ExrCompression, + /// Embed the ICC profile when the format supports it. + pub embed_icc: bool, + /// Embed EXIF/XMP/DPI/text when the format supports it. + pub embed_metadata: bool, +} + +impl Default for EncodeOptions { + fn default() -> Self { + EncodeOptions { + jpeg_quality: 90, + jpeg_chroma_subsampling: true, + png_compression: PngCompression::Default, + png_interlaced: false, + webp_lossless: true, + tiff_compression: TiffCompression::Deflate, + exr_compression: ExrCompression::Zip16, + embed_icc: true, + embed_metadata: true, + } + } +} diff --git a/crates/codecs/tests/common/mod.rs b/crates/codecs/tests/common/mod.rs new file mode 100644 index 00000000..6473c3ba --- /dev/null +++ b/crates/codecs/tests/common/mod.rs @@ -0,0 +1,138 @@ +//! Shared helpers for integration tests: deterministic PRNG, synthetic +//! image generators, comparison metrics. +#![allow(dead_code)] + +use photocraft_codecs::*; + +/// SplitMix64 — tiny deterministic PRNG. +pub struct Rng(u64); + +impl Rng { + pub fn new(seed: u64) -> Self { + Rng(seed) + } + pub fn next_u64(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + /// Uniform in [0, 1). + pub fn f32(&mut self) -> f32 { + (self.next_u64() >> 40) as f32 / (1u64 << 24) as f32 + } + pub fn bytes(&mut self, n: usize) -> Vec { + (0..n).map(|_| self.next_u64() as u8).collect() + } +} + +/// Gradient + noise image with values in [0, 1]. `noise` is the amplitude +/// of uniform noise added to the gradient. Alpha is a separate gradient +/// that includes fully transparent and fully opaque regions. +pub fn synth( + w: u32, + h: u32, + layout: ChannelLayout, + sample: SampleType, + seed: u64, + noise: f32, +) -> Image { + let mut rng = Rng::new(seed); + let nc = layout.channels(); + let mut v = Vec::with_capacity((w * h) as usize * nc); + for y in 0..h { + for x in 0..w { + let fx = x as f32 / (w.max(2) - 1) as f32; + let fy = y as f32 / (h.max(2) - 1) as f32; + for c in 0..nc { + let is_alpha = layout.has_alpha() && c == nc - 1; + let base = if is_alpha { + ((fx + fy) * 0.75 - 0.25).clamp(0.0, 1.0) + } else { + match c % 4 { + 0 => fx, + 1 => fy, + 2 => 1.0 - fx * 0.5 - fy * 0.5, + _ => (fx * fy).sqrt(), + } + }; + let n = (rng.f32() - 0.5) * noise; + v.push((base + n).clamp(0.0, 1.0)); + } + } + } + Image::from_normalized(w, h, layout, sample, &v).unwrap() +} + +/// Default test image: 37x23 with moderate noise. +pub fn test_image(layout: ChannelLayout, sample: SampleType) -> Image { + synth(37, 23, layout, sample, 0xC0DEC, 0.2) +} + +/// PSNR in dB over normalized samples (clamped to [0,1]). +pub fn psnr(a: &Image, b: &Image) -> f64 { + assert_eq!(a.dimensions(), b.dimensions()); + assert_eq!(a.layout(), b.layout()); + let (av, bv) = (a.to_normalized(), b.to_normalized()); + let mse: f64 = av + .iter() + .zip(&bv) + .map(|(x, y)| { + let d = (x.clamp(0.0, 1.0) - y.clamp(0.0, 1.0)) as f64; + d * d + }) + .sum::() + / av.len() as f64; + if mse == 0.0 { + f64::INFINITY + } else { + 10.0 * (1.0 / mse).log10() + } +} + +pub fn max_abs_diff(a: &Image, b: &Image) -> f32 { + a.to_normalized() + .iter() + .zip(b.to_normalized()) + .map(|(x, y)| (x - y).abs()) + .fold(0.0, f32::max) +} + +/// Formats that can be written in this build. +pub fn writable_formats() -> Vec { + Format::ALL.into_iter().filter(|f| caps(*f).write).collect() +} + +/// Formats that can be written *and* read in this build. +pub fn rw_formats() -> Vec { + Format::ALL + .into_iter() + .filter(|f| caps(*f).write && caps(*f).read) + .collect() +} + +/// A minimal valid little-endian TIFF-structured EXIF blob (one IFD with +/// an Orientation entry). +pub fn sample_exif() -> Vec { + let mut v = b"II*\0\x08\0\0\0".to_vec(); + v.extend_from_slice(&1u16.to_le_bytes()); // 1 entry + v.extend_from_slice(&0x0112u16.to_le_bytes()); // Orientation + v.extend_from_slice(&3u16.to_le_bytes()); // SHORT + v.extend_from_slice(&1u32.to_le_bytes()); + v.extend_from_slice(&1u32.to_le_bytes()); + v.extend_from_slice(&0u32.to_le_bytes()); // next IFD + v +} + +/// Fake ICC profile of `len` bytes (header-ish prefix + deterministic noise). +pub fn sample_icc(len: usize) -> Vec { + let mut v = Rng::new(len as u64).bytes(len); + v[..4].copy_from_slice(&(len as u32).to_be_bytes()); + if len >= 40 { + v[36..40].copy_from_slice(b"acsp"); + } + v +} + +pub const SAMPLE_XMP: &str = ""; diff --git a/crates/codecs/tests/detect_caps.rs b/crates/codecs/tests/detect_caps.rs new file mode 100644 index 00000000..4d06d581 --- /dev/null +++ b/crates/codecs/tests/detect_caps.rs @@ -0,0 +1,288 @@ +//! Magic-number detection, extensions, and the capability table +//! (including the symmetric read/write guarantee). + +mod common; +use common::*; +use photocraft_codecs::*; + +// --------------------------------------------------------------------------- +// Symmetric guarantee +// --------------------------------------------------------------------------- + +#[test] +fn every_enabled_format_is_symmetric_or_whitelisted() { + for f in Format::ALL { + let c = caps(f); + if !(c.read || c.write) { + continue; + } + if c.read && c.write { + continue; + } + let exc = ASYMMETRIC_EXCEPTIONS.iter().find(|(g, _)| *g == f); + let (_, reason) = exc.unwrap_or_else(|| { + panic!( + "{f:?} is enabled but not symmetric (read={}, write={})", + c.read, c.write + ) + }); + assert!(reason.len() > 20, "{f:?} exception needs a real reason"); + } +} + +#[test] +fn exceptions_are_documented_and_unique() { + for (i, (f, reason)) in ASYMMETRIC_EXCEPTIONS.iter().enumerate() { + assert!(!reason.is_empty()); + assert!(ASYMMETRIC_EXCEPTIONS[i + 1..].iter().all(|(g, _)| g != f)); + } +} + +#[test] +fn never_write_only_without_exception() { + // Stronger form: no format may be writable-but-unreadable unless listed. + for f in writable_formats() { + if !caps(f).read { + assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(g, _)| *g == f), "{f:?}"); + } + } +} + +#[test] +fn default_build_formats_all_symmetric() { + let expected = [ + Format::Png, + Format::Jpeg, + Format::Tiff, + Format::WebP, + Format::Gif, + Format::Bmp, + Format::Tga, + Format::Ico, + Format::Pnm, + Format::Qoi, + Format::OpenExr, + Format::Hdr, + ]; + for f in expected { + assert!(caps(f).read && caps(f).write, "{f:?}"); + } +} + +#[test] +fn avif_never_readable() { + assert!(!caps(Format::Avif).read); + assert_eq!(caps(Format::Avif).write, cfg!(feature = "avif")); + assert!(matches!( + decode_as(Format::Avif, b"\0\0\0\x1cftypavif"), + Err(CodecError::Unsupported { .. }) + )); +} + +#[test] +fn caps_are_internally_consistent() { + for f in Format::ALL { + let c = caps(f); + assert!(!c.depths.is_empty() && !c.layouts.is_empty(), "{f:?}"); + assert_eq!( + c.alpha, + c.layouts.iter().any(|l| l.has_alpha()), + "{f:?} alpha flag vs layouts" + ); + assert!( + !f.extensions().is_empty() + && !f.name().is_empty() + && f.mime_type().starts_with("image/") + ); + } +} + +#[test] +fn caps_function_matches_method() { + for f in Format::ALL { + assert_eq!(caps(f), f.caps()); + } +} + +// --------------------------------------------------------------------------- +// Detection +// --------------------------------------------------------------------------- + +#[test] +fn detect_encoded_output_of_every_format() { + for f in writable_formats() { + let img = test_image(ChannelLayout::Rgba, SampleType::U8); + let bytes = encode(&img, f, &EncodeOptions::default()).unwrap(); + assert_eq!(detect(&bytes), Some(f)); + } +} + +#[test] +fn detect_magic_png() { + assert_eq!(detect(b"\x89PNG\r\n\x1a\n\0\0"), Some(Format::Png)); +} +#[test] +fn detect_magic_jpeg() { + assert_eq!(detect(&[0xFF, 0xD8, 0xFF, 0xE0]), Some(Format::Jpeg)); + assert_eq!(detect(&[0xFF, 0xD8]), None); +} +#[test] +fn detect_magic_tiff_both_endians_and_bigtiff() { + assert_eq!(detect(b"II*\0\x08\0\0\0"), Some(Format::Tiff)); + assert_eq!(detect(b"MM\0*\0\0\0\x08"), Some(Format::Tiff)); + assert_eq!(detect(b"II+\0\x08\0\0\0"), Some(Format::Tiff)); + assert_eq!(detect(b"MM\0+\0\x08\0\0"), Some(Format::Tiff)); +} +#[test] +fn detect_magic_webp() { + assert_eq!(detect(b"RIFF\x10\0\0\0WEBPVP8L"), Some(Format::WebP)); + assert_eq!(detect(b"RIFF\x10\0\0\0WAVEfmt "), None); +} +#[test] +fn detect_magic_gif() { + assert_eq!(detect(b"GIF87a\x01\0"), Some(Format::Gif)); + assert_eq!(detect(b"GIF89a\x01\0"), Some(Format::Gif)); + assert_eq!(detect(b"GIF90a\x01\0"), None); +} +#[test] +fn detect_magic_bmp() { + let mut b = b"BM".to_vec(); + b.resize(54, 0); + assert_eq!(detect(&b), Some(Format::Bmp)); + assert_eq!(detect(b"BM"), None); +} +#[test] +fn detect_magic_ico() { + assert_eq!(detect(&[0, 0, 1, 0, 1, 0, 16, 16]), Some(Format::Ico)); + assert_eq!(detect(&[0, 0, 1, 0, 0, 0]), None, "zero images"); +} +#[test] +fn detect_magic_pnm_family() { + for m in [ + &b"P1\n"[..], + b"P2 ", + b"P3\t", + b"P4\n", + b"P5\r", + b"P6\n", + b"P7\n", + b"PF\n", + b"Pf\n", + ] { + assert_eq!(detect(m), Some(Format::Pnm), "{:?}", std::str::from_utf8(m)); + } + assert_eq!(detect(b"P8\n"), None); + assert_eq!(detect(b"P6x"), None); +} +#[test] +fn detect_magic_qoi() { + assert_eq!(detect(b"qoif\0\0\0\x01\0\0\0\x01\x03\0"), Some(Format::Qoi)); +} +#[test] +fn detect_magic_exr() { + assert_eq!( + detect(&[0x76, 0x2F, 0x31, 0x01, 2, 0, 0, 0]), + Some(Format::OpenExr) + ); +} +#[test] +fn detect_magic_hdr() { + assert_eq!(detect(b"#?RADIANCE\n"), Some(Format::Hdr)); + assert_eq!(detect(b"#?RGBE\n"), Some(Format::Hdr)); +} +#[test] +fn detect_magic_avif() { + assert_eq!( + detect(b"\0\0\0\x1cftypavif\0\0\0\0avifmif1miaf"), + Some(Format::Avif) + ); + assert_eq!( + detect(b"\0\0\0\x1cftypavis\0\0\0\0avismsf1miaf"), + Some(Format::Avif) + ); + assert_eq!( + detect(b"\0\0\0\x18ftypmif1\0\0\0\0mif1avif"), + Some(Format::Avif), + "compatible brand" + ); + assert_eq!( + detect(b"\0\0\0\x18ftypisom\0\0\0\0isommp41"), + None, + "mp4 is not avif" + ); +} +#[test] +fn detect_tga_heuristic() { + // Uncompressed true-colour 2x2, 24 bpp. + let mut b = vec![0u8, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 2, 0, 24, 0]; + b.extend_from_slice(&[0; 12]); + assert_eq!(detect(&b), Some(Format::Tga)); + let mut bad = b.clone(); + bad[2] = 7; // invalid image type + assert_eq!(detect(&bad), None); + let mut bad = b.clone(); + bad[16] = 13; // invalid depth + assert_eq!(detect(&bad), None); +} +#[test] +fn detect_negatives() { + assert_eq!(detect(b""), None); + assert_eq!(detect(b"x"), None); + assert_eq!(detect(b"hello world, this is not an image"), None); + assert_eq!(detect(b"%PDF-1.7\n"), None); + assert_eq!(detect(b"PK\x03\x04"), None); + assert_eq!(detect(&[0u8; 64]), None); +} +#[test] +fn detect_random_bytes_rarely_matches_tga() { + let mut rng = Rng::new(5); + let hits = (0..2000) + .filter(|_| detect(&rng.bytes(64)) == Some(Format::Tga)) + .count(); + assert!(hits < 10, "{hits} false TGA positives"); +} +#[test] +fn decode_unknown_is_error() { + assert!(matches!( + decode(b"garbage garbage"), + Err(CodecError::UnknownFormat) + )); +} + +// --------------------------------------------------------------------------- +// Extensions +// --------------------------------------------------------------------------- + +#[test] +fn extensions_map_back() { + for f in Format::ALL { + for e in f.extensions() { + assert_eq!(from_extension(e), Some(f), "{e}"); + assert_eq!(from_extension(&e.to_uppercase()), Some(f)); + assert_eq!(from_extension(&format!(".{e}")), Some(f)); + assert_eq!(from_extension(&format!("dir/file.name.{e}")), Some(f)); + } + } +} +#[test] +fn extension_specific_cases() { + assert_eq!(from_extension("JPG"), Some(Format::Jpeg)); + assert_eq!(from_extension("tif"), Some(Format::Tiff)); + assert_eq!(from_extension("pfm"), Some(Format::Pnm)); + assert_eq!(from_extension("pam"), Some(Format::Pnm)); + assert_eq!(from_extension("exr"), Some(Format::OpenExr)); + assert_eq!(from_extension("psd"), None); + assert_eq!(from_extension(""), None); + assert_eq!(from_extension("png.bak"), None); +} +#[test] +fn extensions_unique_across_formats() { + let mut all: Vec<&str> = Format::ALL + .iter() + .flat_map(|f| f.extensions().iter().copied()) + .collect(); + let n = all.len(); + all.sort(); + all.dedup(); + assert_eq!(all.len(), n); +} diff --git a/crates/codecs/tests/fidelity.rs b/crates/codecs/tests/fidelity.rs new file mode 100644 index 00000000..baaf1b7a --- /dev/null +++ b/crates/codecs/tests/fidelity.rs @@ -0,0 +1,331 @@ +//! `fidelity_warnings` cases, and consistency with what `encode` does. + +mod common; +use FidelityWarning as W; +use common::*; +use photocraft_codecs::*; + +fn warns(img: &Image, f: Format) -> Vec { + fidelity_warnings(img, f) +} + +#[test] +fn png_u8_rgb_is_clean() { + assert!(warns(&test_image(ChannelLayout::Rgb, SampleType::U8), Format::Png).is_empty()); +} +#[test] +fn png_u16_rgba_is_clean() { + assert!( + warns( + &test_image(ChannelLayout::Rgba, SampleType::U16), + Format::Png + ) + .is_empty() + ); +} +#[test] +fn tiff_is_clean_for_all_integer_and_f32_layouts() { + for l in ChannelLayout::ALL { + for s in [ + SampleType::U8, + SampleType::U16, + SampleType::F32, + SampleType::F16, + ] { + assert!( + warns(&test_image(l, s), Format::Tiff).is_empty(), + "{l:?} {s:?}" + ); + } + } +} +#[test] +fn sixteen_bit_to_jpeg_reduces() { + let w = warns( + &test_image(ChannelLayout::Rgb, SampleType::U16), + Format::Jpeg, + ); + assert!(w.contains(&W::DepthReduced { + from: SampleType::U16, + to: SampleType::U8 + })); + assert!(w.contains(&W::LossyCompression)); +} +#[test] +fn sixteen_bit_message_text() { + let w = W::DepthReduced { + from: SampleType::U16, + to: SampleType::U8, + }; + assert_eq!(w.to_string(), "16-bit will be reduced to 8-bit"); +} +#[test] +fn alpha_to_jpeg_discarded() { + let w = warns( + &test_image(ChannelLayout::Rgba, SampleType::U8), + Format::Jpeg, + ); + assert!(w.contains(&W::AlphaDiscarded)); +} +#[test] +fn opaque_alpha_not_reported() { + let mut img = test_image(ChannelLayout::Rgba, SampleType::U8); + for px in img.data_mut().chunks_mut(4) { + px[3] = 255; + } + assert!(!warns(&img, Format::Jpeg).contains(&W::AlphaDiscarded)); +} +#[test] +fn alpha_to_hdr_discarded() { + assert!( + warns( + &test_image(ChannelLayout::Rgba, SampleType::F32), + Format::Hdr + ) + .contains(&W::AlphaDiscarded) + ); +} +#[test] +fn cmyk_to_png_converted() { + let w = warns( + &test_image(ChannelLayout::Cmyk, SampleType::U8), + Format::Png, + ); + assert!(w.contains(&W::CmykConverted { + to: ChannelLayout::Rgb + })); +} +#[test] +fn cmyka_to_webp_converted_keeps_alpha() { + let w = warns( + &test_image(ChannelLayout::CmykA, SampleType::U8), + Format::WebP, + ); + assert!(w.contains(&W::CmykConverted { + to: ChannelLayout::Rgba + })); + assert!(!w.contains(&W::AlphaDiscarded)); +} +#[test] +fn cmyk_to_jpeg_and_tiff_not_converted() { + for f in [Format::Jpeg, Format::Tiff, Format::Pnm] { + let w = warns(&test_image(ChannelLayout::Cmyk, SampleType::U8), f); + assert!( + !w.iter().any(|w| matches!(w, W::CmykConverted { .. })), + "{f:?}" + ); + } +} +#[test] +fn icc_dropped_for_bmp() { + let img = test_image(ChannelLayout::Rgb, SampleType::U8).with_icc(Some(sample_icc(100))); + assert!(warns(&img, Format::Bmp).contains(&W::IccDropped)); + assert!(!warns(&img, Format::Png).contains(&W::IccDropped)); +} +#[test] +fn icc_warning_suppressed_when_not_embedding() { + let img = test_image(ChannelLayout::Rgb, SampleType::U8).with_icc(Some(sample_icc(100))); + let opts = EncodeOptions { + embed_icc: false, + ..Default::default() + }; + assert!(!fidelity_warnings_with(&img, Format::Bmp, &opts).contains(&W::IccDropped)); +} +#[test] +fn exif_xmp_dpi_text_dropped_for_qoi() { + let mut img = test_image(ChannelLayout::Rgb, SampleType::U8); + img.meta = Metadata { + exif: Some(sample_exif()), + xmp: Some("x".into()), + dpi: Some((72.0, 72.0)), + text: vec![("a".into(), "b".into())], + }; + let w = warns(&img, Format::Qoi); + for x in [W::ExifDropped, W::XmpDropped, W::DpiDropped, W::TextDropped] { + assert!(w.contains(&x), "{x:?}"); + } +} +#[test] +fn webp_drops_dpi_and_text_only() { + let mut img = test_image(ChannelLayout::Rgb, SampleType::U8); + img.meta = Metadata { + exif: Some(sample_exif()), + xmp: Some("x".into()), + dpi: Some((72.0, 72.0)), + text: vec![("a".into(), "b".into())], + }; + let w = warns(&img, Format::WebP); + assert_eq!(w, vec![W::DpiDropped, W::TextDropped]); +} +#[test] +fn float_to_png_reduces_and_clips() { + let vals: Vec = (0..12).map(|i| i as f32 * 0.3 - 0.5).collect(); + let img = Image::from_f32(2, 2, ChannelLayout::Rgb, &vals).unwrap(); + let w = warns(&img, Format::Png); + assert!(w.contains(&W::DepthReduced { + from: SampleType::F32, + to: SampleType::U16 + })); + assert!(w.contains(&W::RangeClipped)); +} +#[test] +fn in_range_float_not_clipped() { + let w = warns( + &test_image(ChannelLayout::Rgb, SampleType::F32), + Format::Png, + ); + assert!(!w.contains(&W::RangeClipped)); +} +#[test] +fn float_to_exr_and_tiff_is_clean() { + for f in [Format::OpenExr, Format::Tiff, Format::Pnm] { + assert!( + warns(&test_image(ChannelLayout::Rgb, SampleType::F32), f).is_empty(), + "{f:?}" + ); + } +} +#[test] +fn f16_to_exr_is_clean() { + assert!( + warns( + &test_image(ChannelLayout::Rgba, SampleType::F16), + Format::OpenExr + ) + .is_empty() + ); +} +#[test] +fn integer_to_exr_is_lossless() { + for s in [SampleType::U8, SampleType::U16] { + assert!( + warns(&test_image(ChannelLayout::Rgb, s), Format::OpenExr).is_empty(), + "{s:?}" + ); + } +} +#[test] +fn float_pnm_drops_alpha() { + let w = warns( + &test_image(ChannelLayout::Rgba, SampleType::F32), + Format::Pnm, + ); + assert!(w.contains(&W::AlphaDiscarded)); +} +#[test] +fn gif_quantizes_and_binarizes() { + let w = warns( + &test_image(ChannelLayout::Rgba, SampleType::U8), + Format::Gif, + ); + assert!(w.contains(&W::PaletteQuantized)); + assert!(w.contains(&W::AlphaBinarized)); + assert!(!w.contains(&W::LossyCompression)); +} +#[test] +fn hdr_is_lossy() { + assert!( + warns( + &test_image(ChannelLayout::Rgb, SampleType::F32), + Format::Hdr + ) + .contains(&W::LossyCompression) + ); +} +#[test] +fn ico_oversize_is_fatal() { + let img = Image::new(512, 16, ChannelLayout::Rgba, SampleType::U8); + let w = warns(&img, Format::Ico); + assert!(w.iter().any(|w| w.is_fatal())); + assert!(w.contains(&W::DimensionsExceeded { + max_width: 256, + max_height: 256 + })); +} +#[test] +fn avif_default_build_write_unsupported() { + let w = warns( + &test_image(ChannelLayout::Rgb, SampleType::U8), + Format::Avif, + ); + if cfg!(feature = "avif") { + assert!(w.contains(&W::LossyCompression)); + } else { + assert_eq!( + w, + vec![W::WriteUnsupported { + format: Format::Avif + }] + ); + assert!(w[0].is_fatal()); + } +} +#[test] +fn every_warning_has_a_message() { + let all = [ + W::WriteUnsupported { + format: Format::Avif, + }, + W::DepthReduced { + from: SampleType::F32, + to: SampleType::F16, + }, + W::RangeClipped, + W::AlphaDiscarded, + W::AlphaBinarized, + W::CmykConverted { + to: ChannelLayout::Rgb, + }, + W::ColorToGray, + W::PaletteQuantized, + W::LossyCompression, + W::IccDropped, + W::ExifDropped, + W::XmpDropped, + W::DpiDropped, + W::TextDropped, + W::DimensionsExceeded { + max_width: 1, + max_height: 1, + }, + ]; + for w in all { + assert!(w.to_string().len() > 5); + } +} + +/// The warnings must agree with what actually comes back from the file. +#[test] +fn warnings_match_actual_roundtrip_behaviour() { + for f in rw_formats() { + for l in ChannelLayout::ALL { + for s in SampleType::ALL { + let img = test_image(l, s); + let w = warns(&img, f); + let back = decode(&encode(&img, f, &EncodeOptions::default()).unwrap()).unwrap(); + let depth_reduced = w.iter().any(|w| matches!(w, W::DepthReduced { .. })); + let lost_depth = back.sample_type() != s + && back.sample_type() != SampleType::F32 + && s != SampleType::U8; + assert_eq!( + depth_reduced, + lost_depth, + "{f:?} {l:?} {s:?} {w:?} -> {:?}", + back.sample_type() + ); + let alpha_lost = l.has_alpha() && !back.layout().has_alpha(); + assert_eq!( + w.contains(&W::AlphaDiscarded), + alpha_lost, + "{f:?} {l:?} {s:?}" + ); + let cmyk_lost = l.is_cmyk() && !back.layout().is_cmyk(); + assert_eq!( + w.iter().any(|w| matches!(w, W::CmykConverted { .. })), + cmyk_lost, + "{f:?} {l:?} {s:?}" + ); + } + } + } +} diff --git a/crates/codecs/tests/limits.rs b/crates/codecs/tests/limits.rs new file mode 100644 index 00000000..3163d439 --- /dev/null +++ b/crates/codecs/tests/limits.rs @@ -0,0 +1,194 @@ +//! Decompression-bomb guards. + +mod common; +use common::*; +use photocraft_codecs::*; + +fn opts(l: Limits) -> DecodeOptions { + DecodeOptions { limits: l } +} + +fn is_limit(r: Result) -> bool { + matches!(r, Err(CodecError::LimitExceeded(_))) +} + +fn encoded(f: Format) -> Vec { + let img = synth(64, 48, ChannelLayout::Rgba, SampleType::U8, 1, 0.1); + encode(&img, f, &EncodeOptions::default()).unwrap() +} + +#[test] +fn max_pixels_enforced_for_every_format() { + for f in rw_formats() { + let b = encoded(f); + let l = Limits { + max_pixels: 1000, + ..Limits::default() + }; + assert!(is_limit(decode_with(&b, &opts(l))), "{f:?}"); + let l = Limits { + max_pixels: 64 * 48, + ..Limits::default() + }; + assert!( + decode_with(&b, &opts(l)).is_ok(), + "{f:?} exact limit should pass" + ); + } +} + +#[test] +fn max_width_enforced_for_every_format() { + for f in rw_formats() { + let l = Limits { + max_width: 63, + ..Limits::default() + }; + assert!(is_limit(decode_with(&encoded(f), &opts(l))), "{f:?}"); + } +} + +#[test] +fn max_height_enforced_for_every_format() { + for f in rw_formats() { + let l = Limits { + max_height: 47, + ..Limits::default() + }; + assert!(is_limit(decode_with(&encoded(f), &opts(l))), "{f:?}"); + } +} + +#[test] +fn max_alloc_enforced_for_every_format() { + for f in rw_formats() { + let l = Limits { + max_alloc: 64 * 48, + ..Limits::default() + }; + assert!(decode_with(&encoded(f), &opts(l)).is_err(), "{f:?}"); + } +} + +#[test] +fn limits_none_accepts() { + for f in rw_formats() { + assert!( + decode_with(&encoded(f), &opts(Limits::none())).is_ok(), + "{f:?}" + ); + } +} + +fn png_chunk(ty: &[u8; 4], data: &[u8]) -> Vec { + let mut v = (data.len() as u32).to_be_bytes().to_vec(); + v.extend_from_slice(ty); + v.extend_from_slice(data); + let mut crc_in = ty.to_vec(); + crc_in.extend_from_slice(data); + v.extend_from_slice(&crc32(&crc_in).to_be_bytes()); + v +} + +fn crc32(d: &[u8]) -> u32 { + let mut c = 0xFFFF_FFFFu32; + for &b in d { + c ^= b as u32; + for _ in 0..8 { + c = if c & 1 != 0 { + 0xEDB8_8320 ^ (c >> 1) + } else { + c >> 1 + }; + } + } + !c +} + +#[test] +fn png_bomb_header_rejected_before_alloc() { + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&100_000u32.to_be_bytes()); + ihdr.extend_from_slice(&100_000u32.to_be_bytes()); + ihdr.extend_from_slice(&[16, 6, 0, 0, 0]); + let mut b = b"\x89PNG\r\n\x1a\n".to_vec(); + b.extend(png_chunk(b"IHDR", &ihdr)); + b.extend(png_chunk( + b"IDAT", + &[0x78, 0x9C, 0x03, 0x00, 0x00, 0x00, 0x00, 0x01], + )); + b.extend(png_chunk(b"IEND", &[])); + assert!(is_limit(decode(&b))); +} + +#[test] +fn pnm_bomb_header_rejected() { + assert!(is_limit(decode(b"P6\n200000 200000\n255\n\0\0\0"))); + assert!(is_limit(decode( + b"P7\nWIDTH 99999\nHEIGHT 99999\nDEPTH 4\nMAXVAL 65535\nTUPLTYPE RGB_ALPHA\nENDHDR\n" + ))); + assert!(is_limit(decode(b"PF\n100000 100000\n-1.0\n"))); +} + +#[test] +fn jpeg_bomb_header_rejected() { + // SOI + SOF0 claiming 65535x65535x3 with no scan data. + let b = [ + 0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x11, 8, 0xFF, 0xFF, 0xFF, 0xFF, 3, 1, 0x11, 0, 2, 0x11, 0, + 3, 0x11, 0, 0xFF, 0xD9, + ]; + let l = Limits { + max_pixels: 1 << 24, + ..Limits::default() + }; + assert!(is_limit(decode_with(&b, &opts(l)))); +} + +#[test] +fn tiff_bomb_header_rejected() { + // Minimal little-endian TIFF claiming 1_000_000 x 1_000_000 8-bit gray. + let mut b = b"II*\0\x08\0\0\0".to_vec(); + let entries: [(u16, u16, u32, u32); 6] = [ + (256, 4, 1, 1_000_000), + (257, 4, 1, 1_000_000), + (258, 3, 1, 8), + (262, 3, 1, 1), + (273, 4, 1, 0), + (279, 4, 1, 0), + ]; + b.extend_from_slice(&(entries.len() as u16).to_le_bytes()); + for (tag, ty, count, val) in entries { + b.extend_from_slice(&tag.to_le_bytes()); + b.extend_from_slice(&ty.to_le_bytes()); + b.extend_from_slice(&count.to_le_bytes()); + b.extend_from_slice(&val.to_le_bytes()); + } + b.extend_from_slice(&0u32.to_le_bytes()); + assert!(is_limit(decode(&b))); +} + +#[test] +fn limits_check_api() { + let l = Limits::default(); + assert!( + l.check(100, 100, ChannelLayout::Rgba, SampleType::F32) + .is_ok() + ); + assert!(matches!( + l.check(0, 1, ChannelLayout::Gray, SampleType::U8), + Err(CodecError::InvalidImage(_)) + )); + assert!(matches!( + l.check(u32::MAX, 1, ChannelLayout::Gray, SampleType::U8), + Err(CodecError::LimitExceeded(_)) + )); + let l = Limits { + max_alloc: 399, + ..Limits::default() + }; + assert!( + l.check(10, 10, ChannelLayout::Rgba, SampleType::U8) + .is_err() + ); + assert!(l.check(10, 10, ChannelLayout::Rgb, SampleType::U8).is_ok()); +} diff --git a/crates/codecs/tests/malformed.rs b/crates/codecs/tests/malformed.rs new file mode 100644 index 00000000..911d9eb2 --- /dev/null +++ b/crates/codecs/tests/malformed.rs @@ -0,0 +1,257 @@ +//! Malformed input must produce errors, never panics or huge allocations. + +mod common; +use common::*; +use photocraft_codecs::*; +use proptest::prelude::*; + +fn tight() -> DecodeOptions { + DecodeOptions { + limits: Limits { + max_width: 4096, + max_height: 4096, + max_pixels: 1 << 22, + max_alloc: 64 << 20, + }, + } +} + +fn samples() -> Vec<(Format, Vec)> { + let mut v = Vec::new(); + for f in rw_formats() { + for (l, s) in [ + (ChannelLayout::Rgba, SampleType::U8), + (ChannelLayout::Gray, SampleType::U16), + (ChannelLayout::Rgb, SampleType::F32), + ] { + let mut img = synth(19, 13, l, s, 11, 0.3); + img.icc = Some(sample_icc(300)); + img.meta.exif = Some(sample_exif()); + img.meta.xmp = Some(SAMPLE_XMP.into()); + v.push((f, encode(&img, f, &EncodeOptions::default()).unwrap())); + } + } + // Extra variants: interlaced PNG, ASCII PNM, CMYK JPEG. + let img = synth(19, 13, ChannelLayout::Rgb, SampleType::U8, 1, 0.3); + v.push(( + Format::Png, + encode( + &img, + Format::Png, + &EncodeOptions { + png_interlaced: true, + ..Default::default() + }, + ) + .unwrap(), + )); + v.push(( + Format::Pnm, + b"P3\n2 2\n255\n0 1 2 3 4 5 6 7 8 9 10 11\n".to_vec(), + )); + v.push(( + Format::Jpeg, + encode( + &synth(19, 13, ChannelLayout::Cmyk, SampleType::U8, 1, 0.3), + Format::Jpeg, + &EncodeOptions::default(), + ) + .unwrap(), + )); + v +} + +fn no_panic(f: Format, bytes: &[u8]) { + let _ = decode_with(bytes, &tight()); + let _ = decode_as_with(f, bytes, &tight()); +} + +#[test] +fn truncated_at_many_offsets() { + for (f, bytes) in samples() { + let n = bytes.len(); + let step = (n / 97).max(1); + let mut cuts: Vec = (0..n.min(80)).collect(); + cuts.extend((0..n).step_by(step)); + cuts.extend(n.saturating_sub(40)..n); + for cut in cuts { + no_panic(f, &bytes[..cut]); + } + } +} + +#[test] +fn truncated_files_mostly_error() { + // Chopping off the second half must not silently succeed for formats + // with length-checked rasters. + for (f, bytes) in samples() { + if matches!( + f, + Format::Png | Format::Pnm | Format::Qoi | Format::OpenExr | Format::Tiff | Format::Bmp + ) { + let r = decode_as_with(f, &bytes[..bytes.len() / 2], &tight()); + assert!(r.is_err(), "{f:?} decoded half a file"); + } + } +} + +#[test] +fn bit_flips_never_panic() { + let mut rng = common::Rng::new(42); + for (f, bytes) in samples() { + for _ in 0..150 { + let mut b = bytes.clone(); + let flips = 1 + (rng.next_u64() % 4) as usize; + for _ in 0..flips { + let i = (rng.next_u64() as usize) % b.len(); + b[i] ^= 1 << (rng.next_u64() % 8); + } + no_panic(f, &b); + } + } +} + +#[test] +fn byte_overwrites_in_header_never_panic() { + for (f, bytes) in samples() { + for i in 0..bytes.len().min(64) { + for v in [0x00, 0x7F, 0xFF] { + let mut b = bytes.clone(); + b[i] = v; + no_panic(f, &b); + } + } + } +} + +#[test] +fn empty_input_errors_for_every_format() { + for f in Format::ALL { + assert!(decode_as(f, &[]).is_err(), "{f:?}"); + } +} + +#[test] +fn pnm_specific_malformations() { + let cases: &[&[u8]] = &[ + b"P6\n", + b"P6\n2 2\n", + b"P6\n2 2\n255", + b"P6\n2 2\n0\n", + b"P6\n2 2\n70000\n", + b"P6\n-2 2\n255\n", + b"P6\n2 2\n255\n\x01\x02", + b"P5\n2 2\n65535\n\x00\x00\x00", + b"P2\n2 2\n10\n1 2 3 11\n", + b"P1\n2 2\n0 1 2 0\n", + b"P4\n9 2\n\x00", + b"P7\nWIDTH 2\nHEIGHT 2\nDEPTH 9\nMAXVAL 255\nENDHDR\n", + b"P7\nWIDTH 2\nHEIGHT 2\nMAXVAL 255\nENDHDR\n", + b"P7\nWIDTH 2\nBOGUS 3\nENDHDR\n", + b"PF\n2 2\n0.0\n", + b"PF\n2 2\nnan\n", + b"PF\n2 2\n-1.0\n\0\0\0\0", + b"P6 4294967295 4294967295 255\n", + ]; + for c in cases { + assert!(decode(c).is_err(), "{:?}", String::from_utf8_lossy(c)); + } +} + +#[test] +fn pnm_valid_variants() { + let img = decode(b"P1\n# comment\n3 2\n1 0 1\n0 1 0\n").unwrap(); + assert_eq!(img.data(), &[0, 255, 0, 255, 0, 255]); + let img = decode(b"P1\n3 2\n101010").unwrap(); + assert_eq!(img.data(), &[0, 255, 0, 255, 0, 255]); + let img = decode(b"P4\n3 2\n\xA0\x40").unwrap(); + assert_eq!(img.data(), &[0, 255, 0, 255, 0, 255]); + let img = decode(b"P2\n2 1\n15\n0 15\n").unwrap(); + assert_eq!(img.data(), &[0, 255]); + let img = decode(b"P2\n2 1\n1023\n0 1023\n").unwrap(); + assert_eq!(img.sample_type(), SampleType::U16); + assert_eq!(img.to_u16_samples().unwrap(), vec![0, 65535]); + let img = decode(b"P3\n1 1\n255\n1 2 3\n").unwrap(); + assert_eq!( + (img.layout(), img.data()), + (ChannelLayout::Rgb, &[1u8, 2, 3][..]) + ); + let img = decode( + b"P7\nWIDTH 1\nHEIGHT 1\nDEPTH 2\nMAXVAL 255\nTUPLTYPE GRAYSCALE_ALPHA\nENDHDR\n\x10\x20", + ) + .unwrap(); + assert_eq!( + (img.layout(), img.data()), + (ChannelLayout::GrayA, &[0x10u8, 0x20][..]) + ); + let mut pfm = b"PF\n1 2\n1.0\n".to_vec(); // big-endian, bottom-up + for v in [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0] { + pfm.extend_from_slice(&v.to_be_bytes()); + } + let img = decode(&pfm).unwrap(); + assert_eq!( + img.to_f32_samples().unwrap(), + vec![4.0, 5.0, 6.0, 1.0, 2.0, 3.0] + ); +} + +#[test] +fn jpeg_marker_garbage() { + let cases: &[&[u8]] = &[ + &[0xFF, 0xD8, 0xFF], + &[0xFF, 0xD8, 0xFF, 0xE0, 0x00], + &[0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x01], + &[ + 0xFF, 0xD8, 0xFF, 0xE2, 0x00, 0x10, b'I', b'C', b'C', b'_', b'P', b'R', b'O', b'F', + b'I', b'L', b'E', 0, 1, 1, + ], + &[ + 0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x0B, 8, 0xFF, 0xFF, 0xFF, 0xFF, 3, 0, 0, 0, + ], + &[0xFF, 0xD8, 0xFF, 0xD9], + ]; + for c in cases { + assert!(decode(c).is_err()); + } +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 400, .. ProptestConfig::default() })] + + #[test] + fn random_bytes_never_panic(data in proptest::collection::vec(any::(), 0..2048)) { + let _ = decode_with(&data, &tight()); + } + + #[test] + fn random_bytes_with_magic_never_panic(idx in 0usize..13, data in proptest::collection::vec(any::(), 0..1024)) { + let magics: [&[u8]; 13] = [ + b"\x89PNG\r\n\x1a\n", &[0xFF, 0xD8, 0xFF], b"II*\0", b"RIFF\0\0\0\0WEBP", b"GIF89a", b"BM", + &[0, 0, 1, 0, 1, 0], b"P6\n", b"qoif", &[0x76, 0x2F, 0x31, 0x01], b"#?RADIANCE\n", b"P7\n", b"PF\n", + ]; + let mut b = magics[idx].to_vec(); + b.extend_from_slice(&data); + let _ = decode_with(&b, &tight()); + } + + #[test] + fn random_bytes_each_format_never_panic(idx in 0usize..13, data in proptest::collection::vec(any::(), 0..512)) { + let _ = decode_as_with(Format::ALL[idx], &data, &tight()); + } + + #[test] + fn mutated_valid_files_never_panic(which in 0usize..40, pos in any::(), val in any::(), cut in any::()) { + let s = samples_cached(); + let (f, bytes) = &s[which % s.len()]; + let mut b = bytes.clone(); + let p = pos % b.len(); + b[p] = val; + let c = cut % (b.len() + 1); + let _ = decode_as_with(*f, &b[..c.max(p + 1).min(b.len())], &tight()); + } +} + +fn samples_cached() -> &'static [(Format, Vec)] { + static S: std::sync::OnceLock)>> = std::sync::OnceLock::new(); + S.get_or_init(samples) +} diff --git a/crates/codecs/tests/metadata.rs b/crates/codecs/tests/metadata.rs new file mode 100644 index 00000000..246cc21e --- /dev/null +++ b/crates/codecs/tests/metadata.rs @@ -0,0 +1,301 @@ +//! ICC / EXIF / XMP / DPI / text preservation, per format, per caps. + +mod common; +use common::*; +use photocraft_codecs::*; + +fn rich_image(layout: ChannelLayout) -> Image { + let mut img = test_image(layout, SampleType::U8); + img.icc = Some(sample_icc(3000)); + img.meta = Metadata { + exif: Some(sample_exif()), + xmp: Some(SAMPLE_XMP.to_owned()), + dpi: Some((300.0, 150.0)), + text: vec![ + ("Description".into(), "synthetic test image".into()), + ("Software".into(), "photocraft".into()), + ], + }; + img +} + +fn check_format(format: Format) { + let c = caps(format); + if !(c.read && c.write) { + return; + } + let layout = if c.layouts.contains(&ChannelLayout::Rgb) { + ChannelLayout::Rgb + } else { + c.layouts[0] + }; + let img = rich_image(layout); + let bytes = encode(&img, format, &EncodeOptions::default()).unwrap(); + let back = decode(&bytes).unwrap(); + let warnings = fidelity_warnings(&img, format); + + assert_eq!(back.icc.is_some(), c.icc, "{format:?} icc presence"); + if c.icc { + assert_eq!(back.icc, img.icc, "{format:?} icc bytes"); + } else { + assert!( + warnings.contains(&FidelityWarning::IccDropped), + "{format:?}" + ); + } + if c.exif { + assert_eq!(back.meta.exif, img.meta.exif, "{format:?} exif"); + } else { + assert!( + warnings.contains(&FidelityWarning::ExifDropped), + "{format:?}" + ); + } + if c.xmp { + assert_eq!(back.meta.xmp, img.meta.xmp, "{format:?} xmp"); + } else { + assert!(back.meta.xmp.is_none()); + assert!( + warnings.contains(&FidelityWarning::XmpDropped), + "{format:?}" + ); + } + if c.dpi { + let (x, y) = back + .meta + .dpi + .unwrap_or_else(|| panic!("{format:?} lost dpi")); + assert!( + (x - 300.0).abs() < 0.05 && (y - 150.0).abs() < 0.05, + "{format:?} dpi {x},{y}" + ); + } else { + assert!( + warnings.contains(&FidelityWarning::DpiDropped), + "{format:?}" + ); + } + if c.text { + for kv in &img.meta.text { + assert!( + back.meta.text.contains(kv), + "{format:?} lost text {kv:?}: {:?}", + back.meta.text + ); + } + } else { + assert!(back.meta.text.is_empty()); + assert!( + warnings.contains(&FidelityWarning::TextDropped), + "{format:?}" + ); + } +} + +#[test] +fn png_metadata() { + check_format(Format::Png); +} +#[test] +fn jpeg_metadata() { + check_format(Format::Jpeg); +} +#[test] +fn tiff_metadata() { + check_format(Format::Tiff); +} +#[test] +fn webp_metadata() { + check_format(Format::WebP); +} +#[test] +fn gif_metadata() { + check_format(Format::Gif); +} +#[test] +fn bmp_metadata() { + check_format(Format::Bmp); +} +#[test] +fn tga_metadata() { + check_format(Format::Tga); +} +#[test] +fn ico_metadata() { + check_format(Format::Ico); +} +#[test] +fn pnm_metadata() { + check_format(Format::Pnm); +} +#[test] +fn qoi_metadata() { + check_format(Format::Qoi); +} +#[test] +fn exr_metadata() { + check_format(Format::OpenExr); +} +#[test] +fn hdr_metadata() { + check_format(Format::Hdr); +} + +#[test] +fn metadata_on_16bit_png_and_tiff() { + for f in [Format::Png, Format::Tiff] { + let mut img = test_image(ChannelLayout::Rgba, SampleType::U16); + img.icc = Some(sample_icc(512)); + img.meta.dpi = Some((72.0, 72.0)); + let back = decode(&encode(&img, f, &EncodeOptions::default()).unwrap()).unwrap(); + assert_eq!(back.icc, img.icc); + assert_eq!(back.sample_type(), SampleType::U16); + } +} + +#[test] +fn icc_on_cmyk_tiff_and_jpeg() { + for f in [Format::Tiff, Format::Jpeg] { + let img = test_image(ChannelLayout::Cmyk, SampleType::U8).with_icc(Some(sample_icc(900))); + let back = decode(&encode(&img, f, &EncodeOptions::default()).unwrap()).unwrap(); + assert_eq!(back.layout(), ChannelLayout::Cmyk, "{f:?}"); + assert_eq!(back.icc, img.icc, "{f:?}"); + } +} + +#[test] +fn cmyk_icc_not_written_to_rgb_format() { + let img = test_image(ChannelLayout::Cmyk, SampleType::U8).with_icc(Some(sample_icc(900))); + let back = decode(&encode(&img, Format::Png, &EncodeOptions::default()).unwrap()).unwrap(); + assert!( + back.icc.is_none(), + "a CMYK profile must not be attached to converted RGB data" + ); + assert!(fidelity_warnings(&img, Format::Png).contains(&FidelityWarning::IccDropped)); +} + +#[test] +fn jpeg_multi_segment_icc() { + // > 65519 bytes forces several APP2 segments. + for len in [65519, 65520, 150_000, 300_001] { + let img = test_image(ChannelLayout::Rgb, SampleType::U8).with_icc(Some(sample_icc(len))); + let bytes = encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap(); + let app2 = bytes + .windows(14) + .filter(|w| w[0] == 0xFF && w[1] == 0xE2 && &w[4..14] == b"ICC_PROFIL") + .count(); + assert_eq!(app2, len.div_ceil(65519), "segments for {len}"); + assert_eq!(decode(&bytes).unwrap().icc.map(|v| v.len()), Some(len)); + // Oracle: the image crate reads the same profile. + use image::ImageDecoder; + let mut d = image::codecs::jpeg::JpegDecoder::new(std::io::Cursor::new(&bytes)).unwrap(); + assert_eq!(d.icc_profile().unwrap(), img.icc); + } +} + +#[test] +fn embed_flags_disable_metadata() { + let img = rich_image(ChannelLayout::Rgb); + let opts = EncodeOptions { + embed_icc: false, + embed_metadata: false, + ..Default::default() + }; + for f in [Format::Png, Format::Jpeg, Format::Tiff, Format::WebP] { + let back = decode(&encode(&img, f, &opts).unwrap()).unwrap(); + assert!(back.icc.is_none(), "{f:?}"); + assert!(back.meta.exif.is_none() && back.meta.xmp.is_none(), "{f:?}"); + assert!(back.meta.text.is_empty(), "{f:?}"); + assert!( + fidelity_warnings_with(&img, Format::Bmp, &opts) + .iter() + .all(|w| !matches!( + w, + FidelityWarning::IccDropped | FidelityWarning::ExifDropped + )) + ); + } +} + +#[test] +fn png_unicode_text_uses_itxt() { + let mut img = test_image(ChannelLayout::Rgb, SampleType::U8); + img.meta.text = vec![ + ("Title".into(), "Ünïcödé ☃ 雪".into()), + ("Author".into(), "latin1 ok é".into()), + ]; + let bytes = encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + assert!(bytes.windows(4).any(|w| w == b"iTXt")); + assert!(bytes.windows(4).any(|w| w == b"tEXt")); + let mut back = decode(&bytes).unwrap().meta.text; + let mut want = img.meta.text.clone(); + back.sort(); + want.sort(); + assert_eq!(back, want); +} + +#[test] +fn png_xmp_in_itxt_keyword() { + let mut img = test_image(ChannelLayout::Gray, SampleType::U8); + img.meta.xmp = Some(SAMPLE_XMP.into()); + let bytes = encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + assert!(bytes.windows(17).any(|w| w == b"XML:com.adobe.xmp")); + let back = decode(&bytes).unwrap(); + assert_eq!(back.meta.xmp.as_deref(), Some(SAMPLE_XMP)); + assert!(back.meta.text.is_empty(), "XMP must not leak into text"); +} + +#[test] +fn png_exif_chunk_written() { + let mut img = test_image(ChannelLayout::Rgb, SampleType::U8); + img.meta.exif = Some(sample_exif()); + let bytes = encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + assert!(bytes.windows(4).any(|w| w == b"eXIf")); + assert!(!bytes.windows(4).any(|w| w == b"iCCP")); +} + +#[test] +fn jpeg_exif_readable_by_oracle() { + let mut img = test_image(ChannelLayout::Rgb, SampleType::U8); + img.meta.exif = Some(sample_exif()); + let bytes = encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap(); + use image::ImageDecoder; + let mut d = image::codecs::jpeg::JpegDecoder::new(std::io::Cursor::new(&bytes)).unwrap(); + let exif = d.exif_metadata().unwrap().unwrap(); + assert!(exif.ends_with(&sample_exif())); +} + +#[test] +fn jpeg_dpi_in_jfif() { + let mut img = test_image(ChannelLayout::Gray, SampleType::U8); + img.meta.dpi = Some((96.0, 200.0)); + let back = decode(&encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap()).unwrap(); + assert_eq!(back.meta.dpi, Some((96.0, 200.0))); +} + +#[test] +fn tiff_icc_readable_by_oracle() { + let img = test_image(ChannelLayout::Rgb, SampleType::U16).with_icc(Some(sample_icc(1234))); + let bytes = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap(); + use image::ImageDecoder; + let mut d = image::codecs::tiff::TiffDecoder::new(std::io::Cursor::new(&bytes)).unwrap(); + assert_eq!(d.icc_profile().unwrap(), img.icc); +} + +#[test] +fn png_icc_readable_by_oracle() { + let img = test_image(ChannelLayout::Rgba, SampleType::U8).with_icc(Some(sample_icc(777))); + let bytes = encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + use image::ImageDecoder; + let mut d = image::codecs::png::PngDecoder::new(std::io::Cursor::new(&bytes)).unwrap(); + assert_eq!(d.icc_profile().unwrap(), img.icc); +} + +#[test] +fn metadata_survives_depth_conversion() { + let mut img = test_image(ChannelLayout::Rgb, SampleType::F32).with_icc(Some(sample_icc(300))); + img.meta.xmp = Some(SAMPLE_XMP.into()); + let back = decode(&encode(&img, Format::Png, &EncodeOptions::default()).unwrap()).unwrap(); + assert_eq!(back.icc, img.icc); + assert_eq!(back.meta.xmp, img.meta.xmp); +} diff --git a/crates/codecs/tests/png_jpeg.rs b/crates/codecs/tests/png_jpeg.rs new file mode 100644 index 00000000..945d2e73 --- /dev/null +++ b/crates/codecs/tests/png_jpeg.rs @@ -0,0 +1,329 @@ +//! Format-specific behaviour: Adam7 PNG, JPEG colour models, oracle +//! cross-checks against the `image` crate, and the optional PngSuite corpus. + +mod common; +use common::*; +use photocraft_codecs::*; + +// --------------------------------------------------------------------------- +// PNG Adam7 +// --------------------------------------------------------------------------- + +fn interlaced(img: &Image) -> Vec { + encode( + img, + Format::Png, + &EncodeOptions { + png_interlaced: true, + ..Default::default() + }, + ) + .unwrap() +} + +fn check_adam7(w: u32, h: u32, layout: ChannelLayout, sample: SampleType) { + let img = synth(w, h, layout, sample, (w * 31 + h) as u64, 0.5); + let a = interlaced(&img); + let b = encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + assert_eq!(a[28], 1, "IHDR interlace flag"); + assert_eq!(b[28], 0); + let da = decode(&a).unwrap(); + let db = decode(&b).unwrap(); + assert_eq!( + da, db, + "{w}x{h} {layout:?} {sample:?}: interlaced != progressive" + ); + assert_eq!(da.data(), img.data()); + // Oracle: the image crate agrees. + let o = image::load_from_memory_with_format(&a, image::ImageFormat::Png).unwrap(); + let ours = da + .convert(ChannelLayout::Rgba, SampleType::U16) + .to_u16_samples() + .unwrap(); + assert_eq!(o.into_rgba16().into_raw(), ours); +} + +#[test] +fn adam7_small_sizes_all_layouts() { + for (w, h) in [ + (1, 1), + (2, 2), + (3, 3), + (5, 1), + (1, 9), + (7, 5), + (8, 8), + (9, 17), + (33, 31), + ] { + for l in [ + ChannelLayout::Gray, + ChannelLayout::GrayA, + ChannelLayout::Rgb, + ChannelLayout::Rgba, + ] { + check_adam7(w, h, l, SampleType::U8); + } + } +} + +#[test] +fn adam7_16bit() { + for (w, h) in [(1, 1), (4, 3), (13, 11), (64, 40)] { + for l in [ChannelLayout::Gray, ChannelLayout::Rgba] { + check_adam7(w, h, l, SampleType::U16); + } + } +} + +#[test] +fn adam7_with_metadata() { + let mut img = test_image(ChannelLayout::Rgb, SampleType::U8).with_icc(Some(sample_icc(400))); + img.meta.text.push(("Comment".into(), "interlaced".into())); + let back = decode(&interlaced(&img)).unwrap(); + assert_eq!(back.icc, img.icc); + assert_eq!(back.meta.text, img.meta.text); +} + +#[test] +fn adam7_compressions() { + let img = test_image(ChannelLayout::Rgba, SampleType::U8); + for c in [ + PngCompression::None, + PngCompression::Fast, + PngCompression::Best, + ] { + let b = encode( + &img, + Format::Png, + &EncodeOptions { + png_interlaced: true, + png_compression: c, + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(decode(&b).unwrap().data(), img.data()); + } +} + +#[test] +fn png_palette_and_low_bit_depth_expand() { + // Build a 2-bit gray PNG and a palette PNG with tRNS using the png crate. + let mut out = Vec::new(); + { + let mut e = png::Encoder::new(&mut out, 4, 1); + e.set_color(png::ColorType::Grayscale); + e.set_depth(png::BitDepth::Two); + let mut w = e.write_header().unwrap(); + w.write_image_data(&[0b00_01_10_11]).unwrap(); + } + let img = decode(&out).unwrap(); + assert_eq!( + (img.layout(), img.data()), + (ChannelLayout::Gray, &[0u8, 85, 170, 255][..]) + ); + + let mut out = Vec::new(); + { + let mut e = png::Encoder::new(&mut out, 2, 1); + e.set_color(png::ColorType::Indexed); + e.set_depth(png::BitDepth::Eight); + e.set_palette(vec![255, 0, 0, 0, 0, 255]); + e.set_trns(vec![128]); + let mut w = e.write_header().unwrap(); + w.write_image_data(&[0, 1]).unwrap(); + } + let img = decode(&out).unwrap(); + assert_eq!( + (img.layout(), img.data()), + (ChannelLayout::Rgba, &[255u8, 0, 0, 128, 0, 0, 255, 255][..]) + ); +} + +// --------------------------------------------------------------------------- +// JPEG colour models +// --------------------------------------------------------------------------- + +#[test] +fn jpeg_grayscale_decodes_as_gray() { + let img = synth(40, 30, ChannelLayout::Gray, SampleType::U8, 2, 0.02); + let back = decode(&encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap()).unwrap(); + assert_eq!(back.layout(), ChannelLayout::Gray); + assert!(psnr(&img, &back) > 35.0); +} + +#[test] +fn jpeg_cmyk_roundtrip_with_adobe_marker() { + let img = synth(40, 30, ChannelLayout::Cmyk, SampleType::U8, 3, 0.02); + let bytes = encode( + &img, + Format::Jpeg, + &EncodeOptions { + jpeg_quality: 95, + ..Default::default() + }, + ) + .unwrap(); + assert!( + bytes.windows(5).any(|w| w == b"Adobe"), + "APP14 Adobe marker" + ); + let back = decode(&bytes).unwrap(); + assert_eq!(back.layout(), ChannelLayout::Cmyk); + assert!(psnr(&img, &back) > 35.0, "{}", psnr(&img, &back)); +} + +#[test] +fn jpeg_ycck_decodes_to_cmyk() { + let img = synth(40, 32, ChannelLayout::Cmyk, SampleType::U8, 4, 0.02); + let mut bytes = Vec::new(); + let mut enc = jpeg_encoder::Encoder::new(&mut bytes, 95); + enc.set_sampling_factor(jpeg_encoder::SamplingFactor::R_4_4_4); + enc.encode(img.data(), 40, 32, jpeg_encoder::ColorType::CmykAsYcck) + .unwrap(); + let back = decode(&bytes).unwrap(); + assert_eq!(back.layout(), ChannelLayout::Cmyk); + assert!(psnr(&img, &back) > 32.0, "{}", psnr(&img, &back)); +} + +#[test] +fn jpeg_cmyk_flat_colour_exact_ish() { + let img = Image::from_u8( + 16, + 16, + ChannelLayout::Cmyk, + [10u8, 100, 200, 50].repeat(256), + ) + .unwrap(); + let back = decode(&encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap()).unwrap(); + for px in back.data().chunks(4) { + for (a, b) in px.iter().zip([10u8, 100, 200, 50]) { + assert!((*a as i32 - b as i32).abs() <= 2); + } + } +} + +#[test] +fn jpeg_rgb_matches_oracle_decoder() { + let img = test_image(ChannelLayout::Rgb, SampleType::U8); + let bytes = encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap(); + let ours = decode(&bytes).unwrap(); + let theirs = image::load_from_memory_with_format(&bytes, image::ImageFormat::Jpeg) + .unwrap() + .into_rgb8(); + let max = ours + .data() + .iter() + .zip(theirs.as_raw()) + .map(|(a, b)| (*a as i32 - *b as i32).abs()) + .max() + .unwrap(); + assert!(max <= 1, "max diff {max}"); +} + +#[test] +fn jpeg_progressive_from_other_encoder_decodes() { + let img = test_image(ChannelLayout::Rgb, SampleType::U8); + let mut bytes = Vec::new(); + let mut enc = jpeg_encoder::Encoder::new(&mut bytes, 90); + enc.set_progressive(true); + enc.encode( + img.data(), + img.width() as u16, + img.height() as u16, + jpeg_encoder::ColorType::Rgb, + ) + .unwrap(); + let back = decode(&bytes).unwrap(); + assert!(psnr(&img, &back) > 25.0); +} + +// --------------------------------------------------------------------------- +// Oracle: files we write are readable by the image crate +// --------------------------------------------------------------------------- + +#[test] +fn png_output_matches_oracle_all_layouts() { + for l in [ + ChannelLayout::Gray, + ChannelLayout::GrayA, + ChannelLayout::Rgb, + ChannelLayout::Rgba, + ] { + for s in [SampleType::U8, SampleType::U16] { + let img = test_image(l, s); + let b = encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + let o = image::load_from_memory(&b) + .unwrap() + .into_rgba16() + .into_raw(); + assert_eq!(o, img.to_rgba16(), "{l:?} {s:?}"); + } + } +} + +#[test] +fn tiff_output_matches_oracle() { + for l in [ChannelLayout::Gray, ChannelLayout::Rgb, ChannelLayout::Rgba] { + for s in [SampleType::U8, SampleType::U16] { + let img = test_image(l, s); + let b = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap(); + let o = image::load_from_memory(&b) + .unwrap() + .into_rgba16() + .into_raw(); + assert_eq!(o, img.to_rgba16(), "{l:?} {s:?}"); + } + } +} + +#[test] +fn tiff_f32_output_matches_oracle() { + let img = test_image(ChannelLayout::Rgba, SampleType::F32); + let b = encode( + &img, + Format::Tiff, + &EncodeOptions { + tiff_compression: TiffCompression::None, + ..Default::default() + }, + ) + .unwrap(); + let o = image::load_from_memory(&b) + .unwrap() + .into_rgba32f() + .into_raw(); + assert_eq!(o, img.to_f32_samples().unwrap()); +} + +// --------------------------------------------------------------------------- +// Optional PngSuite corpus (repo-root corpus/pngsuite/*.png) +// --------------------------------------------------------------------------- + +#[test] +fn pngsuite_corpus_matches_oracle() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/pngsuite"); + let Ok(entries) = std::fs::read_dir(&dir) else { + return; + }; + let mut checked = 0; + for e in entries.flatten() { + let p = e.path(); + if p.extension().and_then(|s| s.to_str()) != Some("png") { + continue; + } + let bytes = std::fs::read(&p).unwrap(); + let oracle = image::load_from_memory_with_format(&bytes, image::ImageFormat::Png); + let ours = decode(&bytes); + match (ours, oracle) { + (Ok(a), Ok(b)) => { + assert_eq!(a.to_rgba16(), b.into_rgba16().into_raw(), "{}", p.display()); + checked += 1; + } + (Err(e), Ok(_)) => panic!("{}: we failed but oracle decoded: {e}", p.display()), + _ => {} + } + } + eprintln!("pngsuite: {checked} files compared"); +} diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs new file mode 100644 index 00000000..fc788109 --- /dev/null +++ b/crates/codecs/tests/roundtrip.rs @@ -0,0 +1,372 @@ +//! Round-trip every writable format × every sample type × every layout. +//! Lossless formats must reproduce the (planned) conversion of the input +//! exactly; lossy formats must stay above a PSNR threshold. + +mod common; +use common::*; +use photocraft_codecs::*; + +/// Minimum PSNR (dB) for lossy formats at default options. +fn psnr_threshold(format: Format) -> f64 { + match format { + Format::Jpeg => 30.0, + Format::Gif => 20.0, + Format::Hdr => 40.0, + Format::Avif => 28.0, + _ => f64::INFINITY, + } +} + +fn check_roundtrip(format: Format, layout: ChannelLayout, sample: SampleType) { + let c = caps(format); + if !c.write { + return; + } + // GIF quantizes; keep its input smooth so the threshold is meaningful. + let img = if format == Format::Gif { + synth(37, 23, layout, sample, 7, 0.0) + } else if c.lossy { + synth(37, 23, layout, sample, 7, 0.02) + } else { + test_image(layout, sample) + }; + let opts = EncodeOptions::default(); + let bytes = encode(&img, format, &opts) + .unwrap_or_else(|e| panic!("{format:?} {layout:?} {sample:?}: {e}")); + assert_eq!(detect(&bytes), Some(format), "detect after encode"); + if !c.read { + return; + } + let back = + decode(&bytes).unwrap_or_else(|e| panic!("{format:?} {layout:?} {sample:?} decode: {e}")); + assert_eq!(back.dimensions(), img.dimensions()); + + // Native storage must be preserved when the format declares support. + let float_pnm_special = format == Format::Pnm && sample.is_float(); + if c.layouts.contains(&layout) && c.depths.contains(&sample) && !float_pnm_special { + assert_eq!(back.layout(), layout, "{format:?} must keep layout"); + assert_eq!( + back.sample_type(), + sample, + "{format:?} must keep sample type" + ); + } + if c.depths.contains(&sample) && !float_pnm_special { + assert_eq!( + back.sample_type(), + sample, + "{format:?} must keep supported depth" + ); + } + // Integer sources never lose precision in formats offering that depth. + if sample == SampleType::U16 && c.depths.contains(&SampleType::U16) { + assert!(matches!( + back.sample_type(), + SampleType::U16 | SampleType::F32 + )); + } + + let expected = img.convert(back.layout(), back.sample_type()); + let mut expected_cmp = expected.clone(); + if format == Format::Gif && layout.has_alpha() { + // GIF alpha is binary: compare colour only on opaque pixels. + let ch = back.layout().channels(); + let mut e = expected.to_normalized(); + let b = back.to_normalized(); + for (pe, pb) in e.chunks_mut(ch).zip(b.chunks(ch)) { + if pb[ch - 1] == 0.0 { + pe.copy_from_slice(pb); + } + pe[ch - 1] = pb[ch - 1]; + } + expected_cmp = Image::from_normalized( + img.width(), + img.height(), + back.layout(), + back.sample_type(), + &e, + ) + .unwrap(); + } + + if c.lossy { + let p = psnr(&expected_cmp, &back); + assert!( + p >= psnr_threshold(format), + "{format:?} {layout:?} {sample:?}: PSNR {p:.2} dB" + ); + } else { + assert_eq!( + expected_cmp.data(), + back.data(), + "{format:?} {layout:?} {sample:?}: lossless round-trip mismatch (max diff {})", + max_abs_diff(&expected_cmp, &back) + ); + } +} + +macro_rules! cases { + ($fmt:expr) => { + use super::*; + cases!(@ $fmt; + gray_u8: Gray, U8; gray_u16: Gray, U16; gray_f16: Gray, F16; gray_f32: Gray, F32; + graya_u8: GrayA, U8; graya_u16: GrayA, U16; graya_f16: GrayA, F16; graya_f32: GrayA, F32; + rgb_u8: Rgb, U8; rgb_u16: Rgb, U16; rgb_f16: Rgb, F16; rgb_f32: Rgb, F32; + rgba_u8: Rgba, U8; rgba_u16: Rgba, U16; rgba_f16: Rgba, F16; rgba_f32: Rgba, F32; + cmyk_u8: Cmyk, U8; cmyk_u16: Cmyk, U16; cmyk_f16: Cmyk, F16; cmyk_f32: Cmyk, F32; + cmyka_u8: CmykA, U8; cmyka_u16: CmykA, U16; cmyka_f16: CmykA, F16; cmyka_f32: CmykA, F32 + ); + }; + (@ $fmt:expr; $($name:ident : $l:ident, $s:ident);*) => { + $( + #[test] + fn $name() { + check_roundtrip($fmt, ChannelLayout::$l, SampleType::$s); + } + )* + }; +} + +mod png { + cases!(Format::Png); +} +mod jpeg { + cases!(Format::Jpeg); +} +mod tiff { + cases!(Format::Tiff); +} +mod webp { + cases!(Format::WebP); +} +mod gif { + cases!(Format::Gif); +} +mod bmp { + cases!(Format::Bmp); +} +mod tga { + cases!(Format::Tga); +} +mod ico { + cases!(Format::Ico); +} +mod pnm { + cases!(Format::Pnm); +} +mod qoi { + cases!(Format::Qoi); +} +mod exr { + cases!(Format::OpenExr); +} +mod hdr { + cases!(Format::Hdr); +} + +// --------------------------------------------------------------------------- +// Targeted round-trips beyond the matrix +// --------------------------------------------------------------------------- + +/// Float formats must keep HDR values (> 1, and negative where allowed). +#[test] +fn hdr_values_survive_float_formats() { + let vals: Vec = (0..16 * 8 * 3).map(|i| (i as f32 - 40.0) * 0.37).collect(); + let img = Image::from_f32(16, 8, ChannelLayout::Rgb, &vals).unwrap(); + for f in [Format::Tiff, Format::OpenExr, Format::Pnm] { + let back = decode(&encode(&img, f, &EncodeOptions::default()).unwrap()).unwrap(); + assert_eq!(back.to_f32_samples().unwrap(), vals, "{f:?}"); + } +} + +#[test] +fn hdr_format_keeps_bright_values_approximately() { + let vals: Vec = (0..16 * 8 * 3).map(|i| 0.01 + i as f32 * 0.25).collect(); + let img = Image::from_f32(16, 8, ChannelLayout::Rgb, &vals).unwrap(); + let back = decode(&encode(&img, Format::Hdr, &EncodeOptions::default()).unwrap()).unwrap(); + // RGBE shares one exponent per pixel: error is relative to the brightest channel. + let got = back.to_f32_samples().unwrap(); + for (pa, pb) in vals.chunks(3).zip(got.chunks(3)) { + let m = pa.iter().copied().fold(0.0, f32::max); + for (a, b) in pa.iter().zip(pb) { + assert!((a - b).abs() <= m * 0.01, "{a} vs {b}"); + } + } +} + +#[test] +fn f16_exr_is_bit_exact() { + let vals: Vec = (0..20 * 10 * 4) + .map(|i| f16::from_f32(i as f32 * 0.013 - 1.0)) + .collect(); + let img = Image::from_f16(20, 10, ChannelLayout::Rgba, &vals).unwrap(); + for comp in [ + ExrCompression::None, + ExrCompression::Rle, + ExrCompression::Zip1, + ExrCompression::Zip16, + ExrCompression::Piz, + ] { + let opts = EncodeOptions { + exr_compression: comp, + ..Default::default() + }; + let back = decode(&encode(&img, Format::OpenExr, &opts).unwrap()).unwrap(); + assert_eq!(back.to_f16_samples().unwrap(), vals, "{comp:?}"); + } +} + +#[test] +fn tiff_all_compressions_lossless() { + for layout in ChannelLayout::ALL { + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let img = test_image(layout, sample); + for comp in [ + TiffCompression::None, + TiffCompression::Lzw, + TiffCompression::Deflate, + TiffCompression::PackBits, + ] { + let opts = EncodeOptions { + tiff_compression: comp, + ..Default::default() + }; + let back = decode(&encode(&img, Format::Tiff, &opts).unwrap()).unwrap(); + assert_eq!( + back, + img.clone().with_meta(back.meta.clone()), + "{layout:?} {sample:?} {comp:?}" + ); + } + } + } +} + +#[test] +fn png_all_compressions_lossless() { + let img = test_image(ChannelLayout::Rgba, SampleType::U16); + for comp in [ + PngCompression::None, + PngCompression::Fast, + PngCompression::Default, + PngCompression::Best, + ] { + let opts = EncodeOptions { + png_compression: comp, + ..Default::default() + }; + let back = decode(&encode(&img, Format::Png, &opts).unwrap()).unwrap(); + assert_eq!(back.data(), img.data(), "{comp:?}"); + } +} + +#[test] +fn jpeg_quality_affects_size_and_error() { + let img = test_image(ChannelLayout::Rgb, SampleType::U8); + let lo = encode( + &img, + Format::Jpeg, + &EncodeOptions { + jpeg_quality: 20, + ..Default::default() + }, + ) + .unwrap(); + let hi = encode( + &img, + Format::Jpeg, + &EncodeOptions { + jpeg_quality: 98, + ..Default::default() + }, + ) + .unwrap(); + assert!(lo.len() < hi.len()); + let plo = psnr(&img, &decode(&lo).unwrap()); + let phi = psnr(&img, &decode(&hi).unwrap()); + assert!(phi > plo, "{phi} <= {plo}"); +} + +#[test] +fn jpeg_444_beats_420_on_chroma_detail() { + let img = synth(64, 64, ChannelLayout::Rgb, SampleType::U8, 3, 0.5); + let a = encode( + &img, + Format::Jpeg, + &EncodeOptions { + jpeg_chroma_subsampling: false, + jpeg_quality: 95, + ..Default::default() + }, + ) + .unwrap(); + let b = encode( + &img, + Format::Jpeg, + &EncodeOptions { + jpeg_chroma_subsampling: true, + jpeg_quality: 95, + ..Default::default() + }, + ) + .unwrap(); + assert!(psnr(&img, &decode(&a).unwrap()) > psnr(&img, &decode(&b).unwrap())); +} + +#[test] +fn webp_lossy_request_is_honest_error() { + let img = test_image(ChannelLayout::Rgb, SampleType::U8); + let e = encode( + &img, + Format::WebP, + &EncodeOptions { + webp_lossless: false, + ..Default::default() + }, + ) + .unwrap_err(); + assert!(matches!( + e, + CodecError::Unsupported { + format: Format::WebP, + .. + } + )); +} + +#[test] +fn one_pixel_images_roundtrip() { + for f in rw_formats() { + let img = synth(1, 1, ChannelLayout::Rgb, SampleType::U8, 1, 0.0); + let back = decode(&encode(&img, f, &EncodeOptions::default()).unwrap()).unwrap(); + assert_eq!(back.dimensions(), (1, 1), "{f:?}"); + } +} + +#[test] +fn ico_rejects_oversize() { + let img = Image::new(300, 10, ChannelLayout::Rgba, SampleType::U8); + assert!(matches!( + encode(&img, Format::Ico, &EncodeOptions::default()), + Err(CodecError::Encode { .. }) + )); + let img = Image::new(256, 256, ChannelLayout::Rgba, SampleType::U8); + assert!(encode(&img, Format::Ico, &EncodeOptions::default()).is_ok()); +} + +#[test] +fn empty_image_rejected() { + let img = Image::new(0, 5, ChannelLayout::Rgb, SampleType::U8); + for f in writable_formats() { + assert!(encode(&img, f, &EncodeOptions::default()).is_err(), "{f:?}"); + } +} + +#[test] +fn larger_image_png_tiff_exact() { + let img = synth(513, 257, ChannelLayout::Rgba, SampleType::U16, 99, 1.0); + for f in [Format::Png, Format::Tiff, Format::Pnm] { + let back = decode(&encode(&img, f, &EncodeOptions::default()).unwrap()).unwrap(); + assert_eq!(back.data(), img.data(), "{f:?}"); + } +} diff --git a/crates/color/Cargo.toml b/crates/color/Cargo.toml new file mode 100644 index 00000000..253601de --- /dev/null +++ b/crates/color/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "photocraft-color" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true } diff --git a/crates/color/src/blend.rs b/crates/color/src/blend.rs new file mode 100644 index 00000000..06bb8ab2 --- /dev/null +++ b/crates/color/src/blend.rs @@ -0,0 +1,469 @@ +//! Photoshop blend modes (scalar reference implementation). +//! +//! Formulas follow the public PDF / ISO 32000-2 and W3C Compositing definitions, +//! with Photoshop's variants where they differ (Soft Light, Hard Mix, Darker/Lighter Color, +//! Linear Burn/Dodge, Vivid/Linear/Pin Light, Subtract, Divide). This is the CPU reference +//! that the GPU kernels must match. + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum BlendMode { + /// Groups only: children blend directly into the group's backdrop. + PassThrough, + Normal, + Dissolve, + Darken, + Multiply, + ColorBurn, + LinearBurn, + DarkerColor, + Lighten, + Screen, + ColorDodge, + LinearDodge, + LighterColor, + Overlay, + SoftLight, + HardLight, + VividLight, + LinearLight, + PinLight, + HardMix, + Difference, + Exclusion, + Subtract, + Divide, + Hue, + Saturation, + Color, + Luminosity, +} + +impl BlendMode { + /// All modes except PassThrough, in Photoshop menu order. + pub const LAYER_MODES: [BlendMode; 27] = [ + BlendMode::Normal, + BlendMode::Dissolve, + BlendMode::Darken, + BlendMode::Multiply, + BlendMode::ColorBurn, + BlendMode::LinearBurn, + BlendMode::DarkerColor, + BlendMode::Lighten, + BlendMode::Screen, + BlendMode::ColorDodge, + BlendMode::LinearDodge, + BlendMode::LighterColor, + BlendMode::Overlay, + BlendMode::SoftLight, + BlendMode::HardLight, + BlendMode::VividLight, + BlendMode::LinearLight, + BlendMode::PinLight, + BlendMode::HardMix, + BlendMode::Difference, + BlendMode::Exclusion, + BlendMode::Subtract, + BlendMode::Divide, + BlendMode::Hue, + BlendMode::Saturation, + BlendMode::Color, + BlendMode::Luminosity, + ]; + + pub fn label(self) -> &'static str { + match self { + BlendMode::PassThrough => "Pass Through", + BlendMode::Normal => "Normal", + BlendMode::Dissolve => "Dissolve", + BlendMode::Darken => "Darken", + BlendMode::Multiply => "Multiply", + BlendMode::ColorBurn => "Color Burn", + BlendMode::LinearBurn => "Linear Burn", + BlendMode::DarkerColor => "Darker Color", + BlendMode::Lighten => "Lighten", + BlendMode::Screen => "Screen", + BlendMode::ColorDodge => "Color Dodge", + BlendMode::LinearDodge => "Linear Dodge (Add)", + BlendMode::LighterColor => "Lighter Color", + BlendMode::Overlay => "Overlay", + BlendMode::SoftLight => "Soft Light", + BlendMode::HardLight => "Hard Light", + BlendMode::VividLight => "Vivid Light", + BlendMode::LinearLight => "Linear Light", + BlendMode::PinLight => "Pin Light", + BlendMode::HardMix => "Hard Mix", + BlendMode::Difference => "Difference", + BlendMode::Exclusion => "Exclusion", + BlendMode::Subtract => "Subtract", + BlendMode::Divide => "Divide", + BlendMode::Hue => "Hue", + BlendMode::Saturation => "Saturation", + BlendMode::Color => "Color", + BlendMode::Luminosity => "Luminosity", + } + } + + /// PSD 4-character blend key. + pub fn psd_key(self) -> [u8; 4] { + *match self { + BlendMode::PassThrough => b"pass", + BlendMode::Normal => b"norm", + BlendMode::Dissolve => b"diss", + BlendMode::Darken => b"dark", + BlendMode::Multiply => b"mul ", + BlendMode::ColorBurn => b"idiv", + BlendMode::LinearBurn => b"lbrn", + BlendMode::DarkerColor => b"dkCl", + BlendMode::Lighten => b"lite", + BlendMode::Screen => b"scrn", + BlendMode::ColorDodge => b"div ", + BlendMode::LinearDodge => b"lddg", + BlendMode::LighterColor => b"lgCl", + BlendMode::Overlay => b"over", + BlendMode::SoftLight => b"sLit", + BlendMode::HardLight => b"hLit", + BlendMode::VividLight => b"vLit", + BlendMode::LinearLight => b"lLit", + BlendMode::PinLight => b"pLit", + BlendMode::HardMix => b"hMix", + BlendMode::Difference => b"diff", + BlendMode::Exclusion => b"smud", + BlendMode::Subtract => b"fsub", + BlendMode::Divide => b"fdiv", + BlendMode::Hue => b"hue ", + BlendMode::Saturation => b"sat ", + BlendMode::Color => b"colr", + BlendMode::Luminosity => b"lum ", + } + } + + pub fn from_psd_key(key: [u8; 4]) -> Option { + std::iter::once(BlendMode::PassThrough) + .chain(Self::LAYER_MODES) + .find(|m| m.psd_key() == key) + } + + pub fn is_separable(self) -> bool { + !matches!( + self, + BlendMode::Hue | BlendMode::Saturation | BlendMode::Color | BlendMode::Luminosity | BlendMode::DarkerColor | BlendMode::LighterColor + ) + } +} + +/// Separable per-channel blend function `B(cb, cs)`. +#[inline] +pub fn blend_channel(mode: BlendMode, cb: f32, cs: f32) -> f32 { + match mode { + BlendMode::Normal | BlendMode::Dissolve | BlendMode::PassThrough => cs, + BlendMode::Darken => cb.min(cs), + BlendMode::Multiply => cb * cs, + BlendMode::ColorBurn => color_burn(cb, cs), + BlendMode::LinearBurn => (cb + cs - 1.0).max(0.0), + BlendMode::Lighten => cb.max(cs), + BlendMode::Screen => cb + cs - cb * cs, + BlendMode::ColorDodge => color_dodge(cb, cs), + BlendMode::LinearDodge => (cb + cs).min(1.0), + BlendMode::Overlay => hard_light(cs, cb), + BlendMode::SoftLight => soft_light_ps(cb, cs), + BlendMode::HardLight => hard_light(cb, cs), + BlendMode::VividLight => { + if cs <= 0.5 { + color_burn(cb, 2.0 * cs) + } else { + color_dodge(cb, 2.0 * (cs - 0.5)) + } + } + BlendMode::LinearLight => (cb + 2.0 * cs - 1.0).clamp(0.0, 1.0), + BlendMode::PinLight => { + if cs <= 0.5 { + cb.min(2.0 * cs) + } else { + cb.max(2.0 * cs - 1.0) + } + } + BlendMode::HardMix => { + if cb + cs >= 1.0 - 1e-6 { + 1.0 + } else { + 0.0 + } + } + BlendMode::Difference => (cb - cs).abs(), + BlendMode::Exclusion => cb + cs - 2.0 * cb * cs, + BlendMode::Subtract => (cb - cs).max(0.0), + BlendMode::Divide => { + if cs <= 0.0 { + if cb <= 0.0 { 0.0 } else { 1.0 } + } else { + (cb / cs).min(1.0) + } + } + // Non-separable modes are handled by `blend_rgb`; fall back to source. + BlendMode::DarkerColor | BlendMode::LighterColor | BlendMode::Hue | BlendMode::Saturation | BlendMode::Color | BlendMode::Luminosity => cs, + } +} + +#[inline] +fn color_burn(cb: f32, cs: f32) -> f32 { + if cb >= 1.0 { + 1.0 + } else if cs <= 0.0 { + 0.0 + } else { + 1.0 - ((1.0 - cb) / cs).min(1.0) + } +} + +#[inline] +fn color_dodge(cb: f32, cs: f32) -> f32 { + if cb <= 0.0 { + 0.0 + } else if cs >= 1.0 { + 1.0 + } else { + (cb / (1.0 - cs)).min(1.0) + } +} + +#[inline] +fn hard_light(cb: f32, cs: f32) -> f32 { + if cs <= 0.5 { + cb * 2.0 * cs + } else { + let s = 2.0 * cs - 1.0; + cb + s - cb * s + } +} + +/// Photoshop's Soft Light (differs from the W3C/PDF definition in the upper half). +#[inline] +fn soft_light_ps(cb: f32, cs: f32) -> f32 { + if cs <= 0.5 { + 2.0 * cb * cs + cb * cb * (1.0 - 2.0 * cs) + } else { + 2.0 * cb * (1.0 - cs) + cb.max(0.0).sqrt() * (2.0 * cs - 1.0) + } +} + +// ---- non-separable helpers (W3C Compositing Level 1) ---- + +#[inline] +pub fn lum(c: [f32; 3]) -> f32 { + 0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2] +} + +fn clip_color(c: [f32; 3]) -> [f32; 3] { + let l = lum(c); + let n = c[0].min(c[1]).min(c[2]); + let x = c[0].max(c[1]).max(c[2]); + let mut out = c; + if n < 0.0 { + let d = l - n; + for v in &mut out { + *v = if d.abs() < 1e-12 { l } else { l + (*v - l) * l / d }; + } + } + if x > 1.0 { + let d = x - l; + for v in &mut out { + *v = if d.abs() < 1e-12 { l } else { l + (*v - l) * (1.0 - l) / d }; + } + } + out +} + +fn set_lum(c: [f32; 3], l: f32) -> [f32; 3] { + let d = l - lum(c); + clip_color([c[0] + d, c[1] + d, c[2] + d]) +} + +fn sat(c: [f32; 3]) -> f32 { + c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2]) +} + +fn set_sat(c: [f32; 3], s: f32) -> [f32; 3] { + let mut idx = [0usize, 1, 2]; + idx.sort_by(|&a, &b| c[a].partial_cmp(&c[b]).unwrap_or(std::cmp::Ordering::Equal)); + let (imin, imid, imax) = (idx[0], idx[1], idx[2]); + let mut out = [0.0f32; 3]; + if c[imax] > c[imin] { + out[imid] = (c[imid] - c[imin]) * s / (c[imax] - c[imin]); + out[imax] = s; + } + out[imin] = 0.0; + out +} + +/// Blend a whole RGB triple: `B(Cb, Cs)` including non-separable modes. +pub fn blend_rgb(mode: BlendMode, cb: [f32; 3], cs: [f32; 3]) -> [f32; 3] { + match mode { + BlendMode::Hue => set_lum(set_sat(cs, sat(cb)), lum(cb)), + BlendMode::Saturation => set_lum(set_sat(cb, sat(cs)), lum(cb)), + BlendMode::Color => set_lum(cs, lum(cb)), + BlendMode::Luminosity => set_lum(cb, lum(cs)), + BlendMode::DarkerColor => { + if lum(cs) < lum(cb) { cs } else { cb } + } + BlendMode::LighterColor => { + if lum(cs) > lum(cb) { cs } else { cb } + } + m => [blend_channel(m, cb[0], cs[0]), blend_channel(m, cb[1], cs[1]), blend_channel(m, cb[2], cs[2])], + } +} + +/// Composite a straight-alpha source over a straight-alpha backdrop with blend mode and opacity. +/// +/// Uses the W3C/PDF general formula: +/// `αo = αs + αb(1−αs)`, +/// `αo·Co = (1−αs)·αb·Cb + (1−αb)·αs·Cs + αs·αb·B(Cb,Cs)`. +/// Dissolve must be resolved by the caller (per-pixel coverage decision) before calling. +pub fn composite(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity: f32) -> [f32; 4] { + let ab = backdrop[3]; + let as_ = source[3] * opacity; + if as_ <= 0.0 { + return backdrop; + } + let cb = [backdrop[0], backdrop[1], backdrop[2]]; + let cs = [source[0], source[1], source[2]]; + let b = blend_rgb(mode, cb, cs); + let ao = as_ + ab * (1.0 - as_); + if ao <= 0.0 { + return [0.0; 4]; + } + let mut out = [0.0f32; 4]; + for i in 0..3 { + let premul = (1.0 - as_) * ab * cb[i] + (1.0 - ab) * as_ * cs[i] + as_ * ab * b[i]; + out[i] = premul / ao; + } + out[3] = ao; + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const E: f32 = 1e-5; + + fn close(a: f32, b: f32) -> bool { + (a - b).abs() < E + } + + #[test] + fn psd_keys_roundtrip_and_unique() { + let mut seen = std::collections::HashSet::new(); + for m in std::iter::once(BlendMode::PassThrough).chain(BlendMode::LAYER_MODES) { + assert!(seen.insert(m.psd_key()), "duplicate key {m:?}"); + assert_eq!(BlendMode::from_psd_key(m.psd_key()), Some(m)); + } + assert_eq!(BlendMode::from_psd_key(*b"zzzz"), None); + assert_eq!(seen.len(), 28); + } + + #[test] + fn normal_is_source() { + for &(cb, cs) in &[(0.2, 0.7), (1.0, 0.0), (0.0, 1.0)] { + assert!(close(blend_channel(BlendMode::Normal, cb, cs), cs)); + } + } + + #[test] + fn identities_with_white_and_black() { + // Multiply by white / screen with black / add black / darken with white leave backdrop unchanged. + for cb in [0.0f32, 0.1, 0.5, 0.9, 1.0] { + assert!(close(blend_channel(BlendMode::Multiply, cb, 1.0), cb)); + assert!(close(blend_channel(BlendMode::Screen, cb, 0.0), cb)); + assert!(close(blend_channel(BlendMode::LinearDodge, cb, 0.0), cb)); + assert!(close(blend_channel(BlendMode::Darken, cb, 1.0), cb)); + assert!(close(blend_channel(BlendMode::Lighten, cb, 0.0), cb)); + assert!(close(blend_channel(BlendMode::Difference, cb, 0.0), cb)); + assert!(close(blend_channel(BlendMode::Subtract, cb, 0.0), cb)); + assert!(close(blend_channel(BlendMode::LinearBurn, cb, 1.0), cb)); + assert!(close(blend_channel(BlendMode::Exclusion, cb, 0.0), cb)); + // 50% gray is neutral for the "light" family + assert!(close(blend_channel(BlendMode::HardLight, cb, 0.5), cb)); + assert!(close(blend_channel(BlendMode::SoftLight, cb, 0.5), cb)); + assert!(close(blend_channel(BlendMode::LinearLight, cb, 0.5), cb)); + } + } + + #[test] + fn known_values() { + assert!(close(blend_channel(BlendMode::Multiply, 0.5, 0.5), 0.25)); + assert!(close(blend_channel(BlendMode::Screen, 0.5, 0.5), 0.75)); + assert!(close(blend_channel(BlendMode::Overlay, 0.25, 0.5), 0.25)); + assert!(close(blend_channel(BlendMode::Overlay, 0.75, 1.0), 1.0)); + assert!(close(blend_channel(BlendMode::ColorDodge, 0.5, 0.5), 1.0)); + assert!(close(blend_channel(BlendMode::ColorBurn, 0.5, 0.5), 0.0)); + assert!(close(blend_channel(BlendMode::ColorBurn, 1.0, 0.0), 1.0)); + assert!(close(blend_channel(BlendMode::ColorDodge, 0.0, 1.0), 0.0)); + assert!(close(blend_channel(BlendMode::Divide, 0.25, 0.5), 0.5)); + assert!(close(blend_channel(BlendMode::Divide, 0.0, 0.0), 0.0)); + assert!(close(blend_channel(BlendMode::Divide, 0.3, 0.0), 1.0)); + assert!(close(blend_channel(BlendMode::Exclusion, 0.5, 0.5), 0.5)); + assert!(close(blend_channel(BlendMode::PinLight, 0.8, 0.2), 0.4)); + assert!(close(blend_channel(BlendMode::PinLight, 0.1, 0.9), 0.8)); + assert!(close(blend_channel(BlendMode::HardMix, 0.6, 0.4), 1.0)); + assert!(close(blend_channel(BlendMode::HardMix, 0.6, 0.3), 0.0)); + } + + #[test] + fn separable_outputs_stay_in_unit_range() { + let steps: Vec = (0..=20).map(|i| i as f32 / 20.0).collect(); + for m in BlendMode::LAYER_MODES { + for &cb in &steps { + for &cs in &steps { + let v = blend_rgb(m, [cb, cs, 0.5], [cs, cb, 0.25]); + for c in v { + assert!((-E..=1.0 + E).contains(&c), "{m:?} cb={cb} cs={cs} -> {c}"); + } + } + } + } + } + + #[test] + fn luminosity_and_color_swap_components() { + let cb = [0.8, 0.2, 0.2]; + let cs = [0.1, 0.1, 0.9]; + let l = blend_rgb(BlendMode::Luminosity, cb, cs); + assert!(close(lum(l), lum(cs))); + let c = blend_rgb(BlendMode::Color, cb, cs); + assert!(close(lum(c), lum(cb))); + let h = blend_rgb(BlendMode::Hue, cb, cs); + assert!(close(lum(h), lum(cb))); + let s = blend_rgb(BlendMode::Saturation, cb, [0.5, 0.5, 0.5]); + // zero-saturation source desaturates the backdrop + assert!(close(s[0], s[1]) && close(s[1], s[2])); + } + + #[test] + fn darker_lighter_color_pick_whole_pixel() { + let a = [0.9, 0.1, 0.1]; + let b = [0.2, 0.6, 0.2]; + assert_eq!(blend_rgb(BlendMode::DarkerColor, a, b), if lum(b) < lum(a) { b } else { a }); + assert_eq!(blend_rgb(BlendMode::LighterColor, a, b), if lum(b) > lum(a) { b } else { a }); + } + + #[test] + fn composite_alpha_rules() { + let bg = [0.2, 0.4, 0.6, 1.0]; + // Transparent source leaves backdrop. + assert_eq!(composite(BlendMode::Multiply, bg, [1.0, 0.0, 0.0, 0.0], 1.0), bg); + // Opaque normal source replaces. + let r = composite(BlendMode::Normal, bg, [1.0, 0.0, 0.0, 1.0], 1.0); + assert!(close(r[0], 1.0) && close(r[3], 1.0)); + // Half opacity normal = lerp. + let r = composite(BlendMode::Normal, bg, [1.0, 1.0, 1.0, 1.0], 0.5); + assert!(close(r[0], 0.6) && close(r[1], 0.7) && close(r[2], 0.8)); + // Blend over transparent backdrop yields source regardless of mode. + let r = composite(BlendMode::Multiply, [0.0; 4], [0.3, 0.5, 0.7, 1.0], 1.0); + assert!(close(r[0], 0.3) && close(r[1], 0.5) && close(r[2], 0.7) && close(r[3], 1.0)); + // Alpha accumulates. + let r = composite(BlendMode::Screen, [0.0, 0.0, 0.0, 0.5], [0.0, 0.0, 0.0, 0.5], 1.0); + assert!(close(r[3], 0.75)); + } +} diff --git a/crates/color/src/convert.rs b/crates/color/src/convert.rs new file mode 100644 index 00000000..0e248ce7 --- /dev/null +++ b/crates/color/src/convert.rs @@ -0,0 +1,113 @@ +//! Colour conversions that don't need ICC profiles. +//! Full ICC colour management (moxcms) comes in milestone M8; these are the building blocks. + +/// sRGB transfer function: encoded → linear. +#[inline] +pub fn srgb_to_linear(v: f32) -> f32 { + if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } +} + +/// sRGB transfer function: linear → encoded. +#[inline] +pub fn linear_to_srgb(v: f32) -> f32 { + if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } +} + +/// Naive CMYK → RGB (no profile). Used only for previews until ICC is wired in. +pub fn cmyk_to_rgb_naive(c: [f32; 4]) -> [f32; 3] { + let k = 1.0 - c[3]; + [(1.0 - c[0]) * k, (1.0 - c[1]) * k, (1.0 - c[2]) * k] +} + +/// Naive RGB → CMYK with full GCR. +pub fn rgb_to_cmyk_naive(rgb: [f32; 3]) -> [f32; 4] { + let k = 1.0 - rgb[0].max(rgb[1]).max(rgb[2]); + if k >= 1.0 - 1e-6 { + return [0.0, 0.0, 0.0, 1.0]; + } + let d = 1.0 - k; + [(1.0 - rgb[0] - k) / d, (1.0 - rgb[1] - k) / d, (1.0 - rgb[2] - k) / d, k] +} + +const D50: [f32; 3] = [0.964_22, 1.0, 0.825_21]; + +/// CIE L*a*b* (D50) → encoded sRGB (Bradford-adapted to D65). +pub fn lab_to_srgb(lab: [f32; 3]) -> [f32; 3] { + let [l, a, b] = lab; + let fy = (l + 16.0) / 116.0; + let fx = fy + a / 500.0; + let fz = fy - b / 200.0; + let finv = |t: f32| if t > 6.0 / 29.0 { t * t * t } else { 3.0 * (6.0f32 / 29.0).powi(2) * (t - 4.0 / 29.0) }; + let xyz = [finv(fx) * D50[0], finv(fy) * D50[1], finv(fz) * D50[2]]; + // D50 XYZ → linear sRGB (Bradford-adapted matrix) + let m = [ + [3.133_856, -1.616_867, -0.490_615], + [-0.978_768, 1.916_142, 0.033_454], + [0.071_945, -0.228_991, 1.405_243], + ]; + let mut out = [0.0f32; 3]; + for i in 0..3 { + let lin = m[i][0] * xyz[0] + m[i][1] * xyz[1] + m[i][2] * xyz[2]; + out[i] = linear_to_srgb(lin.clamp(0.0, 1.0)); + } + out +} + +/// Encoded sRGB → CIE L*a*b* (D50). +pub fn srgb_to_lab(rgb: [f32; 3]) -> [f32; 3] { + let lin = [srgb_to_linear(rgb[0]), srgb_to_linear(rgb[1]), srgb_to_linear(rgb[2])]; + let m = [ + [0.436_074, 0.385_064, 0.143_080], + [0.222_504, 0.716_878, 0.060_618], + [0.013_932, 0.097_104, 0.714_173], + ]; + let mut xyz = [0.0f32; 3]; + for i in 0..3 { + xyz[i] = (m[i][0] * lin[0] + m[i][1] * lin[1] + m[i][2] * lin[2]) / D50[i]; + } + let f = |t: f32| if t > (6.0f32 / 29.0).powi(3) { t.cbrt() } else { t / (3.0 * (6.0f32 / 29.0).powi(2)) + 4.0 / 29.0 }; + let (fx, fy, fz) = (f(xyz[0]), f(xyz[1]), f(xyz[2])); + [116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)] +} + +/// Rec.601 luma used by Photoshop's Grayscale conversion default. +pub fn rgb_to_gray(rgb: [f32; 3]) -> f32 { + 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn srgb_roundtrip() { + for i in 0..=255 { + let v = i as f32 / 255.0; + assert!((linear_to_srgb(srgb_to_linear(v)) - v).abs() < 1e-5); + } + } + + #[test] + fn cmyk_roundtrip() { + for rgb in [[1.0, 0.0, 0.0], [0.2, 0.4, 0.6], [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]] { + let back = cmyk_to_rgb_naive(rgb_to_cmyk_naive(rgb)); + for i in 0..3 { + assert!((back[i] - rgb[i]).abs() < 1e-5, "{rgb:?} -> {back:?}"); + } + } + } + + #[test] + fn lab_roundtrip_and_anchors() { + let white = srgb_to_lab([1.0, 1.0, 1.0]); + assert!((white[0] - 100.0).abs() < 0.1 && white[1].abs() < 0.5 && white[2].abs() < 0.5, "{white:?}"); + let black = srgb_to_lab([0.0, 0.0, 0.0]); + assert!(black[0].abs() < 0.1); + for rgb in [[0.8, 0.3, 0.1], [0.1, 0.5, 0.9], [0.5, 0.5, 0.5]] { + let back = lab_to_srgb(srgb_to_lab(rgb)); + for i in 0..3 { + assert!((back[i] - rgb[i]).abs() < 2e-3, "{rgb:?} -> {back:?}"); + } + } + } +} diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs new file mode 100644 index 00000000..bd909514 --- /dev/null +++ b/crates/color/src/lib.rs @@ -0,0 +1,220 @@ +//! Pixel formats, colour models and blend-mode math. +//! +//! Bit depth and colour model are *runtime data* (see `plan/architecture.md` §1.1): +//! nothing in the engine assumes 8-bit or RGB. +#![forbid(unsafe_code)] + +pub mod blend; +pub mod convert; + +pub use blend::BlendMode; + +use serde::{Deserialize, Serialize}; + +/// Storage type of one channel sample. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SampleType { + U8, + U16, + F32, +} + +impl SampleType { + pub const ALL: [SampleType; 3] = [SampleType::U8, SampleType::U16, SampleType::F32]; + + pub const fn bytes(self) -> usize { + match self { + SampleType::U8 => 1, + SampleType::U16 => 2, + SampleType::F32 => 4, + } + } + pub const fn bits(self) -> u32 { + self.bytes() as u32 * 8 + } +} + +/// Document colour model (Photoshop "Image → Mode"). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ColorMode { + Bitmap, + Grayscale, + Indexed, + Rgb, + Cmyk, + Lab, + Multichannel, + Duotone, +} + +impl ColorMode { + /// Number of colour (non-alpha) channels used for pixel storage. + /// Indexed and Bitmap documents are stored expanded (RGB / Gray) for editing. + pub const fn color_channels(self) -> usize { + match self { + ColorMode::Bitmap | ColorMode::Grayscale | ColorMode::Duotone => 1, + ColorMode::Indexed | ColorMode::Rgb | ColorMode::Lab => 3, + ColorMode::Cmyk => 4, + // Multichannel documents carry their channel count separately; default to 3. + ColorMode::Multichannel => 3, + } + } +} + +/// Layout of a pixel: colour model, sample type, and whether an alpha channel follows the colour channels. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct PixelFormat { + pub mode: ColorMode, + pub sample: SampleType, + pub alpha: bool, +} + +impl PixelFormat { + pub const RGBA8: PixelFormat = PixelFormat { mode: ColorMode::Rgb, sample: SampleType::U8, alpha: true }; + pub const RGBA16: PixelFormat = PixelFormat { mode: ColorMode::Rgb, sample: SampleType::U16, alpha: true }; + pub const RGBA32F: PixelFormat = PixelFormat { mode: ColorMode::Rgb, sample: SampleType::F32, alpha: true }; + pub const GRAY8: PixelFormat = PixelFormat { mode: ColorMode::Grayscale, sample: SampleType::U8, alpha: false }; + pub const GRAYA8: PixelFormat = PixelFormat { mode: ColorMode::Grayscale, sample: SampleType::U8, alpha: true }; + pub const CMYKA8: PixelFormat = PixelFormat { mode: ColorMode::Cmyk, sample: SampleType::U8, alpha: true }; + + pub const fn new(mode: ColorMode, sample: SampleType, alpha: bool) -> Self { + Self { mode, sample, alpha } + } + pub const fn channels(&self) -> usize { + self.mode.color_channels() + self.alpha as usize + } + pub const fn bytes_per_pixel(&self) -> usize { + self.channels() * self.sample.bytes() + } + pub const fn with_sample(self, sample: SampleType) -> Self { + Self { sample, ..self } + } +} + +/// Read one sample at `index` (sample index, not byte index) as normalised f32. +/// U8/U16 map to [0, 1]; F32 is returned as stored. +#[inline] +pub fn read_sample(bytes: &[u8], sample: SampleType, index: usize) -> f32 { + match sample { + SampleType::U8 => bytes[index] as f32 / 255.0, + SampleType::U16 => { + let o = index * 2; + u16::from_ne_bytes([bytes[o], bytes[o + 1]]) as f32 / 65535.0 + } + SampleType::F32 => { + let o = index * 4; + f32::from_ne_bytes([bytes[o], bytes[o + 1], bytes[o + 2], bytes[o + 3]]) + } + } +} + +/// Write a normalised f32 sample; integer types are clamped and rounded. +#[inline] +pub fn write_sample(bytes: &mut [u8], sample: SampleType, index: usize, v: f32) { + match sample { + SampleType::U8 => bytes[index] = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8, + SampleType::U16 => { + let o = index * 2; + let q = (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16; + bytes[o..o + 2].copy_from_slice(&q.to_ne_bytes()); + } + SampleType::F32 => { + let o = index * 4; + bytes[o..o + 4].copy_from_slice(&v.to_ne_bytes()); + } + } +} + +/// A colour value in the document's model with alpha, as normalised floats. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Color { + pub mode: ColorMode, + /// Up to 4 colour components (unused ones are 0) followed by nothing; alpha kept separately. + pub c: [f32; 4], + pub alpha: f32, +} + +impl Color { + pub const fn rgb(r: f32, g: f32, b: f32) -> Self { + Self { mode: ColorMode::Rgb, c: [r, g, b, 0.0], alpha: 1.0 } + } + pub const fn rgba(r: f32, g: f32, b: f32, a: f32) -> Self { + Self { mode: ColorMode::Rgb, c: [r, g, b, 0.0], alpha: a } + } + pub const fn gray(v: f32) -> Self { + Self { mode: ColorMode::Grayscale, c: [v, 0.0, 0.0, 0.0], alpha: 1.0 } + } + pub const BLACK: Color = Color::rgb(0.0, 0.0, 0.0); + pub const WHITE: Color = Color::rgb(1.0, 1.0, 1.0); + pub const TRANSPARENT: Color = Color::rgba(0.0, 0.0, 0.0, 0.0); + + /// Convert to display RGB (naive, non-ICC conversion; ICC comes via `convert` later). + pub fn to_rgb(&self) -> [f32; 3] { + match self.mode { + ColorMode::Rgb | ColorMode::Indexed | ColorMode::Multichannel => [self.c[0], self.c[1], self.c[2]], + ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone => [self.c[0]; 3], + ColorMode::Cmyk => convert::cmyk_to_rgb_naive([self.c[0], self.c[1], self.c[2], self.c[3]]), + ColorMode::Lab => convert::lab_to_srgb([self.c[0] * 100.0, self.c[1] * 255.0 - 128.0, self.c[2] * 255.0 - 128.0]), + } + } + + pub fn to_rgba8(&self) -> [u8; 4] { + let [r, g, b] = self.to_rgb(); + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + [q(r), q(g), q(b), q(self.alpha)] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pixel_format_sizes() { + assert_eq!(PixelFormat::RGBA8.bytes_per_pixel(), 4); + assert_eq!(PixelFormat::RGBA16.bytes_per_pixel(), 8); + assert_eq!(PixelFormat::RGBA32F.bytes_per_pixel(), 16); + assert_eq!(PixelFormat::GRAY8.bytes_per_pixel(), 1); + assert_eq!(PixelFormat::CMYKA8.channels(), 5); + } + + #[test] + fn sample_roundtrip_all_depths() { + for s in SampleType::ALL { + let mut buf = vec![0u8; 16]; + for (i, v) in [0.0f32, 0.25, 0.5, 1.0].iter().enumerate() { + write_sample(&mut buf, s, i, *v); + let r = read_sample(&buf, s, i); + let tol = match s { + SampleType::U8 => 1.0 / 255.0, + SampleType::U16 => 1.0 / 65535.0, + SampleType::F32 => 0.0, + }; + assert!((r - v).abs() <= tol, "{s:?} {v} -> {r}"); + } + } + } + + #[test] + fn integer_samples_clamp() { + let mut b = [0u8; 2]; + write_sample(&mut b, SampleType::U8, 0, 1.7); + write_sample(&mut b, SampleType::U8, 1, -0.3); + assert_eq!(b, [255, 0]); + } + + #[test] + fn float_samples_keep_hdr() { + let mut b = [0u8; 4]; + write_sample(&mut b, SampleType::F32, 0, 4.5); + assert_eq!(read_sample(&b, SampleType::F32, 0), 4.5); + } + + #[test] + fn color_to_rgba8() { + assert_eq!(Color::WHITE.to_rgba8(), [255, 255, 255, 255]); + assert_eq!(Color::gray(0.5).to_rgba8(), [128, 128, 128, 255]); + let k = Color { mode: ColorMode::Cmyk, c: [0.0, 0.0, 0.0, 1.0], alpha: 1.0 }; + assert_eq!(k.to_rgba8(), [0, 0, 0, 255]); + } +} diff --git a/crates/compose/Cargo.toml b/crates/compose/Cargo.toml new file mode 100644 index 00000000..efa46bed --- /dev/null +++ b/crates/compose/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "photocraft-compose" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +photocraft-geom = { workspace = true } +photocraft-color = { workspace = true } +photocraft-raster = { workspace = true } +photocraft-doc = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs new file mode 100644 index 00000000..5639bdb1 --- /dev/null +++ b/crates/compose/src/adjust.rs @@ -0,0 +1,345 @@ +//! Evaluation of adjustment layers on a composite buffer. +//! +//! Formulas are documented approximations of Photoshop behaviour. Exact matching is tuned in +//! milestone M7 against Photoshop-rendered PSD composites (the oracle in `testkit`). + +use photocraft_color::convert::rgb_to_gray; +use photocraft_doc::Adjustment; +use photocraft_doc::adjust::{CurvePoint, LevelsChannel}; + +use crate::Buffer; + +/// The document tone curve used to linearize values for adjustments that +/// work in linear light (Exposure). Until ICC profiles are wired in (M8) this +/// approximates the Photoshop defaults: sRGB for colour documents and the +/// "Dot Gain 20%" grey profile (≈ gamma 1.73, fitted on the corpus) for +/// grayscale documents. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Transfer { + /// sRGB piecewise curve. + Srgb, + /// Pure power law with this exponent. + Gamma(f32), +} + +impl Transfer { + /// Default transfer for a document colour mode. + pub fn for_mode(mode: photocraft_color::ColorMode) -> Self { + match mode { + photocraft_color::ColorMode::Grayscale | photocraft_color::ColorMode::Duotone | photocraft_color::ColorMode::Bitmap => { + Transfer::Gamma(1.732) + } + _ => Transfer::Srgb, + } + } + fn decode(self, v: f32) -> f32 { + match self { + Transfer::Srgb => photocraft_color::convert::srgb_to_linear(v.max(0.0)), + Transfer::Gamma(g) => v.max(0.0).powf(g), + } + } + fn encode(self, v: f32) -> f32 { + match self { + Transfer::Srgb => photocraft_color::convert::linear_to_srgb(v.max(0.0)), + Transfer::Gamma(g) => v.max(0.0).powf(1.0 / g), + } + } +} + +/// Applies an adjustment assuming an sRGB document. +pub fn apply(adj: &Adjustment, buf: &mut Buffer) { + apply_with(adj, buf, Transfer::Srgb); +} + +/// Applies an adjustment with the document's tone transfer. +pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) { + match adj { + Adjustment::Invert => map_rgb(buf, |c| [1.0 - c[0], 1.0 - c[1], 1.0 - c[2]]), + Adjustment::Threshold { level } => { + // Compared on the 8-bit grid like Photoshop (avoids float ties). + let t = (level * 255.0).round(); + map_rgb(buf, |c| { + let v = if (rgb_to_gray(c) * 255.0).round() >= t { 1.0 } else { 0.0 }; + [v; 3] + }) + } + Adjustment::Posterize { levels } => map_rgb(buf, |c| c.map(|v| posterize(v, *levels))), + Adjustment::BrightnessContrast { brightness, contrast, legacy: true } => { + // Legacy: contrast scales around mid-grey, then brightness is added + // (fitted to Photoshop's rendering, exact on the corpus). + let b = brightness / 255.0; + let c = contrast.clamp(-100.0, 99.0); + let k = if c >= 0.0 { 1.0 / (1.0 - c / 100.0) } else { 1.0 + c / 100.0 }; + map_rgb(buf, |px| px.map(|v| ((v - 0.5) * k + 0.5 + b).clamp(0.0, 1.0))) + } + Adjustment::BrightnessContrast { brightness, contrast, .. } => { + // brightness in [-150,150], contrast in [-50,100] (Photoshop UI ranges) + let b = brightness / 255.0; + let k = if *contrast >= 0.0 { 1.0 + contrast / 50.0 } else { 1.0 + contrast / 100.0 }; + map_rgb(buf, |c| c.map(|v| ((v + b - 0.5) * k + 0.5).clamp(0.0, 1.0))) + } + Adjustment::Exposure { exposure, offset, gamma } => { + // In linear light: (lin·2^exposure + offset)^(1/gamma), then back + // through the document tone curve (fitted on the corpus). + let m = 2f32.powf(*exposure); + let g = gamma.max(0.01); + map_rgb(buf, |c| { + c.map(|v| { + let lin = (transfer.decode(v) * m + offset).max(0.0).powf(1.0 / g); + transfer.encode(lin).clamp(0.0, 1.0) + }) + }) + } + Adjustment::Levels { master, per_channel } => { + let luts: [Vec; 3] = std::array::from_fn(|i| { + (0..LUT_SIZE).map(|k| levels(&per_channel[i], levels(master, k as f32 / (LUT_SIZE - 1) as f32))).collect() + }); + map_rgb(buf, |c| std::array::from_fn(|i| lut(&luts[i], c[i]))) + } + Adjustment::Curves { master, per_channel } => { + // Photoshop applies each channel's curve first, then the + // composite (RGB) curve. + let m = curve_lut(master); + let luts: [Vec; 3] = std::array::from_fn(|i| { + let ch = curve_lut(&per_channel[i]); + ch.iter().map(|&v| lut(&m, v)).collect() + }); + map_rgb(buf, |c| std::array::from_fn(|i| lut(&luts[i], c[i]))) + } + Adjustment::HueSaturation { hue, saturation, lightness, colorize } => { + let (h, s, l) = (*hue, *saturation / 100.0, *lightness / 100.0); + map_rgb(buf, |c| { + let (mut hh, mut ss, ll) = rgb_to_hsl(c); + if *colorize { + hh = h.rem_euclid(360.0) / 360.0; + ss = s.abs().max(0.25); + } else { + hh = (hh + h / 360.0).rem_euclid(1.0); + ss = (ss * (1.0 + s)).clamp(0.0, 1.0); + } + let mut rgb = hsl_to_rgb(hh, ss, ll); + if l > 0.0 { + rgb = rgb.map(|v| v + (1.0 - v) * l); + } else if l < 0.0 { + rgb = rgb.map(|v| v * (1.0 + l)); + } + rgb + }) + } + Adjustment::Vibrance { vibrance, saturation } => { + let (v, s) = (*vibrance / 100.0, *saturation / 100.0); + map_rgb(buf, |c| { + let (h, sat, l) = rgb_to_hsl(c); + let boost = v * (1.0 - sat); // less saturated colours move more + let ns = (sat * (1.0 + s) + boost * sat.max(0.1)).clamp(0.0, 1.0); + hsl_to_rgb(h, ns, l) + }) + } + Adjustment::ChannelMixer { matrix, monochrome } => map_rgb(buf, |c| { + let mix = |row: &[f32; 4]| (row[0] * c[0] + row[1] * c[1] + row[2] * c[2] + row[3]).clamp(0.0, 1.0); + if *monochrome { + [mix(&matrix[0]); 3] + } else { + [mix(&matrix[0]), mix(&matrix[1]), mix(&matrix[2])] + } + }), + Adjustment::PhotoFilter { color, density, preserve_luminosity } => map_rgb(buf, |c| { + let filtered: [f32; 3] = std::array::from_fn(|i| c[i] * (1.0 - density) + c[i] * color[i] * density); + if *preserve_luminosity { + let (l0, l1) = (rgb_to_gray(c), rgb_to_gray(filtered).max(1e-6)); + filtered.map(|v| (v * l0 / l1).clamp(0.0, 1.0)) + } else { + filtered + } + }), + Adjustment::BlackWhite { weights, tint } => map_rgb(buf, |c| { + // weights: reds, yellows, greens, cyans, blues, magentas in percent (PS defaults 40,60,40,60,20,80) + let (h, s, _l) = rgb_to_hsl(c); + let base = rgb_to_gray(c); + let sector = h * 6.0; + let i0 = sector.floor() as usize % 6; + let i1 = (i0 + 1) % 6; + let f = sector.fract(); + let defaults = [40.0, 60.0, 40.0, 60.0, 20.0, 80.0]; + let w = (weights[i0] - defaults[i0]) * (1.0 - f) + (weights[i1] - defaults[i1]) * f; + let g = (base + s * w / 100.0 * 0.5).clamp(0.0, 1.0); + match tint { + Some(t) => std::array::from_fn(|i| (g * t[i] * 2.0).clamp(0.0, 1.0) * 0.5 + g * 0.5), + None => [g; 3], + } + }), + Adjustment::GradientMap { stops, reverse } => map_rgb(buf, |c| { + let mut t = rgb_to_gray(c); + if *reverse { + t = 1.0 - t; + } + gradient(stops, t) + }), + Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity } => map_rgb(buf, |c| { + let l = rgb_to_gray(c); + let ws = (1.0 - l * 2.0).clamp(0.0, 1.0); + let wh = (l * 2.0 - 1.0).clamp(0.0, 1.0); + let wm = 1.0 - ws - wh; + let out: [f32; 3] = std::array::from_fn(|i| (c[i] + (shadows[i] * ws + midtones[i] * wm + highlights[i] * wh) / 100.0 * 0.5).clamp(0.0, 1.0)); + if *preserve_luminosity { + let l1 = rgb_to_gray(out).max(1e-6); + out.map(|v| (v * l / l1).clamp(0.0, 1.0)) + } else { + out + } + }), + // Not yet evaluated: identity (still round-trips through PSD). + Adjustment::SelectiveColor { .. } | Adjustment::ColorLookup { .. } | Adjustment::Unsupported { .. } => {} + } +} + +const LUT_SIZE: usize = 4096; + +fn map_rgb(buf: &mut Buffer, f: impl Fn([f32; 3]) -> [f32; 3]) { + for p in &mut buf.px { + if p[3] <= 0.0 { + continue; + } + let r = f([p[0], p[1], p[2]]); + p[0] = r[0]; + p[1] = r[1]; + p[2] = r[2]; + } +} + +#[inline] +fn lut(table: &[f32], v: f32) -> f32 { + let x = v.clamp(0.0, 1.0) * (table.len() - 1) as f32; + let i = x.floor() as usize; + let j = (i + 1).min(table.len() - 1); + let f = x - i as f32; + table[i] * (1.0 - f) + table[j] * f +} + +/// Photoshop posterize: `n` equal input bins over 0..=255, output levels +/// `floor(k * 255 / (n - 1))` (exact on the corpus for 3, 7, 13 and 21 levels). +pub fn posterize(v: f32, levels: u32) -> f32 { + let n = levels.clamp(2, 255) as f32; + let x = (v.clamp(0.0, 1.0) * 255.0).round(); + let bin = (x * n / 256.0).floor().min(n - 1.0); + (bin * 255.0 / (n - 1.0)).floor() / 255.0 +} + +pub fn levels(ch: &LevelsChannel, v: f32) -> f32 { + let range = (ch.in_white - ch.in_black).max(1e-6); + let t = ((v - ch.in_black) / range).clamp(0.0, 1.0).powf(1.0 / ch.gamma.max(0.01)); + ch.out_black + t * (ch.out_white - ch.out_black) +} + +/// Photoshop curve: a natural cubic spline through the points (it may +/// overshoot between points, as Photoshop's does), constant outside the +/// first/last point, clamped to 0..=1, sampled into a LUT. +pub fn curve_lut(points: &[CurvePoint]) -> Vec { + let mut pts: Vec<(f32, f32)> = points.iter().map(|p| (p.input, p.output)).collect(); + pts.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-6); + if pts.len() < 2 { + return (0..LUT_SIZE).map(|i| i as f32 / (LUT_SIZE - 1) as f32).collect(); + } + let n = pts.len(); + // Second derivatives of the natural spline (tridiagonal solve). + let mut m2 = vec![0.0f64; n]; + if n > 2 { + let x: Vec = pts.iter().map(|p| f64::from(p.0)).collect(); + let y: Vec = pts.iter().map(|p| f64::from(p.1)).collect(); + let mut c = vec![0.0f64; n]; + let mut d = vec![0.0f64; n]; + for i in 1..n - 1 { + let (h0, h1) = (x[i] - x[i - 1], x[i + 1] - x[i]); + let a = h0 / 6.0; + let b = (h0 + h1) / 3.0; + let cc = h1 / 6.0; + let r = (y[i + 1] - y[i]) / h1 - (y[i] - y[i - 1]) / h0; + let denom = b - a * c[i - 1]; + c[i] = cc / denom; + d[i] = (r - a * d[i - 1]) / denom; + } + for i in (1..n - 1).rev() { + m2[i] = d[i] - c[i] * m2[i + 1]; + } + } + (0..LUT_SIZE) + .map(|k| { + let xv = k as f32 / (LUT_SIZE - 1) as f32; + if xv <= pts[0].0 { + return pts[0].1.clamp(0.0, 1.0); + } + if xv >= pts[n - 1].0 { + return pts[n - 1].1.clamp(0.0, 1.0); + } + let i = pts.windows(2).position(|w| xv <= w[1].0).unwrap_or(n - 2); + let (x0, y0) = (f64::from(pts[i].0), f64::from(pts[i].1)); + let (x1, y1) = (f64::from(pts[i + 1].0), f64::from(pts[i + 1].1)); + let h = x1 - x0; + let t = f64::from(xv); + let a = (x1 - t) / h; + let b = (t - x0) / h; + let y = a * y0 + b * y1 + ((a * a * a - a) * m2[i] + (b * b * b - b) * m2[i + 1]) * h * h / 6.0; + (y as f32).clamp(0.0, 1.0) + }) + .collect() +} + +fn gradient(stops: &[(f32, [f32; 3])], t: f32) -> [f32; 3] { + match stops { + [] => [t; 3], + [s] => s.1, + _ => { + if t <= stops[0].0 { + return stops[0].1; + } + for w in stops.windows(2) { + if t <= w[1].0 { + let k = if w[1].0 > w[0].0 { (t - w[0].0) / (w[1].0 - w[0].0) } else { 0.0 }; + return std::array::from_fn(|i| w[0].1[i] + (w[1].1[i] - w[0].1[i]) * k); + } + } + stops[stops.len() - 1].1 + } + } +} + +pub fn rgb_to_hsl(c: [f32; 3]) -> (f32, f32, f32) { + let max = c[0].max(c[1]).max(c[2]); + let min = c[0].min(c[1]).min(c[2]); + let l = (max + min) / 2.0; + if (max - min).abs() < 1e-7 { + return (0.0, 0.0, l); + } + let d = max - min; + let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) }; + let h = if max == c[0] { + ((c[1] - c[2]) / d).rem_euclid(6.0) + } else if max == c[1] { + (c[2] - c[0]) / d + 2.0 + } else { + (c[0] - c[1]) / d + 4.0 + }; + (h / 6.0, s, l) +} + +pub fn hsl_to_rgb(h: f32, s: f32, l: f32) -> [f32; 3] { + if s <= 0.0 { + return [l; 3]; + } + let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s }; + let p = 2.0 * l - q; + let f = |mut t: f32| { + t = t.rem_euclid(1.0); + if t < 1.0 / 6.0 { + p + (q - p) * 6.0 * t + } else if t < 0.5 { + q + } else if t < 2.0 / 3.0 { + p + (q - p) * (2.0 / 3.0 - t) * 6.0 + } else { + p + } + }; + [f(h + 1.0 / 3.0), f(h), f(h - 1.0 / 3.0)] +} diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs new file mode 100644 index 00000000..a3675e55 --- /dev/null +++ b/crates/compose/src/effects.rs @@ -0,0 +1,739 @@ +//! CPU reference renderer for layer effects (layer styles). +//! +//! Render order, bottom to top (Photoshop's layer-style stack read upwards): +//! drop shadows, outer glows, outer bevel, then the layer itself (content at +//! fill opacity) with pattern/gradient/colour overlays, satin, inner glows, +//! inner shadows, strokes and the inner bevel on top. Exterior effects blend +//! straight into the backdrop with their own modes; the layer and its +//! interior effects blend with the layer's mode. Layer opacity applies to +//! the whole stack; fill opacity only to the layer's own pixels. +//! +//! Shapes come from the layer's alpha (after its mask). Distances use an +//! exact Euclidean distance transform; soft falloffs use three box-blur +//! passes (≈ Gaussian) whose total support equals the effect size. + +use photocraft_color::blend::BlendMode; +use photocraft_doc::{ + Bevel, BevelStyle, Contour, Effect, FxPaint, GlobalLight, Glow, GlowSource, GlowTechnique, Gradient, GradientStyle, Layer, Shadow, + StrokePosition, +}; +use photocraft_geom::Rect; + +use crate::{Buffer, psblend}; + +/// `true` if the layer has at least one enabled effect and the master switch is on. +pub fn has_effects(layer: &Layer) -> bool { + layer.effects.enabled && layer.effects.items.iter().any(Effect::enabled) +} + +/// Pixels an effect stack can reach beyond the layer's shape. +pub fn margin(layer: &Layer) -> i32 { + let mut m = 0.0f32; + for e in layer.effects.items.iter().filter(|e| e.enabled()) { + let r = match e { + Effect::DropShadow(s) | Effect::InnerShadow(s) => s.distance + s.size, + Effect::OuterGlow(g) | Effect::InnerGlow(g) => g.size, + Effect::Stroke(s) => s.size, + Effect::Satin(s) => s.distance + s.size, + Effect::BevelEmboss(b) => b.size + b.soften, + _ => 0.0, + }; + if r.is_finite() { + m = m.max(r); + } + } + // Bounded so malformed values can't request huge buffers; effects that + // reach further are clipped at this distance. + m.clamp(0.0, MAX_REACH).ceil() as i32 + 2 +} + +/// Maximum distance (px) an effect is rendered from the layer's shape. +pub const MAX_REACH: f32 = 512.0; + +/// A single-channel map over a rectangle. +#[derive(Clone)] +struct Map { + w: usize, + h: usize, + v: Vec, +} + +impl Map { + fn new(w: usize, h: usize, fill: f32) -> Self { + Map { w, h, v: vec![fill; w * h] } + } + fn get(&self, x: i64, y: i64) -> f32 { + if x < 0 || y < 0 || x >= self.w as i64 || y >= self.h as i64 { 0.0 } else { self.v[y as usize * self.w + x as usize] } + } + /// Shifted copy (bilinear), outside reads `outside`. + fn shifted(&self, dx: f32, dy: f32, outside: f32) -> Map { + let mut out = Map::new(self.w, self.h, 0.0); + let (fx, fy) = (dx.floor(), dy.floor()); + let (ax, ay) = (dx - fx, dy - fy); + let s = |x: i64, y: i64| if x < 0 || y < 0 || x >= self.w as i64 || y >= self.h as i64 { outside } else { self.v[y as usize * self.w + x as usize] }; + for y in 0..self.h as i64 { + for x in 0..self.w as i64 { + let (sx, sy) = (x - fx as i64, y - fy as i64); + let v00 = s(sx, sy); + let v10 = s(sx - 1, sy); + let v01 = s(sx, sy - 1); + let v11 = s(sx - 1, sy - 1); + let top = v00 * (1.0 - ax) + v10 * ax; + let bot = v01 * (1.0 - ax) + v11 * ax; + out.v[y as usize * self.w + x as usize] = top * (1.0 - ay) + bot * ay; + } + } + out + } + fn map(mut self, f: impl Fn(f32) -> f32) -> Self { + for v in &mut self.v { + *v = f(*v); + } + self + } +} + +/// 1D squared distance transform (Felzenszwalb & Huttenlocher). +fn dt1(f: &[f32], out: &mut [f32], v: &mut [usize], z: &mut [f32], near: &mut [usize]) { + let n = f.len(); + let mut k = 0usize; + v[0] = 0; + z[0] = f32::NEG_INFINITY; + z[1] = f32::INFINITY; + for q in 1..n { + loop { + let p = v[k]; + let s = ((f[q] + (q * q) as f32) - (f[p] + (p * p) as f32)) / (2.0 * q as f32 - 2.0 * p as f32); + if s <= z[k] && k > 0 { + k -= 1; + continue; + } + if s <= z[k] { + // k == 0 and parabola dominates: replace. + v[0] = q; + z[0] = f32::NEG_INFINITY; + z[1] = f32::INFINITY; + break; + } + k += 1; + v[k] = q; + z[k] = s; + z[k + 1] = f32::INFINITY; + break; + } + } + k = 0; + for (q, o) in out.iter_mut().enumerate() { + while z[k + 1] < q as f32 { + k += 1; + } + let p = v[k]; + let d = q as f32 - p as f32; + *o = d * d + f[p]; + near[q] = p; + } +} + +/// Exact Euclidean distance from every pixel to the nearest pixel where +/// `inside` is true (0 on those pixels). +#[cfg(test)] +fn edt(inside: &[bool], w: usize, h: usize) -> Vec { + edt_nearest(inside, w, h).0 +} + +/// [`edt`] plus the index of the nearest `inside` pixel. +fn edt_nearest(inside: &[bool], w: usize, h: usize) -> (Vec, Vec) { + const INF: f32 = 1e20; + let mut g: Vec = inside.iter().map(|&b| if b { 0.0 } else { INF }).collect(); + let mut near_row = vec![0usize; w * h]; + let mut nearest = vec![0usize; w * h]; + let n = w.max(h); + let (mut f, mut o, mut v, mut z, mut nr) = (vec![0.0; n], vec![0.0; n], vec![0usize; n], vec![0.0f32; n + 1], vec![0usize; n]); + for x in 0..w { + for y in 0..h { + f[y] = g[y * w + x]; + } + dt1(&f[..h], &mut o[..h], &mut v, &mut z, &mut nr[..h]); + for y in 0..h { + g[y * w + x] = o[y]; + near_row[y * w + x] = nr[y]; + } + } + for y in 0..h { + f[..w].copy_from_slice(&g[y * w..(y + 1) * w]); + dt1(&f[..w], &mut o[..w], &mut v, &mut z, &mut nr[..w]); + for x in 0..w { + g[y * w + x] = o[x].sqrt(); + let px = nr[x]; + nearest[y * w + x] = near_row[y * w + px] * w + px; + } + } + (g, nearest) +} + +/// Distance from each pixel centre outside the shape to the shape's edge. +/// Pixels with non-zero alpha are inside; the edge inside the nearest such +/// pixel is placed by its alpha (coverage), as Photoshop does. Inside pixels +/// get ≤ 0. +fn dist_outside(s: &Map) -> Vec { + let inside: Vec = s.v.iter().map(|&a| a > INSIDE_EPS).collect(); + let (d, near) = edt_nearest(&inside, s.w, s.h); + (0..d.len()).map(|i| if inside[i] { -0.5 } else { d[i] - 0.5 + (1.0 - s.v[near[i]].min(1.0)) }).collect() +} + +/// Distance from each pixel centre inside the shape to the shape's edge +/// (≤ 0 outside). +fn dist_inside(s: &Map) -> Vec { + let outside: Vec = s.v.iter().map(|&a| a <= INSIDE_EPS).collect(); + let (d, _) = edt_nearest(&outside, s.w, s.h); + d.iter().zip(&outside).map(|(d, o)| if *o { -0.5 } else { d - 0.5 }).collect() +} + +/// Alpha relative to the 3×3 neighbourhood maximum: separates edge coverage +/// from the fill's own opacity on shape layers. +fn local_coverage(s: &Map) -> Map { + let mut out = s.clone(); + for y in 0..s.h as i64 { + for x in 0..s.w as i64 { + let a = s.get(x, y); + if a <= INSIDE_EPS { + continue; + } + let mut mx = a; + for dy in -1..=1 { + for dx in -1..=1 { + mx = mx.max(s.get(x + dx, y + dy)); + } + } + out.v[y as usize * s.w + x as usize] = (a / mx).min(1.0); + } + } + out +} + +/// Alpha above which a pixel belongs to the layer's shape (half an 8-bit step). +const INSIDE_EPS: f32 = 0.5 / 255.0; + +/// Photoshop's effect "size" → Gaussian sigma (fitted on the corpus). +fn sigma_for(size: f32) -> f32 { + // Least-squares fit of a drop shadow against Photoshop's composite + // (ag-psd effects fixture): sigma = 0.4 × size, offsets rounded to pixels. + size * 0.4 +} + +/// Separable Gaussian blur for an effect of `size` pixels. +fn blur(m: &mut Map, size: f32) { + let sigma = sigma_for(size); + if sigma < 0.2 { + return; + } + let r = (sigma * 3.0).ceil() as i64; + let kernel: Vec = (-r..=r).map(|i| (-(i * i) as f32 / (2.0 * sigma * sigma)).exp()).collect(); + let sum: f32 = kernel.iter().sum(); + let kernel: Vec = kernel.iter().map(|k| k / sum).collect(); + let (w, h) = (m.w as i64, m.h as i64); + let mut tmp = vec![0.0f32; m.v.len()]; + for y in 0..h { + for x in 0..w { + let mut acc = 0.0; + for (k, kv) in kernel.iter().enumerate() { + let xx = x + k as i64 - r; + if xx >= 0 && xx < w { + acc += m.v[(y * w + xx) as usize] * kv; + } + } + tmp[(y * w + x) as usize] = acc; + } + } + for y in 0..h { + for x in 0..w { + let mut acc = 0.0; + for (k, kv) in kernel.iter().enumerate() { + let yy = y + k as i64 - r; + if yy >= 0 && yy < h { + acc += tmp[(yy * w + x) as usize] * kv; + } + } + m.v[(y * w + x) as usize] = acc; + } + } +} + +/// Grows the shape by `r` pixels (anti-aliased), keeping the original soft edge. +fn dilate(s: &Map, r: f32) -> Map { + if r <= 0.0 { + return s.clone(); + } + let d = dist_outside(s); + let mut out = s.clone(); + for (o, d) in out.v.iter_mut().zip(d) { + *o = o.max((r + 0.5 - d).clamp(0.0, 1.0)); + } + out +} + +fn contour_lut(c: &Contour) -> Option> { + match c { + Contour::Linear => None, + Contour::Custom { points, .. } => Some(crate::adjust::curve_lut(points)), + } +} + +fn apply_contour(m: Map, c: &Contour) -> Map { + match contour_lut(c) { + None => m, + Some(l) => m.map(|v| { + let x = v.clamp(0.0, 1.0) * (l.len() - 1) as f32; + let i = x.floor() as usize; + let j = (i + 1).min(l.len() - 1); + l[i] + (l[j] - l[i]) * (x - i as f32) + }), + } +} + +fn rgb(c: &photocraft_color::Color) -> [f32; 3] { + c.to_rgb() +} + +/// Offset of an effect lit from `angle` at `distance` (shadow falls away from +/// the light), rounded to whole pixels as Photoshop does. +fn offset(angle: f32, distance: f32) -> (f32, f32) { + let a = angle.to_radians(); + ((-a.cos() * distance).round(), (a.sin() * distance).round()) +} + +/// Gradient parameter `t` in `0..=1` for pixel centre `(x, y)` inside `bounds`. +#[allow(clippy::too_many_arguments)] +pub fn gradient_t(style: GradientStyle, angle: f32, scale: f32, reverse: bool, offset: (f32, f32), bounds: Rect, x: f32, y: f32) -> f32 { + let w = bounds.width().max(1) as f32; + let h = bounds.height().max(1) as f32; + let cx = bounds.x0 as f32 + w / 2.0 + offset.0 * w; + let cy = bounds.y0 as f32 + h / 2.0 + offset.1 * h; + let (s, c) = angle.to_radians().sin_cos(); + let (dx, dy) = (x - cx, y - cy); + // Distance along the gradient direction (y axis points down). + let along = dx * c - dy * s; + let across = dx * s + dy * c; + // Gradient length: the bounds' extent along the angle as an ellipse + // norm (a unit gradient scaled to the bounds); fitted on psd-tools + // gradient-styles.psd (cached Photoshop renderings of every style). + let len = ((c * w).powi(2) + (s * h).powi(2)).sqrt().max(1.0) * scale.max(1e-3); + let mut t = match style { + GradientStyle::Linear => along / len + 0.5, + GradientStyle::Reflected => (along / (len / 2.0)).abs(), + GradientStyle::Radial => (dx * dx + dy * dy).sqrt() / (len / 2.0), + GradientStyle::Diamond => (along.abs() + across.abs()) / (len / 2.0), + // Clockwise sweep starting at the gradient angle. + GradientStyle::Angle => ((angle.to_radians() - (-dy).atan2(dx)) / std::f32::consts::TAU).rem_euclid(1.0), + }; + t = t.clamp(0.0, 1.0); + if reverse { 1.0 - t } else { t } +} + +/// Samples colour and opacity stops at `t`. +pub fn sample_gradient(g: &Gradient, t: f32) -> [f32; 4] { + let color = sample_stops(&g.stops.iter().map(|(p, c)| (*p, rgb(c))).collect::>(), t); + let alpha = if g.opacity_stops.is_empty() { + 1.0 + } else { + sample_stops(&g.opacity_stops.iter().map(|(p, a)| (*p, [*a; 3])).collect::>(), t)[0] + }; + [color[0], color[1], color[2], alpha] +} + +fn sample_stops(stops: &[(f32, [f32; 3])], t: f32) -> [f32; 3] { + match stops { + [] => [t; 3], + [s] => s.1, + _ => { + if t <= stops[0].0 { + return stops[0].1; + } + for w in stops.windows(2) { + if t <= w[1].0 { + let k = if w[1].0 > w[0].0 { (t - w[0].0) / (w[1].0 - w[0].0) } else { 0.0 }; + return std::array::from_fn(|i| w[0].1[i] + (w[1].1[i] - w[0].1[i]) * k); + } + } + stops[stops.len() - 1].1 + } + } +} + +/// Composite `color × coverage` into `dst` (both over the same rectangle). +fn paint(dst: &mut Buffer, m: &Map, color: impl Fn(usize) -> [f32; 4], blend: BlendMode, opacity: f32) { + for (i, p) in dst.px.iter_mut().enumerate() { + let a = m.v[i] * opacity; + if a <= 0.0 { + continue; + } + let c = color(i); + *p = psblend::composite(blend, *p, [c[0], c[1], c[2], c[3] * a], 1.0); + } +} + +fn paint_color(dst: &mut Buffer, m: &Map, c: [f32; 3], blend: BlendMode, opacity: f32) { + paint(dst, m, |_| [c[0], c[1], c[2], 1.0], blend, opacity); +} + +fn shadow_map(shape: &Map, s: &Shadow, light: &GlobalLight, inner: bool) -> Map { + let angle = if s.use_global_light { light.angle } else { s.angle }; + let (dx, dy) = offset(angle, s.distance); + let src = if inner { shape.clone().map(|a| 1.0 - a) } else { shape.clone() }; + let mut m = src.shifted(dx, dy, if inner { 1.0 } else { 0.0 }); + m = dilate(&m, s.size * s.spread); + blur(&mut m, s.size * (1.0 - s.spread)); + let mut m = apply_contour(m, &s.contour); + if inner { + for (v, a) in m.v.iter_mut().zip(&shape.v) { + *v *= a; + } + } + m +} + +fn glow_map(shape: &Map, g: &Glow, inner: bool) -> Map { + let src = if inner { + match g.source { + GlowSource::Edge => shape.clone().map(|a| 1.0 - a), + GlowSource::Center => shape.clone(), + } + } else { + shape.clone() + }; + let mut m = match g.technique { + GlowTechnique::Precise => { + let d = if inner && g.source == GlowSource::Edge { dist_inside(shape) } else { dist_outside(&src) }; + let solid = g.size * g.spread; + let soft = (g.size - solid).max(1e-3); + let mut m = Map::new(shape.w, shape.h, 0.0); + for (o, d) in m.v.iter_mut().zip(d) { + *o = if d <= solid { 1.0 } else { (1.0 - (d - solid) / soft).clamp(0.0, 1.0) }; + } + if inner && g.source == GlowSource::Center { + m = m.map(|v| 1.0 - v); + } + m + } + GlowTechnique::Softer => { + let mut m = dilate(&src, g.size * g.spread); + blur(&mut m, g.size * (1.0 - g.spread)); + if inner && g.source == GlowSource::Center { + // Brightest in the middle: coverage from the distance to the edge. + let mut e = dilate(&shape.clone().map(|a| 1.0 - a), g.size * g.spread); + blur(&mut e, g.size * (1.0 - g.spread)); + m = e.map(|v| 1.0 - v); + } + m + } + }; + m = apply_contour(m, &g.contour); + if inner { + for (v, a) in m.v.iter_mut().zip(&shape.v) { + *v *= a; + } + } + m +} + +fn paint_fx(dst: &mut Buffer, m: &Map, p: &FxPaint, shape_bounds: Rect, big: Rect, blend: BlendMode, opacity: f32) { + match p { + FxPaint::Color(c) => paint_color(dst, m, rgb(c), blend, opacity), + FxPaint::Gradient(g) => { + let w = big.width() as usize; + paint( + dst, + m, + |i| { + let (x, y) = ((big.x0 + (i % w) as i32) as f32 + 0.5, (big.y0 + (i / w) as i32) as f32 + 0.5); + sample_gradient(g, gradient_t(g.style, g.angle, g.scale, g.reverse, g.offset, shape_bounds, x, y)) + }, + blend, + opacity, + ) + } + FxPaint::Pattern { .. } => {} + } +} + +/// Bevel highlight and shadow maps for the inner (or outer) bevel. +fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight) -> (Map, Map, bool) { + let outer = b.style == BevelStyle::OuterBevel; + let size = b.size.max(1.0); + let (din, dout) = (dist_inside(shape), dist_outside(shape)); + let mut hmap = Map::new(shape.w, shape.h, 0.0); + for i in 0..hmap.v.len() { + hmap.v[i] = match b.style { + BevelStyle::OuterBevel => 1.0 - (dout[i] / size).clamp(0.0, 1.0), + BevelStyle::Emboss | BevelStyle::PillowEmboss => { + if din[i] > 0.0 { 0.5 + 0.5 * (din[i] / (size / 2.0)).clamp(0.0, 1.0) } else { 0.5 - 0.5 * (dout[i] / (size / 2.0)).clamp(0.0, 1.0) } + } + _ => (din[i] / size).clamp(0.0, 1.0), + }; + } + // Smooth technique: round the profile. + hmap = hmap.map(|v| (v * std::f32::consts::FRAC_PI_2).sin()); + blur(&mut hmap, b.soften.max(1.0)); + let (angle, altitude) = if b.use_global_light { (light.angle, light.altitude) } else { (b.angle, b.altitude) }; + let (sa, ca) = angle.to_radians().sin_cos(); + let (se, ce) = altitude.to_radians().sin_cos(); + let light_v = [ca * ce, -sa * ce, se]; + let depth = b.depth * size * if b.up { 1.0 } else { -1.0 }; + let (mut hi, mut sh) = (Map::new(shape.w, shape.h, 0.0), Map::new(shape.w, shape.h, 0.0)); + for y in 0..shape.h as i64 { + for x in 0..shape.w as i64 { + let i = y as usize * shape.w + x as usize; + let gx = (hmap.get(x + 1, y) - hmap.get(x - 1, y)) * 0.5 * depth; + let gy = (hmap.get(x, y + 1) - hmap.get(x, y - 1)) * 0.5 * depth; + let n = [-gx, -gy, 1.0]; + let len = (n[0] * n[0] + n[1] * n[1] + 1.0).sqrt(); + let shade = (n[0] * light_v[0] + n[1] * light_v[1] + n[2] * light_v[2]) / len; + let region = if outer { (1.0 - shape.v[i]) * f32::from(hmap.v[i] > 0.0) } else { shape.v[i] }; + let k = shade - se; + if k > 0.0 { + hi.v[i] = (k / (1.0 - se).max(1e-3)).clamp(0.0, 1.0) * region; + } else { + sh.v[i] = (-k / se.max(1e-3)).clamp(0.0, 1.0) * region; + } + } + } + (apply_contour(hi, &b.gloss_contour), apply_contour(sh, &b.gloss_contour), outer) +} + +/// Composites `content` (the layer's own pixels over `big`, alpha already +/// masked, clipped layers applied) plus its effects into `backdrop`. +pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Buffer, light: &GlobalLight, layer_bounds: Rect) { + let big = content.rect; + let (w, h) = (big.width() as usize, big.height() as usize); + let shape = Map { w, h, v: content.px.iter().map(|p| p[3]).collect() }; + // Layer bounds (gradients aligned with the layer use the whole layer, + // independent of the render rect). + let sb = layer_bounds; + let rect = backdrop.rect; + let before = backdrop.clone(); + // Exterior effects are painted on a backdrop copy extended to `big`. + let mut work = Buffer::transparent(big); + for y in rect.y0.max(big.y0)..rect.y1.min(big.y1) { + for x in rect.x0.max(big.x0)..rect.x1.min(big.x1) { + work.px[((y - big.y0) as usize) * w + (x - big.x0) as usize] = before.get(x, y); + } + } + let items: Vec<&Effect> = layer.effects.items.iter().filter(|e| e.enabled()).collect(); + let vector_shape = matches!(layer.content, photocraft_doc::LayerContent::Shape(_)); + // Multiple instances: the first listed is on top, so paint in reverse. + let rev = || items.iter().rev().copied(); + + for e in rev() { + if let Effect::DropShadow(s) = e { + let mut m = shadow_map(&shape, s, light, false); + if s.knocks_out { + for (v, a) in m.v.iter_mut().zip(&shape.v) { + *v *= 1.0 - a; + } + } + paint_color(&mut work, &m, rgb(&s.color), s.common.blend, s.common.opacity); + } + } + for e in rev() { + if let Effect::OuterGlow(g) = e { + let m = glow_map(&shape, g, false); + paint_fx(&mut work, &m, &g.paint, sb, big, g.common.blend, g.common.opacity); + } + } + + // The layer: content at fill opacity, then interior effects. + let fill = layer.fill_opacity; + let mut lay = Buffer { rect: big, px: content.px.iter().map(|p| [p[0], p[1], p[2], p[3] * fill]).collect() }; + let full = shape.clone(); + for e in rev() { + if let Effect::GradientOverlay { common, gradient, .. } = e { + paint_fx(&mut lay, &full, &FxPaint::Gradient(gradient.clone()), sb, big, common.blend, common.opacity) + } + } + for e in rev() { + if let Effect::ColorOverlay { common, color } = e { + paint_color(&mut lay, &full, rgb(color), common.blend, common.opacity); + } + } + for e in rev() { + if let Effect::Satin(s) = e { + let (dx, dy) = offset(s.angle, s.distance); + let mut a = shape.shifted(dx, dy, 0.0); + let mut b = shape.shifted(-dx, -dy, 0.0); + blur(&mut a, s.size); + blur(&mut b, s.size); + let mut m = Map { w, h, v: a.v.iter().zip(&b.v).map(|(x, y)| (x - y).abs()).collect() }; + if s.invert { + m = m.map(|v| 1.0 - v); + } + let mut m = apply_contour(m, &s.contour); + for (v, a) in m.v.iter_mut().zip(&shape.v) { + *v *= a; + } + paint_color(&mut lay, &m, rgb(&s.color), s.common.blend, s.common.opacity); + } + } + for e in rev() { + if let Effect::InnerGlow(g) = e { + let m = glow_map(&shape, g, true); + paint_fx(&mut lay, &m, &g.paint, sb, big, g.common.blend, g.common.opacity); + } + } + for e in rev() { + if let Effect::InnerShadow(s) = e { + let m = shadow_map(&shape, s, light, true); + paint_color(&mut lay, &m, rgb(&s.color), s.common.blend, s.common.opacity); + } + } + // Strokes. Outside parts are slid beneath the layer, so the first listed + // (top) instance is processed first; inside parts are painted over it, + // bottom instance first. + let strokes: Vec<&photocraft_doc::StrokeFx> = items.iter().filter_map(|e| if let Effect::Stroke(s) = e { Some(s) } else { None }).collect(); + let (din, dout) = if strokes.is_empty() { (Vec::new(), Vec::new()) } else { (dist_inside(&shape), dist_outside(&shape)) }; + let widths = |st: &photocraft_doc::StrokeFx| match st.position { + StrokePosition::Outside => (0.0, st.size), + StrokePosition::Inside => (st.size, 0.0), + StrokePosition::Center => (st.size / 2.0, st.size / 2.0), + }; + let vcov = if vector_shape && !strokes.is_empty() { Some(local_coverage(&shape)) } else { None }; + let vdout = vcov.as_ref().map(dist_outside); + for st in strokes.iter().copied() { + let (_, out_w) = widths(st); + if out_w <= 0.0 { + continue; + } + // Shape layers: Photoshop strokes the vector outline, estimated from + // local coverage; the stroke never shows through the shape's pixels. + let d = vdout.as_ref().unwrap_or(&dout); + let mut m = Map::new(w, h, 0.0); + for ((mv, a), dv) in m.v.iter_mut().zip(&shape.v).zip(d) { + *mv = if *a > INSIDE_EPS { + if vector_shape { 0.0 } else { 1.0 } + } else { + (out_w + 0.5 - dv).clamp(0.0, 1.0) + }; + } + let mut under = Buffer::transparent(big); + paint_fx(&mut under, &m, &st.paint, sb, big, BlendMode::Normal, st.common.opacity); + for (u, l) in under.px.iter_mut().zip(&lay.px) { + *u = psblend::composite(BlendMode::Normal, *u, *l, 1.0); + } + lay = under; + } + for st in strokes.iter().rev().copied() { + let (in_w, _) = widths(st); + if in_w <= 0.0 { + continue; + } + let mut m = Map::new(w, h, 0.0); + for ((mv, a), dv) in m.v.iter_mut().zip(&shape.v).zip(&din) { + *mv = (in_w + 0.5 - dv).clamp(0.0, 1.0) * a; + } + paint_fx(&mut lay, &m, &st.paint, sb, big, st.common.blend, st.common.opacity); + } + for e in rev() { + if let Effect::BevelEmboss(b) = e { + let (hi, sh, outer) = bevel_maps(&shape, b, light); + let dst = if outer { &mut work } else { &mut lay }; + paint_color(dst, &hi, rgb(&b.highlight_color), b.highlight.blend, b.highlight.opacity); + paint_color(dst, &sh, rgb(&b.shadow_color), b.shadow.blend, b.shadow.opacity); + } + } + + // Layer (with interior effects) onto the exterior result, in the layer's mode. + let mode = if layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend }; + for (wp, lp) in work.px.iter_mut().zip(&lay.px) { + if lp[3] > 0.0 { + *wp = psblend::composite(mode, *wp, *lp, 1.0); + } + } + // Layer opacity applies to the whole stack. + let op = layer.opacity; + for y in rect.y0..rect.y1 { + for x in rect.x0..rect.x1 { + let i = ((y - rect.y0) as usize) * rect.width() as usize + (x - rect.x0) as usize; + let a = before.px[i]; + let b = if x >= big.x0 && x < big.x1 && y >= big.y0 && y < big.y1 { + work.px[((y - big.y0) as usize) * w + (x - big.x0) as usize] + } else { + a + }; + backdrop.px[i] = mix_premul(a, b, op); + } + } +} + +/// Premultiplied linear interpolation between two straight-alpha pixels. +fn mix_premul(a: [f32; 4], b: [f32; 4], k: f32) -> [f32; 4] { + let alpha = a[3] + (b[3] - a[3]) * k; + if alpha <= 0.0 { + return [0.0; 4]; + } + let mut out = [0.0; 4]; + for c in 0..3 { + out[c] = (a[c] * a[3] + (b[c] * b[3] - a[c] * a[3]) * k) / alpha; + } + out[3] = alpha; + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn edt_is_exact() { + let (w, h) = (7, 5); + let mut inside = vec![false; w * h]; + inside[2 * w + 3] = true; + let d = edt(&inside, w, h); + assert_eq!(d[2 * w + 3], 0.0); + assert!((d[2 * w + 6] - 3.0).abs() < 1e-6); + assert!((d[0] - (9.0f32 + 4.0).sqrt()).abs() < 1e-5); + assert!((d[4 * w + 5] - (4.0f32 + 4.0).sqrt()).abs() < 1e-5); + } + + #[test] + fn edt_two_seeds() { + let (w, h) = (10, 1); + let mut inside = vec![false; w]; + inside[0] = true; + inside[9] = true; + let d = edt(&inside, w, h); + assert_eq!(d, vec![0.0, 1.0, 2.0, 3.0, 4.0, 4.0, 3.0, 2.0, 1.0, 0.0]); + } + + #[test] + fn blur_preserves_mass() { + let mut m = Map::new(21, 21, 0.0); + m.v[10 * 21 + 10] = 1.0; + blur(&mut m, 6.0); + let sum: f32 = m.v.iter().sum(); + assert!((sum - 1.0).abs() < 1e-4, "{sum}"); + assert!(m.v[10 * 21 + 10] < 1.0); + } + + #[test] + fn gradient_geometry() { + let b = Rect::new(0, 0, 100, 100); + let t = |s, x, y| gradient_t(s, 0.0, 1.0, false, (0.0, 0.0), b, x, y); + assert!(t(GradientStyle::Linear, 0.0, 50.0) < 0.01); + assert!((t(GradientStyle::Linear, 50.0, 50.0) - 0.5).abs() < 1e-6); + assert!(t(GradientStyle::Linear, 100.0, 50.0) > 0.99); + assert!(t(GradientStyle::Reflected, 50.0, 50.0) < 1e-6); + assert!((t(GradientStyle::Reflected, 0.0, 50.0) - 1.0).abs() < 1e-6); + assert!(t(GradientStyle::Radial, 50.0, 50.0) < 1e-6); + assert!((t(GradientStyle::Radial, 50.0, 0.0) - 1.0).abs() < 1e-6); + assert!((t(GradientStyle::Diamond, 75.0, 75.0) - 1.0).abs() < 1e-6); + let ang = t(GradientStyle::Angle, 50.0, 0.0); + assert!((ang - 0.75).abs() < 1e-3, "{ang}"); + let rev = gradient_t(GradientStyle::Linear, 0.0, 1.0, true, (0.0, 0.0), b, 0.0, 50.0); + assert!(rev > 0.99); + } + + #[test] + fn offsets_follow_light() { + let (dx, dy) = offset(120.0, 10.0); + assert_eq!((dx, dy), (5.0, 9.0)); + } +} diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs new file mode 100644 index 00000000..72da18fa --- /dev/null +++ b/crates/compose/src/lib.rs @@ -0,0 +1,423 @@ +//! CPU reference compositor. +//! +//! Flattens a [`Document`] layer tree into straight-alpha RGBA (f32) for any rectangle. It encodes +//! Photoshop layer semantics in one place: +//! - blend modes, opacity × fill opacity, visibility +//! - layer masks with density +//! - clipping groups (clipped layers composite *atop* their base) +//! - pass-through vs isolated groups +//! - adjustment layers (applied to the composite below) +//! - fill layers, Dissolve +//! +//! This is the reference the GPU backend (milestone M5) must match within 1/255. Compositing +//! currently happens in display RGB. Mode-native (CMYK/Lab) compositing arrives with ICC in M8. +#![forbid(unsafe_code)] + +pub mod adjust; +pub mod effects; +pub mod psblend; + +use photocraft_color::blend::BlendMode; +use psblend as blend; +use photocraft_doc::{Document, Fill, Layer, LayerContent}; +use photocraft_geom::Rect; +use photocraft_raster::{Rgba8Image, Surface, to_rgba}; + +/// Straight-alpha RGBA float buffer covering a rectangle. +#[derive(Clone, Debug, PartialEq)] +pub struct Buffer { + pub rect: Rect, + pub px: Vec<[f32; 4]>, +} + +impl Buffer { + pub fn transparent(rect: Rect) -> Self { + Self { rect, px: vec![[0.0; 4]; rect.width() as usize * rect.height() as usize] } + } + pub fn filled(rect: Rect, c: [f32; 4]) -> Self { + Self { rect, px: vec![c; rect.width() as usize * rect.height() as usize] } + } + #[inline] + pub fn get(&self, x: i32, y: i32) -> [f32; 4] { + self.px[((y - self.rect.y0) as usize) * self.rect.width() as usize + (x - self.rect.x0) as usize] + } + pub fn to_rgba8(&self) -> Rgba8Image { + let mut img = Rgba8Image::new(self.rect.width(), self.rect.height()); + for (o, p) in img.pixels.chunks_exact_mut(4).zip(&self.px) { + for (dst, v) in o.iter_mut().zip(p) { + *dst = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + } + } + img + } + /// Flatten over an opaque background colour. + pub fn over_background(&self, bg: [f32; 3]) -> Buffer { + let mut out = self.clone(); + for p in &mut out.px { + let a = p[3]; + for i in 0..3 { + p[i] = p[i] * a + bg[i] * (1.0 - a); + } + p[3] = 1.0; + } + out + } +} + +/// Composite the whole document over `rect`. +pub fn render(doc: &Document, rect: Rect) -> Buffer { + let mut buf = Buffer::transparent(rect); + composite_stack(&doc.layers, &mut buf, &Ctx { canvas: doc.bounds(), transfer: adjust::Transfer::for_mode(doc.mode), light: doc.global_light }); + buf +} + +/// Composite the full canvas. +pub fn flatten(doc: &Document) -> Buffer { + render(doc, doc.bounds()) +} + +/// Render an arbitrary subset: a single layer (e.g. for thumbnails), isolated. +pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer { + let mut buf = Buffer::transparent(rect); + composite_stack(std::slice::from_ref(layer), &mut buf, &Ctx { canvas: rect, transfer: adjust::Transfer::Srgb, light: photocraft_doc::GlobalLight::default() }); + buf +} + +/// Downscaled RGBA8 render of the document (nearest-neighbour sampling) for thumbnails or navigators. +pub fn thumbnail(doc: &Document, max_side: u32) -> Rgba8Image { + let b = doc.bounds(); + let scale = (max_side as f32 / b.width().max(b.height()).max(1) as f32).min(1.0); + let w = ((b.width() as f32 * scale).round() as u32).max(1); + let h = ((b.height() as f32 * scale).round() as u32).max(1); + let mut img = Rgba8Image::new(w, h); + let full = if scale >= 1.0 { Some(flatten(doc)) } else { None }; + for ty in 0..h { + for tx in 0..w { + let x = ((tx as f32 + 0.5) / scale) as i32; + let y = ((ty as f32 + 0.5) / scale) as i32; + let p = match &full { + Some(f) => f.get(x.min(b.x1 - 1), y.min(b.y1 - 1)), + None => render(doc, Rect::from_xywh(x, y, 1, 1)).px[0], + }; + let o = ((ty * w + tx) * 4) as usize; + for (dst, v) in img.pixels[o..o + 4].iter_mut().zip(p) { + *dst = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + } + } + } + img +} + +/// Composite a sibling list (bottom→top) onto `backdrop`. +/// Rendering context shared down the tree. +struct Ctx { + /// Document canvas: fill layers and gradients are laid out relative to it, never to the render rect. + canvas: Rect, + /// Tone transfer used by adjustments that work in linear light. + transfer: adjust::Transfer, + /// Global light for layer effects. + light: photocraft_doc::GlobalLight, +} + +fn composite_stack(layers: &[Layer], backdrop: &mut Buffer, cx: &Ctx) { + let mut i = 0; + while i < layers.len() { + let base = &layers[i]; + // Collect the clipping group: following layers with `clipped = true`. + let mut j = i + 1; + while j < layers.len() && layers[j].clipped && !base.clipped { + j += 1; + } + let clipped = &layers[i + 1..j]; + if base.visible { + composite_layer(base, clipped, backdrop, cx); + } + i = j.max(i + 1); + } +} + +/// Deterministic hash for Dissolve (document-coordinate based, so it is stable under tiling). +#[inline] +fn dissolve_noise(x: i32, y: i32) -> f32 { + let mut h = (x as u32).wrapping_mul(0x8da6_b343) ^ (y as u32).wrapping_mul(0xd816_3841) ^ 0x9e37_79b9; + h ^= h >> 15; + h = h.wrapping_mul(0x2c1b_3c6d); + h ^= h >> 12; + (h & 0xffff) as f32 / 65536.0 +} + +/// Bounds of a layer's own pixels (union over group children; the canvas +/// for fill layers). +fn layer_bounds(layer: &Layer, canvas: Rect) -> Rect { + match &layer.content { + LayerContent::Group(g) => g.children.iter().filter(|c| c.visible).fold(Rect::EMPTY, |acc, c| { + let b = layer_bounds(c, canvas); + if b.is_empty() { acc } else if acc.is_empty() { b } else { acc.union(&b) } + }), + LayerContent::Fill(_) => canvas, + _ => layer.surface().map_or(Rect::EMPTY, Surface::content_bounds), + } +} + +fn mask_at(layer: &Layer, x: i32, y: i32) -> f32 { + layer.mask.as_ref().map_or(1.0, |m| m.value(x, y)) +} + +/// Render a layer's own content (no blending into the backdrop yet) into an isolated buffer. +/// Returns None for layers that operate on the backdrop (adjustments, pass-through groups). +fn render_content(layer: &Layer, rect: Rect, cx: &Ctx) -> Option { + let mut buf = match &layer.content { + LayerContent::Group(g) => { + let mut b = Buffer::transparent(rect); + composite_stack(&g.children, &mut b, cx); + b + } + LayerContent::Fill(f) => match &layer.fill_cache { + // Photoshop's own rendering, valid while the fill is unchanged. + Some(c) if c.fill == *f => surface_to_buffer(&c.surface, rect), + _ => render_fill(f, rect, cx.canvas), + }, + LayerContent::Adjustment(_) => return None, + _ => match layer.surface() { + Some(s) => surface_to_buffer(s, rect), + None => Buffer::transparent(rect), + }, + }; + if layer.mask.is_some() { + for y in rect.y0..rect.y1 { + for x in rect.x0..rect.x1 { + let i = ((y - rect.y0) as usize) * rect.width() as usize + (x - rect.x0) as usize; + buf.px[i][3] *= mask_at(layer, x, y); + } + } + } + Some(buf) +} + +pub fn surface_to_buffer(s: &Surface, rect: Rect) -> Buffer { + let fmt = s.format(); + let n = fmt.channels(); + let raw = s.read_region(rect); + let px = raw.chunks_exact(n).map(|p| to_rgba(&fmt, p)).collect(); + Buffer { rect, px } +} + +fn render_fill(f: &Fill, rect: Rect, canvas: Rect) -> Buffer { + match f { + Fill::Solid(c) => { + let rgb = c.to_rgb(); + Buffer::filled(rect, [rgb[0], rgb[1], rgb[2], c.alpha]) + } + Fill::Gradient { stops, angle, scale, style, reverse } => { + // Gradient geometry relative to the canvas, independent of the render rect. + let mut b = Buffer::transparent(rect); + for y in rect.y0..rect.y1 { + for x in rect.x0..rect.x1 { + let t = effects::gradient_t(*style, *angle, *scale, *reverse, (0.0, 0.0), canvas, x as f32 + 0.5, y as f32 + 0.5); + let i = ((y - rect.y0) as usize) * rect.width() as usize + (x - rect.x0) as usize; + b.px[i] = sample_stops(stops, t); + } + } + b + } + // Patterns render once the pattern library lands; transparent until then. + Fill::Pattern { .. } => Buffer::transparent(rect), + } +} + +fn sample_stops(stops: &[(f32, photocraft_color::Color)], t: f32) -> [f32; 4] { + let conv = |c: &photocraft_color::Color| { + let r = c.to_rgb(); + [r[0], r[1], r[2], c.alpha] + }; + match stops { + [] => [0.0; 4], + [only] => conv(&only.1), + _ => { + if t <= stops[0].0 { + return conv(&stops[0].1); + } + for w in stops.windows(2) { + let (a, b) = (&w[0], &w[1]); + if t <= b.0 { + let k = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 0.0 }; + let (ca, cb) = (conv(&a.1), conv(&b.1)); + return std::array::from_fn(|i| ca[i] + (cb[i] - ca[i]) * k); + } + } + conv(&stops[stops.len() - 1].1) + } + } +} + +/// Composite `layer` (plus its clipping group) onto `backdrop`. +fn composite_layer(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx) { + let rect = backdrop.rect; + let opacity = layer.opacity * layer.fill_opacity; + + // Pass-through groups composite their children straight into the backdrop. + if let LayerContent::Group(g) = &layer.content + && layer.blend == BlendMode::PassThrough + && !effects::has_effects(layer) + { + let before = backdrop.clone(); + composite_stack(&g.children, backdrop, cx); + let needs_mix = opacity < 1.0 || layer.mask.is_some(); + if needs_mix { + for y in rect.y0..rect.y1 { + for x in rect.x0..rect.x1 { + let i = ((y - rect.y0) as usize) * rect.width() as usize + (x - rect.x0) as usize; + let k = opacity * mask_at(layer, x, y); + let (a, b) = (before.px[i], backdrop.px[i]); + backdrop.px[i] = std::array::from_fn(|c| a[c] + (b[c] - a[c]) * k); + } + } + } + // Layers clipped to a pass-through group sit atop the group's + // isolated rendering; their effect (isolated result with vs. without + // them, each placed over the original backdrop) is added to the + // pass-through result. Exact when the children blend Normal, close + // otherwise (matches psd-tools clipping-mask3/4/5). + if clipped.iter().any(|c| c.visible) + && let Some(iso) = render_content(layer, rect, cx) + { + let mut clipped_iso = iso.clone(); + for c in clipped.iter().filter(|c| c.visible) { + composite_atop(c, &mut clipped_iso, cx); + } + let mut without = before.clone(); + blend_into(&mut without, &iso, BlendMode::Normal, opacity); + let mut with = before; + blend_into(&mut with, &clipped_iso, BlendMode::Normal, opacity); + for ((p, w), wo) in backdrop.px.iter_mut().zip(&with.px).zip(&without.px) { + // Work premultiplied so transparent areas stay consistent. + let pa = p[3]; + let mut pm = [p[0] * pa, p[1] * pa, p[2] * pa, pa]; + for c in 0..3 { + pm[c] += w[c] * w[3] - wo[c] * wo[3]; + } + pm[3] += w[3] - wo[3]; + let a = pm[3].clamp(0.0, 1.0); + *p = if a > 0.0 { [(pm[0] / a).clamp(0.0, 1.0), (pm[1] / a).clamp(0.0, 1.0), (pm[2] / a).clamp(0.0, 1.0), a] } else { [0.0; 4] }; + } + } + return; + } + + // Adjustment layers transform the backdrop, then blend the result back in. + if let LayerContent::Adjustment(adj) = &layer.content { + let before = backdrop.clone(); + let mut adjusted = before.clone(); + adjust::apply_with(adj, &mut adjusted, cx.transfer); + // Clipped layers onto an adjustment are uncommon; they composite atop the adjusted result. + for c in clipped.iter().filter(|c| c.visible) { + composite_atop(c, &mut adjusted, cx); + } + for y in rect.y0..rect.y1 { + for x in rect.x0..rect.x1 { + let i = ((y - rect.y0) as usize) * rect.width() as usize + (x - rect.x0) as usize; + let k = opacity * mask_at(layer, x, y); + if k <= 0.0 { + continue; + } + let b = before.px[i]; + let a = adjusted.px[i]; + let blended = blend::blend_rgb(layer.blend, [b[0], b[1], b[2]], [a[0], a[1], a[2]]); + backdrop.px[i] = [ + b[0] + (blended[0] - b[0]) * k, + b[1] + (blended[1] - b[1]) * k, + b[2] + (blended[2] - b[2]) * k, + b[3], + ]; + } + } + return; + } + + if effects::has_effects(layer) { + // Effects reach beyond the render rect: render the layer larger. + let big = rect.inflate(effects::margin(layer)); + let Some(mut content) = render_content(layer, big, cx) else { return }; + for c in clipped.iter().filter(|c| c.visible) { + composite_atop(c, &mut content, cx); + } + effects::composite_with_effects(layer, &content, backdrop, &cx.light, layer_bounds(layer, cx.canvas)); + return; + } + let Some(mut content) = render_content(layer, rect, cx) else { return }; + for c in clipped.iter().filter(|c| c.visible) { + composite_atop(c, &mut content, cx); + } + blend_into(backdrop, &content, layer.blend, opacity); +} + +/// Composite `layer` onto `base` restricted to the base's alpha (clipping mask semantics). +fn composite_atop(layer: &Layer, base: &mut Buffer, cx: &Ctx) { + let rect = base.rect; + if let LayerContent::Adjustment(adj) = &layer.content { + let mut adjusted = base.clone(); + adjust::apply_with(adj, &mut adjusted, cx.transfer); + for (i, p) in base.px.iter_mut().enumerate() { + let x = rect.x0 + (i as i32 % rect.width() as i32); + let y = rect.y0 + (i as i32 / rect.width() as i32); + let k = layer.opacity * layer.fill_opacity * mask_at(layer, x, y); + let a = adjusted.px[i]; + let bl = blend::blend_rgb(layer.blend, [p[0], p[1], p[2]], [a[0], a[1], a[2]]); + for c in 0..3 { + p[c] += (bl[c] - p[c]) * k; + } + } + return; + } + if effects::has_effects(layer) { + // Effects of a clipped layer are clipped to the base too: render + // them over the base (treated as opaque) and keep the base's alpha. + let big = rect.inflate(effects::margin(layer)); + let Some(content) = render_content(layer, big, cx) else { return }; + let mut opaque = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() }; + effects::composite_with_effects(layer, &content, &mut opaque, &cx.light, layer_bounds(layer, cx.canvas)); + for (p, o) in base.px.iter_mut().zip(&opaque.px) { + if p[3] > 0.0 { + *p = [o[0], o[1], o[2], p[3]]; + } + } + return; + } + let Some(content) = render_content(layer, rect, cx) else { return }; + let opacity = layer.opacity * layer.fill_opacity; + for (i, p) in base.px.iter_mut().enumerate() { + let alpha = p[3]; + if alpha <= 0.0 { + continue; + } + let s = content.px[i]; + // Blend as if the base were opaque, then keep the base's alpha. + let r = blend::composite(layer.blend, [p[0], p[1], p[2], 1.0], s, opacity); + *p = [r[0], r[1], r[2], alpha]; + } +} + +/// Blend an isolated layer buffer into the backdrop. +fn blend_into(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32) { + let rect = backdrop.rect; + let w = rect.width() as i32; + for (i, b) in backdrop.px.iter_mut().enumerate() { + let mut s = src.px[i]; + if s[3] <= 0.0 { + continue; + } + let mut mode = mode; + if mode == BlendMode::Dissolve { + let x = rect.x0 + (i as i32 % w); + let y = rect.y0 + (i as i32 / w); + s[3] = if dissolve_noise(x, y) < s[3] * opacity { 1.0 } else { 0.0 }; + mode = BlendMode::Normal; + *b = blend::composite(mode, *b, s, 1.0); + continue; + } + *b = blend::composite(mode, *b, s, opacity); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/compose/src/psblend.rs b/crates/compose/src/psblend.rs new file mode 100644 index 00000000..91e166a3 --- /dev/null +++ b/crates/compose/src/psblend.rs @@ -0,0 +1,112 @@ +//! Photoshop-exact blend functions where they differ from the generic +//! reference in `photocraft_color::blend`, plus the compositing formula. +//! +//! Derived from Photoshop's rendered composites in the corpus oracle +//! (psd-tools `blend-modes/*.psd`): +//! +//! * **Vivid Light**: the source extremes win: `cs = 0` gives 0 even over a +//! white backdrop, `cs = 1` gives 1 even over black (the generic Color +//! Burn/Dodge let the backdrop extremes win instead). +//! * **Hard Mix**: the thresholded *generic* vivid light (backdrop extremes +//! win): 1 where `VL(cb, cs) >= 0.5`, else 0. For interior values this is +//! the familiar `cb + cs >= 1` rule; the asymmetric edge cases (black +//! source over white → white, white over black → black) match Photoshop. + +use photocraft_color::blend::{self as generic, BlendMode}; + +fn vivid_light_ps(cb: f32, cs: f32) -> f32 { + if cs <= 0.5 { + if cs <= 0.0 { 0.0 } else { 1.0 - ((1.0 - cb) / (2.0 * cs)).min(1.0) } + } else if cs >= 1.0 { + 1.0 + } else { + (cb / (2.0 * (1.0 - cs))).min(1.0) + } +} + +fn vivid_light_generic(cb: f32, cs: f32) -> f32 { + if cs <= 0.5 { + if cb >= 1.0 { + 1.0 + } else if cs <= 0.0 { + 0.0 + } else { + 1.0 - ((1.0 - cb) / (2.0 * cs)).min(1.0) + } + } else if cb <= 0.0 { + 0.0 + } else if cs >= 1.0 { + 1.0 + } else { + (cb / (2.0 * (1.0 - cs))).min(1.0) + } +} + +fn hard_mix_ps(cb: f32, cs: f32) -> f32 { + if vivid_light_generic(cb, cs) >= 0.5 - 1e-6 { 1.0 } else { 0.0 } +} + +/// `B(Cb, Cs)` with Photoshop's variants. +pub fn blend_rgb(mode: BlendMode, cb: [f32; 3], cs: [f32; 3]) -> [f32; 3] { + match mode { + BlendMode::VividLight => std::array::from_fn(|i| vivid_light_ps(cb[i], cs[i])), + BlendMode::HardMix => std::array::from_fn(|i| hard_mix_ps(cb[i], cs[i])), + m => generic::blend_rgb(m, cb, cs), + } +} + +/// Straight-alpha source over straight-alpha backdrop (W3C/PDF general +/// formula) using [`blend_rgb`]. +pub fn composite(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity: f32) -> [f32; 4] { + let ab = backdrop[3]; + let as_ = source[3] * opacity; + if as_ <= 0.0 { + return backdrop; + } + let cb = [backdrop[0], backdrop[1], backdrop[2]]; + let cs = [source[0], source[1], source[2]]; + let b = blend_rgb(mode, cb, cs); + let ao = as_ + ab * (1.0 - as_); + if ao <= 0.0 { + return [0.0; 4]; + } + let mut out = [0.0f32; 4]; + for i in 0..3 { + out[i] = ((1.0 - as_) * ab * cb[i] + (1.0 - ab) * as_ * cs[i] + as_ * ab * b[i]) / ao; + } + out[3] = ao; + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn vivid_light_source_extremes_win() { + assert_eq!(vivid_light_ps(0.0, 1.0), 1.0); + assert_eq!(vivid_light_ps(1.0, 0.0), 0.0); + assert!((vivid_light_ps(0.5, 0.75) - 1.0).abs() < 1e-6); + assert!((vivid_light_ps(0.5, 0.25) - 0.0).abs() < 1e-6); + } + + #[test] + fn hard_mix_edges_match_photoshop() { + assert_eq!(hard_mix_ps(1.0, 0.0), 1.0); + assert_eq!(hard_mix_ps(0.0, 1.0), 0.0); + assert_eq!(hard_mix_ps(0.6, 0.6), 1.0); + assert_eq!(hard_mix_ps(0.3, 0.3), 0.0); + } + + #[test] + fn other_modes_delegate() { + for m in BlendMode::LAYER_MODES { + if matches!(m, BlendMode::VividLight | BlendMode::HardMix) { + continue; + } + let (cb, cs) = ([0.2, 0.5, 0.9], [0.7, 0.1, 0.4]); + assert_eq!(blend_rgb(m, cb, cs), generic::blend_rgb(m, cb, cs)); + assert_eq!(composite(m, [0.2, 0.5, 0.9, 0.7], [0.7, 0.1, 0.4, 0.6], 0.8), generic::composite(m, [0.2, 0.5, 0.9, 0.7], [0.7, 0.1, 0.4, 0.6], 0.8)); + } + } +} diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs new file mode 100644 index 00000000..e4f76a22 --- /dev/null +++ b/crates/compose/src/tests.rs @@ -0,0 +1,568 @@ +use super::*; +use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; +use photocraft_doc::adjust::{CurvePoint, LevelsChannel}; +use photocraft_doc::{Adjustment, Document, Fill, Layer, LayerContent, LayerMask}; +use photocraft_geom::Size; + +const E: f32 = 2.0 / 255.0; + +fn close4(a: [f32; 4], b: [f32; 4]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() <= E) +} + +fn doc_white(w: u32, h: u32) -> Document { + Document::with_background("t", Size::new(w, h), ColorMode::Rgb, SampleType::U8, Color::WHITE) +} + +fn solid_layer(name: &str, rect: Rect, rgba: [f32; 4]) -> Layer { + let mut l = Layer::raster(name, PixelFormat::RGBA8); + l.surface_mut().unwrap().fill_rect(rect, &rgba); + l +} + +fn px(doc: &Document, x: i32, y: i32) -> [f32; 4] { + render(doc, Rect::from_xywh(x, y, 1, 1)).px[0] +} + +#[test] +fn background_only() { + let d = doc_white(8, 8); + assert!(close4(px(&d, 3, 3), [1.0; 4])); + // outside canvas the background layer has no pixels + assert!(close4(px(&d, 20, 3), [0.0; 4])); +} + +#[test] +fn normal_layer_over_background() { + let mut d = doc_white(8, 8); + d.layers.push(solid_layer("red", Rect::new(0, 0, 4, 8), [1.0, 0.0, 0.0, 1.0])); + assert!(close4(px(&d, 1, 1), [1.0, 0.0, 0.0, 1.0])); + assert!(close4(px(&d, 5, 1), [1.0; 4])); +} + +#[test] +fn opacity_and_fill_multiply() { + let mut d = doc_white(4, 4); + let mut l = solid_layer("k", Rect::new(0, 0, 4, 4), [0.0, 0.0, 0.0, 1.0]); + l.opacity = 0.5; + l.fill_opacity = 0.5; + d.layers.push(l); + let p = px(&d, 0, 0); + assert!((p[0] - 0.75).abs() <= E, "{p:?}"); +} + +#[test] +fn hidden_layers_skipped() { + let mut d = doc_white(4, 4); + let mut l = solid_layer("k", Rect::new(0, 0, 4, 4), [0.0, 0.0, 0.0, 1.0]); + l.visible = false; + d.layers.push(l); + assert!(close4(px(&d, 0, 0), [1.0; 4])); +} + +#[test] +fn every_blend_mode_matches_reference_on_opaque_pixels() { + let backdrop = [0.6, 0.3, 0.2, 1.0]; + let source = [0.2, 0.7, 0.5, 1.0]; + for mode in BlendMode::LAYER_MODES { + if mode == BlendMode::Dissolve { + continue; + } + let mut d = Document::new("b", Size::new(2, 2), ColorMode::Rgb, SampleType::F32); + let mut bottom = Layer::raster("b", PixelFormat::RGBA32F); + bottom.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 2), &backdrop); + let mut top = Layer::raster("t", PixelFormat::RGBA32F); + top.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 2), &source); + top.blend = mode; + d.layers = vec![bottom, top]; + let got = px(&d, 0, 0); + let want = blend::blend_rgb(mode, [0.6, 0.3, 0.2], [0.2, 0.7, 0.5]); + for i in 0..3 { + assert!((got[i] - want[i]).abs() < 1e-5, "{mode:?}: {got:?} vs {want:?}"); + } + assert!((got[3] - 1.0).abs() < 1e-6); + } +} + +#[test] +fn layer_mask_hides_pixels() { + let mut d = doc_white(4, 4); + let mut l = solid_layer("k", Rect::new(0, 0, 4, 4), [0.0, 0.0, 0.0, 1.0]); + let mut m = LayerMask::reveal_all(); + m.surface.fill_rect(Rect::new(0, 0, 2, 4), &[0.0]); + l.mask = Some(m); + d.layers.push(l); + assert!(close4(px(&d, 0, 0), [1.0; 4])); + assert!(close4(px(&d, 3, 0), [0.0, 0.0, 0.0, 1.0])); +} + +#[test] +fn disabled_mask_is_ignored() { + let mut d = doc_white(2, 2); + let mut l = solid_layer("k", Rect::new(0, 0, 2, 2), [0.0, 0.0, 0.0, 1.0]); + let mut m = LayerMask::hide_all(); + m.enabled = false; + l.mask = Some(m); + d.layers.push(l); + assert!(close4(px(&d, 0, 0), [0.0, 0.0, 0.0, 1.0])); +} + +#[test] +fn clipping_mask_restricts_to_base_alpha() { + let mut d = doc_white(8, 8); + d.layers.push(solid_layer("base", Rect::new(0, 0, 4, 8), [0.0, 0.0, 1.0, 1.0])); + let mut clip = solid_layer("clip", Rect::new(0, 0, 8, 8), [1.0, 0.0, 0.0, 1.0]); + clip.clipped = true; + d.layers.push(clip); + assert!(close4(px(&d, 1, 1), [1.0, 0.0, 0.0, 1.0]), "inside base: clipped layer visible"); + assert!(close4(px(&d, 6, 1), [1.0; 4]), "outside base: clipped layer hidden"); +} + +#[test] +fn hidden_base_hides_clipping_group() { + let mut d = doc_white(4, 4); + let mut base = solid_layer("base", Rect::new(0, 0, 4, 4), [0.0, 0.0, 1.0, 1.0]); + base.visible = false; + d.layers.push(base); + let mut clip = solid_layer("clip", Rect::new(0, 0, 4, 4), [1.0, 0.0, 0.0, 1.0]); + clip.clipped = true; + d.layers.push(clip); + assert!(close4(px(&d, 1, 1), [1.0; 4])); +} + +#[test] +fn isolated_group_differs_from_pass_through() { + // A Multiply layer inside a group: pass-through multiplies with the background, + // an isolated (Normal) group multiplies only with its own (empty) contents. + let make = |blend: BlendMode| { + let mut d = doc_white(2, 2); + d.layers[0].surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 2), &[0.5, 0.5, 0.5, 1.0]); + let mut m = solid_layer("m", Rect::new(0, 0, 2, 2), [0.5, 0.5, 0.5, 1.0]); + m.blend = BlendMode::Multiply; + let mut g = Layer::group("g", vec![m]); + g.blend = blend; + d.layers.push(g); + px(&d, 0, 0) + }; + let pass = make(BlendMode::PassThrough); + let iso = make(BlendMode::Normal); + assert!((pass[0] - 0.25).abs() <= E, "{pass:?}"); + assert!((iso[0] - 0.5).abs() <= E, "{iso:?}"); +} + +#[test] +fn group_opacity_applies_once() { + let mut d = doc_white(2, 2); + let a = solid_layer("a", Rect::new(0, 0, 2, 2), [0.0, 0.0, 0.0, 1.0]); + let b = solid_layer("b", Rect::new(0, 0, 2, 2), [0.0, 0.0, 0.0, 1.0]); + let mut g = Layer::group("g", vec![a, b]); + g.blend = BlendMode::Normal; + g.opacity = 0.5; + d.layers.push(g); + assert!((px(&d, 0, 0)[0] - 0.5).abs() <= E); +} + +#[test] +fn pass_through_group_opacity_mixes() { + let mut d = doc_white(2, 2); + let a = solid_layer("a", Rect::new(0, 0, 2, 2), [0.0, 0.0, 0.0, 1.0]); + let mut g = Layer::group("g", vec![a]); + g.opacity = 0.25; + d.layers.push(g); + assert!((px(&d, 0, 0)[0] - 0.75).abs() <= E); +} + +#[test] +fn invert_adjustment_layer() { + let mut d = doc_white(2, 2); + d.layers[0].surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 2), &[0.2, 0.4, 0.6, 1.0]); + d.layers.push(Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))); + assert!(close4(px(&d, 0, 0), [0.8, 0.6, 0.4, 1.0])); +} + +#[test] +fn adjustment_opacity_and_mask() { + let mut d = doc_white(4, 1); + d.layers[0].surface_mut().unwrap().fill_rect(Rect::new(0, 0, 4, 1), &[0.0, 0.0, 0.0, 1.0]); + let mut adj = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)); + adj.opacity = 0.5; + let mut m = LayerMask::reveal_all(); + m.surface.fill_rect(Rect::new(2, 0, 4, 1), &[0.0]); + adj.mask = Some(m); + d.layers.push(adj); + assert!((px(&d, 0, 0)[0] - 0.5).abs() <= E); + assert!(px(&d, 3, 0)[0].abs() <= E); +} + +#[test] +fn clipped_adjustment_only_affects_base() { + let mut d = doc_white(4, 1); + d.layers.push(solid_layer("base", Rect::new(0, 0, 2, 1), [0.0, 0.0, 0.0, 1.0])); + let mut adj = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)); + adj.clipped = true; + d.layers.push(adj); + assert!(close4(px(&d, 0, 0), [1.0; 4]), "base inverted to white"); + assert!(close4(px(&d, 3, 0), [1.0; 4]), "background untouched (white)"); +} + +#[test] +fn threshold_posterize_levels_curves() { + let mut buf = Buffer::filled(Rect::new(0, 0, 1, 1), [0.3, 0.3, 0.3, 1.0]); + adjust::apply(&Adjustment::Threshold { level: 0.5 }, &mut buf); + assert!(close4(buf.px[0], [0.0, 0.0, 0.0, 1.0])); + + let mut buf = Buffer::filled(Rect::new(0, 0, 1, 1), [0.3, 0.6, 0.9, 1.0]); + adjust::apply(&Adjustment::Posterize { levels: 2 }, &mut buf); + assert!(close4(buf.px[0], [0.0, 1.0, 1.0, 1.0])); + + let lv = LevelsChannel { in_black: 0.2, in_white: 0.8, ..Default::default() }; + assert!((adjust::levels(&lv, 0.5) - 0.5).abs() < 1e-6); + assert!((adjust::levels(&lv, 0.2)).abs() < 1e-6); + assert!((adjust::levels(&lv, 0.9) - 1.0).abs() < 1e-6); + + let identity = adjust::curve_lut(&[CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }]); + for (i, v) in identity.iter().enumerate().step_by(97) { + assert!((v - i as f32 / (identity.len() - 1) as f32).abs() < 1e-4); + } + let s = adjust::curve_lut(&[ + CurvePoint { input: 0.0, output: 0.0 }, + CurvePoint { input: 0.25, output: 0.15 }, + CurvePoint { input: 0.75, output: 0.85 }, + CurvePoint { input: 1.0, output: 1.0 }, + ]); + assert!(s.windows(2).all(|w| w[1] >= w[0] - 1e-6), "monotone"); + assert!(s[s.len() / 4] < 0.25 && s[3 * s.len() / 4] > 0.75); +} + +#[test] +fn hue_saturation_roundtrips_and_desaturates() { + let c = [0.8, 0.3, 0.1]; + let (h, s, l) = adjust::rgb_to_hsl(c); + let back = adjust::hsl_to_rgb(h, s, l); + for i in 0..3 { + assert!((back[i] - c[i]).abs() < 1e-5); + } + let mut buf = Buffer::filled(Rect::new(0, 0, 1, 1), [0.8, 0.3, 0.1, 1.0]); + adjust::apply(&Adjustment::HueSaturation { hue: 0.0, saturation: -100.0, lightness: 0.0, colorize: false }, &mut buf); + let p = buf.px[0]; + assert!((p[0] - p[1]).abs() < 1e-5 && (p[1] - p[2]).abs() < 1e-5); + let mut buf = Buffer::filled(Rect::new(0, 0, 1, 1), [1.0, 0.0, 0.0, 1.0]); + adjust::apply(&Adjustment::HueSaturation { hue: 120.0, saturation: 0.0, lightness: 0.0, colorize: false }, &mut buf); + assert!(close4(buf.px[0], [0.0, 1.0, 0.0, 1.0])); +} + +#[test] +fn solid_and_gradient_fill_layers() { + let mut d = doc_white(10, 1); + d.layers.push(Layer::new("fill", LayerContent::Fill(Fill::Solid(Color::rgb(0.0, 0.0, 1.0))))); + assert!(close4(px(&d, 5, 0), [0.0, 0.0, 1.0, 1.0])); + + let g = Fill::Gradient { stops: vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], angle: 0.0, scale: 1.0, style: photocraft_doc::GradientStyle::Linear, reverse: false }; + let buf = render_fill(&g, Rect::new(0, 0, 10, 1), Rect::new(0, 0, 10, 1)); + // tile independence: a 1px render of the right edge equals the full render + let one = render_fill(&g, Rect::new(9, 0, 10, 1), Rect::new(0, 0, 10, 1)); + assert_eq!(one.px[0], buf.px[9]); + assert!(buf.px[0][0] < buf.px[9][0], "left dark, right light"); +} + +#[test] +fn dissolve_coverage_matches_opacity() { + let mut d = Document::new("d", Size::new(64, 64), ColorMode::Rgb, SampleType::U8); + let mut l = solid_layer("k", Rect::new(0, 0, 64, 64), [0.0, 0.0, 0.0, 1.0]); + l.blend = BlendMode::Dissolve; + l.opacity = 0.3; + d.layers.push(l); + let b = flatten(&d); + let covered = b.px.iter().filter(|p| p[3] > 0.5).count() as f32 / b.px.len() as f32; + assert!((covered - 0.3).abs() < 0.05, "{covered}"); + // deterministic + assert_eq!(flatten(&d), b); +} + +#[test] +fn render_is_tile_independent() { + // Rendering a sub-rect must equal the same region of a full render. + let mut d = doc_white(300, 300); + let mut l = solid_layer("a", Rect::new(20, 20, 280, 280), [0.1, 0.5, 0.9, 0.7]); + l.blend = BlendMode::Overlay; + d.layers.push(l); + let full = flatten(&d); + let sub = render(&d, Rect::new(250, 250, 270, 262)); + for y in 250..262 { + for x in 250..270 { + assert_eq!(full.get(x, y), sub.get(x, y)); + } + } +} + +#[test] +fn cmyk_document_renders_via_rgb() { + let mut d = Document::new("c", Size::new(2, 2), ColorMode::Cmyk, SampleType::U8); + let mut l = Layer::raster("k", d.pixel_format()); + l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 2), &[0.0, 0.0, 0.0, 1.0, 1.0]); + d.layers.push(l); + assert!(close4(px(&d, 0, 0), [0.0, 0.0, 0.0, 1.0])); +} + +#[test] +fn sixteen_bit_and_float_layers_composite() { + for fmt in [PixelFormat::RGBA16, PixelFormat::RGBA32F] { + let mut d = Document::new("x", Size::new(2, 2), ColorMode::Rgb, fmt.sample); + let mut l = Layer::raster("a", fmt); + l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 2), &[0.25, 0.5, 0.75, 1.0]); + d.layers.push(l); + assert!(close4(px(&d, 1, 1), [0.25, 0.5, 0.75, 1.0]), "{fmt:?}"); + } +} + +#[test] +fn thumbnail_dimensions() { + let d = doc_white(400, 200); + let t = thumbnail(&d, 100); + assert_eq!((t.width, t.height), (100, 50)); + assert_eq!(&t.pixels[0..4], &[255, 255, 255, 255]); + let small = doc_white(10, 5); + let t = thumbnail(&small, 100); + assert_eq!((t.width, t.height), (10, 5)); +} + +#[test] +fn buffer_over_background() { + let b = Buffer::filled(Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 0.5]); + let o = b.over_background([1.0, 1.0, 1.0]); + assert!(close4(o.px[0], [0.5, 0.5, 0.5, 1.0])); + assert_eq!(b.to_rgba8().pixels, vec![0, 0, 0, 128]); +} + +// ---------- layer effects ---------- + +use photocraft_doc::{Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique, Gradient, GradientStyle, Satin, Shadow, StrokeFx, StrokePosition}; + +fn fx_doc(effects: Vec) -> Document { + let mut d = doc_white(40, 40); + let mut l = solid_layer("sq", Rect::new(10, 10, 30, 30), [1.0, 0.0, 0.0, 1.0]); + l.effects.items = effects; + d.layers.push(l); + d +} + +fn stroke(size: f32, position: StrokePosition) -> Effect { + Effect::Stroke(StrokeFx { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), size, position, paint: FxPaint::Color(Color::rgb(0.0, 0.0, 1.0)) }) +} + +#[test] +fn outside_stroke_width() { + let d = fx_doc(vec![stroke(3.0, StrokePosition::Outside)]); + assert!(close4(px(&d, 8, 20), [0.0, 0.0, 1.0, 1.0])); + assert!(close4(px(&d, 7, 20), [0.0, 0.0, 1.0, 1.0])); + assert!(close4(px(&d, 5, 20), [1.0; 4]), "{:?}", px(&d, 5, 20)); + assert!(close4(px(&d, 20, 20), [1.0, 0.0, 0.0, 1.0]), "interior untouched"); +} + +#[test] +fn inside_and_center_strokes() { + let d = fx_doc(vec![stroke(2.0, StrokePosition::Inside)]); + assert!(close4(px(&d, 10, 20), [0.0, 0.0, 1.0, 1.0])); + assert!(close4(px(&d, 11, 20), [0.0, 0.0, 1.0, 1.0])); + assert!(close4(px(&d, 13, 20), [1.0, 0.0, 0.0, 1.0])); + assert!(close4(px(&d, 9, 20), [1.0; 4])); + let d = fx_doc(vec![stroke(4.0, StrokePosition::Center)]); + assert!(close4(px(&d, 8, 20), [0.0, 0.0, 1.0, 1.0])); + assert!(close4(px(&d, 11, 20), [0.0, 0.0, 1.0, 1.0])); + assert!(close4(px(&d, 13, 20), [1.0, 0.0, 0.0, 1.0])); +} + +#[test] +fn master_switch_and_per_effect_enable() { + let mut d = fx_doc(vec![stroke(3.0, StrokePosition::Outside)]); + d.layers[1].effects.enabled = false; + assert!(close4(px(&d, 8, 20), [1.0; 4])); + let mut d = fx_doc(vec![stroke(3.0, StrokePosition::Outside)]); + if let Effect::Stroke(s) = &mut d.layers[1].effects.items[0] { + s.common.enabled = false; + } + assert!(close4(px(&d, 8, 20), [1.0; 4])); +} + +#[test] +fn color_overlay_ignores_fill_opacity_but_not_opacity() { + let mut d = fx_doc(vec![Effect::ColorOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), color: Color::rgb(0.0, 1.0, 0.0) }]); + d.layers[1].fill_opacity = 0.0; + assert!(close4(px(&d, 20, 20), [0.0, 1.0, 0.0, 1.0]), "overlay shows at fill 0"); + d.layers[1].opacity = 0.5; + assert!(close4(px(&d, 20, 20), [0.5, 1.0, 0.5, 1.0]), "{:?}", px(&d, 20, 20)); +} + +fn shadow(distance: f32, angle: f32) -> Shadow { + Shadow { + common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), + color: Color::BLACK, + angle, + use_global_light: false, + distance, + spread: 1.0, + size: 0.0, + contour: photocraft_doc::Contour::Linear, + anti_alias: false, + noise: 0.0, + knocks_out: true, + } +} + +#[test] +fn drop_shadow_falls_away_from_light() { + // Light from the top (90°): shadow below the square. + let d = fx_doc(vec![Effect::DropShadow(shadow(5.0, 90.0))]); + assert!(close4(px(&d, 20, 32), [0.0, 0.0, 0.0, 1.0]), "{:?}", px(&d, 20, 32)); + assert!(close4(px(&d, 20, 8), [1.0; 4])); + // Light from the right (0°, 3 o'clock): shadow to the left. + let d = fx_doc(vec![Effect::DropShadow(shadow(5.0, 0.0))]); + assert!(close4(px(&d, 8, 20), [0.0, 0.0, 0.0, 1.0])); + assert!(close4(px(&d, 32, 20), [1.0; 4])); +} + +#[test] +fn drop_shadow_uses_global_light_and_knockout() { + let mut d = fx_doc(vec![Effect::DropShadow(Shadow { use_global_light: true, ..shadow(5.0, 0.0) })]); + d.global_light.angle = 90.0; + assert!(close4(px(&d, 20, 32), [0.0, 0.0, 0.0, 1.0])); + // Knock-out: with fill 0 the shadow does not show through the layer. + d.layers[1].fill_opacity = 0.0; + assert!(close4(px(&d, 20, 28), [1.0; 4]), "{:?}", px(&d, 20, 28)); + if let Effect::DropShadow(s) = &mut d.layers[1].effects.items[0] { + s.knocks_out = false; + } + assert!(close4(px(&d, 20, 28), [0.0, 0.0, 0.0, 1.0])); +} + +#[test] +fn soft_shadow_is_blurred_and_bounded() { + let d = fx_doc(vec![Effect::DropShadow(Shadow { spread: 0.0, size: 6.0, ..shadow(0.0, 90.0) })]); + let edge = px(&d, 30, 20); // just outside the right edge + let far = px(&d, 39, 20); + assert!(edge[0] < 0.8 && edge[0] > 0.2, "{edge:?}"); + assert!(far[0] > 0.99, "{far:?}"); +} + +#[test] +fn inner_shadow_only_inside() { + let d = fx_doc(vec![Effect::InnerShadow(Shadow { knocks_out: false, ..shadow(4.0, 90.0) })]); + // Top rows of the square are shadowed (light from the top pushes the + // outside's shadow down into the shape). + assert!(close4(px(&d, 20, 11), [0.0, 0.0, 0.0, 1.0]), "{:?}", px(&d, 20, 11)); + assert!(close4(px(&d, 20, 25), [1.0, 0.0, 0.0, 1.0])); + assert!(close4(px(&d, 20, 5), [1.0; 4])); +} + +fn glow(technique: GlowTechnique, source: GlowSource) -> Glow { + Glow { + common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), + paint: FxPaint::Color(Color::rgb(0.0, 1.0, 0.0)), + technique, + spread: 0.0, + size: 4.0, + contour: photocraft_doc::Contour::Linear, + anti_alias: false, + range: 0.5, + jitter: 0.0, + noise: 0.0, + source, + } +} + +#[test] +fn outer_and_inner_glow_regions() { + let d = fx_doc(vec![Effect::OuterGlow(glow(GlowTechnique::Precise, GlowSource::Edge))]); + let near = px(&d, 9, 20); + assert!(near[1] > 0.7 && near[0] < 0.3, "{near:?}"); + assert!(close4(px(&d, 3, 20), [1.0; 4])); + assert!(close4(px(&d, 20, 20), [1.0, 0.0, 0.0, 1.0])); + let d = fx_doc(vec![Effect::InnerGlow(glow(GlowTechnique::Softer, GlowSource::Edge))]); + let edge = px(&d, 10, 20); + assert!(edge[1] > 0.3, "{edge:?}"); + assert!(close4(px(&d, 20, 20), [1.0, 0.0, 0.0, 1.0])); + assert!(close4(px(&d, 5, 20), [1.0; 4])); +} + +#[test] +fn gradient_overlay_follows_angle_and_reverse() { + let g = Gradient { stops: vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], angle: 0.0, ..Gradient::default() }; + let d = fx_doc(vec![Effect::GradientOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), gradient: g.clone(), dither: false }]); + let (l, r) = (px(&d, 10, 20), px(&d, 29, 20)); + assert!(l[0] < 0.1 && r[0] > 0.9, "{l:?} {r:?}"); + let d = fx_doc(vec![Effect::GradientOverlay { + common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), + gradient: Gradient { reverse: true, style: GradientStyle::Linear, ..g }, + dither: false, + }]); + assert!(px(&d, 10, 20)[0] > 0.9); +} + +#[test] +fn satin_and_bevel_stay_inside_shape() { + let satin = Effect::Satin(Satin { + common: FxCommon::new(photocraft_color::BlendMode::Multiply, 1.0), + color: Color::BLACK, + angle: 19.0, + distance: 4.0, + size: 4.0, + contour: photocraft_doc::Contour::Linear, + anti_alias: true, + invert: false, + }); + let d = fx_doc(vec![satin, Effect::BevelEmboss(photocraft_doc::Bevel { + enabled: true, + style: photocraft_doc::BevelStyle::InnerBevel, + technique: photocraft_doc::BevelTechnique::Smooth, + depth: 1.0, + up: true, + size: 4.0, + soften: 0.0, + angle: 90.0, + altitude: 30.0, + use_global_light: false, + gloss_contour: photocraft_doc::Contour::Linear, + highlight: FxCommon::new(photocraft_color::BlendMode::Screen, 0.75), + highlight_color: Color::WHITE, + shadow: FxCommon::new(photocraft_color::BlendMode::Multiply, 0.75), + shadow_color: Color::BLACK, + })]); + for (x, y) in [(5, 5), (35, 20), (20, 35)] { + assert!(close4(px(&d, x, y), [1.0; 4]), "({x},{y}) {:?}", px(&d, x, y)); + } + // Light from the top: the top bevel edge is brighter than the bottom one. + let (top, bot) = (px(&d, 20, 11), px(&d, 20, 28)); + assert!(top[1] > bot[1], "{top:?} {bot:?}"); +} + +#[test] +fn clipped_layer_effects_are_clipped_to_base() { + let mut d = doc_white(40, 40); + d.layers.push(solid_layer("base", Rect::new(10, 10, 30, 30), [1.0, 0.0, 0.0, 1.0])); + let mut c = solid_layer("clip", Rect::new(20, 10, 40, 30), [0.0, 1.0, 0.0, 1.0]); + c.clipped = true; + c.effects.items.push(stroke(3.0, StrokePosition::Outside)); + d.layers.push(c); + assert!(close4(px(&d, 18, 20), [0.0, 0.0, 1.0, 1.0]), "stroke inside base"); + assert!(close4(px(&d, 20, 8), [1.0; 4]), "stroke clipped outside base"); +} + +#[test] +fn group_effects_apply_to_group_shape() { + let mut d = doc_white(40, 40); + let mut g = Layer::group("g", vec![solid_layer("a", Rect::new(10, 10, 20, 30), [1.0, 0.0, 0.0, 1.0]), solid_layer("b", Rect::new(20, 10, 30, 30), [1.0, 0.0, 0.0, 1.0])]); + g.effects.items.push(stroke(2.0, StrokePosition::Outside)); + d.layers.push(g); + assert!(close4(px(&d, 9, 20), [0.0, 0.0, 1.0, 1.0])); + assert!(close4(px(&d, 20, 20), [1.0, 0.0, 0.0, 1.0]), "no stroke at the seam between children"); +} + +#[test] +fn effects_render_identically_in_tiles() { + let d = fx_doc(vec![Effect::DropShadow(Shadow { spread: 0.0, size: 5.0, ..shadow(4.0, 120.0) }), stroke(2.0, StrokePosition::Outside)]); + let full = flatten(&d); + for (x, y) in [(33, 33), (8, 20), (31, 12)] { + let t = render(&d, Rect::from_xywh(x, y, 1, 1)).px[0]; + assert!(close4(t, full.get(x, y)), "({x},{y})"); + } +} diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml new file mode 100644 index 00000000..299880cb --- /dev/null +++ b/crates/doc/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "photocraft-doc" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +photocraft-geom = { workspace = true } +photocraft-color = { workspace = true } +photocraft-raster = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/doc/src/adjust.rs b/crates/doc/src/adjust.rs new file mode 100644 index 00000000..cd37c2f2 --- /dev/null +++ b/crates/doc/src/adjust.rs @@ -0,0 +1,87 @@ +//! Adjustment parameters (data only). Evaluation lives in `photocraft-compose`. + +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CurvePoint { + pub input: f32, + pub output: f32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct LevelsChannel { + pub in_black: f32, + pub in_white: f32, + pub gamma: f32, + pub out_black: f32, + pub out_white: f32, +} + +impl Default for LevelsChannel { + fn default() -> Self { + Self { in_black: 0.0, in_white: 1.0, gamma: 1.0, out_black: 0.0, out_white: 1.0 } + } +} + +/// Photoshop adjustment layers (Layer → New Adjustment Layer). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum Adjustment { + BrightnessContrast { brightness: f32, contrast: f32, legacy: bool }, + /// Composite channel first, then R, G, B. + Levels { master: LevelsChannel, per_channel: [LevelsChannel; 3] }, + /// Master curve then R, G, B curves. + Curves { master: Vec, per_channel: [Vec; 3] }, + Exposure { exposure: f32, offset: f32, gamma: f32 }, + Vibrance { vibrance: f32, saturation: f32 }, + HueSaturation { hue: f32, saturation: f32, lightness: f32, colorize: bool }, + ColorBalance { shadows: [f32; 3], midtones: [f32; 3], highlights: [f32; 3], preserve_luminosity: bool }, + BlackWhite { weights: [f32; 6], tint: Option<[f32; 3]> }, + PhotoFilter { color: [f32; 3], density: f32, preserve_luminosity: bool }, + ChannelMixer { matrix: [[f32; 4]; 3], monochrome: bool }, + ColorLookup { name: String, lut: Option>>, size: u32 }, + Invert, + Posterize { levels: u32 }, + Threshold { level: f32 }, + GradientMap { stops: Vec<(f32, [f32; 3])>, reverse: bool }, + SelectiveColor { relative: bool, adjustments: [[f32; 4]; 9] }, + /// A PSD adjustment we can't evaluate yet; preserved raw for round-trip. + Unsupported { psd_key: String, raw: Vec }, +} + +impl Adjustment { + pub fn label(&self) -> &str { + match self { + Adjustment::BrightnessContrast { .. } => "Brightness/Contrast", + Adjustment::Levels { .. } => "Levels", + Adjustment::Curves { .. } => "Curves", + Adjustment::Exposure { .. } => "Exposure", + Adjustment::Vibrance { .. } => "Vibrance", + Adjustment::HueSaturation { .. } => "Hue/Saturation", + Adjustment::ColorBalance { .. } => "Color Balance", + Adjustment::BlackWhite { .. } => "Black & White", + Adjustment::PhotoFilter { .. } => "Photo Filter", + Adjustment::ChannelMixer { .. } => "Channel Mixer", + Adjustment::ColorLookup { .. } => "Color Lookup", + Adjustment::Invert => "Invert", + Adjustment::Posterize { .. } => "Posterize", + Adjustment::Threshold { .. } => "Threshold", + Adjustment::GradientMap { .. } => "Gradient Map", + Adjustment::SelectiveColor { .. } => "Selective Color", + Adjustment::Unsupported { .. } => "Adjustment", + } + } + + pub fn default_hue_saturation() -> Self { + Adjustment::HueSaturation { hue: 0.0, saturation: 0.0, lightness: 0.0, colorize: false } + } + + pub fn identity_curves() -> Self { + let line = || vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }]; + Adjustment::Curves { master: line(), per_channel: [line(), line(), line()] } + } + + pub fn identity_levels() -> Self { + Adjustment::Levels { master: LevelsChannel::default(), per_channel: Default::default() } + } +} diff --git a/crates/doc/src/effects.rs b/crates/doc/src/effects.rs new file mode 100644 index 00000000..35810f9f --- /dev/null +++ b/crates/doc/src/effects.rs @@ -0,0 +1,277 @@ +//! Layer styles (effects): data only. Rendering lives in `photocraft-compose`, +//! PSD mapping in `photocraft-io`. +//! +//! Angles are in degrees, counter-clockwise from 3 o'clock, and give the +//! direction the light comes *from* (Photoshop convention). Sizes and +//! distances are in pixels, opacities and percentages in `0..=1`. + +use photocraft_color::{BlendMode, Color}; +use serde::{Deserialize, Serialize}; + +use crate::adjust::CurvePoint; + +/// Gradient geometry (fill layers, gradient overlays, gradient strokes). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum GradientStyle { + #[default] + Linear, + Radial, + Angle, + Reflected, + Diamond, +} + +/// A gradient: colour stops plus transparency stops (both at `0..=1`). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Gradient { + pub stops: Vec<(f32, Color)>, + /// Opacity stops; empty means fully opaque. + pub opacity_stops: Vec<(f32, f32)>, + pub style: GradientStyle, + pub angle: f32, + pub scale: f32, + pub reverse: bool, + /// Align the gradient with the layer bounds (else the canvas). + pub align: bool, + /// Centre offset as a fraction of the bounds. + pub offset: (f32, f32), +} + +impl Default for Gradient { + fn default() -> Self { + Gradient { + stops: vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], + opacity_stops: Vec::new(), + style: GradientStyle::Linear, + angle: 90.0, + scale: 1.0, + reverse: false, + align: true, + offset: (0.0, 0.0), + } + } +} + +/// Effect contour (transfer curve applied to the effect's coverage). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub enum Contour { + #[default] + Linear, + /// Custom curve (input → output), both `0..=1`. + Custom { name: String, points: Vec }, +} + +/// Paint used by strokes and glows. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum FxPaint { + Color(Color), + Gradient(Gradient), + /// Pattern by name/id (rendered once pattern data is available). + Pattern { name: String, id: String, scale: f32 }, +} + +/// Blend mode + opacity + enabled, shared by every effect. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct FxCommon { + pub enabled: bool, + pub blend: BlendMode, + pub opacity: f32, +} + +impl FxCommon { + pub fn new(blend: BlendMode, opacity: f32) -> Self { + FxCommon { enabled: true, blend, opacity } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Shadow { + pub common: FxCommon, + pub color: Color, + pub angle: f32, + pub use_global_light: bool, + pub distance: f32, + /// Spread (drop shadow) or choke (inner shadow), `0..=1`. + pub spread: f32, + pub size: f32, + pub contour: Contour, + pub anti_alias: bool, + pub noise: f32, + /// Drop shadow only: the layer knocks out the shadow beneath it. + pub knocks_out: bool, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum GlowTechnique { + #[default] + Softer, + Precise, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum GlowSource { + Center, + #[default] + Edge, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Glow { + pub common: FxCommon, + pub paint: FxPaint, + pub technique: GlowTechnique, + /// Spread (outer) or choke (inner), `0..=1`. + pub spread: f32, + pub size: f32, + pub contour: Contour, + pub anti_alias: bool, + pub range: f32, + pub jitter: f32, + pub noise: f32, + /// Inner glow only. + pub source: GlowSource, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum StrokePosition { + Outside, + Inside, + Center, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct StrokeFx { + pub common: FxCommon, + pub size: f32, + pub position: StrokePosition, + pub paint: FxPaint, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Satin { + pub common: FxCommon, + pub color: Color, + pub angle: f32, + pub distance: f32, + pub size: f32, + pub contour: Contour, + pub anti_alias: bool, + pub invert: bool, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum BevelStyle { + OuterBevel, + #[default] + InnerBevel, + Emboss, + PillowEmboss, + StrokeEmboss, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum BevelTechnique { + #[default] + Smooth, + ChiselHard, + ChiselSoft, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Bevel { + pub enabled: bool, + pub style: BevelStyle, + pub technique: BevelTechnique, + /// Depth, `0..=10` (1 = 100 %). + pub depth: f32, + /// Direction up (true) or down. + pub up: bool, + pub size: f32, + pub soften: f32, + pub angle: f32, + pub altitude: f32, + pub use_global_light: bool, + pub gloss_contour: Contour, + pub highlight: FxCommon, + pub highlight_color: Color, + pub shadow: FxCommon, + pub shadow_color: Color, +} + +/// One layer effect. Several instances of the same kind are allowed +/// (Photoshop's multiple strokes/shadows/overlays). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum Effect { + DropShadow(Shadow), + InnerShadow(Shadow), + OuterGlow(Glow), + InnerGlow(Glow), + Stroke(StrokeFx), + ColorOverlay { common: FxCommon, color: Color }, + GradientOverlay { common: FxCommon, gradient: Gradient, dither: bool }, + PatternOverlay { common: FxCommon, name: String, id: String, scale: f32 }, + Satin(Satin), + BevelEmboss(Bevel), +} + +impl Effect { + /// Whether this instance is switched on. + pub fn enabled(&self) -> bool { + match self { + Effect::DropShadow(s) | Effect::InnerShadow(s) => s.common.enabled, + Effect::OuterGlow(g) | Effect::InnerGlow(g) => g.common.enabled, + Effect::Stroke(s) => s.common.enabled, + Effect::ColorOverlay { common, .. } | Effect::GradientOverlay { common, .. } | Effect::PatternOverlay { common, .. } => { + common.enabled + } + Effect::Satin(s) => s.common.enabled, + Effect::BevelEmboss(b) => b.enabled, + } + } + + /// Short label for UIs. + pub fn label(&self) -> &'static str { + match self { + Effect::DropShadow(_) => "Drop Shadow", + Effect::InnerShadow(_) => "Inner Shadow", + Effect::OuterGlow(_) => "Outer Glow", + Effect::InnerGlow(_) => "Inner Glow", + Effect::Stroke(_) => "Stroke", + Effect::ColorOverlay { .. } => "Color Overlay", + Effect::GradientOverlay { .. } => "Gradient Overlay", + Effect::PatternOverlay { .. } => "Pattern Overlay", + Effect::Satin(_) => "Satin", + Effect::BevelEmboss(_) => "Bevel & Emboss", + } + } + + /// Photoshop's defaults for a drop shadow. + pub fn default_drop_shadow() -> Self { + Effect::DropShadow(Shadow { + common: FxCommon::new(BlendMode::Multiply, 0.75), + color: Color::BLACK, + angle: 120.0, + use_global_light: true, + distance: 5.0, + spread: 0.0, + size: 5.0, + contour: Contour::Linear, + anti_alias: false, + noise: 0.0, + knocks_out: true, + }) + } +} + +/// Document-wide light used by effects with `use_global_light`. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct GlobalLight { + pub angle: f32, + pub altitude: f32, +} + +impl Default for GlobalLight { + fn default() -> Self { + GlobalLight { angle: 120.0, altitude: 30.0 } + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs new file mode 100644 index 00000000..4439cfca --- /dev/null +++ b/crates/doc/src/lib.rs @@ -0,0 +1,634 @@ +//! The Photocraft document model: pure data, no rendering, no UI. +//! +//! Layer order: inside every group (and the root), `children[0]` is the **bottom** layer, matching +//! compositing order and the PSD file order. UIs display the list reversed. +//! +//! Non-destructive kinds (adjustment, fill, text, shape, smart object) and colour modes beyond RGB are +//! part of the model from day one, even where rendering support lands later (architecture §1.1). +#![forbid(unsafe_code)] + +pub mod adjust; +pub mod effects; + +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +pub use adjust::Adjustment; +pub use effects::{ + Bevel, BevelStyle, BevelTechnique, Contour, Effect, FxCommon, FxPaint, GlobalLight, Glow, GlowSource, GlowTechnique, Gradient, + GradientStyle, Satin, Shadow, StrokeFx, StrokePosition, +}; +pub use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType}; +pub use photocraft_geom::{Affine, Rect, Size}; +pub use photocraft_raster::Surface; +use serde::{Deserialize, Serialize}; + +static NEXT_ID: AtomicU64 = AtomicU64::new(1); + +fn next_id() -> u64 { + NEXT_ID.fetch_add(1, Ordering::Relaxed) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct LayerId(pub u64); + +impl LayerId { + pub fn fresh() -> Self { + LayerId(next_id()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct DocId(pub u64); + +impl DocId { + pub fn fresh() -> Self { + DocId(next_id()) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Locks { + pub transparency: bool, + pub pixels: bool, + pub position: bool, + pub artboard: bool, + pub all: bool, +} + +/// Photoshop layer colour labels. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum LabelColor { + #[default] + None, + Red, + Orange, + Yellow, + Green, + Blue, + Violet, + Gray, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct LayerMask { + /// Grayscale surface; untouched pixels read as `default` (0 = hide, 1 = reveal). + pub surface: Surface, + pub enabled: bool, + pub linked: bool, + pub density: f32, + pub feather: f32, +} + +impl LayerMask { + pub fn reveal_all() -> Self { + Self { surface: Surface::with_default(PixelFormat::GRAY8, &[1.0]), enabled: true, linked: true, density: 1.0, feather: 0.0 } + } + pub fn hide_all() -> Self { + Self { surface: Surface::with_default(PixelFormat::GRAY8, &[0.0]), ..Self::reveal_all() } + } + /// Effective mask value at a pixel, including density. + pub fn value(&self, x: i32, y: i32) -> f32 { + if !self.enabled { + return 1.0; + } + let v = self.surface.pixel(x, y)[0]; + 1.0 - self.density * (1.0 - v) + } +} + +/// Layer styles. Parsed PSD effect data is kept raw until the effects engine lands (M9). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Effects { + pub enabled: bool, + pub items: Vec, + /// Original PSD `lfx2` (`lfxs` on groups) block data (object-effects version + descriptor), + /// written back verbatim on PSD export. Not duplicated in `Layer::psd_blocks`. + pub psd_raw: Option>>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Group { + pub children: Vec, + pub expanded: bool, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum Fill { + Solid(Color), + /// Gradient between stops at `angle` degrees (`style` geometry, optionally reversed). + Gradient { stops: Vec<(f32, Color)>, angle: f32, scale: f32, style: GradientStyle, reverse: bool }, + Pattern { name: String, scale: f32 }, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TextLayer { + pub text: String, + pub font_family: String, + pub size_pt: f32, + pub color: Color, + pub transform: Affine, + /// Rasterized appearance (from PSD or our text engine). + pub cache: Option, + /// Data of the PSD `TySh` block. On export it replaces the `TySh` entry in + /// [`Layer::psd_blocks`] (the text engine updates this field when it edits text). + pub psd_raw: Option>>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ShapeLayer { + pub fill: Option, + pub cache: Option, + /// Data of the PSD vector mask block (`vsms`, else `vmsk`). On export it + /// replaces that entry in [`Layer::psd_blocks`]. + pub psd_raw: Option>>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SmartObject { + /// Embedded source file bytes (PSD, PNG, …) or linked path. + pub source: SmartSource, + pub transform: Affine, + pub smart_filters: Vec, + /// Rendered appearance. + pub cache: Option, + /// Data of the PSD placed-layer block (`SoLd`, `PlLd` or `SoLE`). On export + /// it replaces that entry in [`Layer::psd_blocks`]. + pub psd_raw: Option>>, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum SmartSource { + Embedded { file_name: String, bytes: Arc> }, + Linked { path: String }, +} + +/// A filter applied non-destructively: a command id plus its parameters (data, not code). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SmartFilter { + pub command: String, + pub params: serde_json::Value, + pub blend: BlendMode, + pub opacity: f32, + pub visible: bool, +} + +/// Pixels rendered by another application (Photoshop) for a fill layer. +/// +/// Used by the compositor instead of rendering [`Fill`] itself, but only while +/// `fill` still equals the layer's current fill (editing the fill invalidates +/// the cache automatically). +#[derive(Clone, Debug, PartialEq)] +pub struct FillCache { + /// The fill these pixels were rendered for. + pub fill: Fill, + /// Rendered pixels in document coordinates. + pub surface: Surface, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum LayerContent { + Raster(Surface), + Group(Group), + Adjustment(Adjustment), + Fill(Fill), + Text(TextLayer), + Shape(ShapeLayer), + Smart(SmartObject), +} + +impl LayerContent { + pub fn kind_name(&self) -> &'static str { + match self { + LayerContent::Raster(_) => "Pixel", + LayerContent::Group(_) => "Group", + LayerContent::Adjustment(_) => "Adjustment", + LayerContent::Fill(_) => "Fill", + LayerContent::Text(_) => "Type", + LayerContent::Shape(_) => "Shape", + LayerContent::Smart(_) => "Smart Object", + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Layer { + pub id: LayerId, + pub name: String, + pub visible: bool, + pub locks: Locks, + pub blend: BlendMode, + /// Layer opacity (applies to content and effects). + pub opacity: f32, + /// Fill opacity (applies to content but not effects). + pub fill_opacity: f32, + /// Clipped to the layer below (clipping mask). + pub clipped: bool, + pub mask: Option, + pub effects: Effects, + pub label: LabelColor, + pub content: LayerContent, + /// Preserved PSD additional-layer-info blocks (key, data) for lossless + /// round-trip of anything not modelled above: vector masks, blending + /// options, layer version, and the original adjustment/fill blocks (the + /// PSD writer reuses an adjustment or fill block verbatim while it still + /// decodes to the layer's current parameters, so Levels/Curves/Hue-Sat + /// extras survive). Keys regenerated from the fields above (`luni`, + /// `lyid`, `lsct`, `lsdk`, `iOpa`, `lspf`, `lclr`, `lfx2`) are not stored. + pub psd_blocks: Vec<([u8; 4], Arc>)>, + /// PSD layer id (`lyid`), kept so ids stay stable across round trips. + /// Cleared by [`Layer::duplicate`]. + pub psd_id: Option, + /// Photoshop-rendered pixels for fill layers (see [`FillCache`]). + pub fill_cache: Option, +} + +impl Layer { + pub fn new(name: impl Into, content: LayerContent) -> Self { + Layer { + id: LayerId::fresh(), + name: name.into(), + visible: true, + locks: Locks::default(), + blend: BlendMode::Normal, + opacity: 1.0, + fill_opacity: 1.0, + clipped: false, + mask: None, + effects: Effects { enabled: true, ..Default::default() }, + label: LabelColor::None, + content, + psd_blocks: Vec::new(), + psd_id: None, + fill_cache: None, + } + } + pub fn raster(name: impl Into, format: PixelFormat) -> Self { + Self::new(name, LayerContent::Raster(Surface::new(format))) + } + pub fn group(name: impl Into, children: Vec) -> Self { + let mut l = Self::new(name, LayerContent::Group(Group { children, expanded: true })); + l.blend = BlendMode::PassThrough; + l + } + pub fn is_group(&self) -> bool { + matches!(self.content, LayerContent::Group(_)) + } + pub fn children(&self) -> Option<&[Layer]> { + match &self.content { + LayerContent::Group(g) => Some(&g.children), + _ => None, + } + } + pub fn children_mut(&mut self) -> Option<&mut Vec> { + match &mut self.content { + LayerContent::Group(g) => Some(&mut g.children), + _ => None, + } + } + pub fn surface(&self) -> Option<&Surface> { + match &self.content { + LayerContent::Raster(s) => Some(s), + LayerContent::Text(t) => t.cache.as_ref(), + LayerContent::Shape(s) => s.cache.as_ref(), + LayerContent::Smart(s) => s.cache.as_ref(), + _ => None, + } + } + pub fn surface_mut(&mut self) -> Option<&mut Surface> { + match &mut self.content { + LayerContent::Raster(s) => Some(s), + _ => None, + } + } + /// Deep copy with fresh ids (for Duplicate Layer). + pub fn duplicate(&self) -> Layer { + let mut l = self.clone(); + l.reassign_ids(); + l + } + fn reassign_ids(&mut self) { + self.id = LayerId::fresh(); + self.psd_id = None; + if let Some(ch) = self.children_mut() { + for c in ch { + c.reassign_ids(); + } + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Guides { + pub horizontal: Vec, + pub vertical: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct AlphaChannel { + pub name: String, + pub surface: Surface, + /// Spot colour channels carry their ink colour and solidity. + pub spot: Option<(Color, f32)>, +} + +/// A raw PSD global tagged block: (signature, key, data). +pub type PsdGlobalBlock = ([u8; 4], [u8; 4], Arc>); + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Metadata { + pub xmp: Option, + pub exif: Option>>, + /// Raw PSD image resources we don't model yet: (id, name, data), for lossless round-trip. + pub psd_resources: Vec<(u16, String, Arc>)>, + /// Raw PSD global additional-layer-info blocks: (signature, key, data), + /// e.g. `lnk2` embedded smart-object files, `Patt` patterns, `Txt2`. + pub psd_global_blocks: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Document { + pub id: DocId, + pub name: String, + pub size: Size, + pub resolution_dpi: f32, + pub mode: ColorMode, + pub depth: SampleType, + pub icc_profile: Option>>, + /// Bottom-to-top. + pub layers: Vec, + pub channels: Vec, + pub guides: Guides, + /// Active selection as a grayscale coverage surface (None = no selection). + pub selection: Option, + pub metadata: Metadata, + /// Global light used by layer effects (PSD resources 1037/1049). + pub global_light: GlobalLight, +} + +/// Where a layer lives in the tree: indices from the root down. +pub type LayerPath = Vec; + +impl Document { + pub fn new(name: impl Into, size: Size, mode: ColorMode, depth: SampleType) -> Self { + Document { + id: DocId::fresh(), + name: name.into(), + size, + resolution_dpi: 72.0, + mode, + depth, + icc_profile: None, + layers: Vec::new(), + channels: Vec::new(), + guides: Guides::default(), + selection: None, + metadata: Metadata::default(), + global_light: GlobalLight::default(), + } + } + + /// New document with a filled "Background" layer (like File → New). + pub fn with_background(name: impl Into, size: Size, mode: ColorMode, depth: SampleType, fill: Color) -> Self { + let mut d = Self::new(name, size, mode, depth); + let mut bg = Layer::raster("Background", d.pixel_format()); + bg.locks.transparency = true; + bg.locks.position = true; + if let Some(s) = bg.surface_mut() { + let rgba = fill.to_rgb(); + let px = photocraft_raster::from_rgba(&s.format(), [rgba[0], rgba[1], rgba[2], fill.alpha]); + s.fill_rect(d.bounds(), &px); + } + d.layers.push(bg); + d + } + + /// Pixel format for new raster layers in this document. + pub fn pixel_format(&self) -> PixelFormat { + let mode = match self.mode { + ColorMode::Indexed | ColorMode::Multichannel => ColorMode::Rgb, + ColorMode::Bitmap | ColorMode::Duotone => ColorMode::Grayscale, + m => m, + }; + PixelFormat::new(mode, self.depth, true) + } + + pub fn bounds(&self) -> Rect { + Rect::from_size(self.size) + } + + /// Depth-first walk yielding `(path, depth, layer)` bottom-to-top. + pub fn walk(&self) -> Vec<(LayerPath, usize, &Layer)> { + fn rec<'a>(layers: &'a [Layer], prefix: &mut LayerPath, out: &mut Vec<(LayerPath, usize, &'a Layer)>) { + for (i, l) in layers.iter().enumerate() { + prefix.push(i); + out.push((prefix.clone(), prefix.len() - 1, l)); + if let Some(ch) = l.children() { + rec(ch, prefix, out); + } + prefix.pop(); + } + } + let mut out = Vec::new(); + rec(&self.layers, &mut Vec::new(), &mut out); + out + } + + pub fn layer_count(&self) -> usize { + self.walk().len() + } + + pub fn path_of(&self, id: LayerId) -> Option { + self.walk().into_iter().find(|(_, _, l)| l.id == id).map(|(p, _, _)| p) + } + + pub fn layer(&self, id: LayerId) -> Option<&Layer> { + let path = self.path_of(id)?; + self.layer_at(&path) + } + + pub fn layer_mut(&mut self, id: LayerId) -> Option<&mut Layer> { + let path = self.path_of(id)?; + self.layer_at_mut(&path) + } + + pub fn layer_at(&self, path: &[usize]) -> Option<&Layer> { + let (first, rest) = path.split_first()?; + let mut cur = self.layers.get(*first)?; + for &i in rest { + cur = cur.children()?.get(i)?; + } + Some(cur) + } + + pub fn layer_at_mut(&mut self, path: &[usize]) -> Option<&mut Layer> { + let (first, rest) = path.split_first()?; + let mut cur = self.layers.get_mut(*first)?; + for &i in rest { + cur = cur.children_mut()?.get_mut(i)?; + } + Some(cur) + } + + /// The sibling list containing `path` and the index within it. + fn siblings_mut(&mut self, path: &[usize]) -> Option<(&mut Vec, usize)> { + let (&last, parent) = path.split_last()?; + if parent.is_empty() { + return Some((&mut self.layers, last)); + } + let p = self.layer_at_mut(parent)?; + Some((p.children_mut()?, last)) + } + + /// Insert `layer` directly above the layer `above` (or at the top of the root if None). + pub fn insert_above(&mut self, above: Option, layer: Layer) -> LayerId { + let id = layer.id; + match above.and_then(|a| self.path_of(a)) { + Some(path) => { + let (sib, idx) = self.siblings_mut(&path).expect("valid path"); + sib.insert(idx + 1, layer); + } + None => self.layers.push(layer), + } + id + } + + pub fn remove(&mut self, id: LayerId) -> Option { + let path = self.path_of(id)?; + let (sib, idx) = self.siblings_mut(&path)?; + Some(sib.remove(idx)) + } + + /// Move a layer up (+1) or down (-1) among its siblings. Returns false at the ends. + pub fn shift(&mut self, id: LayerId, delta: i32) -> bool { + let Some(path) = self.path_of(id) else { return false }; + let Some((sib, idx)) = self.siblings_mut(&path) else { return false }; + let to = idx as i64 + delta as i64; + if to < 0 || to >= sib.len() as i64 { + return false; + } + let l = sib.remove(idx); + sib.insert(to as usize, l); + true + } + + /// Next unused "Layer N" name. + pub fn next_layer_name(&self, base: &str) -> String { + let names: std::collections::HashSet<&str> = self.walk().into_iter().map(|(_, _, l)| l.name.as_str()).collect(); + (1..).map(|n| format!("{base} {n}")).find(|n| !names.contains(n.as_str())).expect("infinite") + } + + /// Top-most layer id, useful as the default active layer. + pub fn top_layer(&self) -> Option { + self.layers.last().map(|l| l.id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn doc() -> Document { + Document::with_background("t", Size::new(100, 50), ColorMode::Rgb, SampleType::U8, Color::WHITE) + } + + #[test] + fn new_document_has_locked_background() { + let d = doc(); + assert_eq!(d.layers.len(), 1); + let bg = &d.layers[0]; + assert_eq!(bg.name, "Background"); + assert!(bg.locks.transparency); + let s = bg.surface().unwrap(); + assert_eq!(s.pixel(0, 0), vec![1.0, 1.0, 1.0, 1.0]); + assert_eq!(s.pixel(99, 49), vec![1.0, 1.0, 1.0, 1.0]); + assert_eq!(s.pixel(100, 0), vec![0.0; 4]); + } + + #[test] + fn pixel_format_follows_mode_and_depth() { + let d = Document::new("c", Size::new(1, 1), ColorMode::Cmyk, SampleType::U16); + assert_eq!(d.pixel_format(), PixelFormat::new(ColorMode::Cmyk, SampleType::U16, true)); + let b = Document::new("b", Size::new(1, 1), ColorMode::Bitmap, SampleType::U8); + assert_eq!(b.pixel_format().mode, ColorMode::Grayscale); + } + + #[test] + fn insert_remove_shift() { + let mut d = doc(); + let bg = d.layers[0].id; + let a = d.insert_above(Some(bg), Layer::raster("A", d.pixel_format())); + let b = d.insert_above(Some(bg), Layer::raster("B", d.pixel_format())); + // order bottom->top: bg, B, A + let names: Vec<_> = d.layers.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, ["Background", "B", "A"]); + assert!(d.shift(b, 1)); + assert_eq!(d.layers[2].id, b); + assert!(!d.shift(b, 1)); + assert_eq!(d.remove(a).unwrap().name, "A"); + assert!(d.layer(a).is_none()); + } + + #[test] + fn groups_walk_and_paths() { + let mut d = doc(); + let inner = Layer::raster("inner", d.pixel_format()); + let inner_id = inner.id; + let g = Layer::group("G", vec![inner]); + let gid = d.insert_above(None, g); + let walk: Vec<_> = d.walk().into_iter().map(|(p, depth, l)| (p, depth, l.name.clone())).collect(); + assert_eq!(walk, vec![(vec![0], 0, "Background".into()), (vec![1], 0, "G".into()), (vec![1, 0], 1, "inner".into())]); + assert_eq!(d.path_of(inner_id), Some(vec![1, 0])); + assert_eq!(d.layer(gid).unwrap().blend, BlendMode::PassThrough); + // insert above a nested layer stays inside the group + let n = d.insert_above(Some(inner_id), Layer::raster("n", d.pixel_format())); + assert_eq!(d.path_of(n), Some(vec![1, 1])); + assert_eq!(d.layer_count(), 4); + } + + #[test] + fn duplicate_assigns_fresh_ids_recursively() { + let g = Layer::group("G", vec![Layer::raster("x", PixelFormat::RGBA8)]); + let dup = g.duplicate(); + assert_ne!(g.id, dup.id); + assert_ne!(g.children().unwrap()[0].id, dup.children().unwrap()[0].id); + assert_eq!(dup.children().unwrap()[0].name, "x"); + } + + #[test] + fn duplicate_clears_psd_id_but_keeps_blocks() { + let mut l = Layer::raster("x", PixelFormat::RGBA8); + l.psd_id = Some(7); + l.psd_blocks.push((*b"vmsk", Arc::new(vec![1]))); + let d = l.duplicate(); + assert_eq!(d.psd_id, None); + assert_eq!(d.psd_blocks, l.psd_blocks); + } + + #[test] + fn layer_names_increment() { + let mut d = doc(); + assert_eq!(d.next_layer_name("Layer"), "Layer 1"); + d.insert_above(None, Layer::raster("Layer 1", d.pixel_format())); + assert_eq!(d.next_layer_name("Layer"), "Layer 2"); + } + + #[test] + fn mask_density() { + let mut m = LayerMask::hide_all(); + assert_eq!(m.value(5, 5), 0.0); + m.density = 0.5; + assert!((m.value(5, 5) - 0.5).abs() < 1e-6); + m.enabled = false; + assert_eq!(m.value(5, 5), 1.0); + } + + #[test] + fn snapshot_clone_is_independent() { + let mut d = doc(); + let snap = d.clone(); + let bg = d.layers[0].id; + d.layer_mut(bg).unwrap().surface_mut().unwrap().write_pixel(0, 0, &[0.0, 0.0, 0.0, 1.0]); + assert_eq!(snap.layers[0].surface().unwrap().pixel(0, 0), vec![1.0; 4]); + } +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml new file mode 100644 index 00000000..c97e4825 --- /dev/null +++ b/crates/engine/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "photocraft-engine" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +photocraft-geom = { workspace = true } +photocraft-color = { workspace = true } +photocraft-raster = { workspace = true } +photocraft-doc = { workspace = true } +photocraft-ops = { workspace = true } +photocraft-compose = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +photocraft-paint = { workspace = true } +photocraft-algo = { workspace = true } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs new file mode 100644 index 00000000..0c030b53 --- /dev/null +++ b/crates/engine/src/commands.rs @@ -0,0 +1,837 @@ +//! The command registry. Ids follow Photoshop's menu structure (see the parity checklist). + +use photocraft_color::{BlendMode, Color, ColorMode, SampleType}; +use photocraft_doc::adjust::CurvePoint; +use photocraft_doc::{Adjustment, Document, Fill, Layer, LayerContent, LayerId, LayerMask, Size}; +use photocraft_geom::Rect; +use photocraft_paint::{BrushSettings, Stroke, StrokePoint}; +use photocraft_raster::Surface; +use serde_json::{Value, json}; + +use crate::{EngineError, Result, Session, inspect, pixels}; + +type Run = fn(&mut Session, &Value) -> Result; +type Enabled = fn(&Session) -> std::result::Result<(), String>; + +/// Metadata + implementation for one command. +pub struct CommandSpec { + pub id: &'static str, + pub label: &'static str, + /// Menu placement, e.g. `["Layer", "New"]`. Empty = not in menus. + pub menu: &'static [&'static str], + /// Default shortcut in egui-style notation (`Cmd+Shift+N`), mapped per platform by the UI. + pub shortcut: Option<&'static str>, + /// Human/agent-readable parameter description (JSON-ish). + pub params: &'static str, + pub enabled: Enabled, + pub run: Run, + /// Record in the session journal (false for queries). + pub journal: bool, +} + +fn always(_: &Session) -> std::result::Result<(), String> { + Ok(()) +} +fn has_doc(s: &Session) -> std::result::Result<(), String> { + s.active().map(|_| ()).ok_or_else(|| "no document open".into()) +} +fn has_layer(s: &Session) -> std::result::Result<(), String> { + let d = s.active().ok_or("no document open")?; + d.active_layer.filter(|id| d.doc.layer(*id).is_some()).map(|_| ()).ok_or_else(|| "no active layer".into()) +} +fn has_pixel_layer(s: &Session) -> std::result::Result<(), String> { + has_layer(s)?; + let d = s.active().unwrap(); + let l = d.doc.layer(d.active_layer.unwrap()).unwrap(); + if matches!(l.content, LayerContent::Raster(_)) { Ok(()) } else { Err(format!("active layer is a {} layer, not a pixel layer", l.content.kind_name())) } +} +fn can_undo(s: &Session) -> std::result::Result<(), String> { + s.active().filter(|d| d.history.can_undo()).map(|_| ()).ok_or_else(|| "nothing to undo".into()) +} +fn can_redo(s: &Session) -> std::result::Result<(), String> { + s.active().filter(|d| d.history.can_redo()).map(|_| ()).ok_or_else(|| "nothing to redo".into()) +} +fn has_selection(s: &Session) -> std::result::Result<(), String> { + s.active().filter(|d| d.doc.selection.is_some()).map(|_| ()).ok_or_else(|| "no selection".into()) +} + +macro_rules! cmd { + ($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { + CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true } + }; +} + +pub fn command_specs() -> &'static [CommandSpec] { + static SPECS: std::sync::OnceLock> = std::sync::OnceLock::new(); + SPECS.get_or_init(build) +} + +pub fn find(id: &str) -> Option<&'static CommandSpec> { + command_specs().iter().find(|c| c.id == id) +} + +// ---------- param helpers ---------- + +fn bad(cmd: &str, msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } +} +/// Integer parameter that also accepts JSON floats (UIs send `12.0`); rounds. +fn int(p: &Value, key: &str) -> Option { + p.get(key).and_then(|v| v.as_i64().or_else(|| v.as_f64().filter(|f| f.is_finite()).map(|f| f.round() as i64))) +} + +fn f32_or(p: &Value, key: &str, default: f32) -> f32 { + p.get(key).and_then(Value::as_f64).map(|v| v as f32).unwrap_or(default) +} +fn color_param(p: &Value, key: &str, default: [f32; 4]) -> [f32; 4] { + match p.get(key) { + Some(Value::Array(a)) if a.len() >= 3 => { + let v: Vec = a.iter().map(|x| x.as_f64().unwrap_or(0.0) as f32).collect(); + [v[0], v[1], v[2], v.get(3).copied().unwrap_or(1.0)] + } + Some(Value::String(s)) => parse_hex(s).unwrap_or(default), + _ => default, + } +} +fn parse_hex(s: &str) -> Option<[f32; 4]> { + let s = s.trim_start_matches('#'); + let b = |i: usize| u8::from_str_radix(s.get(i..i + 2)?, 16).ok().map(|v| v as f32 / 255.0); + match s.len() { + 6 => Some([b(0)?, b(2)?, b(4)?, 1.0]), + 8 => Some([b(0)?, b(2)?, b(4)?, b(6)?]), + _ => None, + } +} +fn layer_param(s: &Session, p: &Value) -> Result { + match p.get("layer").and_then(Value::as_u64) { + Some(id) => Ok(LayerId(id)), + None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into())), + } +} +pub fn blend_from_str(s: &str) -> Option { + let norm = |x: &str| x.to_ascii_lowercase().replace([' ', '_', '-', '(', ')'], ""); + let want = norm(s); + std::iter::once(BlendMode::PassThrough).chain(BlendMode::LAYER_MODES).find(|m| norm(m.label()) == want || norm(&format!("{m:?}")) == want) +} + +fn new_adjustment(s: &mut Session, adj: Adjustment) -> Result { + let label = format!("New {} Layer", adj.label()); + let name = adj.label().to_string(); + let id = s.edit(&label, |doc, active| { + let l = Layer::new(doc.next_layer_name(&name), LayerContent::Adjustment(adj)); + let id = doc.insert_above(*active, l); + *active = Some(id); + Ok(id) + })?; + Ok(json!({ "layer": id.0 })) +} + +fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment) -> Result { + let id = layer_param(s, &Value::Null)?; + s.edit(label, |doc, _| { + let sel = doc.selection.clone(); + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + let surf = l.surface_mut().ok_or_else(|| EngineError::Other("not a pixel layer".into()))?; + pixels::adjust_surface(surf, &adj, sel.as_ref()); + Ok(()) + })?; + Ok(Value::Null) +} + +pub fn adjustment_from_params(kind: &str, p: &Value) -> Adjustment { + match kind { + "invert" => Adjustment::Invert, + "hueSaturation" => Adjustment::HueSaturation { + hue: f32_or(p, "hue", 0.0), + saturation: f32_or(p, "saturation", 0.0), + lightness: f32_or(p, "lightness", 0.0), + colorize: p.get("colorize").and_then(Value::as_bool).unwrap_or(false), + }, + "brightnessContrast" => Adjustment::BrightnessContrast { brightness: f32_or(p, "brightness", 0.0), contrast: f32_or(p, "contrast", 0.0), legacy: false }, + "threshold" => Adjustment::Threshold { level: f32_or(p, "level", 128.0) / 255.0 }, + "posterize" => Adjustment::Posterize { levels: f32_or(p, "levels", 4.0) as u32 }, + "exposure" => Adjustment::Exposure { exposure: f32_or(p, "exposure", 0.0), offset: f32_or(p, "offset", 0.0), gamma: f32_or(p, "gamma", 1.0) }, + "vibrance" => Adjustment::Vibrance { vibrance: f32_or(p, "vibrance", 0.0), saturation: f32_or(p, "saturation", 0.0) }, + "levels" => { + let mut a = Adjustment::identity_levels(); + if let Adjustment::Levels { master, .. } = &mut a { + master.in_black = f32_or(p, "inBlack", 0.0) / 255.0; + master.in_white = f32_or(p, "inWhite", 255.0) / 255.0; + master.gamma = f32_or(p, "gamma", 1.0); + } + a + } + "curves" => { + let mut a = Adjustment::identity_curves(); + if let (Adjustment::Curves { master, .. }, Some(Value::Array(pts))) = (&mut a, p.get("points")) { + let parsed: Vec = pts + .iter() + .filter_map(|v| { + let a = v.as_array()?; + Some(CurvePoint { input: a.first()?.as_f64()? as f32 / 255.0, output: a.get(1)?.as_f64()? as f32 / 255.0 }) + }) + .collect(); + if parsed.len() >= 2 { + *master = parsed; + } + } + a + } + "blackWhite" => Adjustment::BlackWhite { weights: [40.0, 60.0, 40.0, 60.0, 20.0, 80.0], tint: None }, + "photoFilter" => Adjustment::PhotoFilter { color: [0.925, 0.541, 0.0], density: f32_or(p, "density", 25.0) / 100.0, preserve_luminosity: true }, + "channelMixer" => Adjustment::ChannelMixer { matrix: [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]], monochrome: false }, + "colorBalance" => Adjustment::ColorBalance { shadows: [0.0; 3], midtones: [0.0; 3], highlights: [0.0; 3], preserve_luminosity: true }, + "gradientMap" => Adjustment::GradientMap { stops: vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])], reverse: false }, + _ => Adjustment::Invert, + } +} + +// ---------- the table ---------- + +#[allow(clippy::too_many_lines)] +fn build() -> Vec { + let mut v = vec![ + // File + cmd!("file.new", "New…", ["File"], Some("Cmd+N"), r##"{"width":u32=1920,"height":u32=1080,"mode":"rgb|gray|cmyk|lab"="rgb","depth":8|16|32=8,"background":"white|black|transparent|#rrggbb"="white","name":str}"##, always, |s, p| { + let w = p.get("width").and_then(Value::as_u64).unwrap_or(1920).clamp(1, 300_000) as u32; + let h = p.get("height").and_then(Value::as_u64).unwrap_or(1080).clamp(1, 300_000) as u32; + let mode = match p.get("mode").and_then(Value::as_str).unwrap_or("rgb") { + "gray" | "grayscale" => ColorMode::Grayscale, + "cmyk" => ColorMode::Cmyk, + "lab" => ColorMode::Lab, + _ => ColorMode::Rgb, + }; + let depth = match p.get("depth").and_then(Value::as_u64).unwrap_or(8) { + 16 => SampleType::U16, + 32 => SampleType::F32, + _ => SampleType::U8, + }; + let name = p.get("name").and_then(Value::as_str).unwrap_or("Untitled").to_string(); + let doc = match p.get("background").and_then(Value::as_str).unwrap_or("white") { + "transparent" => { + let mut d = Document::new(name, Size::new(w, h), mode, depth); + d.layers.push(Layer::raster("Layer 1", d.pixel_format())); + d + } + "black" => Document::with_background(name, Size::new(w, h), mode, depth, Color::BLACK), + other => { + let c = parse_hex(other).unwrap_or([1.0; 4]); + Document::with_background(name, Size::new(w, h), mode, depth, Color::rgba(c[0], c[1], c[2], c[3])) + } + }; + let i = s.add_document(doc, None); + Ok(json!({ "document": i })) + }), + cmd!("file.close", "Close", ["File"], Some("Cmd+W"), r##"{"document":index?}"##, has_doc, |s, p| { + let i = p.get("document").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).ok_or(EngineError::NoDocument)?; + s.close(i).ok_or(EngineError::NoDocument)?; + Ok(Value::Null) + }), + // Edit + cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, |s, _| Ok(json!(s.undo()))), + cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |s, _| Ok(json!(s.redo()))), + cmd!("edit.fill", "Fill…", ["Edit"], Some("Shift+F5"), r##"{"color":"#rrggbb|[r,g,b,a]"=foreground}"##, has_pixel_layer, |s, p| { + let color = color_param(p, "color", s.tools.foreground); + let id = layer_param(s, p)?; + s.edit("Fill", |doc, _| { + let sel = doc.selection.clone(); + let area = sel.as_ref().map(|m| m.content_bounds()).unwrap_or(doc.bounds()); + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + let lock = l.locks.transparency; + let surf = l.surface_mut().ok_or_else(|| EngineError::Other("not a pixel layer".into()))?; + pixels::fill_surface(surf, area, color, sel.as_ref(), lock); + Ok(()) + })?; + Ok(Value::Null) + }), + cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{}", has_pixel_layer, |s, p| { + let id = layer_param(s, p)?; + s.edit("Clear", |doc, _| { + let sel = doc.selection.clone(); + let area = sel.as_ref().map(|m| m.content_bounds()).unwrap_or(doc.bounds()); + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + pixels::clear_surface(l.surface_mut().ok_or_else(|| EngineError::Other("not a pixel layer".into()))?, area, sel.as_ref()); + Ok(()) + })?; + Ok(Value::Null) + }), + // Select + cmd!("select.all", "All", ["Select"], Some("Cmd+A"), "{}", has_doc, |s, _| { + s.edit("Select All", |doc, _| { + let mut m = Surface::new(photocraft_color::PixelFormat::GRAY8); + m.fill_rect(doc.bounds(), &[1.0]); + doc.selection = Some(m); + Ok(()) + })?; + Ok(Value::Null) + }), + cmd!("select.deselect", "Deselect", ["Select"], Some("Cmd+D"), "{}", has_selection, |s, _| { + s.edit("Deselect", |doc, _| { + doc.selection = None; + Ok(()) + })?; + Ok(Value::Null) + }), + cmd!("select.inverse", "Inverse", ["Select"], Some("Cmd+Shift+I"), "{}", has_selection, |s, _| { + s.edit("Inverse", |doc, _| { + let old = doc.selection.take().unwrap_or_else(|| Surface::new(photocraft_color::PixelFormat::GRAY8)); + let b = doc.bounds(); + let data: Vec = old.read_region(b).into_iter().map(|v| 1.0 - v).collect(); + let mut m = Surface::new(photocraft_color::PixelFormat::GRAY8); + m.write_region(b, &data); + m.prune(); + doc.selection = Some(m); + Ok(()) + })?; + Ok(Value::Null) + }), + cmd!("select.rect", "Rectangular Selection", [], None, r##"{"x":i32,"y":i32,"width":u32,"height":u32,"mode":"replace|add|subtract|intersect"="replace","ellipse":bool=false}"##, has_doc, |s, p| { + let get = |k: &str| int(p, k).ok_or_else(|| bad("select.rect", format!("missing `{k}`"))); + let r = Rect::from_xywh(get("x")? as i32, get("y")? as i32, get("width")?.max(0) as u32, get("height")?.max(0) as u32); + let mode = p.get("mode").and_then(Value::as_str).unwrap_or("replace").to_string(); + let ellipse = p.get("ellipse").and_then(Value::as_bool).unwrap_or(false); + s.edit("Rectangular Marquee", |doc, _| { + let mut shape = Surface::new(photocraft_color::PixelFormat::GRAY8); + if ellipse { + let (cx, cy) = ((r.x0 + r.x1) as f32 / 2.0, (r.y0 + r.y1) as f32 / 2.0); + let (rx, ry) = (r.width() as f32 / 2.0, r.height() as f32 / 2.0); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let dx = (x as f32 + 0.5 - cx) / rx; + let dy = (y as f32 + 0.5 - cy) / ry; + if dx * dx + dy * dy <= 1.0 { + shape.write_pixel(x, y, &[1.0]); + } + } + } + } else { + shape.fill_rect(r, &[1.0]); + } + let area = doc.bounds(); + let old = doc.selection.take(); + let combined = match (mode.as_str(), old) { + ("add", Some(o)) => combine(&o, &shape, area, |a, b| a.max(b)), + ("subtract", Some(o)) => combine(&o, &shape, area, |a, b| a * (1.0 - b)), + ("intersect", Some(o)) => combine(&o, &shape, area, |a, b| a.min(b)), + _ => shape, + }; + doc.selection = Some(combined); + Ok(()) + })?; + Ok(Value::Null) + }), + // Layer + cmd!("layer.new.layer", "Layer…", ["Layer", "New"], Some("Cmd+Shift+N"), r##"{"name":str?}"##, has_doc, |s, p| { + let id = s.edit("New Layer", |doc, active| { + let name = p.get("name").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| doc.next_layer_name("Layer")); + let id = doc.insert_above(*active, Layer::raster(name, doc.pixel_format())); + *active = Some(id); + Ok(id) + })?; + Ok(json!({ "layer": id.0 })) + }), + cmd!("layer.new.group", "Group…", ["Layer", "New"], None, r##"{"name":str?}"##, has_doc, |s, p| { + let id = s.edit("New Group", |doc, active| { + let name = p.get("name").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| doc.next_layer_name("Group")); + let id = doc.insert_above(*active, Layer::group(name, vec![])); + *active = Some(id); + Ok(id) + })?; + Ok(json!({ "layer": id.0 })) + }), + cmd!("layer.groupLayers", "Group Layers", ["Layer"], Some("Cmd+G"), r##"{"layer":id?}"##, has_layer, |s, p| { + let id = layer_param(s, p)?; + let gid = s.edit("Group Layers", |doc, active| { + let l = doc.remove(id).ok_or(EngineError::NoLayer(id))?; + let path_hint = *active; + let g = Layer::group(doc.next_layer_name("Group"), vec![l]); + let gid = doc.insert_above(path_hint.filter(|a| *a != id), g); + *active = Some(gid); + Ok(gid) + })?; + Ok(json!({ "layer": gid.0 })) + }), + cmd!("layer.duplicate", "Duplicate Layer…", ["Layer"], Some("Cmd+J"), r##"{"layer":id?}"##, has_layer, |s, p| { + let id = layer_param(s, p)?; + let nid = s.edit("Duplicate Layer", |doc, active| { + let mut dup = doc.layer(id).ok_or(EngineError::NoLayer(id))?.duplicate(); + dup.name = format!("{} copy", dup.name); + let nid = doc.insert_above(Some(id), dup); + *active = Some(nid); + Ok(nid) + })?; + Ok(json!({ "layer": nid.0 })) + }), + cmd!("layer.delete", "Delete Layer", ["Layer", "Delete"], None, r##"{"layer":id?}"##, has_layer, |s, p| { + let id = layer_param(s, p)?; + s.edit("Delete Layer", |doc, active| { + doc.remove(id).ok_or(EngineError::NoLayer(id))?; + if *active == Some(id) { + *active = doc.top_layer(); + } + Ok(()) + })?; + Ok(Value::Null) + }), + cmd!("layer.select", "Select Layer", [], None, r##"{"layer":id}"##, has_doc, |s, p| { + let id = p.get("layer").and_then(Value::as_u64).ok_or_else(|| bad("layer.select", "missing `layer`"))?; + s.select_layer(LayerId(id))?; + Ok(Value::Null) + }), + cmd!("layer.setProps", "Layer Properties", [], None, r##"{"layer":id?,"name":str?,"visible":bool?,"opacity":0..1?,"fill":0..1?,"blend":"Multiply|…"?,"clipped":bool?,"locked":bool?}"##, has_layer, |s, p| { + let id = layer_param(s, p)?; + let label = if p.get("visible").is_some() && p.as_object().is_some_and(|o| o.len() <= 2) { "Layer Visibility" } else { "Layer Properties" }; + s.edit(label, |doc, _| { + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + if let Some(v) = p.get("name").and_then(Value::as_str) { + l.name = v.to_string(); + } + if let Some(v) = p.get("visible").and_then(Value::as_bool) { + l.visible = v; + } + if let Some(v) = p.get("opacity").and_then(Value::as_f64) { + l.opacity = (v as f32).clamp(0.0, 1.0); + } + if let Some(v) = p.get("fill").and_then(Value::as_f64) { + l.fill_opacity = (v as f32).clamp(0.0, 1.0); + } + if let Some(v) = p.get("blend").and_then(Value::as_str) { + l.blend = blend_from_str(v).ok_or_else(|| bad("layer.setProps", format!("unknown blend mode `{v}`")))?; + } + if let Some(v) = p.get("clipped").and_then(Value::as_bool) { + l.clipped = v; + } + if let Some(v) = p.get("locked").and_then(Value::as_bool) { + l.locks.all = v; + } + Ok(()) + })?; + Ok(Value::Null) + }), + cmd!("layer.arrange.bringForward", "Bring Forward", ["Layer", "Arrange"], Some("Cmd+]"), r##"{"layer":id?}"##, has_layer, |s, p| arrange(s, p, 1)), + cmd!("layer.arrange.sendBackward", "Send Backward", ["Layer", "Arrange"], Some("Cmd+["), r##"{"layer":id?}"##, has_layer, |s, p| arrange(s, p, -1)), + cmd!("layer.arrange.bringToFront", "Bring to Front", ["Layer", "Arrange"], Some("Cmd+Shift+]"), r##"{"layer":id?}"##, has_layer, |s, p| arrange(s, p, i32::MAX)), + cmd!("layer.arrange.sendToBack", "Send to Back", ["Layer", "Arrange"], Some("Cmd+Shift+["), r##"{"layer":id?}"##, has_layer, |s, p| arrange(s, p, i32::MIN)), + cmd!("layer.createClippingMask", "Create Clipping Mask", ["Layer"], Some("Cmd+Alt+G"), r##"{"layer":id?}"##, has_layer, |s, p| { + let id = layer_param(s, p)?; + s.edit("Create Clipping Mask", |doc, _| { + doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.clipped = true; + Ok(()) + })?; + Ok(Value::Null) + }), + cmd!("layer.releaseClippingMask", "Release Clipping Mask", ["Layer"], None, r##"{"layer":id?}"##, has_layer, |s, p| { + let id = layer_param(s, p)?; + s.edit("Release Clipping Mask", |doc, _| { + doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.clipped = false; + Ok(()) + })?; + Ok(Value::Null) + }), + cmd!("layer.layerMask.revealAll", "Reveal All", ["Layer", "Layer Mask"], None, r##"{"layer":id?}"##, has_layer, |s, p| set_mask(s, p, "Add Layer Mask", Some(LayerMask::reveal_all()))), + cmd!("layer.layerMask.hideAll", "Hide All", ["Layer", "Layer Mask"], None, r##"{"layer":id?}"##, has_layer, |s, p| set_mask(s, p, "Add Layer Mask", Some(LayerMask::hide_all()))), + cmd!("layer.layerMask.revealSelection", "Reveal Selection", ["Layer", "Layer Mask"], None, r##"{"layer":id?}"##, has_selection, |s, p| { + let sel = s.active().and_then(|d| d.doc.selection.clone()).ok_or(EngineError::Other("no selection".into()))?; + let mask = LayerMask { surface: sel, ..LayerMask::reveal_all() }; + set_mask(s, p, "Add Layer Mask", Some(mask)) + }), + cmd!("layer.layerMask.delete", "Delete", ["Layer", "Layer Mask"], None, r##"{"layer":id?}"##, has_layer, |s, p| set_mask(s, p, "Delete Layer Mask", None)), + cmd!("layer.mergeDown", "Merge Down", ["Layer"], Some("Cmd+E"), r##"{"layer":id?}"##, has_layer, |s, p| { + let id = layer_param(s, p)?; + s.edit("Merge Down", |doc, active| { + let path = doc.path_of(id).ok_or(EngineError::NoLayer(id))?; + let idx = *path.last().unwrap(); + if idx == 0 { + return Err(EngineError::Other("no layer below to merge into".into())); + } + let mut below = path.clone(); + *below.last_mut().unwrap() = idx - 1; + let lower = doc.layer_at(&below).ok_or(EngineError::NoLayer(id))?.clone(); + let upper = doc.layer(id).ok_or(EngineError::NoLayer(id))?.clone(); + let merged = pixels::merge_down(doc.bounds(), &lower, &upper, doc.pixel_format()); + doc.remove(id); + *doc.layer_at_mut(&below).unwrap() = merged; + *active = Some(lower.id); + Ok(()) + })?; + Ok(Value::Null) + }), + cmd!("layer.flattenImage", "Flatten Image", ["Layer"], None, "{}", has_doc, |s, _| { + s.edit("Flatten Image", |doc, active| { + let buf = photocraft_compose::flatten(doc).over_background([1.0, 1.0, 1.0]); + let fmt = doc.pixel_format(); + let data: Vec = buf.px.iter().flat_map(|p| photocraft_raster::from_rgba(&fmt, *p)).collect(); + let mut bg = Layer::raster("Background", fmt); + bg.locks.transparency = true; + bg.surface_mut().unwrap().write_region(doc.bounds(), &data); + *active = Some(bg.id); + doc.layers = vec![bg]; + Ok(()) + })?; + Ok(Value::Null) + }), + cmd!("layer.newFillLayer.solidColor", "Solid Color…", ["Layer", "New Fill Layer"], None, r##"{"color":"#rrggbb"=foreground}"##, has_doc, |s, p| { + let c = color_param(p, "color", s.tools.foreground); + let id = s.edit("New Color Fill Layer", |doc, active| { + let l = Layer::new(doc.next_layer_name("Color Fill"), LayerContent::Fill(Fill::Solid(Color::rgba(c[0], c[1], c[2], c[3])))); + let id = doc.insert_above(*active, l); + *active = Some(id); + Ok(id) + })?; + Ok(json!({ "layer": id.0 })) + }), + cmd!("layer.newFillLayer.gradient", "Gradient…", ["Layer", "New Fill Layer"], None, r##"{"from":"#rrggbb","to":"#rrggbb","angle":deg=90,"style":"linear|radial|angle|reflected|diamond","reverse":bool}"##, has_doc, |s, p| { + let a = color_param(p, "from", s.tools.foreground); + let b = color_param(p, "to", s.tools.background); + let angle = f32_or(p, "angle", 90.0); + let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")); + let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); + let id = s.edit("New Gradient Fill Layer", |doc, active| { + let fill = Fill::Gradient { stops: vec![(0.0, Color::rgba(a[0], a[1], a[2], a[3])), (1.0, Color::rgba(b[0], b[1], b[2], b[3]))], angle, scale: 1.0, style, reverse }; + let id = doc.insert_above(*active, Layer::new(doc.next_layer_name("Gradient Fill"), LayerContent::Fill(fill))); + *active = Some(id); + Ok(id) + })?; + Ok(json!({ "layer": id.0 })) + }), + cmd!("layer.setAdjustment", "Adjustment Properties", [], None, r##"{"layer":id?, …params of that adjustment kind}"##, has_layer, |s, p| { + let id = layer_param(s, p)?; + s.edit("Modify Adjustment", |doc, _| { + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + let LayerContent::Adjustment(adj) = &mut l.content else { return Err(EngineError::Other("not an adjustment layer".into())) }; + let kind = adjustment_kind(adj); + *adj = adjustment_from_params(kind, p); + Ok(()) + })?; + Ok(Value::Null) + }), + cmd!("layer.moveTo", "Reorder Layer", [], None, r##"{"layer":id?,"target":id,"position":"above|below|into"="above"}"##, has_layer, |s, p| { + let id = layer_param(s, p)?; + let target = LayerId(p.get("target").and_then(Value::as_u64).ok_or_else(|| bad("layer.moveTo", "missing `target`"))?); + let pos = p.get("position").and_then(Value::as_str).unwrap_or("above").to_string(); + if id == target { + return Ok(Value::Null); + } + s.edit("Reorder Layers", |doc, active| { + // Refuse to move a group into itself or its descendants. + if doc.layer(id).is_some_and(|l| contains_layer(l, target)) { + return Err(EngineError::Other("can't move a group into itself".into())); + } + let layer = doc.remove(id).ok_or(EngineError::NoLayer(id))?; + let path = doc.path_of(target).ok_or(EngineError::NoLayer(target))?; + match pos.as_str() { + "into" => { + let g = doc.layer_at_mut(&path).and_then(|t| t.children_mut()).ok_or_else(|| EngineError::Other("target is not a group".into()))?; + g.push(layer); + } + other => { + let (&last, parent) = path.split_last().expect("non-empty path"); + let sib = if parent.is_empty() { &mut doc.layers } else { doc.layer_at_mut(parent).and_then(|t| t.children_mut()).expect("parent is a group") }; + let at = if other == "below" { last } else { last + 1 }; + sib.insert(at.min(sib.len()), layer); + } + } + *active = Some(id); + Ok(()) + })?; + Ok(Value::Null) + }), + cmd!("layer.translate", "Move Layer", [], None, r##"{"layer":id?,"dx":i32,"dy":i32}"##, has_layer, |s, p| { + let id = layer_param(s, p)?; + let dx = int(p, "dx").unwrap_or(0) as i32; + let dy = int(p, "dy").unwrap_or(0) as i32; + if dx == 0 && dy == 0 { + return Ok(Value::Null); + } + s.edit("Move", |doc, _| { + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + if l.locks.position || l.locks.all { + return Err(EngineError::Other(format!("layer \"{}\" is position-locked", l.name))); + } + translate_layer(l, dx, dy); + Ok(()) + })?; + Ok(Value::Null) + }), + // Image + cmd!("image.imageRotation.flipCanvasHorizontal", "Flip Canvas Horizontal", ["Image", "Image Rotation"], None, "{}", has_doc, |s, _| { + s.edit("Flip Canvas Horizontal", |doc, _| { + let w = doc.size.width as i32; + pixels::remap_document(doc, &|x, y| (w - 1 - x, y)); + Ok(()) + })?; + Ok(Value::Null) + }), + cmd!("image.imageRotation.flipCanvasVertical", "Flip Canvas Vertical", ["Image", "Image Rotation"], None, "{}", has_doc, |s, _| { + s.edit("Flip Canvas Vertical", |doc, _| { + let h = doc.size.height as i32; + pixels::remap_document(doc, &|x, y| (x, h - 1 - y)); + Ok(()) + })?; + Ok(Value::Null) + }), + cmd!("image.imageRotation.180", "180°", ["Image", "Image Rotation"], None, "{}", has_doc, |s, _| { + s.edit("Rotate Canvas", |doc, _| { + let (w, h) = (doc.size.width as i32, doc.size.height as i32); + pixels::remap_document(doc, &|x, y| (w - 1 - x, h - 1 - y)); + Ok(()) + })?; + Ok(Value::Null) + }), + cmd!("image.imageRotation.90cw", "90° Clockwise", ["Image", "Image Rotation"], None, "{}", has_doc, |s, _| { + s.edit("Rotate Canvas", |doc, _| { + let h = doc.size.height as i32; + pixels::remap_document(doc, &|x, y| (h - 1 - y, x)); + doc.size = Size::new(doc.size.height, doc.size.width); + Ok(()) + })?; + Ok(Value::Null) + }), + cmd!("image.imageRotation.90ccw", "90° Counter Clockwise", ["Image", "Image Rotation"], None, "{}", has_doc, |s, _| { + s.edit("Rotate Canvas", |doc, _| { + let w = doc.size.width as i32; + pixels::remap_document(doc, &|x, y| (y, w - 1 - x)); + doc.size = Size::new(doc.size.height, doc.size.width); + Ok(()) + })?; + Ok(Value::Null) + }), + cmd!("image.adjustments.desaturate", "Desaturate", ["Image", "Adjustments"], Some("Cmd+Shift+U"), "{}", has_pixel_layer, |s, _| { + destructive_adjust(s, "Desaturate", Adjustment::HueSaturation { hue: 0.0, saturation: -100.0, lightness: 0.0, colorize: false }) + }), + // Paint + cmd!("paint.stroke", "Brush Stroke", [], None, r##"{"points":[[x,y,pressure?],…],"size":px?,"hardness":0..1?,"opacity":0..1?,"flow":0..1?,"color":"#rrggbb"?=foreground,"erase":bool?,"smoothing":0..1?}"##, has_pixel_layer, |s, p| { + let pts: Vec = p + .get("points") + .and_then(Value::as_array) + .ok_or_else(|| bad("paint.stroke", "missing `points`"))? + .iter() + .filter_map(|v| { + let a = v.as_array()?; + Some(StrokePoint { x: a.first()?.as_f64()?, y: a.get(1)?.as_f64()?, pressure: a.get(2).and_then(Value::as_f64).unwrap_or(1.0) as f32 }) + }) + .collect(); + if pts.is_empty() { + return Err(bad("paint.stroke", "`points` is empty")); + } + let base = s.tools.brush.clone(); + let brush = BrushSettings { + size: f32_or(p, "size", base.size), + hardness: f32_or(p, "hardness", base.hardness), + opacity: f32_or(p, "opacity", base.opacity), + flow: f32_or(p, "flow", base.flow), + spacing: f32_or(p, "spacing", base.spacing), + color: color_param(p, "color", s.tools.foreground), + erase: p.get("erase").and_then(Value::as_bool).unwrap_or(false), + ..base + }; + let smoothing = f32_or(p, "smoothing", 0.0); + let stroke = Stroke { brush, points: if smoothing > 0.0 { photocraft_paint::smooth(&pts, smoothing) } else { pts } }; + let id = layer_param(s, p)?; + let label = if stroke.brush.erase { "Eraser" } else { "Brush Tool" }; + let dmg = s.edit(label, |doc, _| { + let sel = doc.selection.clone(); + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + let lock = l.locks.transparency; + let surf = l.surface_mut().ok_or_else(|| EngineError::Other("not a pixel layer".into()))?; + Ok(photocraft_paint::apply_stroke(surf, &stroke, sel.as_ref(), lock)) + })?; + if let Some(st) = s.active_mut() { + st.last_damage = Some(dmg); + } + Ok(json!({ "damage": [dmg.x0, dmg.y0, dmg.width(), dmg.height()] })) + }), + cmd!("tools.setColors", "Set Colors", [], None, r##"{"foreground":"#rrggbb"?,"background":"#rrggbb"?}"##, always, |s, p| { + s.tools.foreground = color_param(p, "foreground", s.tools.foreground); + s.tools.background = color_param(p, "background", s.tools.background); + Ok(Value::Null) + }), + cmd!("tools.swapColors", "Switch Foreground and Background Colors", [], Some("X"), "{}", always, |s, _| { + std::mem::swap(&mut s.tools.foreground, &mut s.tools.background); + Ok(Value::Null) + }), + cmd!("tools.defaultColors", "Default Foreground and Background Colors", [], Some("D"), "{}", always, |s, _| { + s.tools.foreground = [0.0, 0.0, 0.0, 1.0]; + s.tools.background = [1.0, 1.0, 1.0, 1.0]; + Ok(Value::Null) + }), + // Queries (not journaled) + CommandSpec { id: "session.inspect", label: "Inspect Session", menu: &[], shortcut: None, params: "{}", enabled: always, run: |s, _| Ok(inspect::session(s)), journal: false }, + CommandSpec { id: "document.inspect", label: "Inspect Document", menu: &[], shortcut: None, params: r##"{"document":index?}"##, enabled: has_doc, run: |s, p| { + let i = p.get("document").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).ok_or(EngineError::NoDocument)?; + let d = s.documents().get(i).ok_or(EngineError::NoDocument)?; + Ok(inspect::document(d)) + }, journal: false }, + CommandSpec { id: "document.activate", label: "Activate Document", menu: &[], shortcut: None, params: r##"{"document":index}"##, enabled: has_doc, run: |s, p| { + let i = p.get("document").and_then(Value::as_u64).ok_or_else(|| bad("document.activate", "missing `document`"))?; + if s.set_active(i as usize) { Ok(Value::Null) } else { Err(EngineError::NoDocument) } + }, journal: false }, + CommandSpec { id: "command.list", label: "List Commands", menu: &[], shortcut: None, params: "{}", enabled: always, run: |s, _| { + Ok(Value::Array(command_specs().iter().map(|c| json!({ + "id": c.id, "label": c.label, "menu": c.menu, "shortcut": c.shortcut, "params": c.params, "enabled": (c.enabled)(s).is_ok(), + })).collect())) + }, journal: false }, + CommandSpec { id: "document.pixel", label: "Read Composite Pixel", menu: &[], shortcut: None, params: r##"{"x":i32,"y":i32}"##, enabled: has_doc, run: |s, p| { + let x = int(p, "x").unwrap_or(0) as i32; + let y = int(p, "y").unwrap_or(0) as i32; + let d = s.active().ok_or(EngineError::NoDocument)?; + let px = photocraft_compose::render(&d.doc, Rect::from_xywh(x, y, 1, 1)).px[0]; + Ok(json!(px)) + }, journal: false }, + ]; + + // Adjustment layers + destructive adjustments, generated from one list. + const ADJ: &[(&str, &str, &str)] = &[ + ("brightnessContrast", "Brightness/Contrast…", r##"{"brightness":-150..150,"contrast":-50..100}"##), + ("levels", "Levels…", r##"{"inBlack":0..255,"inWhite":0..255,"gamma":0.1..10}"##), + ("curves", "Curves…", r##"{"points":[[in,out],…] in 0..255}"##), + ("exposure", "Exposure…", r##"{"exposure":-20..20,"offset":-0.5..0.5,"gamma":0.01..9.99}"##), + ("vibrance", "Vibrance…", r##"{"vibrance":-100..100,"saturation":-100..100}"##), + ("hueSaturation", "Hue/Saturation…", r##"{"hue":-180..180,"saturation":-100..100,"lightness":-100..100,"colorize":bool}"##), + ("colorBalance", "Color Balance…", "{}"), + ("blackWhite", "Black & White…", "{}"), + ("photoFilter", "Photo Filter…", r##"{"density":0..100}"##), + ("channelMixer", "Channel Mixer…", "{}"), + ("invert", "Invert", "{}"), + ("posterize", "Posterize…", r##"{"levels":2..255}"##), + ("threshold", "Threshold…", r##"{"level":1..255}"##), + ("gradientMap", "Gradient Map…", "{}"), + ]; + for &(kind, label, params) in ADJ { + let layer_id: &'static str = Box::leak(format!("layer.newAdjustmentLayer.{kind}").into_boxed_str()); + let image_id: &'static str = Box::leak(format!("image.adjustments.{kind}").into_boxed_str()); + v.push(CommandSpec { + id: layer_id, + label, + menu: &["Layer", "New Adjustment Layer"], + shortcut: None, + params, + enabled: has_doc, + run: |s, p| { + let kind = p.get("__kind").and_then(Value::as_str).unwrap_or("invert").to_string(); + new_adjustment(s, adjustment_from_params(&kind, p)) + }, + journal: true, + }); + v.push(CommandSpec { + id: image_id, + label, + menu: &["Image", "Adjustments"], + shortcut: match kind { + "levels" => Some("Cmd+L"), + "curves" => Some("Cmd+M"), + "hueSaturation" => Some("Cmd+U"), + "colorBalance" => Some("Cmd+B"), + "invert" => Some("Cmd+I"), + "blackWhite" => Some("Cmd+Alt+Shift+B"), + _ => None, + }, + params, + enabled: has_pixel_layer, + run: |s, p| { + let kind = p.get("__kind").and_then(Value::as_str).unwrap_or("invert").to_string(); + let adj = adjustment_from_params(&kind, p); + let label = adj.label().to_string(); + destructive_adjust(s, &label, adj) + }, + journal: true, + }); + } + v.extend(crate::layer_style::specs()); + v.extend(crate::filters::specs()); + v +} + +/// Commands generated from a table share one `run` fn; the kind is recovered from the id. +pub(crate) fn inject_kind(id: &str, params: Value) -> Value { + let kind = id.strip_prefix("layer.newAdjustmentLayer.").or_else(|| id.strip_prefix("image.adjustments.")); + match (kind, params) { + (Some(k), Value::Object(mut m)) => { + m.insert("__kind".into(), Value::String(k.into())); + Value::Object(m) + } + (Some(k), _) => json!({ "__kind": k }), + (None, p) => p, + } +} + +pub fn adjustment_kind(a: &Adjustment) -> &'static str { + match a { + Adjustment::BrightnessContrast { .. } => "brightnessContrast", + Adjustment::Levels { .. } => "levels", + Adjustment::Curves { .. } => "curves", + Adjustment::Exposure { .. } => "exposure", + Adjustment::Vibrance { .. } => "vibrance", + Adjustment::HueSaturation { .. } => "hueSaturation", + Adjustment::ColorBalance { .. } => "colorBalance", + Adjustment::BlackWhite { .. } => "blackWhite", + Adjustment::PhotoFilter { .. } => "photoFilter", + Adjustment::ChannelMixer { .. } => "channelMixer", + Adjustment::Posterize { .. } => "posterize", + Adjustment::Threshold { .. } => "threshold", + Adjustment::GradientMap { .. } => "gradientMap", + _ => "invert", + } +} + +fn arrange(s: &mut Session, p: &Value, delta: i32) -> Result { + let id = layer_param(s, p)?; + s.edit("Arrange", |doc, _| { + let steps = match delta { + i32::MAX => 10_000, + i32::MIN => -10_000, + d => d, + }; + let dir = steps.signum(); + let mut moved = false; + for _ in 0..steps.abs() { + if !doc.shift(id, dir) { + break; + } + moved = true; + } + if moved { Ok(()) } else { Err(EngineError::Other("layer can't move further".into())) } + })?; + Ok(Value::Null) +} + +fn set_mask(s: &mut Session, p: &Value, label: &str, mask: Option) -> Result { + let id = layer_param(s, p)?; + s.edit(label, |doc, _| { + doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.mask = mask; + Ok(()) + })?; + Ok(Value::Null) +} + +fn combine(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f32) -> f32) -> Surface { + let ra = a.read_region(area); + let rb = b.read_region(area); + let data: Vec = ra.iter().zip(&rb).map(|(x, y)| f(*x, *y)).collect(); + let mut out = Surface::new(photocraft_color::PixelFormat::GRAY8); + out.write_region(area, &data); + out.prune(); + out +} + +fn translate_layer(l: &mut Layer, dx: i32, dy: i32) { + if let Some(m) = &mut l.mask + && m.linked + { + m.surface = pixels::remap_surface(&m.surface, |x, y| (x + dx, y + dy)); + } + match &mut l.content { + LayerContent::Raster(s) => *s = pixels::remap_surface(s, |x, y| (x + dx, y + dy)), + LayerContent::Group(g) => { + for c in &mut g.children { + translate_layer(c, dx, dy); + } + } + _ => {} + } +} + +/// Does `l` (or any descendant) have id `target`? +fn contains_layer(l: &Layer, target: LayerId) -> bool { + l.id == target || l.children().is_some_and(|c| c.iter().any(|c| contains_layer(c, target))) +} diff --git a/crates/engine/src/filters.rs b/crates/engine/src/filters.rs new file mode 100644 index 00000000..87e094ea --- /dev/null +++ b/crates/engine/src/filters.rs @@ -0,0 +1,397 @@ +//! `filter.*` commands (Photoshop's Filter menu) backed by `photocraft-algo`. +//! +//! Every command acts on the active pixel layer (or the cached pixels of a +//! smart object, recording a smart filter), respects the selection, and is +//! undoable. `filter.lastFilter` re-runs the most recent filter command. + +use photocraft_algo::{self as algo, Distribution, FilterParams, PolarMode, RadialMethod, RippleSize, SpherizeMode, UndefinedAreas, WaveType}; +use photocraft_doc::{LayerContent, SmartFilter}; +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::{EngineError, Result, Session}; + +fn f(p: &Value, k: &str, d: f32) -> f32 { + p.get(k).and_then(Value::as_f64).map_or(d, |v| v as f32) +} +fn i(p: &Value, k: &str, d: i32) -> i32 { + p.get(k).and_then(|v| v.as_i64().or_else(|| v.as_f64().map(|f| f.round() as i64))).map_or(d, |v| v as i32) +} +fn b(p: &Value, k: &str, d: bool) -> bool { + p.get(k).and_then(Value::as_bool).unwrap_or(d) +} +fn s<'a>(p: &'a Value, k: &str, d: &'a str) -> &'a str { + p.get(k).and_then(Value::as_str).unwrap_or(d) +} +fn undefined(p: &Value) -> UndefinedAreas { + match s(p, "undefinedAreas", "wrap") { + "repeat" => UndefinedAreas::Repeat, + "transparent" => UndefinedAreas::Transparent, + _ => UndefinedAreas::Wrap, + } +} + +/// Builds the algorithm parameters for a filter command id from JSON params +/// (Photoshop dialog units). +pub fn params_for(id: &str, p: &Value) -> Option { + Some(match id { + "filter.blur.gaussianBlur" => FilterParams::GaussianBlur { radius: f(p, "radius", 1.0).clamp(0.1, 1000.0) }, + "filter.blur.boxBlur" => FilterParams::BoxBlur { radius: f(p, "radius", 1.0).clamp(1.0, 2000.0) }, + "filter.blur.motionBlur" => FilterParams::MotionBlur { angle: f(p, "angle", 0.0), distance: f(p, "distance", 10.0).clamp(1.0, 2000.0) }, + "filter.blur.radialBlur" => FilterParams::RadialBlur { + amount: f(p, "amount", 10.0).clamp(1.0, 100.0), + method: if s(p, "method", "spin") == "zoom" { RadialMethod::Zoom } else { RadialMethod::Spin }, + center_x: f(p, "centerX", 0.5), + center_y: f(p, "centerY", 0.5), + }, + "filter.blur.surfaceBlur" => FilterParams::SurfaceBlur { radius: f(p, "radius", 5.0).clamp(1.0, 100.0), threshold: f(p, "threshold", 15.0).clamp(2.0, 255.0) }, + "filter.sharpen.unsharpMask" => FilterParams::UnsharpMask { + amount: f(p, "amount", 50.0).clamp(1.0, 500.0), + radius: f(p, "radius", 1.0).clamp(0.1, 1000.0), + threshold: f(p, "threshold", 0.0).clamp(0.0, 255.0), + }, + "filter.sharpen.smartSharpen" => FilterParams::SmartSharpen { + amount: f(p, "amount", 100.0).clamp(1.0, 500.0), + radius: f(p, "radius", 1.0).clamp(0.1, 64.0), + reduce_noise: f(p, "reduceNoise", 10.0).clamp(0.0, 100.0), + }, + "filter.other.highPass" => FilterParams::HighPass { radius: f(p, "radius", 10.0).clamp(0.1, 1000.0) }, + "filter.noise.addNoise" => FilterParams::AddNoise { + amount: f(p, "amount", 12.5).clamp(0.1, 400.0), + distribution: if s(p, "distribution", "uniform") == "gaussian" { Distribution::Gaussian } else { Distribution::Uniform }, + monochromatic: b(p, "monochromatic", false), + seed: i(p, "seed", 0) as u32, + }, + "filter.noise.median" => FilterParams::Median { radius: f(p, "radius", 1.0).clamp(1.0, 500.0) }, + "filter.noise.dustAndScratches" => FilterParams::DustAndScratches { radius: f(p, "radius", 1.0).clamp(1.0, 500.0), threshold: f(p, "threshold", 0.0).clamp(0.0, 255.0) }, + "filter.other.minimum" => FilterParams::Minimum { radius: f(p, "radius", 1.0).clamp(0.2, 500.0) }, + "filter.other.maximum" => FilterParams::Maximum { radius: f(p, "radius", 1.0).clamp(0.2, 500.0) }, + "filter.other.offset" => FilterParams::Offset { horizontal: i(p, "horizontal", 0), vertical: i(p, "vertical", 0), undefined: undefined(p) }, + "filter.pixelate.mosaic" => FilterParams::Mosaic { cell_size: f(p, "cellSize", 10.0).clamp(2.0, 200.0) }, + "filter.stylize.emboss" => FilterParams::Emboss { angle: f(p, "angle", 135.0), height: f(p, "height", 3.0).clamp(1.0, 100.0), amount: f(p, "amount", 100.0).clamp(1.0, 500.0) }, + "filter.stylize.findEdges" => FilterParams::FindEdges, + "filter.stylize.solarize" => FilterParams::Solarize, + "filter.distort.twirl" => FilterParams::Twirl { angle: f(p, "angle", 50.0).clamp(-999.0, 999.0) }, + "filter.distort.pinch" => FilterParams::Pinch { amount: f(p, "amount", 50.0).clamp(-100.0, 100.0) }, + "filter.distort.spherize" => FilterParams::Spherize { + amount: f(p, "amount", 100.0).clamp(-100.0, 100.0), + mode: match s(p, "mode", "normal") { + "horizontalOnly" => SpherizeMode::HorizontalOnly, + "verticalOnly" => SpherizeMode::VerticalOnly, + _ => SpherizeMode::Normal, + }, + }, + "filter.distort.wave" => FilterParams::Wave { + generators: i(p, "generators", 5).clamp(1, 999) as u32, + wavelength_min: f(p, "wavelengthMin", 10.0).clamp(1.0, 998.0), + wavelength_max: f(p, "wavelengthMax", 120.0).clamp(2.0, 999.0), + amplitude_min: f(p, "amplitudeMin", 5.0).clamp(1.0, 998.0), + amplitude_max: f(p, "amplitudeMax", 35.0).clamp(1.0, 999.0), + wave_type: match s(p, "type", "sine") { + "triangle" => WaveType::Triangle, + "square" => WaveType::Square, + _ => WaveType::Sine, + }, + undefined: undefined(p), + seed: i(p, "seed", 0) as u32, + }, + "filter.distort.ripple" => FilterParams::Ripple { + amount: f(p, "amount", 100.0).clamp(-999.0, 999.0), + size: match s(p, "size", "medium") { + "small" => RippleSize::Small, + "large" => RippleSize::Large, + _ => RippleSize::Medium, + }, + }, + "filter.distort.polarCoordinates" => FilterParams::PolarCoordinates { + mode: if s(p, "mode", "rectangularToPolar") == "polarToRectangular" { PolarMode::PolarToRectangular } else { PolarMode::RectangularToPolar }, + }, + _ => return None, + }) +} + +fn has_filterable_layer(s: &Session) -> std::result::Result<(), String> { + let d = s.active().ok_or("no document open")?; + let id = d.active_layer.ok_or("no active layer")?; + let l = d.doc.layer(id).ok_or("no active layer")?; + match &l.content { + LayerContent::Raster(_) => Ok(()), + LayerContent::Smart(sm) if sm.cache.is_some() => Ok(()), + other => Err(format!("filters need a pixel layer (active layer is a {} layer)", other.kind_name())), + } +} + +fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result { + let fp = params_for(id, p).ok_or_else(|| EngineError::Other(format!("unknown filter {id}")))?; + let layer = match p.get("layer").and_then(Value::as_u64) { + Some(l) => photocraft_doc::LayerId(l), + None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into()))?, + }; + let label = fp.label().to_string(); + let mut params = p.clone(); + if let Value::Object(m) = &mut params { + m.remove("__kind"); + } + s.edit(&label, |doc, _| { + let selection = doc.selection.clone(); + let sel_bounds = selection.as_ref().map(photocraft_raster::Surface::content_bounds); + // Distortions centre on the selection when there is one, else the canvas. + let bounds = sel_bounds.filter(|b| !b.is_empty()).unwrap_or_else(|| doc.bounds()); + let l = doc.layer_mut(layer).ok_or(EngineError::NoLayer(layer))?; + let surf = match &mut l.content { + LayerContent::Raster(surf) => surf, + LayerContent::Smart(sm) => { + sm.smart_filters.push(SmartFilter { command: id.to_string(), params: params.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }); + sm.cache.as_mut().ok_or_else(|| EngineError::Other("smart object has no pixels".into()))? + } + _ => return Err(EngineError::Other("not a pixel layer".into())), + }; + let area = algo::output_area(&fp, surf.content_bounds(), bounds, sel_bounds); + *surf = algo::apply(surf, &fp, area, bounds, selection.as_ref()); + Ok(()) + })?; + Ok(json!({ "layer": layer.0, "filter": serde_json::to_value(&fp).unwrap_or(Value::Null) })) +} + +macro_rules! filter_cmd { + ($id:literal, $label:literal, [$($m:literal),*], $params:literal) => { + CommandSpec { + id: $id, + label: $label, + menu: &[$($m),*], + shortcut: None, + params: $params, + enabled: has_filterable_layer, + run: |s, p| run_filter(s, $id, p), + journal: true, + } + }; +} + +/// The filter command specs. +pub fn specs() -> Vec { + vec![ + filter_cmd!("filter.blur.gaussianBlur", "Gaussian Blur…", ["Filter", "Blur"], r##"{"radius":0.1..1000=1}"##), + filter_cmd!("filter.blur.boxBlur", "Box Blur…", ["Filter", "Blur"], r##"{"radius":1..2000=1}"##), + filter_cmd!("filter.blur.motionBlur", "Motion Blur…", ["Filter", "Blur"], r##"{"angle":-360..360=0,"distance":1..2000=10}"##), + filter_cmd!("filter.blur.radialBlur", "Radial Blur…", ["Filter", "Blur"], r##"{"amount":1..100=10,"method":"spin|zoom","centerX":0..1=0.5,"centerY":0..1=0.5}"##), + filter_cmd!("filter.blur.surfaceBlur", "Surface Blur…", ["Filter", "Blur"], r##"{"radius":1..100=5,"threshold":2..255=15}"##), + filter_cmd!("filter.sharpen.unsharpMask", "Unsharp Mask…", ["Filter", "Sharpen"], r##"{"amount":1..500=50,"radius":0.1..1000=1,"threshold":0..255=0}"##), + filter_cmd!("filter.sharpen.smartSharpen", "Smart Sharpen…", ["Filter", "Sharpen"], r##"{"amount":1..500=100,"radius":0.1..64=1,"reduceNoise":0..100=10}"##), + filter_cmd!("filter.noise.addNoise", "Add Noise…", ["Filter", "Noise"], r##"{"amount":0.1..400=12.5,"distribution":"uniform|gaussian","monochromatic":bool,"seed":u32=0}"##), + filter_cmd!("filter.noise.median", "Median…", ["Filter", "Noise"], r##"{"radius":1..500=1}"##), + filter_cmd!("filter.noise.dustAndScratches", "Dust & Scratches…", ["Filter", "Noise"], r##"{"radius":1..500=1,"threshold":0..255=0}"##), + filter_cmd!("filter.pixelate.mosaic", "Mosaic…", ["Filter", "Pixelate"], r##"{"cellSize":2..200=10}"##), + filter_cmd!("filter.stylize.emboss", "Emboss…", ["Filter", "Stylize"], r##"{"angle":-180..180=135,"height":1..100=3,"amount":1..500=100}"##), + filter_cmd!("filter.stylize.findEdges", "Find Edges", ["Filter", "Stylize"], "{}"), + filter_cmd!("filter.stylize.solarize", "Solarize", ["Filter", "Stylize"], "{}"), + filter_cmd!("filter.distort.twirl", "Twirl…", ["Filter", "Distort"], r##"{"angle":-999..999=50}"##), + filter_cmd!("filter.distort.pinch", "Pinch…", ["Filter", "Distort"], r##"{"amount":-100..100=50}"##), + filter_cmd!("filter.distort.spherize", "Spherize…", ["Filter", "Distort"], r##"{"amount":-100..100=100,"mode":"normal|horizontalOnly|verticalOnly"}"##), + filter_cmd!("filter.distort.wave", "Wave…", ["Filter", "Distort"], r##"{"generators":1..999=5,"wavelengthMin":1..998=10,"wavelengthMax":2..999=120,"amplitudeMin":1..998=5,"amplitudeMax":1..999=35,"type":"sine|triangle|square","undefinedAreas":"wrap|repeat","seed":u32=0}"##), + filter_cmd!("filter.distort.ripple", "Ripple…", ["Filter", "Distort"], r##"{"amount":-999..999=100,"size":"small|medium|large"}"##), + filter_cmd!("filter.distort.polarCoordinates", "Polar Coordinates…", ["Filter", "Distort"], r##"{"mode":"rectangularToPolar|polarToRectangular"}"##), + filter_cmd!("filter.other.highPass", "High Pass…", ["Filter", "Other"], r##"{"radius":0.1..1000=10}"##), + filter_cmd!("filter.other.minimum", "Minimum…", ["Filter", "Other"], r##"{"radius":0.2..500=1}"##), + filter_cmd!("filter.other.maximum", "Maximum…", ["Filter", "Other"], r##"{"radius":0.2..500=1}"##), + filter_cmd!("filter.other.offset", "Offset…", ["Filter", "Other"], r##"{"horizontal":px=0,"vertical":px=0,"undefinedAreas":"wrap|repeat|transparent"}"##), + CommandSpec { + id: "filter.lastFilter", + label: "Last Filter", + menu: &["Filter"], + shortcut: Some("Cmd+Alt+F"), + params: "{}", + enabled: |s| { + has_filterable_layer(s)?; + last_filter(s).map(|_| ()).ok_or_else(|| "no filter applied yet".into()) + }, + run: |s, _| { + let (id, params) = last_filter(s).ok_or_else(|| EngineError::Other("no filter applied yet".into()))?; + run_filter(s, &id, ¶ms) + }, + journal: true, + }, + ] +} + +/// The most recent filter command in the session journal. +fn last_filter(s: &Session) -> Option<(String, Value)> { + s.journal.iter().rev().find(|(id, _)| params_for(id, &Value::Null).is_some() && id.starts_with("filter.")).cloned() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 48, "height": 32})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#3366cc"})).ok(); + s + } + + fn active_pixels(s: &Session) -> Vec { + let d = s.active().unwrap(); + let l = d.doc.layer(d.active_layer.unwrap()).unwrap(); + l.surface().unwrap().read_region(photocraft_geom::Rect::new(0, 0, 48, 32)) + } + + fn paint_pattern(s: &mut Session) { + s.edit("pattern", |doc, active| { + let l = doc.layer_mut(active.unwrap()).unwrap(); + let surf = l.surface_mut().unwrap(); + for y in 0..32 { + for x in 0..48 { + let v = ((x * 5 + y * 3) % 17) as f32 / 16.0; + surf.write_pixel(x, y, &[v, 1.0 - v, (x % 3) as f32 / 2.0, 1.0]); + } + } + Ok(()) + }) + .unwrap(); + } + + const ALL: [&str; 24] = [ + "filter.blur.gaussianBlur", + "filter.blur.boxBlur", + "filter.blur.motionBlur", + "filter.blur.radialBlur", + "filter.blur.surfaceBlur", + "filter.sharpen.unsharpMask", + "filter.sharpen.smartSharpen", + "filter.noise.addNoise", + "filter.noise.median", + "filter.noise.dustAndScratches", + "filter.pixelate.mosaic", + "filter.stylize.emboss", + "filter.stylize.findEdges", + "filter.stylize.solarize", + "filter.distort.twirl", + "filter.distort.pinch", + "filter.distort.spherize", + "filter.distort.wave", + "filter.distort.ripple", + "filter.distort.polarCoordinates", + "filter.other.highPass", + "filter.other.minimum", + "filter.other.maximum", + "filter.other.offset", + ]; + + #[test] + fn every_filter_command_runs_changes_pixels_and_undoes() { + for id in ALL { + let mut s = session(); + paint_pattern(&mut s); + let before = active_pixels(&s); + let params = match id { + "filter.other.offset" => json!({"horizontal": 5, "vertical": 2}), + "filter.noise.dustAndScratches" => json!({"radius": 2, "threshold": 0}), + "filter.noise.median" => json!({"radius": 2}), + _ => json!({}), + }; + let r = s.execute(id, params).unwrap_or_else(|e| panic!("{id}: {e}")); + assert!(r.get("filter").is_some(), "{id}"); + assert_ne!(active_pixels(&s), before, "{id} changed nothing"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(active_pixels(&s), before, "{id} undo"); + } + } + + #[test] + fn specs_are_complete_and_documented() { + let specs = specs(); + for id in ALL { + let spec = specs.iter().find(|c| c.id == id).unwrap_or_else(|| panic!("missing {id}")); + assert!(spec.params.starts_with('{'), "{id}"); + assert!(!spec.menu.is_empty(), "{id}"); + } + } + + #[test] + fn disabled_without_pixel_layer() { + let mut s = Session::new(); + assert!(!s.is_enabled("filter.blur.gaussianBlur")); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap(); + assert!(matches!(s.execute("filter.blur.gaussianBlur", json!({})), Err(EngineError::Disabled(..)))); + } + + #[test] + fn selection_limits_the_filter() { + let mut s = session(); + paint_pattern(&mut s); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let has_sel = s.active().unwrap().doc.selection.is_some(); + assert!(has_sel); + let before = active_pixels(&s); + s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(); + let after = active_pixels(&s); + if has_sel { + // A pixel far from the selection is unchanged. + let i = (30 * 48 + 40) * 4; + assert_eq!(after[i..i + 4], before[i..i + 4]); + let j = (5 * 48 + 5) * 4; + assert_ne!(after[j..j + 4], before[j..j + 4]); + } + } + + #[test] + fn last_filter_repeats_with_same_params() { + let mut s = session(); + paint_pattern(&mut s); + assert!(!s.is_enabled("filter.lastFilter")); + s.execute("filter.other.offset", json!({"horizontal": 3})).unwrap(); + let once = active_pixels(&s); + s.execute("filter.lastFilter", json!({})).unwrap(); + let twice = active_pixels(&s); + assert_ne!(once, twice); + // Offsetting 3 twice equals offsetting 6 once. + let mut s2 = session(); + paint_pattern(&mut s2); + s2.execute("filter.other.offset", json!({"horizontal": 6})).unwrap(); + assert_eq!(active_pixels(&s2), twice); + } + + #[test] + fn params_map_to_algorithm_units() { + assert_eq!(params_for("filter.blur.gaussianBlur", &json!({"radius": 4.5})), Some(FilterParams::GaussianBlur { radius: 4.5 })); + assert_eq!( + params_for("filter.noise.addNoise", &json!({"amount": 10, "distribution": "gaussian", "monochromatic": true})), + Some(FilterParams::AddNoise { amount: 10.0, distribution: Distribution::Gaussian, monochromatic: true, seed: 0 }) + ); + assert_eq!(params_for("filter.blur.gaussianBlur", &json!({"radius": -3})), Some(FilterParams::GaussianBlur { radius: 0.1 })); + assert!(params_for("filter.nope", &json!({})).is_none()); + } + + #[test] + fn smart_object_records_smart_filter() { + use photocraft_doc::{Layer, SmartObject, SmartSource}; + let mut s = session(); + s.edit("smart", |doc, active| { + let mut cache = photocraft_raster::Surface::new(doc.pixel_format()); + cache.fill_rect(photocraft_geom::Rect::new(0, 0, 10, 10), &[1.0, 0.0, 0.0, 1.0]); + let l = Layer::new( + "so", + LayerContent::Smart(SmartObject { + source: SmartSource::Linked { path: String::new() }, + transform: photocraft_geom::Affine::IDENTITY, + smart_filters: vec![], + cache: Some(cache), + psd_raw: None, + }), + ); + *active = Some(doc.insert_above(*active, l)); + Ok(()) + }) + .unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap(); + let d = s.active().unwrap(); + let LayerContent::Smart(sm) = &d.doc.layer(d.active_layer.unwrap()).unwrap().content else { panic!() }; + assert_eq!(sm.smart_filters.len(), 1); + assert_eq!(sm.smart_filters[0].command, "filter.blur.gaussianBlur"); + assert_eq!(sm.smart_filters[0].params["radius"], json!(2)); + assert!(sm.cache.as_ref().unwrap().pixel(10, 5)[3] > 0.0, "cache was blurred"); + } +} diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs new file mode 100644 index 00000000..0ac1096c --- /dev/null +++ b/crates/engine/src/inspect.rs @@ -0,0 +1,73 @@ +//! JSON views of engine state for automation, tests and debugging. + +use photocraft_doc::{Layer, LayerContent}; +use serde_json::{Value, json}; + +use crate::{DocState, Session}; + +pub fn session(s: &Session) -> Value { + json!({ + "active": s.active_index(), + "documents": s.documents().iter().enumerate().map(|(i, d)| json!({ + "index": i, + "name": d.doc.name, + "path": d.path, + "width": d.doc.size.width, + "height": d.doc.size.height, + "dirty": d.is_dirty(), + "revision": d.revision, + })).collect::>(), + "foreground": s.tools.foreground, + "background": s.tools.background, + }) +} + +pub fn document(d: &DocState) -> Value { + let doc = &d.doc; + json!({ + "name": doc.name, + "width": doc.size.width, + "height": doc.size.height, + "mode": format!("{:?}", doc.mode), + "depth": doc.depth.bits(), + "resolution": doc.resolution_dpi, + "activeLayer": d.active_layer.map(|l| l.0), + "hasSelection": doc.selection.is_some(), + "selectionBounds": doc.selection.as_ref().map(|s| { let r = s.content_bounds(); [r.x0, r.y0, r.width() as i32, r.height() as i32] }), + "layers": doc.layers.iter().rev().map(layer).collect::>(), + "history": d.history.entries(), + "canUndo": d.history.can_undo(), + "canRedo": d.history.can_redo(), + "revision": d.revision, + }) +} + +/// Layer tree top-to-bottom (display order). +pub fn layer(l: &Layer) -> Value { + let bounds = l.surface().map(|s| { + let r = s.content_bounds(); + [r.x0, r.y0, r.width() as i32, r.height() as i32] + }); + let mut v = json!({ + "id": l.id.0, + "name": l.name, + "kind": l.content.kind_name(), + "visible": l.visible, + "opacity": l.opacity, + "fill": l.fill_opacity, + "blend": l.blend.label(), + "clipped": l.clipped, + "hasMask": l.mask.is_some(), + "bounds": bounds, + }); + match &l.content { + LayerContent::Group(g) => { + v["children"] = Value::Array(g.children.iter().rev().map(layer).collect()); + } + LayerContent::Adjustment(a) => { + v["adjustment"] = serde_json::to_value(a).unwrap_or(Value::Null); + } + _ => {} + } + v +} diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs new file mode 100644 index 00000000..73f86086 --- /dev/null +++ b/crates/engine/src/layer_style.rs @@ -0,0 +1,296 @@ +//! `layer.layerStyle.*` commands: add/replace layer effects on a layer. + +use photocraft_color::{BlendMode, Color}; +use photocraft_doc::{ + Bevel, BevelStyle, BevelTechnique, Contour, Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique, Gradient, GradientStyle, + Satin, Shadow, StrokeFx, StrokePosition, +}; +use serde_json::{Value, json}; + +use crate::commands::{CommandSpec, blend_from_str}; +use crate::{EngineError, Result, Session}; + +/// Parses a gradient style name (`linear`, `radial`, `angle`, `reflected`, `diamond`). +pub fn gradient_style(s: &str) -> GradientStyle { + match s.to_ascii_lowercase().as_str() { + "radial" => GradientStyle::Radial, + "angle" => GradientStyle::Angle, + "reflected" => GradientStyle::Reflected, + "diamond" => GradientStyle::Diamond, + _ => GradientStyle::Linear, + } +} + +fn f(p: &Value, k: &str, d: f32) -> f32 { + p.get(k).and_then(Value::as_f64).map_or(d, |v| v as f32) +} +fn b(p: &Value, k: &str, d: bool) -> bool { + p.get(k).and_then(Value::as_bool).unwrap_or(d) +} +fn color(p: &Value, k: &str, d: [f32; 3]) -> Color { + let c = match p.get(k) { + Some(Value::Array(a)) if a.len() >= 3 => [0, 1, 2].map(|i| a[i].as_f64().unwrap_or(0.0) as f32), + Some(Value::String(s)) => { + let s = s.trim_start_matches('#'); + let h = |i: usize| s.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).map_or(0.0, |v| f32::from(v) / 255.0); + if s.len() >= 6 { [h(0), h(2), h(4)] } else { d } + } + _ => d, + }; + Color::rgb(c[0], c[1], c[2]) +} +fn common(p: &Value, blend: BlendMode, opacity: f32) -> FxCommon { + FxCommon { + enabled: b(p, "enabled", true), + blend: p.get("blend").and_then(Value::as_str).and_then(blend_from_str).unwrap_or(blend), + opacity: (f(p, "opacity", opacity * 100.0) / 100.0).clamp(0.0, 1.0), + } +} +fn gradient(p: &Value) -> Gradient { + Gradient { + stops: vec![(0.0, color(p, "from", [0.0; 3])), (1.0, color(p, "to", [1.0; 3]))], + style: gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")), + angle: f(p, "angle", 90.0), + scale: f(p, "scale", 100.0) / 100.0, + reverse: b(p, "reverse", false), + ..Gradient::default() + } +} + +/// Builds an effect of `kind` from JSON params (Photoshop units: opacity, +/// spread, range in percent; sizes in px; angles in degrees). +pub fn effect_from_params(kind: &str, p: &Value) -> Option { + let shadow = |blend: BlendMode, inner: bool| Shadow { + common: common(p, blend, 0.75), + color: color(p, "color", [0.0; 3]), + angle: f(p, "angle", 120.0), + use_global_light: b(p, "useGlobalLight", true), + distance: f(p, "distance", 5.0), + spread: f(p, if inner { "choke" } else { "spread" }, 0.0) / 100.0, + size: f(p, "size", 5.0), + contour: Contour::Linear, + anti_alias: false, + noise: f(p, "noise", 0.0) / 100.0, + knocks_out: !inner && b(p, "knocksOut", true), + }; + let glow = |inner: bool| Glow { + common: common(p, BlendMode::Screen, 0.75), + paint: FxPaint::Color(color(p, "color", [1.0, 1.0, 190.0 / 255.0])), + technique: if p.get("technique").and_then(Value::as_str) == Some("precise") { GlowTechnique::Precise } else { GlowTechnique::Softer }, + spread: f(p, if inner { "choke" } else { "spread" }, 0.0) / 100.0, + size: f(p, "size", 5.0), + contour: Contour::Linear, + anti_alias: false, + range: f(p, "range", 50.0) / 100.0, + jitter: 0.0, + noise: 0.0, + source: if p.get("source").and_then(Value::as_str) == Some("center") { GlowSource::Center } else { GlowSource::Edge }, + }; + Some(match kind { + "dropShadow" => Effect::DropShadow(shadow(BlendMode::Multiply, false)), + "innerShadow" => Effect::InnerShadow(shadow(BlendMode::Multiply, true)), + "outerGlow" => Effect::OuterGlow(glow(false)), + "innerGlow" => Effect::InnerGlow(glow(true)), + "stroke" => Effect::Stroke(StrokeFx { + common: common(p, BlendMode::Normal, 1.0), + size: f(p, "size", 3.0), + position: match p.get("position").and_then(Value::as_str) { + Some("inside") => StrokePosition::Inside, + Some("center") => StrokePosition::Center, + _ => StrokePosition::Outside, + }, + paint: if p.get("from").is_some() { FxPaint::Gradient(gradient(p)) } else { FxPaint::Color(color(p, "color", [1.0, 0.0, 0.0])) }, + }), + "colorOverlay" => Effect::ColorOverlay { common: common(p, BlendMode::Normal, 1.0), color: color(p, "color", [1.0, 0.0, 0.0]) }, + "gradientOverlay" => Effect::GradientOverlay { common: common(p, BlendMode::Normal, 1.0), gradient: gradient(p), dither: false }, + "satin" => Effect::Satin(Satin { + common: common(p, BlendMode::Multiply, 0.5), + color: color(p, "color", [0.0; 3]), + angle: f(p, "angle", 19.0), + distance: f(p, "distance", 11.0), + size: f(p, "size", 14.0), + contour: Contour::Linear, + anti_alias: true, + invert: b(p, "invert", true), + }), + "bevelEmboss" => Effect::BevelEmboss(Bevel { + enabled: b(p, "enabled", true), + style: match p.get("style").and_then(Value::as_str) { + Some("outer") => BevelStyle::OuterBevel, + Some("emboss") => BevelStyle::Emboss, + Some("pillow") => BevelStyle::PillowEmboss, + _ => BevelStyle::InnerBevel, + }, + technique: BevelTechnique::Smooth, + depth: f(p, "depth", 100.0) / 100.0, + up: p.get("direction").and_then(Value::as_str) != Some("down"), + size: f(p, "size", 5.0), + soften: f(p, "soften", 0.0), + angle: f(p, "angle", 120.0), + altitude: f(p, "altitude", 30.0), + use_global_light: b(p, "useGlobalLight", true), + gloss_contour: Contour::Linear, + highlight: FxCommon::new(BlendMode::Screen, 0.75), + highlight_color: Color::WHITE, + shadow: FxCommon::new(BlendMode::Multiply, 0.75), + shadow_color: Color::BLACK, + }), + _ => return None, + }) +} + +fn same_kind(a: &Effect, b: &Effect) -> bool { + std::mem::discriminant(a) == std::mem::discriminant(b) +} + +fn set_effect(s: &mut Session, p: &Value, kind: &str) -> Result { + let fx = effect_from_params(kind, p).ok_or_else(|| EngineError::Other(format!("unknown effect {kind}")))?; + let label = format!("Layer Style: {}", fx.label()); + let add = b(p, "add", false); + let id = match p.get("layer").and_then(Value::as_u64) { + Some(id) => photocraft_doc::LayerId(id), + None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into()))?, + }; + s.edit(&label, |doc, _| { + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + l.effects.enabled = true; + match l.effects.items.iter_mut().find(|e| same_kind(e, &fx)) { + Some(slot) if !add => *slot = fx, + _ => l.effects.items.push(fx), + } + Ok(()) + })?; + Ok(json!({ "layer": id.0 })) +} + +fn has_layer(s: &Session) -> std::result::Result<(), String> { + let d = s.active().ok_or("no document open")?; + d.active_layer.filter(|id| d.doc.layer(*id).is_some()).map(|_| ()).ok_or_else(|| "no active layer".into()) +} + +macro_rules! style_cmd { + ($kind:literal, $label:literal, $params:literal) => { + CommandSpec { + id: concat!("layer.layerStyle.", $kind), + label: $label, + menu: &["Layer", "Layer Style"], + shortcut: None, + params: $params, + enabled: has_layer, + run: |s, p| set_effect(s, p, $kind), + journal: true, + } + }; +} + +/// The `layer.layerStyle.*` command specs. +pub fn specs() -> Vec { + vec![ + style_cmd!("dropShadow", "Drop Shadow…", r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="multiply","angle":deg=120,"useGlobalLight":bool,"distance":px=5,"spread":0..100,"size":px=5,"knocksOut":bool,"add":bool,"layer":id}"##), + style_cmd!("innerShadow", "Inner Shadow…", r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str,"angle":deg,"distance":px,"choke":0..100,"size":px,"add":bool}"##), + style_cmd!("outerGlow", "Outer Glow…", r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="screen","technique":"softer|precise","spread":0..100,"size":px,"range":0..100,"add":bool}"##), + style_cmd!("innerGlow", "Inner Glow…", r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="screen","technique":"softer|precise","source":"edge|center","choke":0..100,"size":px,"add":bool}"##), + style_cmd!("stroke", "Stroke…", r##"{"size":px=3,"position":"outside|inside|center","color":"#rrggbb","from":"#rrggbb","to":"#rrggbb","style":str,"angle":deg,"opacity":0..100,"blend":str,"add":bool}"##), + style_cmd!("colorOverlay", "Color Overlay…", r##"{"color":"#rrggbb","opacity":0..100=100,"blend":str,"add":bool}"##), + style_cmd!("gradientOverlay", "Gradient Overlay…", r##"{"from":"#rrggbb","to":"#rrggbb","style":"linear|radial|angle|reflected|diamond","angle":deg=90,"scale":10..150=100,"reverse":bool,"opacity":0..100,"blend":str,"add":bool}"##), + style_cmd!("bevelEmboss", "Bevel & Emboss…", r##"{"style":"inner|outer|emboss|pillow","depth":1..1000=100,"direction":"up|down","size":px=5,"soften":px,"angle":deg,"altitude":deg,"add":bool}"##), + style_cmd!("satin", "Satin…", r##"{"color":"#rrggbb","opacity":0..100=50,"blend":str,"angle":deg,"distance":px,"size":px,"invert":bool,"add":bool}"##), + CommandSpec { + id: "layer.layerStyle.clear", + label: "Clear Layer Style", + menu: &["Layer", "Layer Style"], + shortcut: None, + params: r##"{"layer":id}"##, + enabled: has_layer, + run: |s, p| { + let id = match p.get("layer").and_then(Value::as_u64) { + Some(id) => photocraft_doc::LayerId(id), + None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into()))?, + }; + s.edit("Clear Layer Style", |doc, _| { + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + l.effects = photocraft_doc::Effects { enabled: true, ..Default::default() }; + Ok(()) + })?; + Ok(Value::Null) + }, + journal: true, + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s + } + + fn effects(s: &Session) -> Vec { + let d = s.active().unwrap(); + d.doc.layer(d.active_layer.unwrap()).unwrap().effects.items.clone() + } + + #[test] + fn every_style_command_adds_its_effect() { + for kind in ["dropShadow", "innerShadow", "outerGlow", "innerGlow", "stroke", "colorOverlay", "gradientOverlay", "bevelEmboss", "satin"] { + let mut s = session(); + s.execute(&format!("layer.layerStyle.{kind}"), json!({})).unwrap(); + let fx = effects(&s); + assert_eq!(fx.len(), 1, "{kind}"); + assert!(fx[0].enabled()); + } + } + + #[test] + fn params_are_applied_and_replace_or_add() { + let mut s = session(); + s.execute("layer.layerStyle.stroke", json!({"size": 7, "position": "inside", "color": "#00ff00", "opacity": 50})).unwrap(); + match &effects(&s)[0] { + Effect::Stroke(st) => { + assert_eq!(st.size, 7.0); + assert_eq!(st.position, StrokePosition::Inside); + assert_eq!(st.common.opacity, 0.5); + assert_eq!(st.paint, FxPaint::Color(Color::rgb(0.0, 1.0, 0.0))); + } + other => panic!("{other:?}"), + } + s.execute("layer.layerStyle.stroke", json!({"size": 2})).unwrap(); + assert_eq!(effects(&s).len(), 1, "replaces"); + s.execute("layer.layerStyle.stroke", json!({"size": 4, "add": true})).unwrap(); + assert_eq!(effects(&s).len(), 2, "adds a second instance"); + s.execute("layer.layerStyle.dropShadow", json!({"blend": "normal", "distance": 12})).unwrap(); + assert!(matches!(&effects(&s)[2], Effect::DropShadow(sh) if sh.common.blend == BlendMode::Normal && sh.distance == 12.0)); + } + + #[test] + fn clear_and_undo() { + let mut s = session(); + s.execute("layer.layerStyle.colorOverlay", json!({"color": "#ff0000"})).unwrap(); + s.execute("layer.layerStyle.clear", json!({})).unwrap(); + assert!(effects(&s).is_empty()); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(effects(&s).len(), 1); + } + + #[test] + fn color_overlay_renders() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("layer.layerStyle.colorOverlay", json!({"color": "#ff0000"})).unwrap(); + let px: Vec = serde_json::from_value(s.execute("document.pixel", json!({"x": 3, "y": 3})).unwrap()).unwrap(); + assert!(px[0] > 0.99 && px[1] < 0.01, "{px:?}"); + } + + #[test] + fn gradient_styles_parse() { + assert_eq!(gradient_style("Radial"), GradientStyle::Radial); + assert_eq!(gradient_style("diamond"), GradientStyle::Diamond); + assert_eq!(gradient_style("?"), GradientStyle::Linear); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs new file mode 100644 index 00000000..6ff61a45 --- /dev/null +++ b/crates/engine/src/lib.rs @@ -0,0 +1,213 @@ +//! The Photocraft engine façade: open documents, history, and the command registry. +//! +//! Every user-visible action is a command with a stable id (Photoshop-style, such as +//! `layer.newAdjustmentLayer.invert`) and JSON parameters. Every frontend goes through the same +//! [`Session::execute`] entry point: the egui UI, the CLI, the remote-control channel and the MCP +//! server. This is what makes the UI swappable and the app fully scriptable. +#![forbid(unsafe_code)] + +pub mod commands; +pub mod inspect; +pub mod layer_style; +pub mod filters; +mod pixels; + +use std::sync::Arc; + +use photocraft_doc::{Document, LayerId}; +use photocraft_ops::History; +use serde_json::Value; + +pub use commands::{CommandSpec, command_specs}; +pub use photocraft_doc as doc; + +#[derive(Debug, thiserror::Error)] +pub enum EngineError { + #[error("unknown command `{0}`")] + UnknownCommand(String), + #[error("command `{0}` is not available right now: {1}")] + Disabled(String, String), + #[error("invalid parameters for `{cmd}`: {msg}")] + BadParams { cmd: String, msg: String }, + #[error("no active document")] + NoDocument, + #[error("no such layer {0:?}")] + NoLayer(LayerId), + #[error("{0}")] + Other(String), +} + +pub type Result = std::result::Result; + +/// Per-document editing state. +#[derive(Clone, Debug)] +pub struct DocState { + pub doc: Arc, + pub history: History, + pub active_layer: Option, + pub path: Option, + /// Increments on every change; UIs re-render when it moves. + pub revision: u64, + pub saved_revision: u64, + /// Area changed by the latest revision (None = assume everything changed). + pub last_damage: Option, +} + +impl DocState { + pub fn new(doc: Document, path: Option) -> Self { + let active_layer = doc.top_layer(); + Self { doc: Arc::new(doc), history: History::default(), active_layer, path, revision: 1, saved_revision: 1, last_damage: None } + } + pub fn is_dirty(&self) -> bool { + self.revision != self.saved_revision + } +} + +/// App-wide tool state that commands read (foreground colour, brush…). +#[derive(Clone, Debug)] +pub struct ToolState { + pub foreground: [f32; 4], + pub background: [f32; 4], + pub brush: photocraft_paint::BrushSettings, +} + +impl Default for ToolState { + fn default() -> Self { + Self { foreground: [0.0, 0.0, 0.0, 1.0], background: [1.0, 1.0, 1.0, 1.0], brush: Default::default() } + } +} + +#[derive(Default)] +pub struct Session { + docs: Vec, + active: Option, + pub tools: ToolState, + /// Log of executed commands (for action recording and debugging). + pub journal: Vec<(String, Value)>, +} + +impl Session { + pub fn new() -> Self { + Self::default() + } + + pub fn documents(&self) -> &[DocState] { + &self.docs + } + pub fn active_index(&self) -> Option { + self.active + } + pub fn active(&self) -> Option<&DocState> { + self.active.and_then(|i| self.docs.get(i)) + } + pub fn active_mut(&mut self) -> Option<&mut DocState> { + self.active.and_then(|i| self.docs.get_mut(i)) + } + pub fn set_active(&mut self, index: usize) -> bool { + if index < self.docs.len() { + self.active = Some(index); + true + } else { + false + } + } + + /// Add a document (from File → New, an import, etc.) and make it active. + pub fn add_document(&mut self, doc: Document, path: Option) -> usize { + self.docs.push(DocState::new(doc, path)); + let i = self.docs.len() - 1; + self.active = Some(i); + i + } + + pub fn close(&mut self, index: usize) -> Option { + if index >= self.docs.len() { + return None; + } + let d = self.docs.remove(index); + self.active = if self.docs.is_empty() { None } else { Some(index.min(self.docs.len() - 1)) }; + Some(d) + } + + /// Run a command by id with JSON params. Returns a JSON result. + pub fn execute(&mut self, id: &str, params: Value) -> Result { + let spec = commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; + if let Err(why) = (spec.enabled)(self) { + return Err(EngineError::Disabled(id.to_string(), why)); + } + let r = (spec.run)(self, &commands::inject_kind(id, params.clone()))?; + if spec.journal { + self.journal.push((id.to_string(), params)); + } + Ok(r) + } + + /// Is the command currently runnable? (drives menu enablement) + pub fn is_enabled(&self, id: &str) -> bool { + commands::find(id).is_some_and(|s| (s.enabled)(self).is_ok()) + } + + /// Apply an undoable edit to the active document. + pub fn edit(&mut self, label: &str, f: impl FnOnce(&mut Document, &mut Option) -> Result) -> Result { + let st = self.active_mut().ok_or(EngineError::NoDocument)?; + let before = st.doc.clone(); + let mut doc = (*before).clone(); + let mut active = st.active_layer; + let r = f(&mut doc, &mut active)?; + st.doc = Arc::new(doc); + st.active_layer = active; + st.history.record(label, before); + st.revision += 1; + st.last_damage = None; + Ok(r) + } + + /// Replace the active document's active layer without creating a history step. + pub fn select_layer(&mut self, id: LayerId) -> Result<()> { + let st = self.active_mut().ok_or(EngineError::NoDocument)?; + if st.doc.layer(id).is_none() { + return Err(EngineError::NoLayer(id)); + } + st.active_layer = Some(id); + st.revision += 1; + st.last_damage = Some(photocraft_geom::Rect::EMPTY); + Ok(()) + } + + pub fn undo(&mut self) -> bool { + let Some(st) = self.active_mut() else { return false }; + match st.history.undo(st.doc.clone()) { + Some(d) => { + st.doc = d; + fix_active(st); + st.revision += 1; + st.last_damage = None; + true + } + None => false, + } + } + + pub fn redo(&mut self) -> bool { + let Some(st) = self.active_mut() else { return false }; + match st.history.redo(st.doc.clone()) { + Some(d) => { + st.doc = d; + fix_active(st); + st.revision += 1; + st.last_damage = None; + true + } + None => false, + } + } +} + +fn fix_active(st: &mut DocState) { + if st.active_layer.is_none_or(|id| st.doc.layer(id).is_none()) { + st.active_layer = st.doc.top_layer(); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/pixels.rs b/crates/engine/src/pixels.rs new file mode 100644 index 00000000..2402dd07 --- /dev/null +++ b/crates/engine/src/pixels.rs @@ -0,0 +1,131 @@ +//! Destructive pixel operations on layers (Image → Adjustments, flips, fills). + +use photocraft_compose::{Buffer, adjust}; +use photocraft_doc::{Adjustment, Document, Layer, LayerContent}; +use photocraft_geom::Rect; +use photocraft_raster::{Surface, from_rgba, to_rgba}; + +/// Apply an adjustment destructively to a surface, weighted by an optional selection. +pub fn adjust_surface(s: &mut Surface, adj: &Adjustment, selection: Option<&Surface>) { + let r = s.content_bounds(); + if r.is_empty() { + return; + } + let fmt = s.format(); + let n = fmt.channels(); + let raw = s.read_region(r); + let mut buf = Buffer { rect: r, px: raw.chunks_exact(n).map(|p| to_rgba(&fmt, p)).collect() }; + let orig = buf.clone(); + adjust::apply(adj, &mut buf); + let w = r.width() as usize; + let mut out = Vec::with_capacity(raw.len()); + for (i, (a, o)) in buf.px.iter().zip(&orig.px).enumerate() { + let k = selection.map_or(1.0, |sel| sel.pixel(r.x0 + (i % w) as i32, r.y0 + (i / w) as i32)[0]); + let mixed: [f32; 4] = std::array::from_fn(|c| o[c] + (a[c] - o[c]) * k); + out.extend(from_rgba(&fmt, mixed)); + } + s.write_region(r, &out); +} + +/// Fill (respecting selection coverage) with a straight RGBA colour. +pub fn fill_surface(s: &mut Surface, area: Rect, color: [f32; 4], selection: Option<&Surface>, lock_transparency: bool) { + let fmt = s.format(); + let n = fmt.channels(); + let mut region = s.read_region(area); + let w = area.width() as usize; + for (i, px) in region.chunks_exact_mut(n).enumerate() { + let x = area.x0 + (i % w) as i32; + let y = area.y0 + (i / w) as i32; + let k = selection.map_or(1.0, |sel| sel.pixel(x, y)[0]); + if k <= 0.0 { + continue; + } + let d = to_rgba(&fmt, px); + let sa = color[3] * k; + let oa = sa + d[3] * (1.0 - sa); + let mut o = [0.0f32; 4]; + if oa > 0.0 { + for c in 0..3 { + o[c] = (color[c] * sa + d[c] * d[3] * (1.0 - sa)) / oa; + } + } + o[3] = if lock_transparency { d[3] } else { oa }; + px.copy_from_slice(&from_rgba(&fmt, o)[..n]); + } + s.write_region(area, ®ion); +} + +/// Clear pixels (make transparent) within the selection. +pub fn clear_surface(s: &mut Surface, area: Rect, selection: Option<&Surface>) { + let fmt = s.format(); + let n = fmt.channels(); + if !fmt.alpha { + return; + } + let mut region = s.read_region(area); + let w = area.width() as usize; + for (i, px) in region.chunks_exact_mut(n).enumerate() { + let k = selection.map_or(1.0, |sel| sel.pixel(area.x0 + (i % w) as i32, area.y0 + (i / w) as i32)[0]); + px[n - 1] *= 1.0 - k; + } + s.write_region(area, ®ion); + s.prune(); +} + +/// Remap a surface through a pixel-coordinate mapping (used for flips/rotations). +pub fn remap_surface(s: &Surface, map: impl Fn(i32, i32) -> (i32, i32)) -> Surface { + let r = s.content_bounds(); + let mut out = Surface::with_default(s.format(), &s.default_pixel()); + let n = s.channels(); + let raw = s.read_region(r); + let w = r.width() as usize; + for (i, px) in raw.chunks_exact(n).enumerate() { + let (x, y) = (r.x0 + (i % w) as i32, r.y0 + (i / w) as i32); + let (nx, ny) = map(x, y); + out.write_pixel(nx, ny, px); + } + out +} + +/// Apply a coordinate mapping to every raster surface and mask in the document. +pub fn remap_document(doc: &mut Document, map: &dyn Fn(i32, i32) -> (i32, i32)) { + fn rec(layers: &mut [Layer], map: &dyn Fn(i32, i32) -> (i32, i32)) { + for l in layers { + if let Some(m) = &mut l.mask { + m.surface = remap_surface(&m.surface, map); + } + match &mut l.content { + LayerContent::Raster(s) => *s = remap_surface(s, map), + LayerContent::Group(g) => rec(&mut g.children, map), + _ => {} + } + } + } + rec(&mut doc.layers, map); + if let Some(sel) = &doc.selection { + doc.selection = Some(remap_surface(sel, map)); + } +} + +/// Merge `upper` onto `lower` producing a raster layer (Layer → Merge Down). +pub fn merge_down(doc_bounds: Rect, lower: &Layer, upper: &Layer, format: photocraft_color::PixelFormat) -> Layer { + let area = [lower, upper] + .iter() + .map(|l| l.surface().map(|s| s.content_bounds()).unwrap_or(doc_bounds)) + .fold(Rect::EMPTY, |a, b| a.union(&b)); + let stack = vec![lower.clone(), upper.clone()]; + let mut tmp = Document::new("merge", doc_bounds.size(), format.mode, format.sample); + tmp.layers = stack; + let buf = photocraft_compose::render(&tmp, area); + let mut s = Surface::new(format); + let data: Vec = buf.px.iter().flat_map(|p| from_rgba(&format, *p)).collect(); + if !area.is_empty() { + s.write_region(area, &data); + s.prune(); + } + let mut merged = Layer::new(lower.name.clone(), LayerContent::Raster(s)); + merged.id = lower.id; + merged.blend = lower.blend; + merged.opacity = 1.0; + merged +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs new file mode 100644 index 00000000..d1ed9941 --- /dev/null +++ b/crates/engine/src/tests.rs @@ -0,0 +1,313 @@ +use super::*; +use serde_json::json; + +fn session_with_doc() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s +} + +fn px(s: &mut Session, x: i32, y: i32) -> Vec { + serde_json::from_value(s.execute("document.pixel", json!({"x": x, "y": y})).unwrap()).unwrap() +} + +#[test] +fn command_ids_are_unique_and_documented() { + let mut seen = std::collections::HashSet::new(); + for c in command_specs() { + assert!(seen.insert(c.id), "duplicate command id {}", c.id); + assert!(!c.label.is_empty()); + assert!(c.id.contains('.'), "{} should be namespaced", c.id); + } + assert!(command_specs().len() > 60, "{}", command_specs().len()); +} + +#[test] +fn unknown_and_disabled_commands_error() { + let mut s = Session::new(); + assert!(matches!(s.execute("nope.nothing", json!({})), Err(EngineError::UnknownCommand(_)))); + assert!(matches!(s.execute("layer.new.layer", json!({})), Err(EngineError::Disabled(..)))); + assert!(!s.is_enabled("edit.undo")); +} + +#[test] +fn file_new_variants() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 10, "height": 5, "background": "transparent"})).unwrap(); + assert_eq!(s.active().unwrap().doc.layers[0].name, "Layer 1"); + s.execute("file.new", json!({"width": 10, "height": 5, "mode": "cmyk", "depth": 16})).unwrap(); + let d = &s.active().unwrap().doc; + assert_eq!(d.mode, photocraft_color::ColorMode::Cmyk); + assert_eq!(d.depth, photocraft_color::SampleType::U16); + s.execute("file.new", json!({"background": "#ff0000", "width": 4, "height": 4})).unwrap(); + assert_eq!(px(&mut s, 1, 1), vec![1.0, 0.0, 0.0, 1.0]); + assert_eq!(s.documents().len(), 3); + s.execute("file.close", json!({})).unwrap(); + assert_eq!(s.documents().len(), 2); +} + +#[test] +fn layer_lifecycle_with_undo() { + let mut s = session_with_doc(); + let r = s.execute("layer.new.layer", json!({})).unwrap(); + let id = r["layer"].as_u64().unwrap(); + assert_eq!(s.active().unwrap().active_layer, Some(LayerId(id))); + s.execute("layer.setProps", json!({"name": "Ink", "opacity": 0.5, "blend": "multiply"})).unwrap(); + let doc = &s.active().unwrap().doc; + let l = doc.layer(LayerId(id)).unwrap(); + assert_eq!((l.name.as_str(), l.opacity, l.blend), ("Ink", 0.5, photocraft_color::BlendMode::Multiply)); + s.execute("layer.duplicate", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.layer_count(), 3); + s.execute("layer.delete", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.layer_count(), 2); + for _ in 0..4 { + s.execute("edit.undo", json!({})).unwrap(); + } + assert_eq!(s.active().unwrap().doc.layer_count(), 1); + assert!(!s.is_enabled("edit.undo")); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.layer_count(), 2); +} + +#[test] +fn bad_blend_mode_is_a_param_error() { + let mut s = session_with_doc(); + let e = s.execute("layer.setProps", json!({"blend": "sparkle"})).unwrap_err(); + assert!(matches!(e, EngineError::BadParams { .. }), "{e}"); +} + +#[test] +fn adjustment_layers_change_composite() { + let mut s = session_with_doc(); + s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap(); + assert_eq!(px(&mut s, 3, 3), vec![0.0, 0.0, 0.0, 1.0]); + let doc = s.execute("document.inspect", json!({})).unwrap(); + assert_eq!(doc["layers"][0]["kind"], "Adjustment"); + assert_eq!(doc["layers"][0]["name"], "Invert 1"); + s.execute("layer.setProps", json!({"visible": false})).unwrap(); + assert_eq!(px(&mut s, 3, 3), vec![1.0, 1.0, 1.0, 1.0]); +} + +#[test] +fn every_adjustment_command_runs() { + let mut s = session_with_doc(); + let ids: Vec<&str> = command_specs().iter().map(|c| c.id).filter(|id| id.starts_with("layer.newAdjustmentLayer.") || id.starts_with("image.adjustments.")).collect(); + assert!(ids.len() >= 28); + for id in ids { + // re-select the background for destructive ones + let bg = s.active().unwrap().doc.layers[0].id; + s.select_layer(bg).unwrap(); + s.execute(id, json!({})).unwrap_or_else(|e| panic!("{id}: {e}")); + } +} + +#[test] +fn threshold_and_hue_params() { + let mut s = session_with_doc(); + s.execute("edit.fill", json!({"color": "#404040"})).unwrap(); + s.execute("layer.newAdjustmentLayer.threshold", json!({"level": 128})).unwrap(); + assert_eq!(px(&mut s, 0, 0), vec![0.0, 0.0, 0.0, 1.0]); + s.execute("layer.setAdjustment", json!({"level": 10})).unwrap(); + assert_eq!(px(&mut s, 0, 0), vec![1.0, 1.0, 1.0, 1.0]); +} + +#[test] +fn paint_stroke_and_selection() { + let mut s = session_with_doc(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 32, "height": 48})).unwrap(); + let r = s.execute("paint.stroke", json!({"points": [[4, 20], [60, 20]], "size": 6, "color": "#0000ff"})).unwrap(); + assert!(r["damage"][2].as_i64().unwrap() > 0); + assert_eq!(px(&mut s, 10, 20), vec![0.0, 0.0, 1.0, 1.0]); + assert_eq!(px(&mut s, 50, 20), vec![1.0, 1.0, 1.0, 1.0], "outside selection stays white"); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("paint.stroke", json!({"points": [[4, 30], [60, 30]], "size": 6, "erase": false})).unwrap(); + assert_eq!(px(&mut s, 50, 30), vec![0.0, 0.0, 0.0, 1.0], "foreground is black"); +} + +#[test] +fn selection_modes() { + let mut s = session_with_doc(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + s.execute("select.rect", json!({"x": 20, "y": 0, "width": 10, "height": 10, "mode": "add"})).unwrap(); + let b = s.execute("document.inspect", json!({})).unwrap()["selectionBounds"].clone(); + assert_eq!(b, json!([0, 0, 30, 10])); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 10, "mode": "subtract"})).unwrap(); + assert_eq!(s.execute("document.inspect", json!({})).unwrap()["selectionBounds"], json!([20, 0, 10, 10])); + s.execute("select.inverse", json!({})).unwrap(); + s.execute("select.all", json!({})).unwrap(); + assert_eq!(s.execute("document.inspect", json!({})).unwrap()["selectionBounds"], json!([0, 0, 64, 48])); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20, "ellipse": true})).unwrap(); +} + +#[test] +fn fill_clear_and_masks() { + let mut s = session_with_doc(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap(); + assert_eq!(px(&mut s, 5, 5), vec![0.0, 1.0, 0.0, 1.0]); + s.execute("layer.layerMask.hideAll", json!({})).unwrap(); + assert_eq!(px(&mut s, 5, 5), vec![1.0, 1.0, 1.0, 1.0]); + s.execute("layer.layerMask.delete", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + s.execute("edit.clear", json!({})).unwrap(); + assert_eq!(px(&mut s, 5, 5), vec![1.0, 1.0, 1.0, 1.0]); + assert_eq!(px(&mut s, 20, 20), vec![0.0, 1.0, 0.0, 1.0]); +} + +#[test] +fn clipping_and_merge_and_flatten() { + let mut s = session_with_doc(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 48})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + s.execute("layer.createClippingMask", json!({})).unwrap(); + assert_eq!(px(&mut s, 5, 5), vec![0.0, 0.0, 1.0, 1.0]); + assert_eq!(px(&mut s, 30, 5), vec![1.0, 1.0, 1.0, 1.0]); + s.execute("layer.mergeDown", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.layer_count(), 2); + assert_eq!(px(&mut s, 5, 5), vec![0.0, 0.0, 1.0, 1.0]); + assert_eq!(px(&mut s, 30, 5), vec![1.0, 1.0, 1.0, 1.0]); + s.execute("layer.flattenImage", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.layer_count(), 1); + assert_eq!(px(&mut s, 5, 5), vec![0.0, 0.0, 1.0, 1.0]); +} + +#[test] +fn arrange_and_group() { + let mut s = session_with_doc(); + let a = s.execute("layer.new.layer", json!({"name": "A"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.new.layer", json!({"name": "B"})).unwrap(); + s.execute("layer.select", json!({"layer": a})).unwrap(); + s.execute("layer.arrange.bringToFront", json!({})).unwrap(); + let names: Vec = s.active().unwrap().doc.layers.iter().map(|l| l.name.clone()).collect(); + assert_eq!(names, ["Background", "B", "A"]); + assert!(s.execute("layer.arrange.bringForward", json!({})).is_err()); + s.execute("layer.groupLayers", json!({})).unwrap(); + let doc = s.execute("document.inspect", json!({})).unwrap(); + assert_eq!(doc["layers"][0]["kind"], "Group"); + assert_eq!(doc["layers"][0]["children"][0]["name"], "A"); +} + +#[test] +fn canvas_flips_and_rotation() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 10, "height": 4})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 2, "height": 1})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("image.imageRotation.flipCanvasHorizontal", json!({})).unwrap(); + assert_eq!(px(&mut s, 9, 0), vec![1.0, 0.0, 0.0, 1.0]); + assert_eq!(px(&mut s, 0, 0), vec![1.0, 1.0, 1.0, 1.0]); + s.execute("image.imageRotation.flipCanvasVertical", json!({})).unwrap(); + assert_eq!(px(&mut s, 9, 3), vec![1.0, 0.0, 0.0, 1.0]); + s.execute("image.imageRotation.90cw", json!({})).unwrap(); + let d = &s.active().unwrap().doc; + assert_eq!((d.size.width, d.size.height), (4, 10)); + assert_eq!(px(&mut s, 0, 9), vec![1.0, 0.0, 0.0, 1.0]); + s.execute("image.imageRotation.90ccw", json!({})).unwrap(); + s.execute("image.imageRotation.180", json!({})).unwrap(); + assert_eq!(px(&mut s, 0, 0), vec![1.0, 0.0, 0.0, 1.0]); +} + +#[test] +fn fill_layers_and_colors() { + let mut s = session_with_doc(); + s.execute("tools.setColors", json!({"foreground": "#112233"})).unwrap(); + s.execute("tools.swapColors", json!({})).unwrap(); + assert_eq!(s.tools.foreground, [1.0, 1.0, 1.0, 1.0]); + s.execute("tools.defaultColors", json!({})).unwrap(); + s.execute("layer.newFillLayer.solidColor", json!({"color": "#ff00ff"})).unwrap(); + assert_eq!(px(&mut s, 1, 1), vec![1.0, 0.0, 1.0, 1.0]); + s.execute("layer.newFillLayer.gradient", json!({"from": "#000000", "to": "#ffffff", "angle": 0})).unwrap(); + let l = px(&mut s, 0, 10)[0]; + let r = px(&mut s, 63, 10)[0]; + assert!(l < r); +} + +#[test] +fn journal_records_mutations_only() { + let mut s = session_with_doc(); + s.execute("document.inspect", json!({})).unwrap(); + s.execute("layer.new.layer", json!({"name": "x"})).unwrap(); + let ids: Vec<&str> = s.journal.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(ids, ["file.new", "layer.new.layer"]); + // replaying the journal reproduces the document + let mut replay = Session::new(); + for (id, p) in s.journal.clone() { + replay.execute(&id, p).unwrap(); + } + assert_eq!(replay.active().unwrap().doc.layer_count(), s.active().unwrap().doc.layer_count()); +} + +#[test] +fn command_list_reports_enablement() { + let mut s = Session::new(); + let list = s.execute("command.list", json!({})).unwrap(); + let find = |id: &str| list.as_array().unwrap().iter().find(|c| c["id"] == id).unwrap().clone(); + assert_eq!(find("file.new")["enabled"], true); + assert_eq!(find("layer.new.layer")["enabled"], false); +} + +#[test] +fn translate_moves_pixels_and_respects_locks() { + let mut s = session_with_doc(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 4, "height": 4})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("layer.translate", json!({"dx": 10, "dy": 5})).unwrap(); + assert_eq!(px(&mut s, 11, 6), vec![1.0, 0.0, 0.0, 1.0]); + assert_eq!(px(&mut s, 1, 1), vec![1.0, 1.0, 1.0, 1.0]); + // Background is position-locked + let bg = s.active().unwrap().doc.layers[0].id; + s.select_layer(bg).unwrap(); + assert!(s.execute("layer.translate", json!({"dx": 1, "dy": 0})).is_err()); +} + +#[test] +fn damage_is_reported_for_strokes_only() { + let mut s = session_with_doc(); + s.execute("layer.new.layer", json!({})).unwrap(); + assert_eq!(s.active().unwrap().last_damage, None); + s.execute("paint.stroke", json!({"points": [[10, 10], [20, 10]], "size": 4})).unwrap(); + let d = s.active().unwrap().last_damage.unwrap(); + assert!(d.contains(15, 10) && d.width() < 30); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().last_damage, None); +} + +#[test] +fn integer_params_accept_json_floats() { + // UIs send coordinates as floats (e.g. 12.0); every integer parameter must accept them. + let mut s = session_with_doc(); + s.execute("select.rect", json!({"x": 1.0, "y": 2.0, "width": 10.0, "height": 5.4})).unwrap(); + assert_eq!(s.execute("document.inspect", json!({})).unwrap()["selectionBounds"], json!([1, 2, 10, 5])); + let px: Vec = serde_json::from_value(s.execute("document.pixel", json!({"x": 3.0, "y": 3.0})).unwrap()).unwrap(); + assert_eq!(px, vec![1.0, 1.0, 1.0, 1.0]); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("layer.translate", json!({"dx": 2.0, "dy": -1.0})).unwrap(); +} + +#[test] +fn move_to_reorders_and_nests() { + let mut s = session_with_doc(); + let a = s.execute("layer.new.layer", json!({"name": "A"})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.new.layer", json!({"name": "B"})).unwrap()["layer"].as_u64().unwrap(); + let g = s.execute("layer.new.group", json!({"name": "G"})).unwrap()["layer"].as_u64().unwrap(); + let names = |s: &Session| s.active().unwrap().doc.layers.iter().map(|l| l.name.clone()).collect::>(); + // bottom->top: Background, A, B, G ; move A above B + s.execute("layer.moveTo", json!({"layer": a, "target": b, "position": "above"})).unwrap(); + assert_eq!(names(&s), ["Background", "B", "A", "G"]); + s.execute("layer.moveTo", json!({"layer": a, "target": g, "position": "into"})).unwrap(); + assert_eq!(names(&s), ["Background", "B", "G"]); + assert_eq!(s.execute("document.inspect", json!({})).unwrap()["layers"][0]["children"][0]["name"], "A"); + // groups can't go into themselves + assert!(s.execute("layer.moveTo", json!({"layer": g, "target": a, "position": "into"})).is_err()); + // one undo step + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(names(&s), ["Background", "B", "A", "G"]); +} diff --git a/crates/geom/Cargo.toml b/crates/geom/Cargo.toml new file mode 100644 index 00000000..2c3541d3 --- /dev/null +++ b/crates/geom/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "photocraft-geom" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true } diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs new file mode 100644 index 00000000..e26ed3c6 --- /dev/null +++ b/crates/geom/src/lib.rs @@ -0,0 +1,249 @@ +//! Geometry primitives shared by every Photocraft crate. +//! +//! Document space is integer pixels with the origin at the canvas top-left. +//! Layers may extend beyond the canvas (negative coordinates are valid). +#![forbid(unsafe_code)] + +use serde::{Deserialize, Serialize}; + +/// Edge length of a raster tile in pixels. +pub const TILE_SIZE: i32 = 256; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct Size { + pub width: u32, + pub height: u32, +} + +impl Size { + pub const fn new(width: u32, height: u32) -> Self { + Self { width, height } + } + pub fn area(self) -> u64 { + self.width as u64 * self.height as u64 + } + pub fn is_empty(self) -> bool { + self.width == 0 || self.height == 0 + } +} + +/// Integer rectangle, half-open: `[x0, x1) × [y0, y1)`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct Rect { + pub x0: i32, + pub y0: i32, + pub x1: i32, + pub y1: i32, +} + +impl Rect { + pub const EMPTY: Rect = Rect { x0: 0, y0: 0, x1: 0, y1: 0 }; + + pub const fn new(x0: i32, y0: i32, x1: i32, y1: i32) -> Self { + Self { x0, y0, x1, y1 } + } + pub fn from_xywh(x: i32, y: i32, w: u32, h: u32) -> Self { + Self::new(x, y, x.saturating_add(w as i32), y.saturating_add(h as i32)) + } + pub fn from_size(size: Size) -> Self { + Self::from_xywh(0, 0, size.width, size.height) + } + pub fn width(&self) -> u32 { + (self.x1 - self.x0).max(0) as u32 + } + pub fn height(&self) -> u32 { + (self.y1 - self.y0).max(0) as u32 + } + pub fn size(&self) -> Size { + Size::new(self.width(), self.height()) + } + pub fn is_empty(&self) -> bool { + self.x1 <= self.x0 || self.y1 <= self.y0 + } + pub fn contains(&self, x: i32, y: i32) -> bool { + x >= self.x0 && x < self.x1 && y >= self.y0 && y < self.y1 + } + pub fn intersect(&self, o: &Rect) -> Rect { + let r = Rect::new(self.x0.max(o.x0), self.y0.max(o.y0), self.x1.min(o.x1), self.y1.min(o.y1)); + if r.is_empty() { Rect::EMPTY } else { r } + } + pub fn union(&self, o: &Rect) -> Rect { + if self.is_empty() { + return *o; + } + if o.is_empty() { + return *self; + } + Rect::new(self.x0.min(o.x0), self.y0.min(o.y0), self.x1.max(o.x1), self.y1.max(o.y1)) + } + pub fn translate(&self, dx: i32, dy: i32) -> Rect { + Rect::new(self.x0 + dx, self.y0 + dy, self.x1 + dx, self.y1 + dy) + } + pub fn inflate(&self, d: i32) -> Rect { + Rect::new(self.x0 - d, self.y0 - d, self.x1 + d, self.y1 + d) + } + /// All tiles overlapping this rect. + pub fn tiles(&self) -> impl Iterator + use<> { + let (a, b) = if self.is_empty() { + (TileCoord::new(0, 0), TileCoord::new(-1, -1)) + } else { + (TileCoord::containing(self.x0, self.y0), TileCoord::containing(self.x1 - 1, self.y1 - 1)) + }; + (a.ty..=b.ty).flat_map(move |ty| (a.tx..=b.tx).map(move |tx| TileCoord::new(tx, ty))) + } +} + +/// Index of a tile in the infinite tile grid. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct TileCoord { + pub tx: i32, + pub ty: i32, +} + +impl TileCoord { + pub const fn new(tx: i32, ty: i32) -> Self { + Self { tx, ty } + } + pub fn containing(x: i32, y: i32) -> Self { + Self::new(x.div_euclid(TILE_SIZE), y.div_euclid(TILE_SIZE)) + } + pub fn rect(&self) -> Rect { + Rect::from_xywh(self.tx * TILE_SIZE, self.ty * TILE_SIZE, TILE_SIZE as u32, TILE_SIZE as u32) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Point { + pub x: f64, + pub y: f64, +} + +impl Point { + pub const fn new(x: f64, y: f64) -> Self { + Self { x, y } + } +} + +/// 2D affine transform `[a c e; b d f; 0 0 1]` (column vectors). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Affine { + pub m: [f64; 6], +} + +impl Default for Affine { + fn default() -> Self { + Self::IDENTITY + } +} + +impl Affine { + pub const IDENTITY: Affine = Affine { m: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0] }; + + pub fn translate(dx: f64, dy: f64) -> Self { + Affine { m: [1.0, 0.0, 0.0, 1.0, dx, dy] } + } + pub fn scale(s: f64) -> Self { + Affine { m: [s, 0.0, 0.0, s, 0.0, 0.0] } + } + pub fn rotate(radians: f64) -> Self { + let (s, c) = radians.sin_cos(); + Affine { m: [c, s, -s, c, 0.0, 0.0] } + } + /// `self * other`: apply `other` first, then `self`. + pub fn then(&self, other: &Affine) -> Affine { + other.mul(self) + } + pub fn mul(&self, o: &Affine) -> Affine { + let [a, b, c, d, e, f] = self.m; + let [oa, ob, oc, od, oe, of] = o.m; + Affine { + m: [ + a * oa + c * ob, + b * oa + d * ob, + a * oc + c * od, + b * oc + d * od, + a * oe + c * of + e, + b * oe + d * of + f, + ], + } + } + pub fn apply(&self, p: Point) -> Point { + let [a, b, c, d, e, f] = self.m; + Point::new(a * p.x + c * p.y + e, b * p.x + d * p.y + f) + } + pub fn determinant(&self) -> f64 { + self.m[0] * self.m[3] - self.m[1] * self.m[2] + } + pub fn inverse(&self) -> Option { + let det = self.determinant(); + if det.abs() < 1e-12 { + return None; + } + let [a, b, c, d, e, f] = self.m; + let inv = 1.0 / det; + Some(Affine { + m: [d * inv, -b * inv, -c * inv, a * inv, (c * f - d * e) * inv, (b * e - a * f) * inv], + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rect_basics() { + let r = Rect::from_xywh(10, 20, 30, 40); + assert_eq!(r.width(), 30); + assert_eq!(r.height(), 40); + assert!(r.contains(10, 20)); + assert!(!r.contains(40, 20)); + assert!(Rect::new(5, 5, 5, 10).is_empty()); + } + + #[test] + fn intersect_and_union() { + let a = Rect::new(0, 0, 10, 10); + let b = Rect::new(5, 5, 15, 15); + assert_eq!(a.intersect(&b), Rect::new(5, 5, 10, 10)); + assert_eq!(a.union(&b), Rect::new(0, 0, 15, 15)); + assert_eq!(a.intersect(&Rect::new(20, 20, 30, 30)), Rect::EMPTY); + assert_eq!(Rect::EMPTY.union(&a), a); + } + + #[test] + fn tile_coords_handle_negatives() { + assert_eq!(TileCoord::containing(0, 0), TileCoord::new(0, 0)); + assert_eq!(TileCoord::containing(255, 255), TileCoord::new(0, 0)); + assert_eq!(TileCoord::containing(256, 0), TileCoord::new(1, 0)); + assert_eq!(TileCoord::containing(-1, -1), TileCoord::new(-1, -1)); + assert_eq!(TileCoord::containing(-256, -257), TileCoord::new(-1, -2)); + } + + #[test] + fn rect_tiles_enumeration() { + let r = Rect::new(-10, 0, 300, 10); + let t: Vec<_> = r.tiles().collect(); + assert_eq!(t, vec![TileCoord::new(-1, 0), TileCoord::new(0, 0), TileCoord::new(1, 0)]); + assert_eq!(Rect::EMPTY.tiles().count(), 0); + assert_eq!(Rect::new(0, 0, 256, 256).tiles().count(), 1); + assert_eq!(Rect::new(0, 0, 257, 257).tiles().count(), 4); + } + + #[test] + fn affine_inverse_roundtrip() { + let t = Affine::translate(5.0, -3.0).then(&Affine::rotate(0.7)).then(&Affine::scale(2.5)); + let inv = t.inverse().unwrap(); + let p = Point::new(12.0, -7.5); + let q = inv.apply(t.apply(p)); + assert!((q.x - p.x).abs() < 1e-9 && (q.y - p.y).abs() < 1e-9); + assert!(Affine::scale(0.0).inverse().is_none()); + } + + #[test] + fn affine_then_order() { + let t = Affine::translate(10.0, 0.0).then(&Affine::scale(2.0)); + // translate first, then scale + assert_eq!(t.apply(Point::new(1.0, 0.0)), Point::new(22.0, 0.0)); + } +} diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml new file mode 100644 index 00000000..82d7f76c --- /dev/null +++ b/crates/io/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "photocraft-io" +description = "Import/export between photocraft documents and PSD/PSB and flat raster formats" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +photocraft-geom = { workspace = true } +photocraft-color = { workspace = true } +photocraft-raster = { workspace = true } +photocraft-doc = { workspace = true } +photocraft-compose = { workspace = true } +photocraft-psd = { workspace = true } +photocraft-codecs = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +photocraft-psd = { workspace = true, features = ["testgen"] } +proptest = { workspace = true } diff --git a/crates/io/src/adjust_map.rs b/crates/io/src/adjust_map.rs new file mode 100644 index 00000000..5c71beac --- /dev/null +++ b/crates/io/src/adjust_map.rs @@ -0,0 +1,367 @@ +//! Mapping between PSD adjustment-layer blocks and [`Adjustment`]. +//! +//! Binary layouts follow the Adobe spec ("Adjustment layer" section). The +//! mapped subset keeps the master/composite parameters; other channels of +//! Levels/Curves/Hue-Sat are written back with defaults. + +use photocraft_doc::Adjustment; +use photocraft_doc::adjust::{CurvePoint, LevelsChannel}; +use photocraft_psd::descriptor::{Descriptor, Value, VersionedDescriptor}; + +/// All PSD adjustment keys recognized as adjustment layers. +pub const ADJUSTMENT_KEYS: [&[u8; 4]; 16] = [ + b"levl", b"curv", b"hue2", b"brit", b"nvrt", b"thrs", b"post", b"expA", b"vibA", b"blnc", b"mixr", b"grdm", b"phfl", + b"selc", b"blwh", b"clrL", +]; + +fn be16(d: &[u8], at: usize) -> Option { + d.get(at..at + 2).map(|b| u16::from_be_bytes([b[0], b[1]])) +} +fn bei16(d: &[u8], at: usize) -> Option { + be16(d, at).map(|v| v as i16) +} +fn bef32(d: &[u8], at: usize) -> Option { + d.get(at..at + 4).map(|b| f32::from_be_bytes([b[0], b[1], b[2], b[3]])) +} + +fn unsupported(key: &[u8; 4], data: &[u8]) -> Adjustment { + Adjustment::Unsupported { psd_key: String::from_utf8_lossy(key).into_owned(), raw: data.to_vec() } +} + +fn levels_rec(d: &[u8], at: usize) -> Option { + Some(LevelsChannel { + in_black: f32::from(be16(d, at)?) / 255.0, + in_white: f32::from(be16(d, at + 2)?) / 255.0, + out_black: f32::from(be16(d, at + 4)?) / 255.0, + out_white: f32::from(be16(d, at + 6)?) / 255.0, + gamma: f32::from(be16(d, at + 8)?) / 100.0, + }) +} + +fn parse_curves(d: &[u8]) -> Option { + // pad(1) version(2) bitmap(4) + let version = be16(d, 1)?; + if version != 1 && version != 4 { + return None; + } + let bits = u32::from_be_bytes(d.get(3..7)?.try_into().ok()?); + let mut at = 7; + let line = || vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }]; + let mut curves: Vec> = vec![line(), line(), line(), line()]; + for bit in 0..32usize { + if bits & (1 << bit) == 0 { + continue; + } + let n = usize::from(be16(d, at)?); + at += 2; + let mut pts = Vec::with_capacity(n.min(64)); + for _ in 0..n { + let out = be16(d, at)?; + let inp = be16(d, at + 2)?; + at += 4; + pts.push(CurvePoint { input: f32::from(inp) / 255.0, output: f32::from(out) / 255.0 }); + } + if let Some(slot) = curves.get_mut(bit) { + *slot = pts; + } + } + let mut it = curves.into_iter(); + let master = it.next()?; + let (r, g, b) = (it.next()?, it.next()?, it.next()?); + Some(Adjustment::Curves { master, per_channel: [r, g, b] }) +} + +fn desc_num(d: &Descriptor, key: &str) -> Option { + match d.get(key)? { + Value::Integer(v) => Some(*v as f32), + Value::Double(v) => Some(*v as f32), + Value::UnitFloat { value, .. } => Some(*value as f32), + _ => None, + } +} + +fn desc_bool(d: &Descriptor, key: &str) -> Option { + match d.get(key)? { + Value::Boolean(b) => Some(*b), + _ => None, + } +} + +/// Channel interpretation of per-channel Levels/Curves records. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Channels { + /// Records 1..=3 are R, G, B. + Rgb, + /// Record 1 is the gray channel (applied to all three display channels). + Gray, + /// Channel records do not map to display RGB (CMYK, Lab); ignored. + Other, +} + +/// Parses an adjustment block. `cged` is the optional `CgEd` block data +/// (modern brightness/contrast parameters). Levels/Curves store the +/// composite record first, then one per document channel (see [`Channels`]). +pub fn parse(key: &[u8; 4], data: &[u8], cged: Option<&[u8]>, channels: Channels) -> Adjustment { + let mut a = parse_any(key, data, cged); + match (&mut a, channels) { + (_, Channels::Rgb) => {} + (Adjustment::Levels { per_channel, .. }, Channels::Gray) => { + let g = per_channel[0].clone(); + *per_channel = [g.clone(), g.clone(), g]; + } + (Adjustment::Curves { per_channel, .. }, Channels::Gray) => { + let g = per_channel[0].clone(); + *per_channel = [g.clone(), g.clone(), g]; + } + (Adjustment::Levels { per_channel, .. }, Channels::Other) => *per_channel = Default::default(), + (Adjustment::Curves { per_channel, .. }, Channels::Other) => { + let line = || vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }]; + *per_channel = [line(), line(), line()]; + } + _ => {} + } + a +} + +fn parse_any(key: &[u8; 4], data: &[u8], cged: Option<&[u8]>) -> Adjustment { + let parsed = match key { + b"nvrt" => Some(Adjustment::Invert), + b"thrs" => be16(data, 0).map(|v| Adjustment::Threshold { level: f32::from(v) / 255.0 }), + b"post" => be16(data, 0).map(|v| Adjustment::Posterize { levels: u32::from(v) }), + b"brit" => { + let modern = cged.and_then(|c| VersionedDescriptor::parse_prefix(c).ok()).and_then(|(v, _)| { + let d = v.descriptor; + Some(Adjustment::BrightnessContrast { + brightness: desc_num(&d, "Brgh")?, + contrast: desc_num(&d, "Cntr")?, + legacy: desc_bool(&d, "useLegacy").unwrap_or(false), + }) + }); + modern.or_else(|| { + Some(Adjustment::BrightnessContrast { + brightness: f32::from(bei16(data, 0)?), + contrast: f32::from(bei16(data, 2)?), + legacy: true, + }) + }) + } + b"hue2" => (|| { + let colorize = *data.get(2)? != 0; + let base = if colorize { 4 } else { 10 }; + Some(Adjustment::HueSaturation { + hue: f32::from(bei16(data, base)?), + saturation: f32::from(bei16(data, base + 2)?), + lightness: f32::from(bei16(data, base + 4)?), + colorize, + }) + })(), + b"expA" => (|| { + Some(Adjustment::Exposure { exposure: bef32(data, 2)?, offset: bef32(data, 6)?, gamma: bef32(data, 10)? }) + })(), + b"levl" => (|| { + let m = levels_rec(data, 2)?; + let r = levels_rec(data, 12)?; + let g = levels_rec(data, 22)?; + let b = levels_rec(data, 32)?; + Some(Adjustment::Levels { master: m, per_channel: [r, g, b] }) + })(), + b"curv" => parse_curves(data), + _ => None, + }; + parsed.unwrap_or_else(|| unsupported(key, data)) +} + +fn put16(v: &mut Vec, x: u16) { + v.extend_from_slice(&x.to_be_bytes()); +} +fn q255(v: f32) -> u16 { + (v.clamp(0.0, 1.0) * 255.0).round() as u16 +} +fn clamp_i16(v: f32) -> i16 { + v.round().clamp(-32768.0, 32767.0) as i16 +} + +fn levels_write(v: &mut Vec, c: &LevelsChannel) { + put16(v, q255(c.in_black)); + put16(v, q255(c.in_white)); + put16(v, q255(c.out_black)); + put16(v, q255(c.out_white)); + put16(v, (c.gamma * 100.0).round().clamp(1.0, 999.0) as u16); +} + +/// Serializes an adjustment into its PSD blocks: `(key, data)` pairs +/// (Brightness/Contrast also writes a `CgEd` descriptor). +pub fn write(adj: &Adjustment) -> Vec<([u8; 4], Vec)> { + let mut v = Vec::new(); + match adj { + Adjustment::Invert => return vec![(*b"nvrt", Vec::new())], + Adjustment::Threshold { level } => { + put16(&mut v, q255(*level).max(1)); + put16(&mut v, 0); + return vec![(*b"thrs", v)]; + } + Adjustment::Posterize { levels } => { + put16(&mut v, (*levels).clamp(2, 255) as u16); + put16(&mut v, 0); + return vec![(*b"post", v)]; + } + Adjustment::BrightnessContrast { brightness, contrast, legacy } => { + v.extend_from_slice(&clamp_i16(*brightness).to_be_bytes()); + v.extend_from_slice(&clamp_i16(*contrast).to_be_bytes()); + put16(&mut v, 127); + v.push(0); + let d = Descriptor::new("null") + .with("Vrsn", Value::Integer(1)) + .with("Brgh", Value::Integer(brightness.round() as i32)) + .with("Cntr", Value::Integer(contrast.round() as i32)) + .with("means", Value::Integer(127)) + .with("Lab ", Value::Boolean(false)) + .with("useLegacy", Value::Boolean(*legacy)) + .with("Auto", Value::Boolean(false)); + return vec![(*b"brit", v), (*b"CgEd", VersionedDescriptor::new(d).to_bytes())]; + } + Adjustment::HueSaturation { hue, saturation, lightness, colorize } => { + put16(&mut v, 2); + v.push(u8::from(*colorize)); + v.push(0); + for _ in 0..2 { + for x in [hue, saturation, lightness] { + v.extend_from_slice(&clamp_i16(*x).to_be_bytes()); + } + } + // Six default hue ranges (reds, yellows, greens, cyans, blues, magentas). + let ranges: [[u16; 4]; 6] = [ + [315, 345, 15, 45], + [15, 45, 75, 105], + [75, 105, 135, 165], + [135, 165, 195, 225], + [195, 225, 255, 285], + [255, 285, 315, 345], + ]; + for r in ranges { + for x in r { + put16(&mut v, x); + } + v.extend_from_slice(&[0; 6]); + } + return vec![(*b"hue2", v)]; + } + Adjustment::Exposure { exposure, offset, gamma } => { + put16(&mut v, 1); + for x in [exposure, offset, gamma] { + v.extend_from_slice(&x.to_be_bytes()); + } + v.push(1); // color space flag (spec: "1 byte") + return vec![(*b"expA", v)]; + } + Adjustment::Levels { master, per_channel } => { + put16(&mut v, 2); + levels_write(&mut v, master); + for c in per_channel { + levels_write(&mut v, c); + } + for _ in 4..29 { + levels_write(&mut v, &LevelsChannel::default()); + } + return vec![(*b"levl", v)]; + } + Adjustment::Curves { master, per_channel } => { + v.push(0); + put16(&mut v, 1); + v.extend_from_slice(&0b1111u32.to_be_bytes()); + for c in std::iter::once(master).chain(per_channel.iter()) { + put16(&mut v, c.len().min(19) as u16); + for p in c.iter().take(19) { + put16(&mut v, q255(p.output)); + put16(&mut v, q255(p.input)); + } + } + return vec![(*b"curv", v)]; + } + Adjustment::Unsupported { psd_key, raw } => { + let k = psd_key.as_bytes(); + if k.len() == 4 { + return vec![([k[0], k[1], k[2], k[3]], raw.clone())]; + } + } + _ => {} + } + Vec::new() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rt(a: Adjustment) { + let blocks = write(&a); + assert!(!blocks.is_empty(), "{a:?}"); + let cged = blocks.iter().find(|b| &b.0 == b"CgEd").map(|b| &b.1[..]); + let back = parse(&blocks[0].0, &blocks[0].1, cged, Channels::Rgb); + assert_eq!(back, a); + } + + #[test] + fn roundtrips() { + rt(Adjustment::Invert); + rt(Adjustment::Threshold { level: 128.0 / 255.0 }); + rt(Adjustment::Posterize { levels: 4 }); + rt(Adjustment::BrightnessContrast { brightness: 20.0, contrast: -10.0, legacy: false }); + rt(Adjustment::BrightnessContrast { brightness: -150.0, contrast: 100.0, legacy: true }); + rt(Adjustment::HueSaturation { hue: 30.0, saturation: -20.0, lightness: 5.0, colorize: false }); + rt(Adjustment::HueSaturation { hue: 200.0, saturation: 50.0, lightness: 0.0, colorize: true }); + rt(Adjustment::Exposure { exposure: 1.5, offset: -0.01, gamma: 0.9 }); + let lc = |a: u16, b: u16| LevelsChannel { + in_black: f32::from(a) / 255.0, + in_white: f32::from(b) / 255.0, + gamma: 1.2, + out_black: 0.0, + out_white: 1.0, + }; + rt(Adjustment::Levels { master: lc(10, 240), per_channel: [lc(0, 255), lc(5, 250), lc(20, 200)] }); + let pts = |v: &[(u8, u8)]| { + v.iter().map(|&(i, o)| CurvePoint { input: f32::from(i) / 255.0, output: f32::from(o) / 255.0 }).collect::>() + }; + rt(Adjustment::Curves { + master: pts(&[(0, 0), (128, 150), (255, 255)]), + per_channel: [pts(&[(0, 10), (255, 255)]), pts(&[(0, 0), (255, 245)]), pts(&[(0, 0), (64, 32), (255, 255)])], + }); + rt(Adjustment::Unsupported { psd_key: "selc".into(), raw: vec![1, 2, 3] }); + } + + #[test] + fn malformed_falls_back_to_unsupported() { + assert!(matches!(parse(b"levl", &[0, 2, 1], None, Channels::Rgb), Adjustment::Unsupported { .. })); + assert!(matches!(parse(b"curv", &[0, 0, 9], None, Channels::Rgb), Adjustment::Unsupported { .. })); + assert!(matches!(parse(b"thrs", &[], None, Channels::Rgb), Adjustment::Unsupported { .. })); + assert!(matches!(parse(b"blnc", &[0; 40], None, Channels::Rgb), Adjustment::Unsupported { .. })); + } + + #[test] + fn non_rgb_ignores_channel_records() { + let lc = LevelsChannel { in_black: 0.1, ..Default::default() }; + let a = Adjustment::Levels { master: LevelsChannel::default(), per_channel: [lc.clone(), lc.clone(), lc] }; + let b = write(&a); + match parse(&b[0].0, &b[0].1, None, Channels::Other) { + Adjustment::Levels { per_channel, .. } => assert_eq!(per_channel, <[LevelsChannel; 3]>::default()), + other => panic!("{other:?}"), + } + } + + #[test] + fn gray_uses_first_channel_record_for_all() { + let lc = LevelsChannel { in_black: 44.0 / 255.0, ..Default::default() }; + let a = Adjustment::Levels { master: LevelsChannel::default(), per_channel: [lc.clone(), Default::default(), Default::default()] }; + let b = write(&a); + match parse(&b[0].0, &b[0].1, None, Channels::Gray) { + Adjustment::Levels { per_channel, .. } => assert_eq!(per_channel, [lc.clone(), lc.clone(), lc]), + other => panic!("{other:?}"), + } + } + + #[test] + fn legacy_brit_without_cged() { + let a = parse(b"brit", &[0, 10, 0xff, 0xf6, 0, 127, 0], None, Channels::Rgb); + assert_eq!(a, Adjustment::BrightnessContrast { brightness: 10.0, contrast: -10.0, legacy: true }); + } +} diff --git a/crates/io/src/blocks.rs b/crates/io/src/blocks.rs new file mode 100644 index 00000000..4cb2e413 --- /dev/null +++ b/crates/io/src/blocks.rs @@ -0,0 +1,393 @@ +//! Tagged-block helpers: the preserved block-list encoding, fills, text, +//! smart objects, locks and label colors. + +use photocraft_color::{Color, ColorMode}; +use photocraft_doc::{Fill, GradientStyle, LabelColor, Locks}; +use photocraft_geom::Affine; +use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; + +/// `lspf` bits (Adobe spec: bit 0 transparency, 1 composite, 2 position). +/// Artboard (bit 3... here `0x10` as observed by ag-psd) and "all" (bit 31) +/// are undocumented. +pub fn locks_from_lspf(v: u32) -> Locks { + Locks { + transparency: v & 1 != 0, + pixels: v & 2 != 0, + position: v & 4 != 0, + artboard: v & 0x10 != 0, + all: v & 0x8000_0000 != 0, + } +} + +/// Inverse of [`locks_from_lspf`]. +pub fn lspf_from_locks(l: &Locks) -> u32 { + u32::from(l.transparency) + | u32::from(l.pixels) << 1 + | u32::from(l.position) << 2 + | u32::from(l.artboard) << 4 + | u32::from(l.all) << 31 +} + +/// `lclr` index → label. +pub fn label_from_index(v: u16) -> LabelColor { + match v { + 1 => LabelColor::Red, + 2 => LabelColor::Orange, + 3 => LabelColor::Yellow, + 4 => LabelColor::Green, + 5 => LabelColor::Blue, + 6 => LabelColor::Violet, + 7 => LabelColor::Gray, + _ => LabelColor::None, + } +} + +/// Label → `lclr` index. +pub fn label_index(l: LabelColor) -> u16 { + match l { + LabelColor::None => 0, + LabelColor::Red => 1, + LabelColor::Orange => 2, + LabelColor::Yellow => 3, + LabelColor::Green => 4, + LabelColor::Blue => 5, + LabelColor::Violet => 6, + LabelColor::Gray => 7, + } +} + +pub(crate) fn num(v: Option<&Value>) -> Option { + match v? { + Value::Double(d) => Some(*d), + Value::UnitFloat { value, .. } => Some(*value), + Value::Integer(i) => Some(f64::from(*i)), + _ => None, + } +} + +/// Descriptor color (`RGBC`, `CMYC`, `Grsc`, `HSBC`) → [`Color`]. +pub fn color_from_desc(d: &Descriptor) -> Option { + let id = d.class_id.as_bytes(); + let g = |k: &str| num(d.get(k)).map(|v| v as f32); + Some(match id { + b"RGBC" => Color::rgb(g("Rd ")? / 255.0, g("Grn ")? / 255.0, g("Bl ").or_else(|| g("Bl "))? / 255.0), + b"CMYC" => Color { + mode: ColorMode::Cmyk, + c: [g("Cyn ")? / 100.0, g("Mgnt")? / 100.0, g("Ylw ")? / 100.0, g("Blck")? / 100.0], + alpha: 1.0, + }, + b"Grsc" => Color::gray(1.0 - g("Gry ")? / 100.0), + // Newer Photoshop writes 0..1 floats. + _ if d.get("redFloat").is_some() => Color::rgb(g("redFloat")?, g("greenFloat")?, g("blueFloat")?), + _ => { + // Fall back to any RGB-like triple. + Color::rgb(g("Rd ")? / 255.0, g("Grn ")? / 255.0, g("Bl ")? / 255.0) + } + }) +} + +/// [`Color`] → descriptor in its own model. +pub fn color_to_desc(c: &Color) -> Descriptor { + let d = |v: f32| Value::Double(f64::from(v)); + match c.mode { + ColorMode::Cmyk => Descriptor::new("CMYC") + .with("Cyn ", d(c.c[0] * 100.0)) + .with("Mgnt", d(c.c[1] * 100.0)) + .with("Ylw ", d(c.c[2] * 100.0)) + .with("Blck", d(c.c[3] * 100.0)), + ColorMode::Grayscale => Descriptor::new("Grsc").with("Gry ", d((1.0 - c.c[0]) * 100.0)), + _ => { + let rgb = c.to_rgb(); + Descriptor::new("RGBC").with("Rd ", d(rgb[0] * 255.0)).with("Grn ", d(rgb[1] * 255.0)).with("Bl ", d(rgb[2] * 255.0)) + } + } +} + +pub(crate) fn get_desc<'a>(d: &'a Descriptor, key: &str) -> Option<&'a Descriptor> { + match d.get(key)? { + Value::Descriptor(x) | Value::GlobalObject(x) => Some(x), + _ => None, + } +} + +/// Enumerated value of `key` (4-char or long id), if present. +pub(crate) fn enum_of<'a>(d: &'a Descriptor, key: &str) -> Option<&'a [u8]> { + match d.get(key)? { + Value::Enumerated { value, .. } => Some(value.as_bytes()), + _ => None, + } +} + +pub(crate) fn bool_of(d: &Descriptor, key: &str) -> bool { + matches!(d.get(key), Some(Value::Boolean(true))) +} + +/// `GrdT` gradient type. +pub(crate) fn gradient_style(d: &Descriptor) -> GradientStyle { + match enum_of(d, "Type") { + Some(b"Rdl ") => GradientStyle::Radial, + Some(b"Angl") => GradientStyle::Angle, + Some(b"Rflc") => GradientStyle::Reflected, + Some(b"Dmnd") => GradientStyle::Diamond, + _ => GradientStyle::Linear, + } +} + +pub(crate) fn gradient_style_value(s: GradientStyle) -> Value { + let v = match s { + GradientStyle::Linear => "Lnr ", + GradientStyle::Radial => "Rdl ", + GradientStyle::Angle => "Angl", + GradientStyle::Reflected => "Rflc", + GradientStyle::Diamond => "Dmnd", + }; + Value::Enumerated { type_id: Id::new("GrdT"), value: Id::new(v) } +} + +/// Colour and opacity stops of a `Grdn` descriptor (locations 0..4096 → 0..1). +/// Colour stops and opacity stops of a gradient. +pub(crate) type Stops = (Vec<(f32, Color)>, Vec<(f32, f32)>); + +pub(crate) fn gradient_stops(grad: &Descriptor) -> Stops { + let mut stops = Vec::new(); + let mut opacity = Vec::new(); + if let Some(Value::List(items)) = grad.get("Clrs") { + for it in items { + if let Value::Descriptor(s) = it { + let loc = num(s.get("Lctn")).unwrap_or(0.0) as f32 / 4096.0; + if let Some(c) = get_desc(s, "Clr ").and_then(color_from_desc) { + stops.push((loc, c)); + } + } + } + } + if let Some(Value::List(items)) = grad.get("Trns") { + for it in items { + if let Value::Descriptor(s) = it { + let loc = num(s.get("Lctn")).unwrap_or(0.0) as f32 / 4096.0; + opacity.push((loc, num(s.get("Opct")).unwrap_or(100.0) as f32 / 100.0)); + } + } + } + (stops, opacity) +} + +/// A `Grdn` descriptor for colour and opacity stops. +pub(crate) fn gradient_desc(stops: &[(f32, Color)], opacity: &[(f32, f32)]) -> Descriptor { + let clrs = stops + .iter() + .map(|(t, c)| { + Value::Descriptor( + Descriptor::new("Clrt") + .with("Clr ", Value::Descriptor(color_to_desc(c))) + .with("Type", Value::Enumerated { type_id: Id::new("Clry"), value: Id::new("UsrS") }) + .with("Lctn", Value::Integer((t * 4096.0).round() as i32)) + .with("Mdpn", Value::Integer(50)), + ) + }) + .collect(); + let op: Vec<(f32, f32)> = if opacity.is_empty() { stops.iter().map(|(t, c)| (*t, c.alpha)).collect() } else { opacity.to_vec() }; + let trns = op + .iter() + .map(|(t, a)| { + Value::Descriptor( + Descriptor::new("TrnS") + .with("Opct", Value::UnitFloat { unit: *b"#Prc", value: f64::from(a * 100.0) }) + .with("Lctn", Value::Integer((t * 4096.0).round() as i32)) + .with("Mdpn", Value::Integer(50)), + ) + }) + .collect(); + Descriptor::new("Grdn") + .with("Nm ", Value::Text(UnicodeString::new_nul("Custom"))) + .with("GrdF", Value::Enumerated { type_id: Id::new("GrdF"), value: Id::new("CstS") }) + .with("Intr", Value::Double(4096.0)) + .with("Clrs", Value::List(clrs)) + .with("Trns", Value::List(trns)) +} + +/// Parses a fill block (`SoCo`, `GdFl`, `PtFl`). +pub fn parse_fill(key: &[u8; 4], data: &[u8]) -> Option { + // Block data may carry trailing padding after the descriptor. + let d = parse_prefix_versioned(data)?; + match key { + b"SoCo" => Some(Fill::Solid(color_from_desc(get_desc(&d, "Clr ")?)?)), + b"GdFl" => { + let angle = num(d.get("Angl")).unwrap_or(90.0) as f32; + let scale = num(d.get("Scl ")).map_or(1.0, |v| v as f32 / 100.0); + let (mut stops, _) = get_desc(&d, "Grad").map(gradient_stops).unwrap_or_default(); + if stops.is_empty() { + stops = vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]; + } + Some(Fill::Gradient { stops, angle, scale, style: gradient_style(&d), reverse: bool_of(&d, "Rvrs") }) + } + b"PtFl" => { + let name = get_desc(&d, "Ptrn") + .and_then(|p| match p.get("Nm ") { + Some(Value::Text(t)) => Some(t.to_string_lossy()), + _ => None, + }) + .unwrap_or_default(); + let scale = num(d.get("Scl ")).map_or(1.0, |v| v as f32 / 100.0); + Some(Fill::Pattern { name, scale }) + } + _ => None, + } +} + +/// Serializes a fill to its block. +pub fn write_fill(f: &Fill) -> ([u8; 4], Vec) { + match f { + Fill::Solid(c) => { + let d = Descriptor::new("null").with("Clr ", Value::Descriptor(color_to_desc(c))); + (*b"SoCo", VersionedDescriptor::new(d).to_bytes()) + } + Fill::Gradient { stops, angle, scale, style, reverse } => { + let mut d = Descriptor::new("null") + .with("Angl", Value::UnitFloat { unit: *b"#Ang", value: f64::from(*angle) }) + .with("Type", gradient_style_value(*style)) + .with("Scl ", Value::UnitFloat { unit: *b"#Prc", value: f64::from(scale * 100.0) }) + .with("Grad", Value::Descriptor(gradient_desc(stops, &[]))); + if *reverse { + d = d.with("Rvrs", Value::Boolean(true)); + } + (*b"GdFl", VersionedDescriptor::new(d).to_bytes()) + } + Fill::Pattern { name, scale } => { + let p = Descriptor::new("Ptrn").with("Nm ", Value::Text(UnicodeString::new_nul(name))); + let d = Descriptor::new("null") + .with("Scl ", Value::UnitFloat { unit: *b"#Prc", value: f64::from(scale * 100.0) }) + .with("Ptrn", Value::Descriptor(p)); + (*b"PtFl", VersionedDescriptor::new(d).to_bytes()) + } + } +} + +/// Text content and transform from a `TySh` block (best effort). +pub fn parse_tysh(data: &[u8]) -> Option<(String, Affine)> { + let f = |at: usize| data.get(at..at + 8).map(|b| f64::from_be_bytes(b.try_into().unwrap_or([0; 8]))); + let mut m = [0.0; 6]; + for (i, v) in m.iter_mut().enumerate() { + *v = f(2 + i * 8)?; + } + // text version (2) at 50, descriptor version (4) at 52, descriptor at 56. + let rest = data.get(52..)?; + let text = parse_prefix_versioned(rest).and_then(|d| match d.get("Txt ") { + Some(Value::Text(t)) => Some(t.to_string_lossy()), + _ => None, + }); + Some((text.unwrap_or_default(), Affine { m })) +} + +/// Parses a version-16 descriptor at the start of `data` (trailing bytes allowed). +fn parse_prefix_versioned(data: &[u8]) -> Option { + VersionedDescriptor::parse_prefix(data).ok().map(|(v, _)| v.descriptor) +} + +/// Smart object identifier and transform from `SoLd`/`PlLd` (best effort). +pub fn parse_smart(key: &[u8; 4], data: &[u8]) -> (String, Affine) { + // SoLd: 'soLD' + version(4) + versioned descriptor. + let desc = if key == b"SoLd" || key == b"SoLE" { + data.get(8..).and_then(parse_prefix_versioned) + } else { + None + }; + let Some(d) = desc else { return (String::new(), Affine::IDENTITY) }; + let id = match d.get("Idnt") { + Some(Value::Text(t)) => t.to_string_lossy(), + _ => String::new(), + }; + let mut affine = Affine::IDENTITY; + if let (Some(Value::List(pts)), Some(sz)) = (d.get("Trnf"), get_desc(&d, "Sz ")) { + let p: Vec = pts.iter().filter_map(|v| num(Some(v))).collect(); + let (w, h) = (num(sz.get("Wdth")).unwrap_or(0.0), num(sz.get("Hght")).unwrap_or(0.0)); + if p.len() == 8 && w > 0.0 && h > 0.0 { + affine = Affine { m: [(p[2] - p[0]) / w, (p[3] - p[1]) / w, (p[6] - p[0]) / h, (p[7] - p[1]) / h, p[0], p[1]] }; + } + } + (id, affine) +} + +/// `masterFXSwitch` from an `lfx2` block (defaults to `true`). +pub fn effects_enabled(lfx2: &[u8]) -> bool { + lfx2.get(4..) + .and_then(parse_prefix_versioned) + .and_then(|d| match d.get("masterFXSwitch") { + Some(Value::Boolean(b)) => Some(*b), + _ => None, + }) + .unwrap_or(true) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn locks_and_labels() { + for bits in [0u32, 1, 2, 4, 0x10, 0x8000_0000, 0x8000_0017] { + assert_eq!(lspf_from_locks(&locks_from_lspf(bits)), bits); + } + for i in 0..8 { + assert_eq!(label_index(label_from_index(i)), i); + } + } + + #[test] + fn fills_roundtrip() { + for f in [ + Fill::Solid(Color::rgb(1.0, 0.5, 0.0)), + Fill::Solid(Color { mode: ColorMode::Cmyk, c: [0.1, 0.2, 0.3, 0.4], alpha: 1.0 }), + Fill::Solid(Color::gray(0.25)), + Fill::Gradient { stops: vec![(0.0, Color::rgb(1.0, 0.0, 0.0)), (1.0, Color::rgb(0.0, 0.0, 1.0))], angle: 45.0, scale: 1.0, style: GradientStyle::Reflected, reverse: true }, + Fill::Pattern { name: "Bubbles".into(), scale: 0.5 }, + ] { + let (k, d) = write_fill(&f); + let back = parse_fill(&k, &d).unwrap(); + match (&f, &back) { + (Fill::Solid(a), Fill::Solid(b)) => { + assert_eq!(a.mode, b.mode); + for i in 0..4 { + assert!((a.c[i] - b.c[i]).abs() < 1e-5); + } + } + _ => assert_eq!(back, f), + } + } + } +} + +#[cfg(test)] +mod more_tests { + use super::*; + + #[test] + fn smart_transform_from_sold() { + let pts = [10.0, 20.0, 110.0, 20.0, 110.0, 70.0, 10.0, 70.0].map(Value::Double).to_vec(); + let d = Descriptor::new("null") + .with("Idnt", Value::Text(UnicodeString::new_nul("uuid-1"))) + .with("Trnf", Value::List(pts)) + .with("Sz ", Value::Descriptor(Descriptor::new("Pnt ").with("Wdth", Value::Double(50.0)).with("Hght", Value::Double(25.0)))); + let mut data = b"soLD".to_vec(); + data.extend_from_slice(&4u32.to_be_bytes()); + data.extend(VersionedDescriptor::new(d).to_bytes()); + data.extend_from_slice(&[0, 0]); + let (id, a) = parse_smart(b"SoLd", &data); + assert_eq!(id, "uuid-1"); + assert_eq!(a.m, [2.0, 0.0, 0.0, 2.0, 10.0, 20.0]); + assert_eq!(parse_smart(b"PlLd", &data).0, ""); + } + + #[test] + fn effects_switch() { + let mut lfx = vec![0, 0, 0, 0]; + lfx.extend(VersionedDescriptor::new(Descriptor::new("null").with("masterFXSwitch", Value::Boolean(false))).to_bytes()); + assert!(!effects_enabled(&lfx)); + assert!(effects_enabled(&[0, 0, 0, 0])); + } + + #[test] + fn tysh_garbage_is_none() { + assert!(parse_tysh(&[0; 10]).is_none()); + } +} diff --git a/crates/io/src/effects_map.rs b/crates/io/src/effects_map.rs new file mode 100644 index 00000000..04df4dc9 --- /dev/null +++ b/crates/io/src/effects_map.rs @@ -0,0 +1,702 @@ +//! Layer effects ↔ PSD `lfx2` (object-based effects descriptor) and legacy `lrFX`. +//! +//! Key names follow the descriptors Photoshop writes (documented by +//! reverse-engineering in MIT-licensed psd-tools / ag-psd). + +use photocraft_color::{BlendMode, Color}; +use photocraft_doc::adjust::CurvePoint; +use photocraft_doc::{ + Bevel, BevelStyle, BevelTechnique, Contour, Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique, Gradient, Satin, Shadow, + StrokeFx, StrokePosition, +}; +use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; + +use crate::blocks::{bool_of, color_from_desc, color_to_desc, enum_of, get_desc, gradient_desc, gradient_stops, gradient_style, gradient_style_value, num}; + +const BLEND_NAMES: [(BlendMode, &str); 28] = [ + (BlendMode::Normal, "Nrml"), + (BlendMode::Dissolve, "Dslv"), + (BlendMode::Darken, "Drkn"), + (BlendMode::Multiply, "Mltp"), + (BlendMode::ColorBurn, "CBrn"), + (BlendMode::LinearBurn, "linearBurn"), + (BlendMode::DarkerColor, "darkerColor"), + (BlendMode::Lighten, "Lghn"), + (BlendMode::Screen, "Scrn"), + (BlendMode::ColorDodge, "CDdg"), + (BlendMode::LinearDodge, "linearDodge"), + (BlendMode::LighterColor, "lighterColor"), + (BlendMode::Overlay, "Ovrl"), + (BlendMode::SoftLight, "SftL"), + (BlendMode::HardLight, "HrdL"), + (BlendMode::VividLight, "vividLight"), + (BlendMode::LinearLight, "linearLight"), + (BlendMode::PinLight, "pinLight"), + (BlendMode::HardMix, "hardMix"), + (BlendMode::Difference, "Dfrn"), + (BlendMode::Exclusion, "Xclu"), + (BlendMode::Subtract, "blendSubtraction"), + (BlendMode::Divide, "blendDivide"), + (BlendMode::Hue, "H "), + (BlendMode::Saturation, "Strt"), + (BlendMode::Color, "Clr "), + (BlendMode::Luminosity, "Lmns"), + (BlendMode::PassThrough, "passThrough"), +]; + +fn blend_of(d: &Descriptor, key: &str, default: BlendMode) -> BlendMode { + enum_of(d, key).and_then(|v| BLEND_NAMES.iter().find(|(_, n)| n.as_bytes() == v).map(|(m, _)| *m)).unwrap_or(default) +} + +fn blend_value(m: BlendMode) -> Value { + let n = BLEND_NAMES.iter().find(|(b, _)| *b == m).map_or("Nrml", |(_, n)| n); + Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new(n) } +} + +fn f(d: &Descriptor, key: &str, default: f32) -> f32 { + num(d.get(key)).map_or(default, |v| v as f32) +} +fn pct(d: &Descriptor, key: &str, default: f32) -> f32 { + f(d, key, default * 100.0) / 100.0 +} +fn color(d: &Descriptor, key: &str) -> Color { + get_desc(d, key).and_then(color_from_desc).unwrap_or(Color::BLACK) +} +fn contour(d: &Descriptor, key: &str) -> Contour { + let Some(c) = get_desc(d, key) else { return Contour::Linear }; + let name = match c.get("Nm ") { + Some(Value::Text(t)) => t.to_string_lossy(), + _ => String::new(), + }; + let mut points = Vec::new(); + if let Some(Value::List(items)) = c.get("Crv ") { + for it in items { + if let Value::Descriptor(p) = it { + points.push(CurvePoint { input: f(p, "Hrzn", 0.0) / 255.0, output: f(p, "Vrtc", 0.0) / 255.0 }); + } + } + } + let linear = points.len() == 2 && points[0].input == 0.0 && points[0].output == 0.0 && points[1].input == 1.0 && points[1].output == 1.0; + if points.is_empty() || (linear && (name.is_empty() || name == "Linear")) { + Contour::Linear + } else { + Contour::Custom { name, points } + } +} +fn contour_value(c: &Contour) -> Value { + let (name, points) = match c { + Contour::Linear => ("Linear".to_string(), vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }]), + Contour::Custom { name, points } => (name.clone(), points.clone()), + }; + let pts = points + .iter() + .map(|p| { + Value::Descriptor( + Descriptor::new("CrPt").with("Hrzn", Value::Double(f64::from(p.input * 255.0))).with("Vrtc", Value::Double(f64::from(p.output * 255.0))), + ) + }) + .collect(); + Value::Descriptor(Descriptor::new("ShpC").with("Nm ", Value::Text(UnicodeString::new_nul(&name))).with("Crv ", Value::List(pts))) +} + +fn common(d: &Descriptor, blend: BlendMode, opacity: f32) -> FxCommon { + FxCommon { + enabled: !matches!(d.get("enab"), Some(Value::Boolean(false))), + blend: blend_of(d, "Md ", blend), + opacity: pct(d, "Opct", opacity), + } +} + +fn gradient_from(d: &Descriptor) -> Gradient { + let (stops, opacity_stops) = get_desc(d, "Grad").map(gradient_stops).unwrap_or_default(); + let off = get_desc(d, "Ofst"); + Gradient { + stops: if stops.is_empty() { Gradient::default().stops } else { stops }, + opacity_stops, + style: gradient_style(d), + angle: f(d, "Angl", 90.0), + scale: pct(d, "Scl ", 1.0), + reverse: bool_of(d, "Rvrs"), + align: !matches!(d.get("Algn"), Some(Value::Boolean(false))), + offset: off.map_or((0.0, 0.0), |o| (pct(o, "Hrzn", 0.0), pct(o, "Vrtc", 0.0))), + } +} + +fn parse_shadow(d: &Descriptor, inner: bool) -> Shadow { + Shadow { + common: common(d, BlendMode::Multiply, 0.75), + color: color(d, "Clr "), + angle: f(d, "lagl", 120.0), + use_global_light: !matches!(d.get("uglg"), Some(Value::Boolean(false))), + distance: f(d, "Dstn", 5.0), + spread: pct(d, "Ckmt", 0.0), + size: f(d, "blur", 5.0), + contour: contour(d, "TrnS"), + anti_alias: bool_of(d, "AntA"), + noise: pct(d, "Nose", 0.0), + knocks_out: !inner && !matches!(d.get("layerConceals"), Some(Value::Boolean(false))), + } +} + +fn parse_glow(d: &Descriptor, inner: bool) -> Glow { + let paint = if d.get("Grad").is_some() { FxPaint::Gradient(gradient_from(d)) } else { FxPaint::Color(color(d, "Clr ")) }; + Glow { + common: common(d, BlendMode::Screen, 0.75), + paint, + technique: if enum_of(d, "GlwT") == Some(b"PrBL") { GlowTechnique::Precise } else { GlowTechnique::Softer }, + spread: pct(d, "Ckmt", 0.0), + size: f(d, "blur", 5.0), + contour: contour(d, "TrnS"), + anti_alias: bool_of(d, "AntA"), + range: pct(d, "Inpr", 0.5), + jitter: pct(d, "ShdN", 0.0), + noise: pct(d, "Nose", 0.0), + source: if inner && enum_of(d, "glwS") == Some(b"SrcC") { GlowSource::Center } else { GlowSource::Edge }, + } +} + +fn parse_stroke(d: &Descriptor) -> StrokeFx { + let paint = match enum_of(d, "PntT") { + Some(b"GrFl") => FxPaint::Gradient(gradient_from(d)), + Some(b"Ptrn") => { + let p = get_desc(d, "Ptrn"); + let text = |k: &str| match p.and_then(|p| p.get(k)) { + Some(Value::Text(t)) => t.to_string_lossy(), + _ => String::new(), + }; + FxPaint::Pattern { name: text("Nm "), id: text("Idnt"), scale: pct(d, "Scl ", 1.0) } + } + _ => FxPaint::Color(color(d, "Clr ")), + }; + StrokeFx { + common: common(d, BlendMode::Normal, 1.0), + size: f(d, "Sz ", 3.0), + position: match enum_of(d, "Styl") { + Some(b"InsF") => StrokePosition::Inside, + Some(b"CtrF") => StrokePosition::Center, + _ => StrokePosition::Outside, + }, + paint, + } +} + +fn parse_bevel(d: &Descriptor) -> Bevel { + Bevel { + enabled: !matches!(d.get("enab"), Some(Value::Boolean(false))), + style: match enum_of(d, "bvlS") { + Some(b"OtrB") => BevelStyle::OuterBevel, + Some(b"Embs") => BevelStyle::Emboss, + Some(b"PlEb") => BevelStyle::PillowEmboss, + Some(b"strokeEmboss") => BevelStyle::StrokeEmboss, + _ => BevelStyle::InnerBevel, + }, + technique: match enum_of(d, "bvlT") { + Some(b"PrBL") => BevelTechnique::ChiselHard, + Some(b"Slmt") => BevelTechnique::ChiselSoft, + _ => BevelTechnique::Smooth, + }, + depth: pct(d, "srgR", 1.0), + up: enum_of(d, "bvlD") != Some(b"Out "), + size: f(d, "blur", 5.0), + soften: f(d, "Sftn", 0.0), + angle: f(d, "lagl", 120.0), + altitude: f(d, "Lald", 30.0), + use_global_light: !matches!(d.get("uglg"), Some(Value::Boolean(false))), + gloss_contour: contour(d, "TrnS"), + highlight: FxCommon { enabled: true, blend: blend_of(d, "hglM", BlendMode::Screen), opacity: pct(d, "hglO", 0.75) }, + highlight_color: get_desc(d, "hglC").and_then(color_from_desc).unwrap_or(Color::WHITE), + shadow: FxCommon { enabled: true, blend: blend_of(d, "sdwM", BlendMode::Multiply), opacity: pct(d, "sdwO", 0.75) }, + shadow_color: color(d, "sdwC"), + } +} + +fn parse_one(key: &str, d: &Descriptor) -> Option { + Some(match key { + "DrSh" => Effect::DropShadow(parse_shadow(d, false)), + "IrSh" => Effect::InnerShadow(parse_shadow(d, true)), + "OrGl" => Effect::OuterGlow(parse_glow(d, false)), + "IrGl" => Effect::InnerGlow(parse_glow(d, true)), + "FrFX" => Effect::Stroke(parse_stroke(d)), + "SoFi" => Effect::ColorOverlay { common: common(d, BlendMode::Normal, 1.0), color: color(d, "Clr ") }, + "GrFl" => Effect::GradientOverlay { common: common(d, BlendMode::Normal, 1.0), gradient: gradient_from(d), dither: bool_of(d, "Dthr") }, + "patternFill" => { + let p = get_desc(d, "Ptrn"); + let text = |k: &str| match p.and_then(|p| p.get(k)) { + Some(Value::Text(t)) => t.to_string_lossy(), + _ => String::new(), + }; + Effect::PatternOverlay { common: common(d, BlendMode::Normal, 1.0), name: text("Nm "), id: text("Idnt"), scale: pct(d, "Scl ", 1.0) } + } + "ChFX" => Effect::Satin(Satin { + common: common(d, BlendMode::Multiply, 0.5), + color: color(d, "Clr "), + angle: f(d, "lagl", 19.0), + distance: f(d, "Dstn", 11.0), + size: f(d, "blur", 14.0), + contour: contour(d, "MpgS"), + anti_alias: bool_of(d, "AntA"), + invert: bool_of(d, "Invr"), + }), + "ebbl" => Effect::BevelEmboss(parse_bevel(d)), + _ => return None, + }) +} + +/// Single-instance key and multi-instance list key for each kind. +const KINDS: [(&str, &str); 10] = [ + ("DrSh", "dropShadowMulti"), + ("IrSh", "innerShadowMulti"), + ("OrGl", ""), + ("IrGl", ""), + ("ebbl", ""), + ("ChFX", ""), + ("SoFi", "solidFillMulti"), + ("GrFl", "gradientFillMulti"), + ("patternFill", ""), + ("FrFX", "frameFXMulti"), +]; + +/// Parses `lfx2` block data: `(master switch, effects)`. +pub fn parse_lfx2(data: &[u8]) -> Option<(bool, Vec)> { + let (vd, _) = VersionedDescriptor::parse_prefix(data.get(4..)?).ok()?; + let d = vd.descriptor; + let master = !matches!(d.get("masterFXSwitch"), Some(Value::Boolean(false))); + let mut out = Vec::new(); + for (single, multi) in KINDS { + let from_multi = (!multi.is_empty()).then(|| d.get(multi)).flatten(); + match from_multi { + Some(Value::List(items)) => { + for it in items { + if let Value::Descriptor(x) = it + && let Some(e) = parse_one(single, x) + { + out.push(e); + } + } + } + _ => { + if let Some(x) = get_desc(&d, single) + && let Some(e) = parse_one(single, x) + { + out.push(e); + } + } + } + } + Some((master, out)) +} + +fn unit(u: &[u8; 4], v: f32) -> Value { + Value::UnitFloat { unit: *u, value: f64::from(v) } +} + +fn with_common(d: Descriptor, c: &FxCommon) -> Descriptor { + d.with("enab", Value::Boolean(c.enabled)) + .with("present", Value::Boolean(true)) + .with("showInDialog", Value::Boolean(true)) + .with("Md ", blend_value(c.blend)) + .with("Opct", unit(b"#Prc", c.opacity * 100.0)) +} + +fn gradient_keys(mut d: Descriptor, g: &Gradient) -> Descriptor { + d = d + .with("Grad", Value::Descriptor(gradient_desc(&g.stops, &g.opacity_stops))) + .with("Angl", unit(b"#Ang", g.angle)) + .with("Type", gradient_style_value(g.style)) + .with("Rvrs", Value::Boolean(g.reverse)) + .with("Algn", Value::Boolean(g.align)) + .with("Scl ", unit(b"#Prc", g.scale * 100.0)); + d.with( + "Ofst", + Value::Descriptor(Descriptor::new("Pnt ").with("Hrzn", unit(b"#Prc", g.offset.0 * 100.0)).with("Vrtc", unit(b"#Prc", g.offset.1 * 100.0))), + ) +} + +fn write_one(e: &Effect) -> (&'static str, Descriptor) { + match e { + Effect::DropShadow(s) | Effect::InnerShadow(s) => { + let inner = matches!(e, Effect::InnerShadow(_)); + let mut d = with_common(Descriptor::new(if inner { "IrSh" } else { "DrSh" }), &s.common) + .with("Clr ", Value::Descriptor(color_to_desc(&s.color))) + .with("uglg", Value::Boolean(s.use_global_light)) + .with("lagl", unit(b"#Ang", s.angle)) + .with("Dstn", unit(b"#Pxl", s.distance)) + .with("Ckmt", unit(b"#Pxl", s.spread * 100.0)) + .with("blur", unit(b"#Pxl", s.size)) + .with("Nose", unit(b"#Prc", s.noise * 100.0)) + .with("AntA", Value::Boolean(s.anti_alias)) + .with("TrnS", contour_value(&s.contour)); + if !inner { + d = d.with("layerConceals", Value::Boolean(s.knocks_out)); + } + (if inner { "IrSh" } else { "DrSh" }, d) + } + Effect::OuterGlow(g) | Effect::InnerGlow(g) => { + let inner = matches!(e, Effect::InnerGlow(_)); + let mut d = with_common(Descriptor::new(if inner { "IrGl" } else { "OrGl" }), &g.common); + d = match &g.paint { + FxPaint::Gradient(gr) => gradient_keys(d, gr), + FxPaint::Color(c) => d.with("Clr ", Value::Descriptor(color_to_desc(c))), + FxPaint::Pattern { .. } => d.with("Clr ", Value::Descriptor(color_to_desc(&Color::WHITE))), + }; + d = d + .with( + "GlwT", + Value::Enumerated { type_id: Id::new("BETE"), value: Id::new(if g.technique == GlowTechnique::Precise { "PrBL" } else { "SfBL" }) }, + ) + .with("Ckmt", unit(b"#Pxl", g.spread * 100.0)) + .with("blur", unit(b"#Pxl", g.size)) + .with("Nose", unit(b"#Prc", g.noise * 100.0)) + .with("ShdN", unit(b"#Prc", g.jitter * 100.0)) + .with("AntA", Value::Boolean(g.anti_alias)) + .with("TrnS", contour_value(&g.contour)) + .with("Inpr", unit(b"#Prc", g.range * 100.0)); + if inner { + d = d.with( + "glwS", + Value::Enumerated { type_id: Id::new("IGSr"), value: Id::new(if g.source == GlowSource::Center { "SrcC" } else { "SrcE" }) }, + ); + } + (if inner { "IrGl" } else { "OrGl" }, d) + } + Effect::Stroke(s) => { + let styl = match s.position { + StrokePosition::Outside => "OutF", + StrokePosition::Inside => "InsF", + StrokePosition::Center => "CtrF", + }; + let mut d = with_common(Descriptor::new("FrFX"), &s.common) + .with("Styl", Value::Enumerated { type_id: Id::new("FStl"), value: Id::new(styl) }) + .with("Sz ", unit(b"#Pxl", s.size)); + d = match &s.paint { + FxPaint::Color(c) => d + .with("PntT", Value::Enumerated { type_id: Id::new("FrFl"), value: Id::new("SClr") }) + .with("Clr ", Value::Descriptor(color_to_desc(c))), + FxPaint::Gradient(g) => gradient_keys(d.with("PntT", Value::Enumerated { type_id: Id::new("FrFl"), value: Id::new("GrFl") }), g), + FxPaint::Pattern { name, id, scale } => d + .with("PntT", Value::Enumerated { type_id: Id::new("FrFl"), value: Id::new("Ptrn") }) + .with( + "Ptrn", + Value::Descriptor( + Descriptor::new("Ptrn") + .with("Nm ", Value::Text(UnicodeString::new_nul(name))) + .with("Idnt", Value::Text(UnicodeString::new_nul(id))), + ), + ) + .with("Scl ", unit(b"#Prc", scale * 100.0)), + }; + ("FrFX", d) + } + Effect::ColorOverlay { common: c, color } => { + ("SoFi", with_common(Descriptor::new("SoFi"), c).with("Clr ", Value::Descriptor(color_to_desc(color)))) + } + Effect::GradientOverlay { common: c, gradient, dither } => { + ("GrFl", gradient_keys(with_common(Descriptor::new("GrFl"), c), gradient).with("Dthr", Value::Boolean(*dither))) + } + Effect::PatternOverlay { common: c, name, id, scale } => ( + "patternFill", + with_common(Descriptor::new("patternFill"), c) + .with( + "Ptrn", + Value::Descriptor( + Descriptor::new("Ptrn").with("Nm ", Value::Text(UnicodeString::new_nul(name))).with("Idnt", Value::Text(UnicodeString::new_nul(id))), + ), + ) + .with("Scl ", unit(b"#Prc", scale * 100.0)), + ), + Effect::Satin(s) => ( + "ChFX", + with_common(Descriptor::new("ChFX"), &s.common) + .with("Clr ", Value::Descriptor(color_to_desc(&s.color))) + .with("AntA", Value::Boolean(s.anti_alias)) + .with("Invr", Value::Boolean(s.invert)) + .with("lagl", unit(b"#Ang", s.angle)) + .with("Dstn", unit(b"#Pxl", s.distance)) + .with("blur", unit(b"#Pxl", s.size)) + .with("MpgS", contour_value(&s.contour)), + ), + Effect::BevelEmboss(b) => { + let style = match b.style { + BevelStyle::OuterBevel => "OtrB", + BevelStyle::InnerBevel => "InrB", + BevelStyle::Emboss => "Embs", + BevelStyle::PillowEmboss => "PlEb", + BevelStyle::StrokeEmboss => "strokeEmboss", + }; + let tech = match b.technique { + BevelTechnique::Smooth => "SfBL", + BevelTechnique::ChiselHard => "PrBL", + BevelTechnique::ChiselSoft => "Slmt", + }; + let d = Descriptor::new("ebbl") + .with("enab", Value::Boolean(b.enabled)) + .with("present", Value::Boolean(true)) + .with("showInDialog", Value::Boolean(true)) + .with("hglM", blend_value(b.highlight.blend)) + .with("hglC", Value::Descriptor(color_to_desc(&b.highlight_color))) + .with("hglO", unit(b"#Prc", b.highlight.opacity * 100.0)) + .with("sdwM", blend_value(b.shadow.blend)) + .with("sdwC", Value::Descriptor(color_to_desc(&b.shadow_color))) + .with("sdwO", unit(b"#Prc", b.shadow.opacity * 100.0)) + .with("bvlT", Value::Enumerated { type_id: Id::new("bvlT"), value: Id::new(tech) }) + .with("bvlS", Value::Enumerated { type_id: Id::new("BESl"), value: Id::new(style) }) + .with("uglg", Value::Boolean(b.use_global_light)) + .with("lagl", unit(b"#Ang", b.angle)) + .with("Lald", unit(b"#Ang", b.altitude)) + .with("srgR", unit(b"#Prc", b.depth * 100.0)) + .with("blur", unit(b"#Pxl", b.size)) + .with("bvlD", Value::Enumerated { type_id: Id::new("BESs"), value: Id::new(if b.up { "In " } else { "Out " }) }) + .with("TrnS", contour_value(&b.gloss_contour)) + .with("Sftn", unit(b"#Pxl", b.soften)); + ("ebbl", d) + } + } +} + +/// Serializes effects as `lfx2` block data. +pub fn write_lfx2(master: bool, items: &[Effect]) -> Vec { + let mut d = Descriptor::new("null").with("Scl ", unit(b"#Prc", 100.0)).with("masterFXSwitch", Value::Boolean(master)); + for (single, multi) in KINDS { + let of_kind: Vec = items.iter().map(write_one).filter(|(k, _)| *k == single).map(|(_, d)| d).collect(); + match of_kind.len() { + 0 => {} + 1 => d = d.with(single, Value::Descriptor(of_kind.into_iter().next().unwrap_or_default())), + _ if !multi.is_empty() => d = d.with(multi, Value::List(of_kind.into_iter().map(Value::Descriptor).collect())), + // Kinds without a multi key keep only the first instance. + _ => d = d.with(single, Value::Descriptor(of_kind.into_iter().next().unwrap_or_default())), + } + } + let mut out = 0u32.to_be_bytes().to_vec(); + out.extend(VersionedDescriptor::new(d).to_bytes()); + out +} + +fn fixed(v: u32) -> f32 { + v as i32 as f32 / 65536.0 +} + +/// Parses legacy `lrFX` effects (Photoshop 5 layout): drop/inner shadows, +/// outer/inner glows and solid fill. Bevels are skipped. +pub fn parse_lrfx(data: &[u8]) -> Option<(bool, Vec)> { + let be16 = |at: usize| data.get(at..at + 2).map(|b| u16::from_be_bytes([b[0], b[1]])); + let be32 = |at: usize| data.get(at..at + 4).map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]])); + let color_at = |at: usize| -> Option { + // space (2) + 4 × u16 components (RGB uses the first three). + let c = |k: usize| be16(at + 2 + k * 2).map(|v| f32::from(v >> 8) / 255.0); + Some(Color::rgb(c(0)?, c(1)?, c(2)?)) + }; + let blend_at = |at: usize| -> BlendMode { + data.get(at + 4..at + 8) + .and_then(|k| photocraft_color::BlendMode::from_psd_key([k[0], k[1], k[2], k[3]])) + .unwrap_or(BlendMode::Normal) + }; + let count = be16(2)?; + let mut at = 4; + let mut out = Vec::new(); + for _ in 0..count { + let sig = data.get(at..at + 4)?; + if sig != b"8BIM" { + return None; + } + let key: [u8; 4] = data.get(at + 4..at + 8)?.try_into().ok()?; + let size = be32(at + 8)? as usize; + let b = at + 12; // start of this effect's data (after the size field) + match &key { + b"dsdw" | b"isdw" => { + // Blur, angle and distance are 16.16 fixed point in practice. + let blur = fixed(be32(b + 4)?); + let angle = fixed(be32(b + 12)?); + let distance = fixed(be32(b + 16)?); + let color = color_at(b + 20)?; + let blend = blend_at(b + 30); + let enabled = *data.get(b + 38)? != 0; + let use_global = *data.get(b + 39)? != 0; + let opacity = f32::from(*data.get(b + 40)?) / 255.0; + let s = Shadow { + common: FxCommon { enabled, blend, opacity }, + color, + angle, + use_global_light: use_global, + distance, + spread: 0.0, + size: blur, + contour: Contour::Linear, + anti_alias: false, + noise: 0.0, + knocks_out: &key == b"dsdw", + }; + out.push(if &key == b"dsdw" { Effect::DropShadow(s) } else { Effect::InnerShadow(s) }); + } + b"oglw" | b"iglw" => { + let blur = fixed(be32(b + 4)?); + let color = color_at(b + 12)?; + let blend = blend_at(b + 22); + let enabled = *data.get(b + 30)? != 0; + let opacity = f32::from(*data.get(b + 31)?) / 255.0; + let g = Glow { + common: FxCommon { enabled, blend, opacity }, + paint: FxPaint::Color(color), + technique: GlowTechnique::Softer, + spread: 0.0, + size: blur, + contour: Contour::Linear, + anti_alias: false, + range: 0.5, + jitter: 0.0, + noise: 0.0, + source: GlowSource::Edge, + }; + out.push(if &key == b"oglw" { Effect::OuterGlow(g) } else { Effect::InnerGlow(g) }); + } + b"sofi" => { + let blend = blend_at(b + 4); + let color = color_at(b + 12)?; + let opacity = f32::from(*data.get(b + 22)?) / 255.0; + let enabled = *data.get(b + 23)? != 0; + out.push(Effect::ColorOverlay { common: FxCommon { enabled, blend, opacity }, color }); + } + _ => {} + } + at = b + size; + } + Some((true, out)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_effects() -> Vec { + let g = Gradient { + stops: vec![(0.0, Color::rgb(1.0, 0.0, 0.0)), (1.0, Color::rgb(0.0, 0.0, 1.0))], + opacity_stops: vec![(0.0, 1.0), (1.0, 0.5)], + ..Gradient::default() + }; + vec![ + Effect::default_drop_shadow(), + Effect::DropShadow(Shadow { distance: 9.0, spread: 0.25, contour: Contour::Custom { name: "Cone".into(), points: vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 0.5, output: 1.0 }, CurvePoint { input: 1.0, output: 0.0 }] }, ..match Effect::default_drop_shadow() { Effect::DropShadow(s) => s, _ => unreachable!() } }), + Effect::InnerShadow(Shadow { knocks_out: false, ..match Effect::default_drop_shadow() { Effect::DropShadow(s) => s, _ => unreachable!() } }), + Effect::OuterGlow(Glow { + common: FxCommon::new(BlendMode::Screen, 0.5), + paint: FxPaint::Color(Color::rgb(1.0, 1.0, 0.0)), + technique: GlowTechnique::Precise, + spread: 0.1, + size: 8.0, + contour: Contour::Linear, + anti_alias: true, + range: 0.5, + jitter: 0.0, + noise: 0.0, + source: GlowSource::Edge, + }), + Effect::InnerGlow(Glow { + common: FxCommon::new(BlendMode::Screen, 0.75), + paint: FxPaint::Gradient(g.clone()), + technique: GlowTechnique::Softer, + spread: 0.0, + size: 5.0, + contour: Contour::Linear, + anti_alias: false, + range: 0.5, + jitter: 0.0, + noise: 0.0, + source: GlowSource::Center, + }), + Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size: 3.0, position: StrokePosition::Inside, paint: FxPaint::Color(Color::rgb(0.0, 1.0, 0.0)) }), + Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Multiply, 0.5), size: 6.0, position: StrokePosition::Center, paint: FxPaint::Gradient(g.clone()) }), + Effect::ColorOverlay { common: FxCommon::new(BlendMode::Overlay, 0.5), color: Color::rgb(0.0, 0.0, 1.0) }, + Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient: g, dither: true }, + Effect::PatternOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), name: "Bubbles".into(), id: "abc".into(), scale: 1.0 }, + Effect::Satin(Satin { common: FxCommon::new(BlendMode::Multiply, 0.5), color: Color::BLACK, angle: 19.0, distance: 11.0, size: 14.0, contour: Contour::Linear, anti_alias: true, invert: true }), + Effect::BevelEmboss(Bevel { + enabled: true, + style: BevelStyle::Emboss, + technique: BevelTechnique::ChiselSoft, + depth: 1.5, + up: false, + size: 7.0, + soften: 2.0, + angle: 45.0, + altitude: 40.0, + use_global_light: false, + gloss_contour: Contour::Linear, + highlight: FxCommon::new(BlendMode::Screen, 0.75), + highlight_color: Color::WHITE, + shadow: FxCommon::new(BlendMode::Multiply, 0.6), + shadow_color: Color::BLACK, + }), + ] + } + + fn approx(a: &[Effect], b: &[Effect]) { + // Colours go through 0..255 doubles; compare via re-serialization. + assert_eq!(a.len(), b.len()); + assert_eq!(write_lfx2(true, a), write_lfx2(true, b)); + } + + #[test] + fn lfx2_roundtrip_all_kinds() { + let fx = sample_effects(); + let data = write_lfx2(false, &fx); + let (master, back) = parse_lfx2(&data).unwrap(); + assert!(!master); + approx(&fx, &back); + // Kind order is canonical: shadows first ... strokes last. + assert!(matches!(back[0], Effect::DropShadow(_))); + assert!(matches!(back.last().unwrap(), Effect::Stroke(_))); + } + + #[test] + fn multi_instances_use_multi_keys() { + let fx = sample_effects(); + let data = write_lfx2(true, &fx); + let (vd, _) = VersionedDescriptor::parse_prefix(&data[4..]).unwrap(); + assert!(vd.descriptor.get("dropShadowMulti").is_some()); + assert!(vd.descriptor.get("frameFXMulti").is_some()); + assert!(vd.descriptor.get("SoFi").is_some()); + } + + #[test] + fn blend_names_are_unique() { + let mut s = std::collections::HashSet::new(); + for (_, n) in BLEND_NAMES { + assert!(s.insert(n)); + } + } + + #[test] + fn garbage_is_none() { + assert!(parse_lfx2(&[0, 0]).is_none()); + assert!(parse_lrfx(&[0, 0, 0, 1, b'X']).is_none()); + } + + #[test] + fn lrfx_drop_shadow() { + // version 0, count 1, 'dsdw' v2 record. + let mut d = vec![0, 0, 0, 1]; + d.extend_from_slice(b"8BIMdsdw"); + let mut rec = Vec::new(); + rec.extend_from_slice(&2u32.to_be_bytes()); // version + rec.extend_from_slice(&(7u32 << 16).to_be_bytes()); // blur (16.16) + rec.extend_from_slice(&0u32.to_be_bytes()); // intensity + rec.extend_from_slice(&(90u32 << 16).to_be_bytes()); // angle + rec.extend_from_slice(&(4u32 << 16).to_be_bytes()); // distance + rec.extend_from_slice(&[0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0]); // color: red + rec.extend_from_slice(b"8BIMmul "); + rec.extend_from_slice(&[1, 1, 128]); + rec.extend_from_slice(&[0; 10]); + d.extend_from_slice(&(rec.len() as u32).to_be_bytes()); + d.extend(rec); + let (_, fx) = parse_lrfx(&d).unwrap(); + match &fx[0] { + Effect::DropShadow(s) => { + assert_eq!(s.size, 7.0); + assert_eq!(s.distance, 4.0); + assert_eq!(s.common.blend, BlendMode::Multiply); + assert!((s.common.opacity - 128.0 / 255.0).abs() < 1e-6); + assert_eq!(s.color.c[0], 1.0); + } + other => panic!("{other:?}"), + } + } +} diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs new file mode 100644 index 00000000..55ed2b2a --- /dev/null +++ b/crates/io/src/flat.rs @@ -0,0 +1,151 @@ +//! Flat raster formats via `photocraft-codecs`. + +use std::sync::Arc; + +use photocraft_codecs::{self as codecs, ChannelLayout, Format, Image, SampleType as CSample}; +use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent}; +use photocraft_geom::{Rect, Size}; +use photocraft_raster::Surface; + +use crate::{ExportOptions, ExportResult, ImportResult, IoError}; + +/// Decodes a flat image into a single-layer document. +pub fn import_flat(name: &str, bytes: &[u8]) -> Result { + let img = codecs::decode(bytes)?; + let mut warnings = Vec::new(); + let (mode, target_layout) = match img.layout() { + ChannelLayout::Gray | ChannelLayout::GrayA => (ColorMode::Grayscale, ChannelLayout::GrayA), + ChannelLayout::Rgb | ChannelLayout::Rgba => (ColorMode::Rgb, ChannelLayout::Rgba), + ChannelLayout::Cmyk | ChannelLayout::CmykA => (ColorMode::Cmyk, ChannelLayout::CmykA), + }; + let (depth, csample) = match img.sample_type() { + CSample::U8 => (SampleType::U8, CSample::U8), + CSample::U16 => (SampleType::U16, CSample::U16), + CSample::F16 => { + warnings.push("16-bit float samples are stored as 32-bit float".to_string()); + (SampleType::F32, CSample::F32) + } + CSample::F32 => (SampleType::F32, CSample::F32), + }; + let (w, h) = img.dimensions(); + let mut doc = Document::new(name, Size::new(w, h), mode, depth); + let conv = img.convert(target_layout, csample); + let fmt = PixelFormat::new(mode, depth, true); + let mut s = Surface::from_interleaved(fmt, Rect::new(0, 0, w as i32, h as i32), conv.data()); + s.prune(); + let mut bg = Layer::new("Background", LayerContent::Raster(s)); + if !img.layout().has_alpha() { + bg.locks.transparency = true; + bg.locks.position = true; + } + doc.layers.push(bg); + doc.icc_profile = img.icc.clone().map(Arc::new); + doc.metadata.exif = img.meta.exif.clone().map(Arc::new); + doc.metadata.xmp = img.meta.xmp.clone(); + if let Some((x, _)) = img.meta.dpi { + doc.resolution_dpi = x; + } + if !img.meta.text.is_empty() { + warnings.push(format!("{} text metadata entries are not kept in the document", img.meta.text.len())); + } + Ok(ImportResult { document: doc, warnings }) +} + +/// `Some(surface)` when the document is exactly one visible, unmasked, +/// normal, fully opaque raster layer: its pixels can be written natively +/// (keeping CMYK / depth exactly) instead of going through the compositor. +fn single_layer(doc: &Document) -> Option<&Surface> { + let [l] = &doc.layers[..] else { return None }; + let ok = l.visible + && l.opacity >= 1.0 + && l.fill_opacity >= 1.0 + && l.mask.is_none() + && l.effects.items.is_empty() + && l.effects.psd_raw.is_none() + && matches!(l.blend, BlendMode::Normal | BlendMode::PassThrough); + match (&l.content, ok) { + (LayerContent::Raster(s), true) if s.format() == doc.pixel_format() => Some(s), + _ => None, + } +} + +fn layout_for(mode: ColorMode, alpha: bool) -> ChannelLayout { + match (mode, alpha) { + (ColorMode::Grayscale, false) => ChannelLayout::Gray, + (ColorMode::Grayscale, true) => ChannelLayout::GrayA, + (ColorMode::Cmyk, false) => ChannelLayout::Cmyk, + (ColorMode::Cmyk, true) => ChannelLayout::CmykA, + (_, false) => ChannelLayout::Rgb, + (_, true) => ChannelLayout::Rgba, + } +} + +fn csample(s: SampleType) -> CSample { + match s { + SampleType::U8 => CSample::U8, + SampleType::U16 => CSample::U16, + SampleType::F32 => CSample::F32, + } +} + +/// Renders the document to a flat codec image (native pixels when possible). +pub fn document_to_image(doc: &Document, warnings: &mut Vec) -> Result { + let (w, h) = (doc.size.width, doc.size.height); + let canvas = doc.bounds(); + let fmt = doc.pixel_format(); + let n = (w as usize) * (h as usize); + let img = if let Some(s) = single_layer(doc) { + // Native path: keep model and depth. + let vals = s.read_region(canvas); + let ch = fmt.channels(); + let opaque = vals.chunks_exact(ch).all(|p| p[ch - 1] >= 1.0); + let layout = layout_for(fmt.mode, !opaque); + let data: Vec = if opaque { vals.chunks_exact(ch).flat_map(|p| p[..ch - 1].to_vec()).collect() } else { vals }; + Image::from_normalized(w, h, layout, csample(fmt.sample), &data)? + } else { + let count = doc.layer_count(); + warnings.push(format!("{count} layer(s) flattened; layers, masks and blend modes are not kept")); + let buf = photocraft_compose::flatten(doc); + let opaque = buf.px.iter().all(|p| p[3] >= 1.0); + // The compositor works in RGB; write RGB/gray. + let gray = fmt.mode == ColorMode::Grayscale; + if fmt.mode == ColorMode::Cmyk || fmt.mode == ColorMode::Lab { + warnings.push(format!("{:?} composite written as RGB (naive conversion, no color management)", fmt.mode)); + } + let layout = layout_for(if gray { ColorMode::Grayscale } else { ColorMode::Rgb }, !opaque); + let mut data = Vec::with_capacity(n * layout.channels()); + for p in &buf.px { + if gray { + data.push(photocraft_color::convert::rgb_to_gray([p[0], p[1], p[2]])); + } else { + data.extend_from_slice(&p[..3]); + } + if !opaque { + data.push(p[3]); + } + } + Image::from_normalized(w, h, layout, csample(fmt.sample), &data)? + }; + let meta = codecs::Metadata { + exif: doc.metadata.exif.as_ref().map(|e| e.to_vec()), + xmp: doc.metadata.xmp.clone(), + dpi: Some((doc.resolution_dpi, doc.resolution_dpi)), + text: Vec::new(), + }; + Ok(img.with_icc(doc.icc_profile.as_ref().map(|i| i.to_vec())).with_meta(meta)) +} + +/// Flattens and encodes as `format`. +pub fn export_flat(doc: &Document, format: Format, opts: &ExportOptions) -> Result { + let mut warnings = Vec::new(); + let img = document_to_image(doc, &mut warnings)?; + for w in codecs::fidelity_warnings_with(&img, format, &opts.encode) { + if w.is_fatal() { + return Err(IoError::Unsupported(w.to_string())); + } + warnings.push(w.to_string()); + } + let bytes = codecs::encode(&img, format, &opts.encode)?; + Ok(ExportResult { bytes, warnings }) +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs new file mode 100644 index 00000000..0a263519 --- /dev/null +++ b/crates/io/src/lib.rs @@ -0,0 +1,143 @@ +//! # photocraft-io +//! +//! Import and export between [`photocraft_doc::Document`] and files: +//! +//! * PSD / PSB (detected by the `8BPS` signature) via `photocraft-psd`, with +//! fidelity levels 1–3 (raster layers, masks, blend/opacity/fill, +//! visibility, names, groups, clipping, adjustment and fill layers). +//! Every layer keeps its unmodelled tagged blocks in `Layer::psd_blocks` +//! (vector masks, blending options, original adjustment/fill blocks, text +//! and smart-object data) and re-export writes them back; text, shape and +//! smart-object layers also keep their pixels as the cached raster, and fill +//! layers keep Photoshop's rendering in `Layer::fill_cache`. +//! * Every other format goes through `photocraft-codecs` as a single +//! "Background" layer (depth and Gray/RGB/CMYK model preserved). +//! +//! Exports to PSD render the merged composite with +//! `photocraft_compose::flatten`; flat exports report what is lost. +#![forbid(unsafe_code)] + +mod adjust_map; +pub mod effects_map; +pub mod blocks; +mod flat; +mod pixels; +mod psd_export; +mod psd_import; + +use photocraft_codecs::{CodecError, EncodeOptions}; +use photocraft_doc::Document; +use photocraft_psd::{PsdError, PsdFile}; + +pub use adjust_map::ADJUSTMENT_KEYS; +pub use flat::document_to_image; +pub use psd_export::{PsdExportOptions, document_to_psd, document_to_psd_with}; +pub use psd_import::psd_to_document; + +/// Errors from import/export. +#[derive(Debug, thiserror::Error)] +pub enum IoError { + /// PSD parse/write failure. + #[error("PSD: {0}")] + Psd(#[from] PsdError), + /// Codec failure. + #[error("codec: {0}")] + Codec(#[from] CodecError), + /// The file type could not be determined. + #[error("unknown file format: {0}")] + UnknownFormat(String), + /// The requested conversion is not possible. + #[error("unsupported: {0}")] + Unsupported(String), +} + +/// Result of [`import`]. +#[derive(Debug, Clone)] +pub struct ImportResult { + /// The imported document. + pub document: Document, + /// Human-readable notes about anything approximated or dropped. + pub warnings: Vec, +} + +/// Result of [`export`]. +#[derive(Debug, Clone)] +pub struct ExportResult { + /// Encoded file. + pub bytes: Vec, + /// Human-readable notes about anything approximated or dropped. + pub warnings: Vec, +} + +/// Export options. +#[derive(Debug, Clone, Default)] +pub struct ExportOptions { + /// Codec options for flat formats. + pub encode: EncodeOptions, + /// Write PSB even for `.psd` names when the document is small. + pub force_psb: bool, +} + +/// `true` if `bytes` start with the PSD/PSB signature. +pub fn is_psd(bytes: &[u8]) -> bool { + bytes.starts_with(b"8BPS") +} + +/// Imports a file. PSD/PSB are detected by magic; everything else is decoded +/// with `photocraft-codecs`. +pub fn import(name: &str, bytes: &[u8]) -> Result { + if is_psd(bytes) { + let file = PsdFile::from_bytes(bytes)?; + let (mut document, warnings) = psd_to_document(&file); + document.name = name.to_string(); + return Ok(ImportResult { document, warnings }); + } + flat::import_flat(name, bytes) +} + +fn extension(name_or_ext: &str) -> String { + name_or_ext.rsplit(['.', '/', '\\']).next().unwrap_or(name_or_ext).to_ascii_lowercase() +} + +/// Exports `doc` to the format named by `name_or_ext` (a file name, path or +/// bare extension). +pub fn export(doc: &Document, name_or_ext: &str, opts: &ExportOptions) -> Result { + let ext = extension(name_or_ext); + if ext == "psd" || ext == "psb" { + let o = PsdExportOptions { force_psb: opts.force_psb || ext == "psb" }; + let (file, warnings) = document_to_psd_with(doc, &o); + let bytes = file.to_bytes()?; + return Ok(ExportResult { bytes, warnings }); + } + let format = photocraft_codecs::from_extension(&ext).ok_or_else(|| IoError::UnknownFormat(name_or_ext.to_string()))?; + flat::export_flat(doc, format, opts) +} + +/// The PSD's merged composite as straight RGBA floats (row-major), converted +/// with the document pixel model and un-matted from white (Photoshop mattes +/// the merged image of transparent documents). Used as the compositing oracle. +pub fn merged_composite(file: &PsdFile) -> Result, IoError> { + let img = file.composite_rgba8().ok(); + let h = &file.header; + if let (Some(img), false) = (img, matches!(h.color_mode, photocraft_psd::ColorMode::Lab)) { + let unmatte = file.merged_has_alpha(); + return Ok(img + .data + .chunks_exact(4) + .map(|p| { + let mut v: [f32; 4] = std::array::from_fn(|i| f32::from(p[i]) / 255.0); + if unmatte { + for c in 0..3 { + v[c] = pixels::unmatte(v[c], v[3], 1.0); + } + } + v + }) + .collect()); + } + // Generic path (Lab and others) via the raster model conversion. + let (doc, _) = psd_to_document(&PsdFile { layer_info: None, ..file.clone() }); + let l = doc.layers.first().ok_or_else(|| IoError::Unsupported("no merged image".into()))?; + let s = l.surface().ok_or_else(|| IoError::Unsupported("no merged image".into()))?; + Ok(photocraft_compose::surface_to_buffer(s, doc.bounds()).px) +} diff --git a/crates/io/src/pixels.rs b/crates/io/src/pixels.rs new file mode 100644 index 00000000..d8c5aa08 --- /dev/null +++ b/crates/io/src/pixels.rs @@ -0,0 +1,179 @@ +//! Conversions between PSD planar big-endian channel data and interleaved +//! native-endian surface bytes. + +use photocraft_color::SampleType; + +/// PSD depth (bits) for a sample type. +pub fn psd_depth(s: SampleType) -> u16 { + match s { + SampleType::U8 => 8, + SampleType::U16 => 16, + SampleType::F32 => 32, + } +} + +/// Sample type for a PSD depth (1-bit is expanded to 8). +pub fn sample_for_depth(depth: u16) -> SampleType { + match depth { + 16 => SampleType::U16, + 32 => SampleType::F32, + _ => SampleType::U8, + } +} + +fn invert_sample(bytes: &mut [u8], s: SampleType) { + match s { + SampleType::U8 => bytes[0] = 255 - bytes[0], + SampleType::U16 => { + let v = u16::from_ne_bytes([bytes[0], bytes[1]]); + bytes.copy_from_slice(&(65535 - v).to_ne_bytes()); + } + SampleType::F32 => { + let v = f32::from_ne_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); + bytes.copy_from_slice(&(1.0 - v).to_ne_bytes()); + } + } +} + +/// Interleaves planes (big-endian samples, `n` pixels each). `None` planes +/// are filled with `fill[c]` (a native-endian encoded sample). Channels whose +/// `invert[c]` is set are inverted (PSD stores CMYK as `max - ink`). +pub fn interleave(planes: &[Option<&[u8]>], fill: &[Vec], n: usize, s: SampleType, invert: &[bool]) -> Vec { + let bps = s.bytes(); + let ch = planes.len(); + let mut out = vec![0u8; n * ch * bps]; + for (c, plane) in planes.iter().enumerate() { + for i in 0..n { + let dst = &mut out[(i * ch + c) * bps..(i * ch + c + 1) * bps]; + match plane { + Some(p) if p.len() >= (i + 1) * bps => { + let src = &p[i * bps..(i + 1) * bps]; + for k in 0..bps { + dst[k] = src[bps - 1 - k]; + } + if cfg!(target_endian = "big") { + dst.copy_from_slice(src); + } + if invert[c] { + invert_sample(dst, s); + } + } + _ => dst.copy_from_slice(&fill[c]), + } + } + } + out +} + +/// Splits interleaved native-endian bytes into big-endian planes. +pub fn deinterleave(bytes: &[u8], ch: usize, s: SampleType, invert: &[bool]) -> Vec> { + let bps = s.bytes(); + let n = bytes.len() / (ch * bps).max(1); + let mut planes = vec![Vec::with_capacity(n * bps); ch]; + let mut tmp = [0u8; 4]; + for i in 0..n { + for (c, plane) in planes.iter_mut().enumerate() { + let src = &bytes[(i * ch + c) * bps..(i * ch + c + 1) * bps]; + tmp[..bps].copy_from_slice(src); + if invert[c] { + invert_sample(&mut tmp[..bps], s); + } + if cfg!(target_endian = "big") { + plane.extend_from_slice(&tmp[..bps]); + } else { + plane.extend(tmp[..bps].iter().rev()); + } + } + } + planes +} + +/// Native-endian encoding of the maximum sample value. +pub fn max_sample(s: SampleType) -> Vec { + match s { + SampleType::U8 => vec![255], + SampleType::U16 => 65535u16.to_ne_bytes().to_vec(), + SampleType::F32 => 1.0f32.to_ne_bytes().to_vec(), + } +} + +/// Native-endian encoding of zero. +pub fn zero_sample(s: SampleType) -> Vec { + vec![0; s.bytes()] +} + +/// Quantizes a normalized value to big-endian bytes. +pub fn encode_be(v: f32, s: SampleType, out: &mut Vec) { + match s { + SampleType::U8 => out.push((v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8), + SampleType::U16 => out.extend_from_slice(&((v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).to_be_bytes()), + SampleType::F32 => out.extend_from_slice(&v.to_be_bytes()), + } +} + +/// Decodes a big-endian sample at `index` to a normalized value. +#[cfg(test)] +pub fn decode_be(bytes: &[u8], index: usize, s: SampleType) -> f32 { + match s { + SampleType::U8 => f32::from(bytes[index]) / 255.0, + SampleType::U16 => f32::from(u16::from_be_bytes([bytes[index * 2], bytes[index * 2 + 1]])) / 65535.0, + SampleType::F32 => { + let o = index * 4; + f32::from_be_bytes([bytes[o], bytes[o + 1], bytes[o + 2], bytes[o + 3]]) + } + } +} + +/// Photoshop stores the merged image of a transparent document matted +/// against white: `m = c·a + w·(1 − a)` per stored channel, where `w` is the +/// model's white. These convert between straight and matted values. +pub fn matte(c: f32, a: f32, white: f32) -> f32 { + c * a + white * (1.0 - a) +} + +/// Inverse of [`matte`] (returns `white` where alpha is 0). +pub fn unmatte(m: f32, a: f32, white: f32) -> f32 { + if a <= 0.0 { white } else { ((m - white * (1.0 - a)) / a).clamp(0.0, 1.0) } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn interleave_roundtrip_all_depths() { + for s in [SampleType::U8, SampleType::U16, SampleType::F32] { + let bps = s.bytes(); + let planes: Vec> = (0..3).map(|c| (0..4 * bps).map(|i| (i * 7 + c * 3) as u8).collect()).collect(); + let refs: Vec> = planes.iter().map(|p| Some(&p[..])).collect(); + let fill = vec![zero_sample(s); 3]; + let inv = [false, true, false]; + let il = interleave(&refs, &fill, 4, s, &inv); + let back = deinterleave(&il, 3, s, &inv); + if s == SampleType::F32 { + // 1 - (1 - v) may not be exact for floats; only check non-inverted planes. + assert_eq!(back[0], planes[0]); + assert_eq!(back[2], planes[2]); + } else { + assert_eq!(back, planes); + } + } + } + + #[test] + fn missing_planes_use_fill() { + let il = interleave(&[None, Some(&[5, 6])], &[vec![9], vec![0]], 2, SampleType::U8, &[false, false]); + assert_eq!(il, vec![9, 5, 9, 6]); + } + + #[test] + fn be_codec() { + let mut v = Vec::new(); + encode_be(1.0, SampleType::U16, &mut v); + assert_eq!(v, vec![0xff, 0xff]); + assert_eq!(decode_be(&v, 0, SampleType::U16), 1.0); + let mut f = Vec::new(); + encode_be(2.5, SampleType::F32, &mut f); + assert_eq!(decode_be(&f, 0, SampleType::F32), 2.5); + } +} diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs new file mode 100644 index 00000000..007556d4 --- /dev/null +++ b/crates/io/src/psd_export.rs @@ -0,0 +1,550 @@ +//! [`Document`] → PSD/PSB. + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent, LayerMask}; +use photocraft_psd::file::{GlobalLayerMask, LayerInfoPlacement}; +use photocraft_psd::layer::{BlendingRanges, ChannelData, LayerFlags, LayerInfo, LayerMask as PsdMask, MaskData, MaskParameters}; +use photocraft_psd::resources::{ImageResource, ResolutionInfo, ids, version_info_resource}; +use photocraft_psd::{ + BlendMode as PsdBlend, ColorMode as PsdMode, Compression, Header, ImageData, LayerRecord, PsdFile, Rect as PsdRect, + SectionType, TaggedBlock, Version, +}; +use photocraft_raster::Surface; + +use crate::adjust_map; +use crate::blocks; +use crate::pixels::{deinterleave, encode_be, psd_depth}; +use crate::psd_import::REGENERATED; + + +/// Options for [`document_to_psd_with`]. +#[derive(Debug, Clone, Default)] +pub struct PsdExportOptions { + /// Write PSB even when the document fits PSD limits. + pub force_psb: bool, +} + +struct Ex { + fmt: PixelFormat, + mask_fmt: PixelFormat, + cc: usize, + cmyk: bool, + version: Version, + next_id: u32, + /// PSD ids assigned to document layers (kept from `psd_id` when unique). + layer_ids: std::collections::HashMap, + canvas: photocraft_geom::Rect, + records: Vec, + warnings: Vec, +} + +fn psd_mode(m: ColorMode) -> PsdMode { + match m { + ColorMode::Grayscale => PsdMode::Grayscale, + ColorMode::Cmyk => PsdMode::Cmyk, + ColorMode::Lab => PsdMode::Lab, + _ => PsdMode::Rgb, + } +} + +fn to_psd_rect(r: photocraft_geom::Rect) -> PsdRect { + PsdRect { top: r.y0, left: r.x0, bottom: r.y1, right: r.x1 } +} + +fn q255(v: f32) -> u8 { + (v.clamp(0.0, 1.0) * 255.0).round() as u8 +} + +impl Ex { + fn compression(&self) -> Compression { + if self.fmt.sample == SampleType::F32 { Compression::ZipPrediction } else { Compression::Rle } + } + + fn encode(&self, id: i16, plane: &[u8], w: usize, h: usize) -> ChannelData { + let depth = psd_depth(self.fmt.sample); + ChannelData::encode(id, self.compression(), plane, w, h, depth, self.version) + .or_else(|_| ChannelData::encode(id, Compression::Raw, plane, w, h, depth, self.version)) + .unwrap_or(ChannelData { id, compression: Some(Compression::Raw), data: plane.to_vec() }) + } + + fn empty_channels(&self) -> Vec { + (-1..self.cc as i16).map(|id| self.encode(id, &[], 0, 0)).collect() + } + + fn pixel_channels(&mut self, s: &Surface, name: &str) -> (PsdRect, Vec) { + let s = if s.format() != self.fmt { + self.warnings.push(format!("layer \"{name}\": pixels converted from {:?} to {:?}", s.format(), self.fmt)); + s.convert(self.fmt) + } else { + s.clone() + }; + let r = s.content_bounds(); + if r.is_empty() { + return (PsdRect::default(), self.empty_channels()); + } + let bytes = s.to_interleaved(r); + let mut invert = vec![self.cmyk; self.cc]; + invert.push(false); + let planes = deinterleave(&bytes, self.cc + 1, self.fmt.sample, &invert); + let (w, h) = (r.width() as usize, r.height() as usize); + let mut ch = vec![self.encode(-1, &planes[self.cc], w, h)]; + for (c, plane) in planes.iter().take(self.cc).enumerate() { + ch.push(self.encode(c as i16, plane, w, h)); + } + (to_psd_rect(r), ch) + } + + fn mask(&self, m: &LayerMask) -> (MaskData, Option) { + let s = if m.surface.format() != self.mask_fmt { m.surface.convert(self.mask_fmt) } else { m.surface.clone() }; + let default = s.default_pixel().first().copied().unwrap_or(0.0); + let r = s.content_bounds(); + let (w, h) = (r.width() as usize, r.height() as usize); + let plane = if r.is_empty() { + Vec::new() + } else { + deinterleave(&s.to_interleaved(r), 1, self.mask_fmt.sample, &[false]).remove(0) + }; + let mut flags = 0u8; + if !m.linked { + flags |= PsdMask::FLAG_RELATIVE; + } + if !m.enabled { + flags |= PsdMask::FLAG_DISABLED; + } + let density = (m.density < 1.0).then(|| q255(m.density)); + let feather = (m.feather != 0.0).then_some(f64::from(m.feather)); + let parameters = (density.is_some() || feather.is_some()).then(|| { + flags |= PsdMask::FLAG_PARAMETERS; + MaskParameters { + flags: u8::from(density.is_some()) | u8::from(feather.is_some()) << 1, + user_density: density, + user_feather: feather, + vector_density: None, + vector_feather: None, + } + }); + let pm = PsdMask { + rect: if r.is_empty() { PsdRect::default() } else { to_psd_rect(r) }, + default_color: q255(default), + flags, + trailing: if parameters.is_none() { vec![0, 0] } else { Vec::new() }, + parameters, + real: None, + }; + (MaskData::Mask(pm), Some(self.encode(-2, &plane, w, h))) + } + + /// Ids for section dividers: above every layer id. + fn fresh_id(&mut self) -> u32 { + self.next_id += 1; + self.next_id + } + + fn layer_id(&mut self, l: &Layer) -> u32 { + match self.layer_ids.get(&l.id) { + Some(id) => *id, + None => self.fresh_id(), + } + } + + fn record(&mut self, l: &Layer, extra: Vec, pixels: Option<&Surface>) -> LayerRecord { + let id = self.layer_id(l); + let (rect, mut channels) = match pixels { + Some(s) => self.pixel_channels(s, &l.name), + None => (PsdRect::default(), self.empty_channels()), + }; + let mut mask = MaskData::None; + if let Some(m) = &l.mask { + let (md, ch) = self.mask(m); + mask = md; + channels.extend(ch); + } + let mut flags = LayerFlags(0); + flags.set_hidden(!l.visible); + flags.set_transparency_protected(l.locks.transparency); + let mut blocks = vec![TaggedBlock::unicode_name(&l.name), TaggedBlock::layer_id(id)]; + let lspf = blocks::lspf_from_locks(&l.locks); + if lspf != 0 { + blocks.push(TaggedBlock::protection(lspf)); + } + if l.label != photocraft_doc::LabelColor::None { + blocks.push(TaggedBlock::sheet_color(blocks::label_index(l.label))); + } + blocks.push(TaggedBlock::fill_opacity(q255(l.fill_opacity))); + // Effects: the original lfx2 while the effects are unchanged (or + // could not be decoded at all); otherwise regenerated. + let fx = &l.effects; + let lfx2 = match effects_unchanged(l) { + Some(true) | None if fx.psd_raw.is_some() => fx.psd_raw.as_ref().map(|r| r.to_vec()), + _ => (!fx.items.is_empty()).then(|| crate::effects_map::write_lfx2(fx.enabled, &fx.items)), + }; + if let Some(d) = lfx2 { + let key = if l.is_group() { *b"lfxs" } else { *b"lfx2" }; + blocks.push(TaggedBlock::new(key, d)); + } + blocks.extend(extra); + LayerRecord { + rect, + channels, + blend_mode: PsdBlend::from_key(l.blend.psd_key()), + opacity: q255(l.opacity), + clipping: u8::from(l.clipped), + flags, + filler: 0, + mask, + blending_ranges: BlendingRanges::full(self.cc), + name: legacy_name(&l.name), + blocks, + extra_trailing: Vec::new(), + } + } + + /// Content blocks for `l`: preserved raw blocks (with `psd_raw` + /// overrides) plus regenerated adjustment/fill blocks. A preserved + /// adjustment or fill block is reused verbatim while it still decodes to + /// the layer's current parameters; otherwise it is regenerated. + fn content_blocks(&mut self, l: &Layer) -> Vec { + let mut raw: Vec<([u8; 4], Vec)> = + l.psd_blocks.iter().filter(|(k, _)| !REGENERATED.contains(&k)).map(|(k, d)| (*k, d.to_vec())).collect(); + // A stale multi-effects block would override the regenerated lfx2. + if effects_unchanged(l) == Some(false) { + raw.retain(|(k, _)| k != b"lmfx"); + } + let mut regenerated: Vec<([u8; 4], Vec)> = Vec::new(); + let set_principal = |raw: &mut Vec<([u8; 4], Vec)>, keys: &[&[u8; 4]], data: Option<&std::sync::Arc>>| { + let Some(d) = data else { return }; + match raw.iter_mut().find(|(k, _)| keys.contains(&k)) { + Some(e) => e.1 = d.to_vec(), + None => raw.push((*keys[0], d.to_vec())), + } + }; + let fill_rule = |raw: &mut Vec<([u8; 4], Vec)>, regenerated: &mut Vec<([u8; 4], Vec)>, f: &photocraft_doc::Fill| { + let keep = raw.iter().any(|(k, d)| matches!(k, b"SoCo" | b"GdFl" | b"PtFl") && blocks::parse_fill(k, d).as_ref() == Some(f)); + if !keep { + raw.retain(|(k, _)| !matches!(k, b"SoCo" | b"GdFl" | b"PtFl")); + regenerated.push(blocks::write_fill(f)); + } + }; + match &l.content { + LayerContent::Adjustment(a) => { + let channels = match self.fmt.mode { + ColorMode::Rgb => adjust_map::Channels::Rgb, + ColorMode::Grayscale => adjust_map::Channels::Gray, + _ => adjust_map::Channels::Other, + }; + let cged = raw.iter().find(|(k, _)| k == b"CgEd").map(|(_, d)| d.clone()); + let keep = raw + .iter() + .any(|(k, d)| adjust_map::ADJUSTMENT_KEYS.contains(&k) && adjust_map::parse(k, d, cged.as_deref(), channels) == *a); + if !keep { + raw.retain(|(k, _)| !adjust_map::ADJUSTMENT_KEYS.contains(&k) && k != b"CgEd"); + let w = adjust_map::write(a); + if w.is_empty() { + self.warnings.push(format!("layer \"{}\": {} adjustment is not yet written to PSD", l.name, a.label())); + } + regenerated.extend(w); + } + } + LayerContent::Fill(f) => fill_rule(&mut raw, &mut regenerated, f), + LayerContent::Shape(sh) => { + if let Some(f) = &sh.fill { + fill_rule(&mut raw, &mut regenerated, f); + } + set_principal(&mut raw, &[b"vsms", b"vmsk"], sh.psd_raw.as_ref()); + } + LayerContent::Text(t) => { + set_principal(&mut raw, &[b"TySh"], t.psd_raw.as_ref()); + if !raw.iter().any(|(k, _)| k == b"TySh") { + self.warnings.push(format!("layer \"{}\": text layer written as pixels (no TySh data)", l.name)); + } + } + LayerContent::Smart(sm) => { + set_principal(&mut raw, &[b"SoLd", b"PlLd", b"SoLE"], sm.psd_raw.as_ref()); + if !raw.iter().any(|(k, _)| matches!(k, b"SoLd" | b"PlLd" | b"SoLE")) { + self.warnings.push(format!("layer \"{}\": smart object written as pixels", l.name)); + } + } + LayerContent::Raster(_) | LayerContent::Group(_) => {} + } + regenerated.into_iter().chain(raw).map(|(k, d)| TaggedBlock::new(k, d)).collect() + } + + /// Pixels for a fill layer: Photoshop's cached rendering while valid, + /// otherwise our own rendering of the fill over the canvas. + fn fill_pixels(&self, l: &Layer, f: &photocraft_doc::Fill) -> Surface { + if let Some(c) = &l.fill_cache + && c.fill == *f + { + return c.surface.clone(); + } + let mut plain = Layer::new("fill", LayerContent::Fill(f.clone())); + plain.fill_cache = None; + let buf = photocraft_compose::render_layer(&plain, self.canvas); + let mut s = Surface::new(self.fmt); + let vals: Vec = buf.px.iter().flat_map(|p| photocraft_raster::from_rgba(&self.fmt, *p)).collect(); + s.write_region(self.canvas, &vals); + s.prune(); + s + } + + fn emit(&mut self, layers: &[Layer]) { + for l in layers { + let extra = self.content_blocks(l); + match &l.content { + LayerContent::Group(g) => { + let id = self.fresh_id(); + self.records.push(LayerRecord { + channels: self.empty_channels(), + blending_ranges: BlendingRanges::full(self.cc), + name: b"".to_vec(), + blocks: vec![ + TaggedBlock::unicode_name(""), + TaggedBlock::layer_id(id), + TaggedBlock::section_divider(SectionType::BoundingDivider, None, None), + ], + ..Default::default() + }); + self.emit(&g.children); + let kind = if g.expanded { SectionType::OpenFolder } else { SectionType::ClosedFolder }; + let lsct = TaggedBlock::section_divider(kind, Some(PsdBlend::from_key(l.blend.psd_key())), None); + let r = self.record(l, std::iter::once(lsct).chain(extra).collect(), None); + self.records.push(r); + } + LayerContent::Raster(s) => { + let r = self.record(l, extra, Some(s)); + self.records.push(r); + } + LayerContent::Adjustment(_) => { + let r = self.record(l, extra, None); + self.records.push(r); + } + LayerContent::Fill(f) => { + let px = self.fill_pixels(l, f); + let r = self.record(l, extra, Some(&px)); + self.records.push(r); + } + LayerContent::Text(t) => { + let r = self.record(l, extra, t.cache.as_ref()); + self.records.push(r); + } + LayerContent::Shape(sh) => { + let r = self.record(l, extra, sh.cache.as_ref()); + self.records.push(r); + } + LayerContent::Smart(sm) => { + let r = self.record(l, extra, sm.cache.as_ref()); + self.records.push(r); + } + } + } + } +} + +/// Whether the layer's effects still equal what its preserved `lmfx`/`lfx2` +/// decodes to. `None` when there is nothing preserved or it can't be decoded. +fn effects_unchanged(l: &Layer) -> Option { + let src = l.psd_blocks.iter().find(|(k, _)| k == b"lmfx").map(|(_, d)| d.clone()).or_else(|| l.effects.psd_raw.clone())?; + let (m, items) = crate::effects_map::parse_lfx2(&src)?; + Some(m == l.effects.enabled && items == l.effects.items) +} + +fn legacy_name(s: &str) -> Vec { + s.chars().map(|c| if c.is_ascii() && !c.is_ascii_control() { c as u8 } else { b'?' }).take(255).collect() +} + +fn unicode_names_resource(names: &[&str]) -> Vec { + let mut v = Vec::new(); + for n in names { + let units: Vec = n.encode_utf16().chain(std::iter::once(0)).collect(); + v.extend_from_slice(&(units.len() as u32).to_be_bytes()); + for u in units { + v.extend_from_slice(&u.to_be_bytes()); + } + } + v +} + +fn pascal_names_resource(names: &[&str]) -> Vec { + let mut v = Vec::new(); + for n in names { + let b = legacy_name(n); + v.push(b.len() as u8); + v.extend_from_slice(&b); + } + v +} + +fn guides_resource(doc: &Document) -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(&1u32.to_be_bytes()); + v.extend_from_slice(&576u32.to_be_bytes()); + v.extend_from_slice(&576u32.to_be_bytes()); + let n = doc.guides.horizontal.len() + doc.guides.vertical.len(); + v.extend_from_slice(&(n as u32).to_be_bytes()); + for (list, dir) in [(&doc.guides.vertical, 0u8), (&doc.guides.horizontal, 1u8)] { + for p in list { + v.extend_from_slice(&((p * 32.0).round() as i32).to_be_bytes()); + v.push(dir); + } + } + v +} + +/// Converts a document to a PSD file model (see [`document_to_psd_with`]). +pub fn document_to_psd(doc: &Document) -> PsdFile { + document_to_psd_with(doc, &PsdExportOptions::default()).0 +} + +/// Converts a document to a PSD/PSB file model, returning warnings about +/// anything that could not be represented. The merged composite is rendered +/// with `photocraft_compose::flatten`. +pub fn document_to_psd_with(doc: &Document, opts: &PsdExportOptions) -> (PsdFile, Vec) { + let fmt = doc.pixel_format(); + let sample = fmt.sample; + let cc = fmt.mode.color_channels(); + let big = doc.size.width > 30_000 || doc.size.height > 30_000; + let version = if opts.force_psb || big { Version::Psb } else { Version::Psd }; + let mut ex = Ex { + fmt, + mask_fmt: PixelFormat::new(ColorMode::Grayscale, sample, false), + cc, + cmyk: fmt.mode == ColorMode::Cmyk, + version, + next_id: 0, + layer_ids: Default::default(), + canvas: doc.bounds(), + records: Vec::new(), + warnings: Vec::new(), + }; + if big && !opts.force_psb { + ex.warnings.push("document exceeds 30000 px; written as PSB".into()); + } + if fmt.mode != doc.mode { + ex.warnings.push(format!("{:?} document written as {:?}", doc.mode, fmt.mode)); + } + // Assign layer ids up front (stable across round trips): keep unique + // `psd_id`s, number the rest after them; dividers come after all. + { + let walk = doc.walk(); + let mut used = std::collections::HashSet::new(); + for (_, _, l) in &walk { + if let Some(id) = l.psd_id + && used.insert(id) + { + ex.layer_ids.insert(l.id, id); + } + } + let mut next = 1u32; + for (_, _, l) in &walk { + if let std::collections::hash_map::Entry::Vacant(e) = ex.layer_ids.entry(l.id) { + while used.contains(&next) { + next += 1; + } + used.insert(next); + e.insert(next); + } + } + ex.next_id = used.iter().copied().max().unwrap_or(0); + } + ex.emit(&doc.layers); + + // Merged composite. + let comp = photocraft_compose::flatten(doc); + let n = comp.px.len(); + let has_alpha = comp.px.iter().any(|p| q255(p[3]) < 255 || (sample == SampleType::F32 && p[3] < 1.0)); + let mut color_planes: Vec> = vec![Vec::with_capacity(n * sample.bytes()); cc + 1]; + let white = photocraft_raster::from_rgba(&fmt, [1.0, 1.0, 1.0, 1.0]); + for p in &comp.px { + let v = photocraft_raster::from_rgba(&fmt, *p); + for c in 0..=cc { + // Matte against white like Photoshop (see `pixels::matte`). + let m = if c < cc && has_alpha { crate::pixels::matte(v[c], v[cc], white[c]) } else { v[c] }; + let x = if ex.cmyk && c < cc { 1.0 - m } else { m }; + encode_be(x, sample, &mut color_planes[c]); + } + } + let mut planes = Vec::new(); + for p in &color_planes[..cc] { + planes.extend_from_slice(p); + } + if has_alpha { + planes.extend_from_slice(&color_planes[cc]); + } + let canvas = doc.bounds(); + let max_extra = 56 - cc - usize::from(has_alpha); + if doc.channels.len() > max_extra { + ex.warnings.push(format!("only {max_extra} alpha channels fit in PSD; the rest were dropped")); + } + let extra: Vec<_> = doc.channels.iter().take(max_extra).collect(); + for a in &extra { + let s = if a.surface.format() != ex.mask_fmt { a.surface.convert(ex.mask_fmt) } else { a.surface.clone() }; + let bytes = s.to_interleaved(canvas); + planes.extend(deinterleave(&bytes, 1, sample, &[false]).remove(0)); + } + let channels = (cc + usize::from(has_alpha) + extra.len()) as u16; + let header = Header::new(version, doc.size.width, doc.size.height, channels, psd_depth(sample), psd_mode(fmt.mode)); + let mcomp = if sample == SampleType::F32 { Compression::Raw } else { Compression::Rle }; + let image_data = ImageData::encode(mcomp, &planes, &header) + .or_else(|_| ImageData::encode(Compression::Raw, &planes, &header)) + .unwrap_or(ImageData { compression: Compression::Raw, data: planes }); + + // Resources. + let mut resources = vec![ + ImageResource::new(ids::RESOLUTION_INFO, ResolutionInfo::from_dpi(f64::from(doc.resolution_dpi)).to_bytes()), + ImageResource::new(ids::GLOBAL_ANGLE, (doc.global_light.angle.round() as i32).to_be_bytes().to_vec()), + ImageResource::new(ids::GLOBAL_ALTITUDE, (doc.global_light.altitude.round() as i32).to_be_bytes().to_vec()), + ]; + if let Some(icc) = &doc.icc_profile { + resources.push(ImageResource::new(ids::ICC_PROFILE, icc.to_vec())); + } + if !doc.guides.horizontal.is_empty() || !doc.guides.vertical.is_empty() { + resources.push(ImageResource::new(1032, guides_resource(doc))); + } + if !extra.is_empty() { + let names: Vec<&str> = extra.iter().map(|a| a.name.as_str()).collect(); + resources.push(ImageResource::new(1006, pascal_names_resource(&names))); + resources.push(ImageResource::new(1045, unicode_names_resource(&names))); + } + if let Some(x) = &doc.metadata.xmp { + resources.push(ImageResource::new(ids::XMP, x.as_bytes().to_vec())); + } + if let Some(e) = &doc.metadata.exif { + resources.push(ImageResource::new(ids::EXIF, e.to_vec())); + } + let mut global_blocks = Vec::new(); + for (sig, key, data) in &doc.metadata.psd_global_blocks { + let mut tb = TaggedBlock::new(*key, data.to_vec()); + tb.signature = *sig; + global_blocks.push(tb); + } + for (id, name, data) in &doc.metadata.psd_resources { + let mut r = ImageResource::new(*id, data.to_vec()); + r.name = legacy_name(name); + resources.push(r); + } + resources.push(version_info_resource(true)); + + let has_layers = !ex.records.is_empty(); + let placement = match (has_layers, sample) { + (true, SampleType::U16) => { + LayerInfoPlacement::GlobalBlock { index: 0, signature: *b"8BIM", key: *b"Lr16", padding: None } + } + (true, SampleType::F32) => { + LayerInfoPlacement::GlobalBlock { index: 0, signature: *b"8BIM", key: *b"Lr32", padding: None } + } + _ => LayerInfoPlacement::Section, + }; + let records = std::mem::take(&mut ex.records); + let file = PsdFile { + header, + color_mode_data: Vec::new(), + resources, + layer_info: has_layers.then_some(LayerInfo { merged_alpha: has_alpha, layers: records, padding: None }), + layer_info_placement: placement, + global_layer_mask: (has_layers || !global_blocks.is_empty()).then(GlobalLayerMask::default), + global_blocks, + layer_mask_trailing: Vec::new(), + image_data, + }; + (file, ex.warnings) +} diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs new file mode 100644 index 00000000..78310225 --- /dev/null +++ b/crates/io/src/psd_import.rs @@ -0,0 +1,476 @@ +//! PSD/PSB → [`Document`]. + +use std::sync::Arc; + +use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{ + AlphaChannel, Document, Effects, FillCache, Group, Layer, LayerContent, LayerMask, ShapeLayer, SmartObject, SmartSource, TextLayer, +}; +use photocraft_geom::{Rect, Size}; +use photocraft_psd::layer::{CHANNEL_REAL_USER_MASK, CHANNEL_TRANSPARENCY, CHANNEL_USER_MASK}; +use photocraft_psd::resources::ids; +use photocraft_psd::tagged::BlockData; +use photocraft_psd::{ColorMode as PsdMode, LayerNode, LayerRecord, PsdFile, TaggedBlock}; +use photocraft_raster::Surface; + +use crate::adjust_map::{self, ADJUSTMENT_KEYS}; +use crate::blocks; +use crate::pixels::{interleave, max_sample, sample_for_depth, zero_sample}; + +/// Resources that are mapped to document fields or regenerated on export. +pub(crate) const MAPPED_RESOURCES: [u16; 16] = [ + ids::GLOBAL_ANGLE, + ids::GLOBAL_ALTITUDE, + ids::RESOLUTION_INFO, + ids::ICC_PROFILE, + ids::XMP, + ids::EXIF, + ids::VERSION_INFO, + ids::THUMBNAIL, + ids::THUMBNAIL_PS4, + ids::LAYER_STATE, + ids::LAYER_GROUP_INFO, + 1032, // grid and guides + 1045, // unicode alpha names + 1006, // pascal alpha names + 1069, // layer selection ids + 1072, // layer group(s) enabled id +]; + +/// Blocks regenerated from document fields on export; not kept in +/// `Layer::psd_blocks`. +pub(crate) const REGENERATED: [&[u8; 4]; 9] = [b"luni", b"lyid", b"lsct", b"lsdk", b"iOpa", b"lspf", b"lclr", b"lfx2", b"lfxs"]; + +pub(crate) struct Ctx<'a> { + pub file: &'a PsdFile, + pub fmt: PixelFormat, + pub mask_fmt: PixelFormat, + pub cc: usize, + pub cmyk: bool, + pub warnings: Vec, +} + +fn doc_mode(m: PsdMode) -> Option { + Some(match m { + PsdMode::Bitmap => ColorMode::Bitmap, + PsdMode::Grayscale => ColorMode::Grayscale, + PsdMode::Indexed => ColorMode::Indexed, + PsdMode::Rgb => ColorMode::Rgb, + PsdMode::Cmyk => ColorMode::Cmyk, + PsdMode::Multichannel => ColorMode::Multichannel, + PsdMode::Duotone => ColorMode::Duotone, + PsdMode::Lab => ColorMode::Lab, + PsdMode::Unknown(_) => return None, + }) +} + +impl Ctx<'_> { + fn warn(&mut self, s: impl Into) { + self.warnings.push(s.into()); + } + + fn channel_plane(&mut self, rec: &LayerRecord, id: i16, name: &str) -> Option> { + rec.channel(id)?; + match rec.decode_channel(id, self.file.header.depth, self.file.header.version) { + Ok(p) => Some(p), + Err(e) => { + self.warn(format!("layer \"{name}\": channel {id} could not be decoded ({e}); treated as empty")); + None + } + } + } + + fn record_surface(&mut self, rec: &LayerRecord, name: &str) -> Surface { + let r = rec.rect; + if r.is_empty() || r.size().is_err() { + return Surface::new(self.fmt); + } + let (w, h) = r.size().unwrap_or((0, 0)); + let s = self.fmt.sample; + let mut planes = Vec::with_capacity(self.cc + 1); + for c in 0..self.cc { + planes.push(self.channel_plane(rec, c as i16, name)); + } + planes.push(self.channel_plane(rec, CHANNEL_TRANSPARENCY, name)); + let refs: Vec> = planes.iter().map(|p| p.as_deref()).collect(); + let mut fill: Vec> = vec![zero_sample(s); self.cc]; + fill.push(max_sample(s)); + let mut invert = vec![self.cmyk; self.cc]; + invert.push(false); + let bytes = interleave(&refs, &fill, w * h, s, &invert); + let mut surf = Surface::from_interleaved(self.fmt, Rect::new(r.left, r.top, r.right, r.bottom), &bytes); + surf.prune(); + surf + } + + fn record_mask(&mut self, rec: &LayerRecord, name: &str) -> Option { + let m = rec.layer_mask()?; + let (id, rect, default, flags) = match (rec.channel(CHANNEL_REAL_USER_MASK), m.real) { + (Some(_), Some(real)) => (CHANNEL_REAL_USER_MASK, real.rect, real.background, real.flags), + _ => { + rec.channel(CHANNEL_USER_MASK)?; + (CHANNEL_USER_MASK, m.rect, m.default_color, m.flags) + } + }; + let mut surface = Surface::with_default(self.mask_fmt, &[f32::from(default) / 255.0]); + if !rect.is_empty() + && let Ok((w, h)) = rect.size() + && let Some(plane) = self.channel_plane(rec, id, name) + { + let s = self.mask_fmt.sample; + let bytes = interleave(&[Some(&plane)], &[zero_sample(s)], w * h, s, &[false]); + surface.write_interleaved(Rect::new(rect.left, rect.top, rect.right, rect.bottom), &bytes); + surface.prune(); + } + let params = m.parameters; + Some(LayerMask { + surface, + enabled: flags & 2 == 0, + linked: flags & 1 == 0, + density: params.and_then(|p| p.user_density).map_or(1.0, |d| f32::from(d) / 255.0), + feather: params.and_then(|p| p.user_feather).map_or(0.0, |f| f as f32), + }) + } + + fn apply_common(&mut self, l: &mut Layer, rec: &LayerRecord, blend_override: Option) { + l.visible = rec.is_visible(); + l.opacity = f32::from(rec.opacity) / 255.0; + l.fill_opacity = f32::from(rec.fill_opacity()) / 255.0; + l.clipped = rec.clipping != 0; + let key = blend_override.unwrap_or(rec.blend_mode).key(); + l.blend = match BlendMode::from_psd_key(key) { + Some(b) => b, + None => { + self.warn(format!( + "layer \"{}\": unknown blend mode {:?}; using Normal", + l.name, + String::from_utf8_lossy(&key) + )); + BlendMode::Normal + } + }; + if let Some(Ok(BlockData::Protection(v))) = rec.block(b"lspf").and_then(TaggedBlock::parsed) { + l.locks = blocks::locks_from_lspf(v); + } + if rec.flags.transparency_protected() { + l.locks.transparency = true; + } + if let Some(Ok(BlockData::SheetColor(v))) = rec.block(b"lclr").and_then(TaggedBlock::parsed) { + l.label = blocks::label_from_index(v); + } + // `lmfx` (multiple instances per kind) supersedes `lfx2` when present; + // it stays in `psd_blocks`, `lfx2` in `Effects::psd_raw`. + // Groups store their effects under `lfxs` (same layout as `lfx2`). + let single = rec.block(b"lfx2").or_else(|| rec.block(b"lfxs")); + if let Some(fx) = rec.block(b"lmfx").or(single) { + let (enabled, items) = crate::effects_map::parse_lfx2(&fx.data).unwrap_or_else(|| (blocks::effects_enabled(&fx.data), Vec::new())); + l.effects = Effects { enabled, items, psd_raw: single.map(|b| Arc::new(b.data.clone())) }; + } else if let Some(fx) = rec.block(b"lrFX") + && !rec.section_type().is_folder() + && let Some((enabled, items)) = crate::effects_map::parse_lrfx(&fx.data) + { + // Legacy effects only on non-group layers (Photoshop ignores + // them on groups, see psd-tools effects/shape-fx.psd). + l.effects = Effects { enabled, items, psd_raw: None }; + } + l.psd_id = rec.layer_id(); + let name = l.name.clone(); + l.mask = self.record_mask(rec, &name); + } + + fn layer_from_record(&mut self, rec: &LayerRecord) -> Layer { + let name = rec.name(); + let fill_key = [b"SoCo", b"GdFl", b"PtFl"].into_iter().find(|k| rec.block(k).is_some()); + let vector_key = [b"vsms", b"vmsk"].into_iter().find(|k| rec.block(k).is_some()); + let adj_key = ADJUSTMENT_KEYS.into_iter().find(|k| rec.block(k).is_some()); + let smart_key = [b"SoLd", b"PlLd", b"SoLE"].into_iter().find(|k| rec.block(k).is_some()); + let blocks = preserved_blocks(rec); + // `psd_raw` shares the Arc of the matching `psd_blocks` entry. + let principal = |k: &[u8; 4]| blocks.iter().find(|(bk, _)| bk == k).map(|(_, d)| d.clone()); + let mut fill_cache = None; + + let content = if let Some(k) = adj_key { + let data = rec.block(k).map(|b| b.data.clone()).unwrap_or_default(); + let cged = rec.block(b"CgEd").map(|b| &b.data[..]); + LayerContent::Adjustment(adjust_map::parse(k, &data, cged, match self.fmt.mode { + ColorMode::Rgb => adjust_map::Channels::Rgb, + ColorMode::Grayscale => adjust_map::Channels::Gray, + _ => adjust_map::Channels::Other, + })) + } else if rec.block(b"TySh").is_some() { + let (text, transform) = rec.block(b"TySh").and_then(|b| blocks::parse_tysh(&b.data)).unwrap_or_default(); + LayerContent::Text(TextLayer { + text, + font_family: String::new(), + size_pt: 0.0, + color: photocraft_color::Color::BLACK, + transform, + cache: Some(self.record_surface(rec, &name)), + psd_raw: principal(b"TySh"), + }) + } else if let Some(k) = smart_key { + let (id, transform) = rec.block(k).map(|b| blocks::parse_smart(k, &b.data)).unwrap_or_default(); + LayerContent::Smart(SmartObject { + source: SmartSource::Linked { path: id }, + transform, + smart_filters: Vec::new(), + cache: Some(self.record_surface(rec, &name)), + psd_raw: principal(k), + }) + } else if (vector_key.is_some() && fill_key.is_some()) || rec.block(b"vscg").is_some() { + let fill = fill_key.and_then(|k| rec.block(k).and_then(|b| blocks::parse_fill(k, &b.data))); + LayerContent::Shape(ShapeLayer { + fill, + cache: Some(self.record_surface(rec, &name)), + psd_raw: vector_key.and_then(principal), + }) + } else if let Some(k) = fill_key { + match rec.block(k).and_then(|b| blocks::parse_fill(k, &b.data)) { + Some(f) => { + // Keep Photoshop's rendering of the fill so it composites exactly. + if !rec.rect.is_empty() { + fill_cache = Some(FillCache { fill: f.clone(), surface: self.record_surface(rec, &name) }); + } + LayerContent::Fill(f) + } + None => { + self.warn(format!("layer \"{name}\": unreadable {} fill; imported as pixels", String::from_utf8_lossy(k))); + LayerContent::Raster(self.record_surface(rec, &name)) + } + } + } else { + LayerContent::Raster(self.record_surface(rec, &name)) + }; + let rendered = matches!(content, LayerContent::Shape(_) | LayerContent::Text(_) | LayerContent::Smart(_)); + let mut l = Layer::new(name, content); + l.psd_blocks = blocks; + l.fill_cache = fill_cache; + self.apply_common(&mut l, rec, None); + // Mask flag bit 3: the user mask was rendered from vector data. For + // shape/text/smart layers the cached pixels already include that + // coverage, so applying it again would double-mask. + if rendered && rec.layer_mask().is_some_and(|m| m.flags & 8 != 0) { + l.mask = None; + } + l + } + + fn build(&mut self, nodes: &[LayerNode]) -> Vec { + let layers = self.file.layers(); + nodes + .iter() + .map(|n| match n { + LayerNode::Layer { index } => self.layer_from_record(&layers[*index]), + LayerNode::Group { index, children, .. } => { + let rec = &layers[*index]; + let children = self.build(children); + let sd = rec.section_divider(); + let expanded = sd.is_none_or(|s| s.kind != photocraft_psd::SectionType::ClosedFolder); + let mut l = Layer::new(rec.name(), LayerContent::Group(Group { children, expanded })); + l.psd_blocks = preserved_blocks(rec); + self.apply_common(&mut l, rec, sd.and_then(|s| s.blend_mode)); + l + } + }) + .collect() + } +} + +fn preserved_blocks(rec: &LayerRecord) -> Vec<([u8; 4], Arc>)> { + rec.blocks.iter().filter(|b| !REGENERATED.contains(&&b.key)).map(|b| (b.key, Arc::new(b.data.clone()))).collect() +} + +fn be_to_ne_plane(plane: &[u8], s: SampleType, n: usize) -> Vec { + interleave(&[Some(plane)], &[zero_sample(s)], n, s, &[false]) +} + +fn unicode_names(data: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut at = 0; + while at + 4 <= data.len() { + let n = u32::from_be_bytes([data[at], data[at + 1], data[at + 2], data[at + 3]]) as usize; + at += 4; + let Some(b) = data.get(at..at + n * 2) else { break }; + let units: Vec = b.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); + out.push(String::from_utf16_lossy(&units).trim_end_matches('\0').to_string()); + at += n * 2; + } + out +} + +fn parse_guides(data: &[u8], doc: &mut Document) { + let rd = |at: usize| data.get(at..at + 4).map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]])); + let Some(count) = rd(12) else { return }; + for i in 0..count as usize { + let at = 16 + i * 5; + let (Some(loc), Some(&dir)) = (rd(at), data.get(at + 4)) else { break }; + let pos = loc as i32 as f32 / 32.0; + if dir == 1 { + doc.guides.horizontal.push(pos); + } else { + doc.guides.vertical.push(pos); + } + } +} + +/// Converts a parsed PSD into a document. Never fails: problems become warnings. +pub fn psd_to_document(file: &PsdFile) -> (Document, Vec) { + let h = &file.header; + let mut warnings = Vec::new(); + let mode = doc_mode(h.color_mode).unwrap_or_else(|| { + warnings.push(format!("unknown color mode {}; importing as RGB", h.color_mode.as_u16())); + ColorMode::Rgb + }); + let layered = matches!(mode, ColorMode::Grayscale | ColorMode::Rgb | ColorMode::Cmyk | ColorMode::Lab) && h.depth != 1; + let depth = if layered { sample_for_depth(h.depth) } else { SampleType::U8 }; + let mut doc = Document::new("Untitled", Size::new(h.width, h.height), mode, depth); + + // Resources. + for r in &file.resources { + match r.id { + ids::RESOLUTION_INFO => { + if let Ok(ri) = photocraft_psd::ResolutionInfo::from_bytes(&r.data) { + let f = if ri.h_res_unit == 2 { 2.54 } else { 1.0 }; + doc.resolution_dpi = (ri.h_res() * f) as f32; + } + } + ids::ICC_PROFILE => doc.icc_profile = Some(Arc::new(r.data.clone())), + ids::XMP => doc.metadata.xmp = Some(String::from_utf8_lossy(&r.data).into_owned()), + ids::EXIF => doc.metadata.exif = Some(Arc::new(r.data.clone())), + 1032 => parse_guides(&r.data, &mut doc), + ids::GLOBAL_ANGLE | ids::GLOBAL_ALTITUDE => { + if let Some(b) = r.data.get(..4) { + let v = i32::from_be_bytes([b[0], b[1], b[2], b[3]]) as f32; + if r.id == ids::GLOBAL_ANGLE { + doc.global_light.angle = v; + } else { + doc.global_light.altitude = v; + } + } + } + id if MAPPED_RESOURCES.contains(&id) => {} + id => doc.metadata.psd_resources.push((id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone()))), + } + } + for b in &file.global_blocks { + doc.metadata.psd_global_blocks.push((b.signature, b.key, Arc::new(b.data.clone()))); + } + + let fmt = doc.pixel_format(); + let cc = fmt.mode.color_channels(); + let mut cx = Ctx { + file, + fmt, + mask_fmt: PixelFormat::new(ColorMode::Grayscale, depth, false), + cc, + cmyk: fmt.mode == ColorMode::Cmyk, + warnings, + }; + + let (w, hh) = (h.width as usize, h.height as usize); + let n = w * hh; + let canvas = Rect::new(0, 0, h.width as i32, h.height as i32); + let merged = file.decode_merged(); + if let Err(e) = &merged { + cx.warn(format!("merged image could not be decoded: {e}")); + } + + if layered { + let tree = file.layer_tree(); + doc.layers = cx.build(&tree); + if doc.layers.is_empty() + && let Ok(all) = &merged + { + // Flattened file: the merged image becomes the background layer. + let plane = h.row_bytes() * hh; + let alpha_idx = if file.merged_has_alpha() { Some(cc) } else { None }; + let mut planes: Vec> = (0..cc).map(|c| all.get(c * plane..(c + 1) * plane)).collect(); + planes.push(alpha_idx.and_then(|a| all.get(a * plane..(a + 1) * plane))); + let mut fill = vec![zero_sample(depth); cc]; + fill.push(max_sample(depth)); + let mut inv = vec![cx.cmyk; cc]; + inv.push(false); + let bytes = interleave(&planes, &fill, n, depth, &inv); + let mut s = Surface::from_interleaved(fmt, canvas, &bytes); + if alpha_idx.is_some() { + // Undo Photoshop's white matting of the merged image. + let white = photocraft_raster::from_rgba(&fmt, [1.0, 1.0, 1.0, 1.0]); + let mut vals = s.read_region(canvas); + for px in vals.chunks_exact_mut(cc + 1) { + let a = px[cc]; + for c in 0..cc { + px[c] = if a <= 0.0 { 0.0 } else { crate::pixels::unmatte(px[c], a, white[c]) }; + } + } + s.write_region(canvas, &vals); + } + s.prune(); + let mut bg = Layer::new("Background", LayerContent::Raster(s)); + if alpha_idx.is_none() { + bg.locks.transparency = true; + bg.locks.position = true; + } + doc.layers.push(bg); + } + } else { + if !file.layers().is_empty() { + cx.warn(format!( + "{:?} documents are imported flattened: {} layer records were not imported", + h.color_mode, + file.layers().len() + )); + } else { + cx.warn(format!("{:?} {}-bit document converted to {:?} 8-bit for editing", h.color_mode, h.depth, fmt.mode)); + } + if !file.color_mode_data.is_empty() { + cx.warn("color mode data (palette / duotone curves) is not preserved"); + } + let rgba = file.composite_rgba8().or_else(|_| { + // Multichannel: show the first channels as RGB. + let all = merged.clone()?; + let plane = h.row_bytes() * hh; + let mut data = vec![255u8; n * 4]; + for c in 0..3.min(usize::from(h.channels)) { + let p = photocraft_psd::pixels::plane_to_u8(&all[c * plane..(c + 1) * plane], h.depth, w, hh)?; + for i in 0..n { + data[i * 4 + c] = p[i]; + } + } + Ok::<_, photocraft_psd::PsdError>(photocraft_psd::RgbaImage { left: 0, top: 0, width: h.width, height: h.height, data }) + }); + if let Ok(img) = rgba { + let mut s = Surface::new(fmt); + let vals: Vec = img + .data + .chunks_exact(4) + .flat_map(|p| { + let v = photocraft_raster::from_rgba(&fmt, [p[0], p[1], p[2], p[3]].map(|x| f32::from(x) / 255.0)); + v.into_iter() + }) + .collect(); + s.write_region(canvas, &vals); + s.prune(); + doc.layers.push(Layer::new("Background", LayerContent::Raster(s))); + } + } + + // Extra (alpha / spot) channels of the merged image. + if layered && let Ok(all) = &merged { + let first = cc + usize::from(file.merged_has_alpha()); + let plane = h.row_bytes() * hh; + let names = file + .resource(1045) + .map(|r| unicode_names(&r.data)) + .unwrap_or_default(); + for (k, idx) in (first..usize::from(h.channels)).enumerate() { + let Some(p) = all.get(idx * plane..(idx + 1) * plane) else { break }; + let mut s = Surface::new(cx.mask_fmt); + s.write_interleaved(canvas, &be_to_ne_plane(p, depth, n)); + s.prune(); + let name = names.get(k).cloned().unwrap_or_else(|| format!("Alpha {}", k + 1)); + doc.channels.push(AlphaChannel { name, surface: s, spot: None }); + } + } + + (doc, cx.warnings) +} diff --git a/crates/io/tests/common/mod.rs b/crates/io/tests/common/mod.rs new file mode 100644 index 00000000..6885bb73 --- /dev/null +++ b/crates/io/tests/common/mod.rs @@ -0,0 +1,322 @@ +//! Shared test helpers: document generators and structural comparison. +#![allow(dead_code)] + +use std::sync::Arc; + +use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType}; +use photocraft_doc::adjust::{CurvePoint, LevelsChannel}; +use photocraft_doc::*; +use photocraft_geom::{Rect, Size}; +use photocraft_raster::Surface; + +/// Quantize to the 1/255 grid so values survive 8-bit PSD fields. +pub fn g(v: u8) -> f32 { + f32::from(v) / 255.0 +} + +/// Pattern surface of `fmt` over `r` (deterministic; values on the 8-bit +/// grid so every depth round-trips exactly). +pub fn pattern(fmt: PixelFormat, r: Rect, seed: u32, alpha: bool) -> Surface { + let mut s = Surface::new(fmt); + let n = fmt.channels(); + let mut vals = Vec::with_capacity(r.width() as usize * r.height() as usize * n); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + for c in 0..n { + let v = if c == n - 1 && fmt.alpha { + if alpha { (i64::from(x + y) * 37 + i64::from(seed)).rem_euclid(256) } else { 255 } + } else { + (i64::from(x * 13 + y * 7) + i64::from(seed) * 31 + c as i64 * 50).rem_euclid(256) + }; + vals.push(v as f32 / 255.0); + } + } + } + s.write_region(r, &vals); + s.prune(); + s +} + +pub fn mask_surface(sample: SampleType, r: Rect, default: f32, seed: u32) -> Surface { + let fmt = PixelFormat::new(ColorMode::Grayscale, sample, false); + let mut s = Surface::with_default(fmt, &[default]); + let vals: Vec = (0..r.width() * r.height()).map(|i| ((i * 29 + seed) % 256) as f32 / 255.0).collect(); + s.write_region(r, &vals); + s.prune(); + s +} + +pub fn fill_color(mode: ColorMode) -> Color { + match mode { + ColorMode::Cmyk => Color { mode: ColorMode::Cmyk, c: [0.2, 0.4, 0.6, 0.1], alpha: 1.0 }, + ColorMode::Grayscale => Color::gray(0.25), + _ => Color::rgb(1.0, 0.5, 0.25), + } +} + +/// Feature flags for generated docs. +#[derive(Clone, Copy)] +pub struct Features { + pub groups: bool, + pub masks: bool, + pub adjustments: bool, + pub fills: bool, + pub all_blends: bool, + pub extras: bool, +} + +impl Features { + pub const ALL: Features = Features { groups: true, masks: true, adjustments: true, fills: true, all_blends: true, extras: true }; + pub const PIXELS: Features = + Features { groups: true, masks: true, adjustments: false, fills: false, all_blends: true, extras: false }; +} + +pub fn raster(name: &str, fmt: PixelFormat, r: Rect, seed: u32, alpha: bool) -> Layer { + Layer::new(name, LayerContent::Raster(pattern(fmt, r, seed, alpha))) +} + +/// A generated document exercising the level 1-3 mapping. +pub fn gen_doc(mode: ColorMode, depth: SampleType, f: Features) -> Document { + let size = Size::new(24, 16); + let mut d = Document::new("gen", size, mode, depth); + let fmt = d.pixel_format(); + let mut bg = raster("Background", fmt, d.bounds(), 1, false); + bg.locks.transparency = true; + bg.locks.position = true; + d.layers.push(bg); + + let mut neg = raster("Negative \u{1F600}", fmt, Rect::new(-5, -3, 7, 6), 2, true); + neg.opacity = g(200); + neg.fill_opacity = g(128); + neg.blend = BlendMode::Multiply; + neg.label = LabelColor::Blue; + d.layers.push(neg); + + let mut hidden = raster("Hidden", fmt, Rect::new(3, 3, 9, 9), 3, true); + hidden.visible = false; + d.layers.push(hidden); + + let mut clipped = raster("Clipped", fmt, Rect::new(0, 0, 10, 10), 4, true); + clipped.clipped = true; + clipped.blend = BlendMode::Screen; + d.layers.push(clipped); + + d.layers.push(Layer::new("Empty", LayerContent::Raster(Surface::new(fmt)))); + + if f.masks { + let mut m = raster("Masked", fmt, Rect::new(2, 2, 14, 12), 5, true); + m.mask = Some(LayerMask { + surface: mask_surface(depth, Rect::new(4, 4, 10, 9), 1.0, 1), + enabled: true, + linked: true, + density: 1.0, + feather: 0.0, + }); + d.layers.push(m); + let mut m2 = raster("Masked2", fmt, Rect::new(-2, 1, 6, 5), 6, true); + m2.mask = Some(LayerMask { + surface: mask_surface(depth, Rect::new(-1, 0, 3, 4), 0.0, 2), + enabled: false, + linked: false, + density: g(128), + feather: 2.5, + }); + d.layers.push(m2); + } + + if f.groups { + let inner_child = raster("Inner child", fmt, Rect::new(1, 1, 5, 5), 7, true); + let mut inner = Layer::group("Inner", vec![inner_child]); + inner.blend = BlendMode::Multiply; + inner.opacity = g(180); + if let LayerContent::Group(gr) = &mut inner.content { + gr.expanded = false; + } + let a = raster("Outer child", fmt, Rect::new(6, 2, 12, 8), 8, true); + let mut outer = Layer::group("Outer", vec![a, inner]); + outer.locks.position = true; + outer.label = LabelColor::Red; + d.layers.push(outer); + d.layers.push(Layer::group("Empty group", vec![])); + } + + if f.all_blends { + for (i, m) in BlendMode::LAYER_MODES.iter().enumerate() { + if *m == BlendMode::Dissolve { + continue; // random, excluded from composite comparisons + } + let mut l = raster(&format!("blend {}", m.label()), fmt, Rect::from_xywh(i as i32 % 20, i as i32 % 12, 3, 2), 10 + i as u32, true); + l.blend = *m; + d.layers.push(l); + } + } + + if f.adjustments { + let adjs = vec![ + Adjustment::Invert, + Adjustment::Threshold { level: g(100) }, + Adjustment::Posterize { levels: 5 }, + Adjustment::BrightnessContrast { brightness: 20.0, contrast: -10.0, legacy: false }, + Adjustment::HueSaturation { hue: 30.0, saturation: -20.0, lightness: 5.0, colorize: false }, + Adjustment::Exposure { exposure: 0.5, offset: 0.0, gamma: 1.0 }, + Adjustment::Levels { + master: LevelsChannel { in_black: g(10), in_white: g(240), gamma: 1.2, out_black: 0.0, out_white: 1.0 }, + per_channel: Default::default(), + }, + Adjustment::Curves { + master: vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: g(128), output: g(150) }, CurvePoint { input: 1.0, output: 1.0 }], + per_channel: [ + vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }], + vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }], + vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }], + ], + }, + Adjustment::Unsupported { psd_key: "selc".into(), raw: vec![0, 1, 0, 0] }, + ]; + for (i, a) in adjs.into_iter().enumerate() { + let mut l = Layer::new(format!("adj {i}"), LayerContent::Adjustment(a)); + l.opacity = g(if i % 2 == 0 { 255 } else { 128 }); + d.layers.push(l); + } + } + + if f.fills { + let mut solid = Layer::new("Solid fill", LayerContent::Fill(Fill::Solid(fill_color(mode)))); + solid.opacity = g(64); + d.layers.push(solid); + } + + if f.extras { + d.resolution_dpi = 300.0; + d.icc_profile = Some(Arc::new(vec![1, 2, 3, 4])); + d.metadata.xmp = Some("".into()); + d.metadata.exif = Some(Arc::new(b"MM\0*\0\0\0\x08".to_vec())); + d.metadata.psd_resources.push((4000, String::new(), Arc::new(vec![9, 9, 9]))); + d.guides.horizontal.push(5.5); + d.guides.vertical.push(12.0); + d.channels.push(AlphaChannel { + name: "Selection".into(), + surface: mask_surface(depth, d.bounds(), 0.0, 3), + spot: None, + }); + } + d +} + +fn pruned(s: &Surface) -> Surface { + let mut s = s.clone(); + s.prune(); + s +} + +fn surface_eq(a: &Surface, b: &Surface, ctx: &str) { + let (a, b) = (pruned(a), pruned(b)); + assert_eq!(a.format(), b.format(), "{ctx}: format"); + assert_eq!(a.default_pixel(), b.default_pixel(), "{ctx}: default pixel"); + let r = a.content_bounds().union(&b.content_bounds()); + assert_eq!(a.content_bounds(), b.content_bounds(), "{ctx}: bounds"); + if !r.is_empty() { + assert!(a.to_interleaved(r) == b.to_interleaved(r), "{ctx}: pixels differ"); + } +} + +fn color_close(a: &Color, b: &Color, ctx: &str) { + assert_eq!(a.mode, b.mode, "{ctx}: color mode"); + for i in 0..4 { + assert!((a.c[i] - b.c[i]).abs() < 1e-4, "{ctx}: color {a:?} vs {b:?}"); + } +} + +pub fn assert_layers_eq(a: &Layer, b: &Layer, path: &str) { + let ctx = format!("{path}/{}", a.name); + assert_eq!(a.name, b.name, "{ctx}: name"); + assert_eq!(a.visible, b.visible, "{ctx}: visible"); + assert_eq!(a.locks, b.locks, "{ctx}: locks"); + assert_eq!(a.blend, b.blend, "{ctx}: blend"); + assert_eq!(a.opacity, b.opacity, "{ctx}: opacity"); + assert_eq!(a.fill_opacity, b.fill_opacity, "{ctx}: fill"); + assert_eq!(a.clipped, b.clipped, "{ctx}: clipped"); + assert_eq!(a.label, b.label, "{ctx}: label"); + assert_eq!(a.effects.psd_raw, b.effects.psd_raw, "{ctx}: effects raw"); + assert_eq!(a.effects.enabled, b.effects.enabled, "{ctx}: effects enabled"); + if a.psd_id.is_some() { + assert_eq!(a.psd_id, b.psd_id, "{ctx}: psd id"); + } + for blk in &a.psd_blocks { + assert!(b.psd_blocks.contains(blk), "{ctx}: preserved block {:?} missing", String::from_utf8_lossy(&blk.0)); + } + if let Some(fc) = &a.fill_cache { + let other = b.fill_cache.as_ref().expect("fill cache kept"); + surface_eq(&fc.surface, &other.surface, &format!("{ctx}: fill cache")); + } + match (&a.mask, &b.mask) { + (None, None) => {} + (Some(x), Some(y)) => { + assert_eq!(x.enabled, y.enabled, "{ctx}: mask enabled"); + assert_eq!(x.linked, y.linked, "{ctx}: mask linked"); + assert_eq!(x.density, y.density, "{ctx}: mask density"); + assert_eq!(x.feather, y.feather, "{ctx}: mask feather"); + surface_eq(&x.surface, &y.surface, &format!("{ctx}: mask")); + } + _ => panic!("{ctx}: mask presence differs"), + } + match (&a.content, &b.content) { + (LayerContent::Raster(x), LayerContent::Raster(y)) => surface_eq(x, y, &ctx), + (LayerContent::Group(x), LayerContent::Group(y)) => { + assert_eq!(x.expanded, y.expanded, "{ctx}: expanded"); + assert_eq!(x.children.len(), y.children.len(), "{ctx}: child count"); + for (p, q) in x.children.iter().zip(&y.children) { + assert_layers_eq(p, q, &ctx); + } + } + (LayerContent::Adjustment(x), LayerContent::Adjustment(y)) => assert_eq!(x, y, "{ctx}: adjustment"), + (LayerContent::Fill(Fill::Solid(x)), LayerContent::Fill(Fill::Solid(y))) => color_close(x, y, &ctx), + (LayerContent::Fill(x), LayerContent::Fill(y)) => assert_eq!(x, y, "{ctx}: fill"), + (LayerContent::Text(x), LayerContent::Text(y)) => { + assert_eq!(x.text, y.text, "{ctx}: text"); + surface_eq(x.cache.as_ref().unwrap(), y.cache.as_ref().unwrap(), &ctx); + } + (LayerContent::Shape(x), LayerContent::Shape(y)) => { + surface_eq(x.cache.as_ref().unwrap(), y.cache.as_ref().unwrap(), &ctx); + } + (LayerContent::Smart(x), LayerContent::Smart(y)) => { + surface_eq(x.cache.as_ref().unwrap(), y.cache.as_ref().unwrap(), &ctx); + } + (x, y) => panic!("{ctx}: kind {} vs {}", x.kind_name(), y.kind_name()), + } +} + +pub fn assert_docs_eq(a: &Document, b: &Document) { + assert_eq!(a.size, b.size); + assert_eq!(a.mode, b.mode); + assert_eq!(a.depth, b.depth); + assert!((a.resolution_dpi - b.resolution_dpi).abs() < 1e-3, "dpi"); + assert_eq!(a.icc_profile, b.icc_profile, "icc"); + assert_eq!(a.metadata, b.metadata, "metadata"); + assert_eq!(a.guides, b.guides, "guides"); + assert_eq!(a.channels.len(), b.channels.len(), "alpha channels"); + for (x, y) in a.channels.iter().zip(&b.channels) { + assert_eq!(x.name, y.name); + surface_eq(&x.surface, &y.surface, &format!("channel {}", x.name)); + } + assert_eq!(a.layers.len(), b.layers.len(), "root layer count"); + for (x, y) in a.layers.iter().zip(&b.layers) { + assert_layers_eq(x, y, ""); + } +} + +/// Max per-channel difference between two RGBA float buffers, over pixels +/// where either is not fully transparent (color is irrelevant at alpha 0). +pub fn max_diff(a: &[[f32; 4]], b: &[[f32; 4]]) -> f32 { + let mut m = 0.0f32; + for (p, q) in a.iter().zip(b) { + if p[3] <= 0.0 && q[3] <= 0.0 { + continue; + } + for c in 0..4 { + let (x, y) = if c < 3 { (p[c] * p[3], q[c] * q[3]) } else { (p[c], q[c]) }; + m = m.max((x - y).abs()); + } + } + m +} diff --git a/crates/io/tests/composite.rs b/crates/io/tests/composite.rs new file mode 100644 index 00000000..2795fc9f --- /dev/null +++ b/crates/io/tests/composite.rs @@ -0,0 +1,166 @@ +//! Composite oracle: the merged image stored in a PSD must match our +//! compositor's rendering of the imported document. + +mod common; + +use common::*; +use photocraft_color::{BlendMode, ColorMode, SampleType}; +use photocraft_io::*; +use photocraft_psd::{PixelData, PsdBuilder, PsdFile}; + +const TOL: f32 = 1.0 / 255.0 + 1e-4; + +fn check_export_oracle(d: &photocraft_doc::Document, tol: f32) { + let r = export(d, "x.psd", &ExportOptions::default()).unwrap(); + let f = PsdFile::from_bytes(&r.bytes).unwrap(); + let merged = merged_composite(&f).unwrap(); + let d2 = import("x.psd", &r.bytes).unwrap().document; + let ours = photocraft_compose::flatten(&d2).px; + let m = max_diff(&ours, &merged); + assert!(m <= tol, "max diff {m} > {tol}"); +} + +macro_rules! oracle { + ($name:ident, $mode:expr, $depth:expr, $f:expr, $tol:expr) => { + #[test] + fn $name() { + check_export_oracle(&gen_doc($mode, $depth, $f), $tol); + } + }; +} + +oracle!(oracle_rgb8, ColorMode::Rgb, SampleType::U8, Features::ALL, TOL); +oracle!(oracle_rgb16, ColorMode::Rgb, SampleType::U16, Features::ALL, TOL); +oracle!(oracle_rgb32, ColorMode::Rgb, SampleType::F32, Features::ALL, TOL); +oracle!(oracle_gray8, ColorMode::Grayscale, SampleType::U8, Features::ALL, TOL); +oracle!(oracle_gray16, ColorMode::Grayscale, SampleType::U16, Features::ALL, TOL); +// CMYK/Lab merged data is converted naively from the RGB composite, so the +// stored merged image differs by a quantization round trip through the model. +oracle!(oracle_cmyk8, ColorMode::Cmyk, SampleType::U8, Features::PIXELS, 3.0 / 255.0); +// Lab: one 8-bit Lab quantization step is amplified up to ~15/255 near the sRGB gamut +// edge (the sRGB toe has slope 12.92), so the tolerance is wider. +oracle!(oracle_lab8, ColorMode::Lab, SampleType::U8, Features::PIXELS, 16.0 / 255.0); + +/// Builds a PSD with the *builder* (independent writer) whose merged image +/// is produced by photocraft-compose, then checks import + flatten against it. +fn builder_oracle(depth: u16, mode: ColorMode) { + let sample = if depth == 16 { SampleType::U16 } else { SampleType::U8 }; + let d = gen_doc(mode, sample, Features { groups: false, masks: true, adjustments: false, fills: false, all_blends: true, extras: false }); + let flat = photocraft_compose::flatten(&d); + let to_pixels = |px: &[[f32; 4]]| -> PixelData { + let fmt = d.pixel_format(); + let vals: Vec = px + .iter() + .flat_map(|p| { + let v = photocraft_raster::from_rgba(&fmt, *p); + v.into_iter() + }) + .collect(); + match (mode, depth) { + (ColorMode::Grayscale, 8) => PixelData::GrayA8(vals.iter().map(|v| (v * 255.0).round() as u8).collect()), + (ColorMode::Grayscale, _) => PixelData::GrayA16(vals.iter().map(|v| (v * 65535.0).round() as u16).collect()), + (_, 8) => PixelData::Rgba8(vals.iter().map(|v| (v * 255.0).round() as u8).collect()), + _ => PixelData::Rgba16(vals.iter().map(|v| (v * 65535.0).round() as u16).collect()), + } + }; + let mut b = PsdBuilder::new(d.size.width, d.size.height).depth(depth).color_mode(match mode { + ColorMode::Grayscale => photocraft_psd::ColorMode::Grayscale, + _ => photocraft_psd::ColorMode::Rgb, + }); + for l in &d.layers { + let s = l.surface().unwrap(); + let r = s.content_bounds(); + let buf: Vec<[f32; 4]> = if r.is_empty() { + Vec::new() + } else { + let fmt = s.format(); + s.read_region(r).chunks_exact(fmt.channels()).map(|p| photocraft_raster::to_rgba(&fmt, p)).collect() + }; + let mut spec = photocraft_psd::LayerSpec::new(l.name.clone(), r.x0, r.y0, r.width(), r.height(), to_pixels(&buf)); + spec.blend_mode = photocraft_psd::BlendMode::from_key(l.blend.psd_key()); + spec.opacity = (l.opacity * 255.0).round() as u8; + spec.fill_opacity = Some((l.fill_opacity * 255.0).round() as u8); + spec.visible = l.visible; + spec.clipping = l.clipped; + if let Some(m) = &l.mask { + let mr = m.surface.content_bounds(); + let data = m.surface.read_region(mr).iter().map(|v| (v * 255.0).round() as u8).collect(); + spec.mask = Some(photocraft_psd::MaskSpec { + rect: photocraft_psd::Rect { top: mr.y0, left: mr.x0, bottom: mr.y1, right: mr.x1 }, + data, + default_color: (m.surface.default_pixel()[0] * 255.0).round() as u8, + disabled: !m.enabled, + }); + } + b.push_layer(spec); + } + b.composite(to_pixels(&flat.px)); + let bytes = b.to_bytes().unwrap(); + let f = PsdFile::from_bytes(&bytes).unwrap(); + let merged = merged_composite(&f).unwrap(); + let d2 = import("b.psd", &bytes).unwrap().document; + let ours = photocraft_compose::flatten(&d2).px; + let m = max_diff(&ours, &merged); + // Density/feather of masks are not written by the builder; the generated + // doc uses them only on a disabled mask, so results must match. + assert!(m <= 2.0 / 255.0, "builder oracle {mode:?} {depth}: max diff {m}"); +} + +#[test] +fn builder_oracle_rgb8() { + builder_oracle(8, ColorMode::Rgb); +} +#[test] +fn builder_oracle_rgb16() { + builder_oracle(16, ColorMode::Rgb); +} +#[test] +fn builder_oracle_gray8() { + builder_oracle(8, ColorMode::Grayscale); +} +#[test] +fn builder_oracle_gray16() { + builder_oracle(16, ColorMode::Grayscale); +} + +#[test] +fn every_blend_mode_single_layer_oracle() { + for m in BlendMode::LAYER_MODES { + if m == BlendMode::Dissolve { + continue; + } + let mut d = photocraft_doc::Document::new("b", photocraft_geom::Size::new(8, 8), ColorMode::Rgb, SampleType::U8); + let fmt = d.pixel_format(); + d.layers.push(raster("bg", fmt, d.bounds(), 1, false)); + let mut top = raster("top", fmt, photocraft_geom::Rect::new(2, 2, 7, 7), 9, true); + top.blend = m; + top.opacity = g(190); + d.layers.push(top); + check_export_oracle(&d, TOL); + } +} + +#[test] +fn hidden_and_clipped_affect_composite() { + let d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); + let f = document_to_psd(&d); + let merged = merged_composite(&f).unwrap(); + let mut d2 = d.clone(); + for l in &mut d2.layers { + l.visible = true; + } + let other = photocraft_compose::flatten(&d2).px; + assert!(max_diff(&other, &merged) > 0.0, "unhiding a layer must change the composite"); +} + +#[test] +fn merged_alpha_written_when_transparent() { + let mut d = photocraft_doc::Document::new("t", photocraft_geom::Size::new(4, 4), ColorMode::Rgb, SampleType::U8); + let fmt = d.pixel_format(); + d.layers.push(raster("half", fmt, photocraft_geom::Rect::new(0, 0, 2, 4), 3, true)); + let f = document_to_psd(&d); + assert_eq!(f.header.channels, 4); + assert!(f.merged_has_alpha()); + let merged = merged_composite(&f).unwrap(); + assert_eq!(merged[3][3], 0.0); +} diff --git a/crates/io/tests/corpus.rs b/crates/io/tests/corpus.rs new file mode 100644 index 00000000..2b8880e1 --- /dev/null +++ b/crates/io/tests/corpus.rs @@ -0,0 +1,114 @@ +//! Optional real-file corpus: `corpus/psd/**/*.{psd,psb}` at the workspace +//! root (gitignored; see `corpus/psd/SOURCES.md`). Skips silently if absent. +//! +//! For each file: parse → document → flatten, compared with the file's own +//! merged composite (Photoshop's rendering) as the oracle; then document → +//! PSD → parse. Prints a per-file table. The oracle comparison is reported, +//! not asserted: differences are expected where features are not yet +//! rendered (effects, text engine, smart filters, some adjustments). +//! Set `PHOTOCRAFT_CORPUS_STRICT=1` to fail on import/export errors. + +mod common; + +use std::path::{Path, PathBuf}; + +use photocraft_io::*; +use photocraft_psd::PsdFile; + +fn collect(dir: &Path, out: &mut Vec) { + let Ok(rd) = std::fs::read_dir(dir) else { return }; + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + collect(&p, out); + } else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("psd") || e.eq_ignore_ascii_case("psb")) { + out.push(p); + } + } +} + +const PASS_TOL: f32 = 2.0 / 255.0; + +#[test] +fn corpus_import_flatten_oracle() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd"); + if !root.is_dir() { + return; + } + let mut files = Vec::new(); + collect(&root, &mut files); + files.sort(); + let (mut pass, mut diff, mut skipped, mut errors) = (0, 0, 0, 0); + eprintln!("{:<60} {:>6} {:>9} {:>8} status", "file", "layers", "max_err", "bad_px%"); + for p in &files { + let name = p.strip_prefix(&root).unwrap_or(p).display().to_string(); + let bytes = std::fs::read(p).unwrap_or_default(); + let file = match PsdFile::from_bytes(&bytes) { + Ok(f) => f, + Err(e) => { + eprintln!("{name:<60} {:>6} {:>9} {:>8} PARSE-ERROR {e}", "-", "-", "-"); + errors += 1; + continue; + } + }; + let imp = match import(&name, &bytes) { + Ok(i) => i, + Err(e) => { + eprintln!("{name:<60} IMPORT-ERROR {e}"); + errors += 1; + continue; + } + }; + let doc = &imp.document; + // Export must always succeed and re-parse. + match export(doc, "x.psd", &ExportOptions::default()) { + Ok(r) => { + if let Err(e) = PsdFile::from_bytes(&r.bytes) { + eprintln!("{name:<60} REEXPORT-PARSE-ERROR {e}"); + errors += 1; + continue; + } + } + Err(e) => { + eprintln!("{name:<60} EXPORT-ERROR {e}"); + errors += 1; + continue; + } + } + let layers = doc.layer_count(); + if file.has_real_merged_data() == Some(false) || file.layers().is_empty() { + eprintln!("{name:<60} {layers:>6} {:>9} {:>8} SKIP (no layers or no real composite)", "-", "-"); + skipped += 1; + continue; + } + let Ok(merged) = merged_composite(&file) else { + eprintln!("{name:<60} {layers:>6} {:>9} {:>8} SKIP (merged not decodable)", "-", "-"); + skipped += 1; + continue; + }; + let ours = photocraft_compose::flatten(doc).px; + let m = common::max_diff(&ours, &merged); + let bad = ours + .iter() + .zip(&merged) + .filter(|(a, b)| (0..4).any(|c| (a[c] * a[3] - b[c] * b[3]).abs() > PASS_TOL)) + .count(); + let pct = 100.0 * bad as f32 / ours.len().max(1) as f32; + let status = if m <= PASS_TOL { + pass += 1; + "PASS" + } else { + diff += 1; + "DIFF" + }; + let notes: Vec<&str> = imp.warnings.iter().map(String::as_str).take(2).collect(); + eprintln!("{name:<60} {layers:>6} {:>9.4} {:>7.2}% {status} {}", m, pct, notes.join(" | ")); + } + eprintln!( + "io corpus: {} files: {pass} pass (<= 2/255), {diff} differ, {skipped} skipped, {errors} errors", + files.len() + ); + if std::env::var_os("PHOTOCRAFT_CORPUS_STRICT").is_some() { + assert_eq!(errors, 0); + } +} diff --git a/crates/io/tests/flat.rs b/crates/io/tests/flat.rs new file mode 100644 index 00000000..f1a4b41a --- /dev/null +++ b/crates/io/tests/flat.rs @@ -0,0 +1,203 @@ +//! Flat formats through photocraft-codecs, and export warnings. + +mod common; + +use common::*; +use photocraft_color::{ColorMode, SampleType}; +use photocraft_io::*; + +fn single(mode: ColorMode, depth: SampleType, alpha: bool) -> photocraft_doc::Document { + let mut d = photocraft_doc::Document::new("s", photocraft_geom::Size::new(9, 6), mode, depth); + let fmt = d.pixel_format(); + d.layers.push(raster("Background", fmt, d.bounds(), 3, alpha)); + d +} + +fn pixels_eq(a: &photocraft_doc::Document, b: &photocraft_doc::Document, tol: f32) { + let sa = a.layers[0].surface().unwrap(); + let sb = b.layers[0].surface().unwrap(); + let r = a.bounds(); + let (va, vb) = (sa.read_region(r), sb.read_region(r)); + assert_eq!(va.len(), vb.len()); + let m = va.iter().zip(&vb).map(|(x, y)| (x - y).abs()).fold(0.0f32, f32::max); + assert!(m <= tol, "max diff {m}"); +} + +macro_rules! codec_rt { + ($name:ident, $ext:expr, $mode:expr, $depth:expr, $alpha:expr, $tol:expr) => { + #[test] + fn $name() { + let d = single($mode, $depth, $alpha); + let r = export(&d, $ext, &ExportOptions::default()).expect("export"); + let back = import(concat!("x.", $ext), &r.bytes).expect("import").document; + assert_eq!(back.size, d.size); + assert_eq!(back.mode, d.mode, "mode"); + assert_eq!(back.depth, d.depth, "depth"); + pixels_eq(&d, &back, $tol); + } + }; +} + +codec_rt!(png_rgba8, "png", ColorMode::Rgb, SampleType::U8, true, 0.0); +codec_rt!(png_rgb8_opaque, "png", ColorMode::Rgb, SampleType::U8, false, 0.0); +codec_rt!(png_rgba16, "png", ColorMode::Rgb, SampleType::U16, true, 0.0); +codec_rt!(png_gray8, "png", ColorMode::Grayscale, SampleType::U8, false, 0.0); +codec_rt!(png_graya16, "png", ColorMode::Grayscale, SampleType::U16, true, 0.0); +codec_rt!(tiff_rgba8, "tiff", ColorMode::Rgb, SampleType::U8, true, 0.0); +codec_rt!(tiff_rgb16, "tif", ColorMode::Rgb, SampleType::U16, false, 0.0); +codec_rt!(tiff_cmyk8, "tiff", ColorMode::Cmyk, SampleType::U8, false, 0.0); +codec_rt!(tiff_cmyka16, "tiff", ColorMode::Cmyk, SampleType::U16, true, 0.0); +codec_rt!(tiff_gray8, "tiff", ColorMode::Grayscale, SampleType::U8, false, 0.0); +codec_rt!(exr_rgba32, "exr", ColorMode::Rgb, SampleType::F32, true, 0.0); + +fn smooth(mode: ColorMode) -> photocraft_doc::Document { + let mut d = photocraft_doc::Document::new("s", photocraft_geom::Size::new(32, 16), mode, SampleType::U8); + let fmt = d.pixel_format(); + let mut s = photocraft_raster::Surface::new(fmt); + let n = fmt.channels(); + let mut v = Vec::new(); + for y in 0..16 { + for x in 0..32 { + for c in 0..n - 1 { + v.push(((x * 4 + y * 2 + c * 20) as f32 / 255.0).min(1.0)); + } + v.push(1.0); + } + } + s.write_region(d.bounds(), &v); + d.layers.push(photocraft_doc::Layer::new("Background", photocraft_doc::LayerContent::Raster(s))); + d +} + +fn jpeg_rt(mode: ColorMode, ext: &str) { + let d = smooth(mode); + let r = export(&d, ext, &ExportOptions::default()).unwrap(); + assert!(r.warnings.iter().any(|w| w.to_lowercase().contains("lossy")), "{:?}", r.warnings); + let back = import("x.jpg", &r.bytes).unwrap().document; + assert_eq!(back.mode, mode); + let (a, b) = (d.layers[0].surface().unwrap().read_region(d.bounds()), back.layers[0].surface().unwrap().read_region(d.bounds())); + let mean = a.iter().zip(&b).map(|(x, y)| (x - y).abs()).sum::() / a.len() as f32; + assert!(mean < 0.02, "mean error {mean}"); +} + +#[test] +fn jpeg_rgb8() { + jpeg_rt(ColorMode::Rgb, "jpg"); +} + +#[test] +fn jpeg_gray8() { + jpeg_rt(ColorMode::Grayscale, "jpeg"); +} + +#[test] +fn metadata_roundtrip_png() { + let mut d = single(ColorMode::Rgb, SampleType::U8, false); + d.resolution_dpi = 300.0; + d.icc_profile = Some(std::sync::Arc::new(sample_icc())); + d.metadata.xmp = Some("".into()); + let r = export(&d, "png", &ExportOptions::default()).unwrap(); + let back = import("x.png", &r.bytes).unwrap().document; + assert!((back.resolution_dpi - 300.0).abs() < 1.0); + assert_eq!(back.icc_profile, d.icc_profile); + assert_eq!(back.metadata.xmp, d.metadata.xmp); +} + +fn sample_icc() -> Vec { + // Arbitrary bytes are fine for PNG iCCP (the codec does not validate). + let mut v = vec![0u8; 128]; + v[36..40].copy_from_slice(b"acsp"); + v +} + +#[test] +fn layered_to_png_warns_flatten() { + let d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); + let r = export(&d, "out.png", &ExportOptions::default()).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("flattened")), "{:?}", r.warnings); + let back = import("out.png", &r.bytes).unwrap().document; + // Composite equals our flatten. + let flat = photocraft_compose::flatten(&d).px; + let s = back.layers[0].surface().unwrap(); + let px: Vec<[f32; 4]> = s.read_region(back.bounds()).chunks_exact(4).map(|p| [p[0], p[1], p[2], p[3]]).collect(); + assert!(max_diff(&px, &flat) <= 1.0 / 255.0 + 1e-4); +} + +#[test] +fn sixteen_bit_to_jpeg_warns_depth() { + let d = single(ColorMode::Rgb, SampleType::U16, false); + let r = export(&d, "a.jpg", &ExportOptions::default()).unwrap(); + assert!(r.warnings.iter().any(|w| w.to_lowercase().contains("bit")), "{:?}", r.warnings); +} + +#[test] +fn alpha_to_jpeg_warns() { + let d = single(ColorMode::Rgb, SampleType::U8, true); + let r = export(&d, "a.jpg", &ExportOptions::default()).unwrap(); + assert!(r.warnings.len() >= 2, "{:?}", r.warnings); +} + +#[test] +fn cmyk_layered_to_png_warns_conversion() { + let d = gen_doc(ColorMode::Cmyk, SampleType::U8, Features::PIXELS); + let r = export(&d, "a.png", &ExportOptions::default()).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("RGB")), "{:?}", r.warnings); +} + +#[test] +fn effects_are_written_to_psd_and_read_back() { + let mut d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); + d.layers[1].effects.items.push(photocraft_doc::Effect::ColorOverlay { + common: photocraft_doc::FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), + color: photocraft_color::Color::BLACK, + }); + d.layers[1].effects.items.push(photocraft_doc::Effect::default_drop_shadow()); + let r = export(&d, "a.psd", &ExportOptions::default()).unwrap(); + assert!(!r.warnings.iter().any(|w| w.contains("effects")), "{:?}", r.warnings); + let back = import("a.psd", &r.bytes).unwrap().document; + assert_eq!(back.layers[1].effects.items.len(), 2); + assert!(back.layers[1].effects.psd_raw.is_some()); +} + +#[test] +fn unknown_extension_errors() { + let d = single(ColorMode::Rgb, SampleType::U8, false); + assert!(matches!(export(&d, "a.xyz", &ExportOptions::default()), Err(IoError::UnknownFormat(_)))); +} + +#[test] +fn garbage_import_errors() { + assert!(import("x", b"definitely not an image").is_err()); + assert!(matches!(import("x.psd", b"8BPS\0\x01"), Err(IoError::Psd(_)))); +} + +#[test] +fn extension_forms() { + let d = single(ColorMode::Rgb, SampleType::U8, false); + for e in ["png", ".png", "a/b/c.PNG", "C:\\x\\y.png"] { + let r = export(&d, e, &ExportOptions::default()).unwrap(); + assert!(r.bytes.starts_with(b"\x89PNG"), "{e}"); + } +} + +#[test] +fn background_lock_follows_alpha() { + let r = export(&single(ColorMode::Rgb, SampleType::U8, false), "png", &ExportOptions::default()).unwrap(); + let d = import("a.png", &r.bytes).unwrap().document; + assert!(d.layers[0].locks.transparency); + let r = export(&single(ColorMode::Rgb, SampleType::U8, true), "png", &ExportOptions::default()).unwrap(); + let d = import("a.png", &r.bytes).unwrap().document; + assert!(!d.layers[0].locks.transparency); +} + +#[test] +fn psd_to_png_to_psd_chain() { + let d = gen_doc(ColorMode::Rgb, SampleType::U16, Features::ALL); + let psd = export(&d, "a.psd", &ExportOptions::default()).unwrap(); + let d2 = import("a.psd", &psd.bytes).unwrap().document; + let png = export(&d2, "a.png", &ExportOptions::default()).unwrap(); + let d3 = import("a.png", &png.bytes).unwrap().document; + assert_eq!(d3.depth, SampleType::U16); + let psd2 = export(&d3, "b.psd", &ExportOptions::default()).unwrap(); + assert!(photocraft_psd::PsdFile::from_bytes(&psd2.bytes).is_ok()); +} diff --git a/crates/io/tests/psd_structure.rs b/crates/io/tests/psd_structure.rs new file mode 100644 index 00000000..09c21f63 --- /dev/null +++ b/crates/io/tests/psd_structure.rs @@ -0,0 +1,431 @@ +//! PSD (testgen / builder) → Document → PSD structural equality. + +use photocraft_doc::LayerContent; +use photocraft_io::*; +use photocraft_psd::testgen; +use photocraft_psd::{ColorMode, Compression, LayerNode, PsdFile, SectionType, Version}; + +/// Sample of a layer channel in document coordinates (0 outside the rect). +fn sample(f: &PsdFile, li: usize, id: i16, x: i32, y: i32, cache: &mut std::collections::HashMap<(usize, i16), Vec>) -> Vec { + let rec = &f.layers()[li]; + let r = rec.channel_rect(id); + let bps = usize::from(f.header.depth / 8); + if x < r.left || x >= r.right || y < r.top || y >= r.bottom || rec.channel(id).is_none() { + let default = if id == -2 { rec.layer_mask().map_or(0, |m| m.default_color) } else { 0 }; + return if default == 0 || bps != 1 { vec![0; bps] } else { vec![default] }; + } + let plane = cache + .entry((li, id)) + .or_insert_with(|| rec.decode_channel(id, f.header.depth, f.header.version).unwrap_or_default()); + let w = (r.right - r.left) as usize; + let i = ((y - r.top) as usize * w + (x - r.left) as usize) * bps; + plane.get(i..i + bps).map(<[u8]>::to_vec).unwrap_or(vec![0; bps]) +} + +fn shape(f: &PsdFile, nodes: &[LayerNode]) -> String { + nodes + .iter() + .map(|n| { + let r = &f.layers()[n.index()]; + let st = r.section_type(); + let kids = if n.is_group() { format!("[{}]", shape(f, n.children())) } else { String::new() }; + format!("{}{}{}", r.name(), if st == SectionType::ClosedFolder { "(c)" } else { "" }, kids) + }) + .collect::>() + .join(",") +} + +fn leaves(nodes: &[LayerNode], out: &mut Vec) { + for n in nodes { + out.push(n.index()); + leaves(n.children(), out); + } +} + +fn assert_structurally_equal(a: &PsdFile, b: &PsdFile) { + assert_eq!(a.header.width, b.header.width); + assert_eq!(a.header.height, b.header.height); + assert_eq!(a.header.depth, b.header.depth); + assert_eq!(a.header.color_mode, b.header.color_mode); + let (ta, tb) = (a.layer_tree(), b.layer_tree()); + assert_eq!(shape(a, &ta), shape(b, &tb), "tree shape"); + let (mut la, mut lb) = (Vec::new(), Vec::new()); + leaves(&ta, &mut la); + leaves(&tb, &mut lb); + let cc = testgen::mode_channels(a.header.color_mode) as i16; + let (mut ca, mut cb) = (Default::default(), Default::default()); + for (&ia, &ib) in la.iter().zip(&lb) { + let (ra, rb) = (&a.layers()[ia], &b.layers()[ib]); + let n = ra.name(); + assert_eq!(n, rb.name()); + let blend = if matches!(ra.blend_mode, photocraft_psd::BlendMode::Unknown(_)) { + photocraft_psd::BlendMode::Normal + } else { + ra.blend_mode + }; + if ra.section_type().is_folder() { + assert_eq!(ra.section_divider().and_then(|s| s.blend_mode), rb.section_divider().and_then(|s| s.blend_mode), "{n}"); + } else { + assert_eq!(blend, rb.blend_mode, "{n}: blend"); + } + assert_eq!(ra.opacity, rb.opacity, "{n}: opacity"); + assert_eq!(ra.fill_opacity(), rb.fill_opacity(), "{n}: fill"); + assert_eq!(ra.is_visible(), rb.is_visible(), "{n}: visible"); + assert_eq!(ra.clipping, rb.clipping, "{n}: clipping"); + assert_eq!(ra.block(b"lfx2").map(|x| &x.data), rb.block(b"lfx2").map(|x| &x.data), "{n}: lfx2"); + if ra.section_type().is_folder() { + continue; + } + // Pixels: compare every channel over the union of both rects. + let u = |r: photocraft_psd::Rect, s: photocraft_psd::Rect| { + (r.left.min(s.left), r.top.min(s.top), r.right.max(s.right), r.bottom.max(s.bottom)) + }; + let mut ids: Vec = (-1..cc).collect(); + if ra.layer_mask().is_some() && (ra.channel(-2).is_some() || ra.channel(-3).is_some()) { + ids.push(-2); + } + for id in ids { + // The exporter writes the real mask (-3) as the user mask (-2). + let src_id = if id == -2 && ra.channel(-3).is_some() && ra.layer_mask().is_some_and(|m| m.real.is_some()) { -3 } else { id }; + let (x0, y0, x1, y1) = u(ra.channel_rect(src_id), rb.channel_rect(id)); + for y in y0..y1 { + for x in x0..x1 { + let pa = sample(a, ia, src_id, x, y, &mut ca); + let pb = sample(b, ib, id, x, y, &mut cb); + assert_eq!(pa, pb, "{n}: channel {id} at ({x},{y})"); + } + } + } + } +} + +fn via_doc(f: &PsdFile) -> PsdFile { + let bytes = f.to_bytes().unwrap(); + let imp = import("t.psd", &bytes).unwrap(); + let out = export(&imp.document, "t.psd", &ExportOptions::default()).unwrap(); + PsdFile::from_bytes(&out.bytes).unwrap() +} + +macro_rules! tg { + ($name:ident, $v:expr, $mode:expr, $depth:expr, $c:expr) => { + #[test] + fn $name() { + let f = testgen::layered($v, $mode, $depth, $c); + let g = via_doc(&f); + assert_structurally_equal(&f, &g); + } + }; +} + +tg!(testgen_rgb8_rle, Version::Psd, ColorMode::Rgb, 8, Compression::Rle); +tg!(testgen_rgb8_raw, Version::Psd, ColorMode::Rgb, 8, Compression::Raw); +tg!(testgen_rgb16_zip, Version::Psd, ColorMode::Rgb, 16, Compression::Zip); +tg!(testgen_rgb32_zipp, Version::Psd, ColorMode::Rgb, 32, Compression::ZipPrediction); +tg!(testgen_gray8_zipp, Version::Psd, ColorMode::Grayscale, 8, Compression::ZipPrediction); +tg!(testgen_gray16_rle, Version::Psd, ColorMode::Grayscale, 16, Compression::Rle); +tg!(testgen_gray32_raw, Version::Psd, ColorMode::Grayscale, 32, Compression::Raw); +tg!(testgen_cmyk8_rle, Version::Psd, ColorMode::Cmyk, 8, Compression::Rle); +tg!(testgen_cmyk16_zip, Version::Psd, ColorMode::Cmyk, 16, Compression::Zip); +tg!(testgen_lab8_rle, Version::Psd, ColorMode::Lab, 8, Compression::Rle); +tg!(testgen_psb_rgb8_rle, Version::Psb, ColorMode::Rgb, 8, Compression::Rle); +tg!(testgen_psb_cmyk16_zipp, Version::Psb, ColorMode::Cmyk, 16, Compression::ZipPrediction); + +#[test] +fn testgen_small_all_compressions() { + for v in [Version::Psd, Version::Psb] { + for c in Compression::ALL { + let f = testgen::small(v, c); + assert_structurally_equal(&f, &via_doc(&f)); + } + } +} + +#[test] +fn testgen_import_details() { + let f = testgen::layered(Version::Psd, ColorMode::Rgb, 8, Compression::Rle); + let (d, warnings) = psd_to_document(&f); + // Unknown blend mode warned about. + assert!(warnings.iter().any(|w| w.contains("unknown blend mode")), "{warnings:?}"); + // Groups: Outer (open) containing Inner (closed). + let outer = d.layers.iter().find(|l| l.name == "Outer").expect("outer"); + let LayerContent::Group(g) = &outer.content else { panic!() }; + assert!(g.expanded); + assert_eq!(outer.blend, photocraft_color::BlendMode::PassThrough); + let inner = g.children.iter().find(|l| l.name == "Inner").unwrap(); + let LayerContent::Group(gi) = &inner.content else { panic!() }; + assert!(!gi.expanded); + // Negative offsets preserved. + let neg = d.layers.iter().find(|l| l.name.starts_with("Neg offset")).unwrap(); + let b = neg.surface().unwrap().content_bounds(); + assert!(b.x0 < 0 && b.y0 < 0); + assert!(neg.mask.is_some()); + assert!(d.icc_profile.is_some()); + assert!((d.resolution_dpi - 72.0).abs() < 1e-3); + assert!(d.metadata.xmp.is_some()); + // Unmapped named resource preserved. + assert!(d.metadata.psd_resources.iter().any(|r| r.0 == 2000 && r.1 == "Path 1")); + // Global blocks preserved. + assert!(d.metadata.psd_global_blocks.iter().any(|b| &b.1 == b"Patt")); + // lfx2 kept on effects. + assert!(d.walk().iter().any(|(_, _, l)| l.effects.psd_raw.is_some())); +} + +#[test] +fn fallback_modes_import_flattened() { + for mode in [ColorMode::Indexed, ColorMode::Bitmap, ColorMode::Duotone, ColorMode::Multichannel] { + let depth = testgen::mode_depths(mode)[0]; + let f = testgen::merged_only(Version::Psd, mode, depth, Compression::Rle, 9, 5); + let (d, w) = psd_to_document(&f); + assert_eq!(d.layers.len(), 1, "{mode:?}"); + assert!(!w.is_empty(), "{mode:?} should warn"); + assert_eq!(d.depth, photocraft_color::SampleType::U8); + // Re-export produces a valid PSD. + let out = export(&d, "x.psd", &ExportOptions::default()).unwrap(); + assert!(PsdFile::from_bytes(&out.bytes).is_ok()); + } +} + +#[test] +fn indexed_colors_match_palette() { + let f = testgen::merged_only(Version::Psd, ColorMode::Indexed, 8, Compression::Raw, 4, 2); + let (d, _) = psd_to_document(&f); + let s = d.layers[0].surface().unwrap(); + let rgba = f.composite_rgba8().unwrap(); + for y in 0..2 { + for x in 0..4 { + let p = s.pixel(x, y); + let i = ((y * 4 + x) * 4) as usize; + for (c, v) in p.iter().take(3).enumerate() { + assert_eq!((v * 255.0).round() as u8, rgba.data[i + c]); + } + } + } +} + +#[test] +fn flattened_psd_becomes_background() { + let f = testgen::merged_only(Version::Psd, ColorMode::Rgb, 16, Compression::ZipPrediction, 7, 3); + let (d, _) = psd_to_document(&f); + assert_eq!(d.layers.len(), 1); + assert_eq!(d.layers[0].name, "Background"); + assert_eq!(d.depth, photocraft_color::SampleType::U16); + let s = d.layers[0].surface().unwrap(); + let merged = f.decode_merged().unwrap(); + let v = u16::from_be_bytes([merged[0], merged[1]]); + assert_eq!((s.pixel(0, 0)[0] * 65535.0).round() as u16, v); +} + +#[test] +fn builder_psd_imports() { + use photocraft_psd::{GroupSpec, LayerSpec, MaskSpec, PixelData, PsdBuilder, Rect}; + let mut b = PsdBuilder::new(4, 4); + let mut s = LayerSpec::new("a", 0, 0, 2, 2, PixelData::Rgba8(vec![255, 0, 0, 255, 0, 255, 0, 128, 0, 0, 255, 0, 9, 9, 9, 9])); + s.mask = Some(MaskSpec { rect: Rect::from_xywh(0, 0, 1, 1), data: vec![100], default_color: 255, disabled: true }); + s.fill_opacity = Some(77); + b.push_layer(s); + b.begin_group(GroupSpec { open: false, ..GroupSpec::new("G") }); + b.push_layer(LayerSpec::new("b", 1, 1, 1, 1, PixelData::Rgba8(vec![1, 2, 3, 4]))); + b.end_group().unwrap(); + let bytes = b.to_bytes().unwrap(); + let d = import("b.psd", &bytes).unwrap().document; + assert_eq!(d.layers.len(), 2); + let a = &d.layers[0]; + assert_eq!(a.fill_opacity, 77.0 / 255.0); + let m = a.mask.as_ref().unwrap(); + assert!(!m.enabled); + assert_eq!(m.surface.pixel(0, 0), vec![100.0 / 255.0]); + assert_eq!(m.surface.pixel(3, 3), vec![1.0]); + let s = a.surface().unwrap(); + assert_eq!(s.pixel(1, 0), vec![0.0, 1.0, 0.0, 128.0 / 255.0]); + assert!(matches!(&d.layers[1].content, LayerContent::Group(g) if !g.expanded && g.children.len() == 1)); +} + +#[test] +fn locks_and_labels_from_psd() { + use photocraft_psd::TaggedBlock; + let mut f = testgen::small(Version::Psd, Compression::Raw); + f.layers_mut()[0].blocks.push(TaggedBlock::protection(0b101)); + f.layers_mut()[0].blocks.push(TaggedBlock::sheet_color(3)); + let (d, _) = psd_to_document(&f); + let l = &d.layers[0]; + assert!(l.locks.transparency && l.locks.position && !l.locks.pixels); + assert_eq!(l.label, photocraft_doc::LabelColor::Yellow); +} + +#[test] +fn adjustment_and_fill_layers_from_psd() { + use photocraft_psd::{LayerRecord, TaggedBlock}; + let mut f = testgen::small(Version::Psd, Compression::Raw); + let mk = |name: &str, key: &[u8; 4], data: Vec| LayerRecord { + name: name.as_bytes().to_vec(), + blocks: vec![TaggedBlock::unicode_name(name), TaggedBlock::new(*key, data)], + ..Default::default() + }; + let soco = { + use photocraft_psd::descriptor::*; + let c = Descriptor::new("RGBC").with("Rd ", Value::Double(255.0)).with("Grn ", Value::Double(0.0)).with("Bl ", Value::Double(0.0)); + VersionedDescriptor::new(Descriptor::new("null").with("Clr ", Value::Descriptor(c))).to_bytes() + }; + f.layers_mut().push(mk("inv", b"nvrt", vec![])); + f.layers_mut().push(mk("post", b"post", vec![0, 4, 0, 0])); + f.layers_mut().push(mk("sel", b"selc", vec![1, 2, 3])); + f.layers_mut().push(mk("red", b"SoCo", soco)); + let (d, _) = psd_to_document(&f); + let n = d.layers.len(); + use photocraft_doc::{Adjustment, Fill}; + assert!(matches!(d.layers[n - 4].content, LayerContent::Adjustment(Adjustment::Invert))); + assert!(matches!(d.layers[n - 3].content, LayerContent::Adjustment(Adjustment::Posterize { levels: 4 }))); + assert!(matches!(&d.layers[n - 2].content, LayerContent::Adjustment(Adjustment::Unsupported { psd_key, raw }) if psd_key == "selc" && raw == &vec![1, 2, 3])); + assert!(matches!(&d.layers[n - 1].content, LayerContent::Fill(Fill::Solid(c)) if c.c[0] == 1.0)); +} + +#[test] +fn unmodelled_blocks_are_preserved() { + use photocraft_psd::TaggedBlock; + let mut f = testgen::small(Version::Psd, Compression::Raw); + f.layers_mut()[0].blocks.push(TaggedBlock::new(*b"vmsk", vec![0; 8])); + let (d, _) = psd_to_document(&f); + assert!(d.layers[0].psd_blocks.iter().any(|b| &b.0 == b"vmsk")); + assert!(!d.layers[0].psd_blocks.iter().any(|b| &b.0 == b"luni" || &b.0 == b"lyid")); + assert_eq!(d.layers[0].psd_id, f.layers()[0].layer_id()); +} + +#[test] +fn fill_layer_keeps_photoshop_pixels() { + use photocraft_psd::{ChannelData, LayerRecord, TaggedBlock}; + let mut f = testgen::small(Version::Psd, Compression::Raw); + let soco = { + use photocraft_psd::descriptor::*; + let c = Descriptor::new("RGBC").with("Rd ", Value::Double(255.0)).with("Grn ", Value::Double(0.0)).with("Bl ", Value::Double(0.0)); + VersionedDescriptor::new(Descriptor::new("null").with("Clr ", Value::Descriptor(c))).to_bytes() + }; + // Photoshop-rendered pixels deliberately differ from the parametric fill. + let plane = |v: u8| ChannelData { id: 0, compression: Some(Compression::Raw), data: vec![v; 4] }; + let mut chans = vec![plane(255), plane(10), plane(20), plane(30)]; + for (i, c) in chans.iter_mut().enumerate() { + c.id = i as i16 - 1; + } + f.layers_mut().push(LayerRecord { + rect: photocraft_psd::Rect::from_xywh(0, 0, 2, 2), + name: b"fill".to_vec(), + channels: chans, + blocks: vec![TaggedBlock::unicode_name("fill"), TaggedBlock::new(*b"SoCo", soco)], + ..Default::default() + }); + let (d, _) = psd_to_document(&f); + let l = d.layers.last().unwrap(); + assert!(matches!(l.content, LayerContent::Fill(_))); + let fc = l.fill_cache.as_ref().expect("cache"); + assert_eq!(fc.surface.pixel(1, 1), vec![10.0 / 255.0, 20.0 / 255.0, 30.0 / 255.0, 1.0]); + // The compositor uses the cache while the fill is unchanged. + let flat = photocraft_compose::flatten(&d); + let p = flat.get(1, 1); + assert!((p[0] - 10.0 / 255.0).abs() < 1e-3, "{p:?}"); + // Editing the fill invalidates the cache. + let mut d2 = d.clone(); + let n = d2.layers.len(); + d2.layers[n - 1].content = LayerContent::Fill(photocraft_doc::Fill::Solid(photocraft_color::Color::rgb(0.0, 0.0, 1.0))); + let p = photocraft_compose::flatten(&d2).get(1, 1); + assert!(p[2] > 0.99 && p[0] < 0.01, "{p:?}"); +} + +#[test] +fn text_and_smart_detected() { + use photocraft_psd::{LayerRecord, TaggedBlock}; + let mut f = testgen::small(Version::Psd, Compression::Raw); + for (name, key) in [("t", b"TySh"), ("s", b"PlLd"), ("sh", b"vscg")] { + f.layers_mut().push(LayerRecord { + name: name.as_bytes().to_vec(), + blocks: vec![TaggedBlock::unicode_name(name), TaggedBlock::new(*key, vec![0; 8])], + ..Default::default() + }); + } + let (d, _) = psd_to_document(&f); + let n = d.layers.len(); + assert_eq!(d.layers[n - 3].content.kind_name(), "Type"); + assert_eq!(d.layers[n - 2].content.kind_name(), "Smart Object"); + assert_eq!(d.layers[n - 1].content.kind_name(), "Shape"); +} + +#[test] +fn guides_and_alpha_names_roundtrip() { + let mut d = photocraft_doc::Document::new("g", photocraft_geom::Size::new(8, 8), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8); + d.guides.horizontal = vec![1.0, 2.5]; + d.guides.vertical = vec![7.03125]; + for name in ["Spot \u{e9}", "Alpha 2"] { + d.channels.push(photocraft_doc::AlphaChannel { + name: name.into(), + surface: photocraft_raster::Surface::new(photocraft_color::PixelFormat::new(photocraft_color::ColorMode::Grayscale, photocraft_color::SampleType::U8, false)), + spot: None, + }); + } + let f = document_to_psd(&d); + // RGB + merged transparency (the empty document is transparent) + 2 alpha channels. + assert_eq!(f.header.channels, 6); + let (d2, _) = psd_to_document(&f); + assert_eq!(d2.guides, d.guides); + let names: Vec<_> = d2.channels.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, ["Spot \u{e9}", "Alpha 2"]); +} + +#[test] +fn resolution_in_cm_converted() { + let mut f = testgen::small(Version::Psd, Compression::Raw); + let mut ri = photocraft_psd::ResolutionInfo::from_dpi(100.0); + ri.h_res_unit = 2; + f.resources[0] = photocraft_psd::ImageResource::new(1005, ri.to_bytes()); + let (d, _) = psd_to_document(&f); + assert!((d.resolution_dpi - 254.0).abs() < 1e-3); +} + +#[test] +fn stale_index_resources_dropped() { + let mut f = testgen::small(Version::Psd, Compression::Raw); + for id in [1024u16, 1026, 1036, 1057, 1069, 1072] { + f.resources.push(photocraft_psd::ImageResource::new(id, vec![0, 0])); + } + let (d, _) = psd_to_document(&f); + assert!(d.metadata.psd_resources.iter().all(|r| ![1024u16, 1026, 1036, 1057, 1069, 1072].contains(&r.0))); +} + +#[test] +fn merged_composite_unmattes_photoshop_white() { + // A 1x1 RGB+alpha flattened file with color matted against white. + let mut f = testgen::merged_only(Version::Psd, ColorMode::Rgb, 8, Compression::Raw, 1, 1); + f.header.channels = 4; + // straight red at alpha 0.5, matted: r = 1, g = b = 0.5 + f.image_data = photocraft_psd::ImageData { compression: Compression::Raw, data: vec![255, 128, 128, 128] }; + let m = merged_composite(&f).unwrap(); + assert!((m[0][0] - 1.0).abs() < 0.01 && m[0][1] < 0.01 && m[0][2] < 0.01, "{:?}", m[0]); + let (d, _) = psd_to_document(&f); + let p = d.layers[0].surface().unwrap().pixel(0, 0); + assert!(p[1] < 0.01 && (p[3] - 128.0 / 255.0).abs() < 1e-6, "{p:?}"); +} + +#[test] +fn vector_rendered_mask_not_doubled_on_shapes() { + use photocraft_psd::{LayerRecord, TaggedBlock}; + let mut f = testgen::small(Version::Psd, Compression::Raw); + let mut mask = photocraft_psd::LayerMask::new(photocraft_psd::Rect::default(), 0, 8); + mask.flags = 8; + f.layers_mut().push(LayerRecord { + name: b"shape".to_vec(), + mask: photocraft_psd::MaskData::Mask(mask), + channels: vec![photocraft_psd::ChannelData { id: -2, compression: Some(Compression::Raw), data: vec![] }], + blocks: vec![TaggedBlock::unicode_name("shape"), TaggedBlock::new(*b"SoCo", vec![]), TaggedBlock::new(*b"vmsk", vec![0; 8])], + ..Default::default() + }); + let (d, _) = psd_to_document(&f); + let l = d.layers.last().unwrap(); + assert_eq!(l.content.kind_name(), "Shape"); + assert!(l.mask.is_none()); +} + +#[test] +fn every_blend_mode_maps_both_ways() { + for m in std::iter::once(photocraft_color::BlendMode::PassThrough).chain(photocraft_color::BlendMode::LAYER_MODES) { + let k = photocraft_psd::BlendMode::from_key(m.psd_key()); + assert!(!matches!(k, photocraft_psd::BlendMode::Unknown(_)), "{m:?}"); + assert_eq!(photocraft_color::BlendMode::from_psd_key(k.key()), Some(m)); + } +} diff --git a/crates/io/tests/roundtrip.rs b/crates/io/tests/roundtrip.rs new file mode 100644 index 00000000..dfbf8abb --- /dev/null +++ b/crates/io/tests/roundtrip.rs @@ -0,0 +1,342 @@ +//! Document → PSD → Document round trips. + +mod common; + +use common::*; +use photocraft_color::{ColorMode, SampleType}; +use photocraft_io::*; +use photocraft_psd::PsdFile; + +fn roundtrip(doc: &photocraft_doc::Document) -> photocraft_doc::Document { + let r = export(doc, "x.psd", &ExportOptions::default()).expect("export"); + let file = PsdFile::from_bytes(&r.bytes).expect("parse"); + // Written files are byte-stable through the psd crate. + assert_eq!(file.to_bytes().unwrap(), r.bytes); + let imp = import("x.psd", &r.bytes).expect("import"); + imp.document +} + +macro_rules! rt_case { + ($name:ident, $mode:expr, $depth:expr, $f:expr) => { + #[test] + fn $name() { + let d = gen_doc($mode, $depth, $f); + let back = roundtrip(&d); + assert_docs_eq(&d, &back); + } + }; +} + +rt_case!(rt_rgb8_all, ColorMode::Rgb, SampleType::U8, Features::ALL); +rt_case!(rt_rgb16_all, ColorMode::Rgb, SampleType::U16, Features::ALL); +rt_case!(rt_rgb32_all, ColorMode::Rgb, SampleType::F32, Features::ALL); +rt_case!(rt_gray8_all, ColorMode::Grayscale, SampleType::U8, Features::ALL); +rt_case!(rt_gray16_all, ColorMode::Grayscale, SampleType::U16, Features::ALL); +rt_case!(rt_gray32_all, ColorMode::Grayscale, SampleType::F32, Features::ALL); +rt_case!(rt_cmyk8_all, ColorMode::Cmyk, SampleType::U8, Features::ALL); +rt_case!(rt_cmyk16_all, ColorMode::Cmyk, SampleType::U16, Features::ALL); +rt_case!(rt_lab8_all, ColorMode::Lab, SampleType::U8, Features::ALL); +rt_case!(rt_lab16_all, ColorMode::Lab, SampleType::U16, Features::ALL); +rt_case!(rt_rgb8_pixels, ColorMode::Rgb, SampleType::U8, Features::PIXELS); +rt_case!(rt_gray16_pixels, ColorMode::Grayscale, SampleType::U16, Features::PIXELS); +rt_case!(rt_cmyk8_pixels, ColorMode::Cmyk, SampleType::U8, Features::PIXELS); + +#[test] +fn psb_roundtrip() { + let d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::ALL); + let r = export(&d, "x.psb", &ExportOptions::default()).unwrap(); + assert_eq!(r.bytes[5], 2); + let back = import("x.psb", &r.bytes).unwrap().document; + assert_docs_eq(&d, &back); +} + +#[test] +fn force_psb_option() { + let d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); + let r = export(&d, "x.psd", &ExportOptions { force_psb: true, ..Default::default() }).unwrap(); + assert_eq!(r.bytes[5], 2); +} + +#[test] +fn import_sets_name() { + let d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); + let r = export(&d, "psd", &ExportOptions::default()).unwrap(); + assert_eq!(import("hello.psd", &r.bytes).unwrap().document.name, "hello.psd"); +} + +#[test] +fn empty_document() { + let d = photocraft_doc::Document::new("e", photocraft_geom::Size::new(4, 3), ColorMode::Rgb, SampleType::U8); + let back = roundtrip(&d); + // A flattened, fully transparent file comes back as one background layer. + assert!(back.layers.len() <= 1); + assert_eq!(back.size, d.size); +} + +#[test] +fn round_trip_reaches_a_fixed_point() { + // The first export renders fill layers ourselves; after import they carry + // those pixels as their fill cache, so from the second generation on the + // bytes are identical. + let d = gen_doc(ColorMode::Rgb, SampleType::U16, Features::ALL); + let a = export(&d, "a.psd", &ExportOptions::default()).unwrap().bytes; + let b = export(&import("a.psd", &a).unwrap().document, "a.psd", &ExportOptions::default()).unwrap().bytes; + let c = export(&import("b.psd", &b).unwrap().document, "a.psd", &ExportOptions::default()).unwrap().bytes; + assert_eq!(b, c, "export(import(x)) == x for x = export(import(export(d)))"); + // Layer records are already stable in the first generation. + let (fa, fb) = (PsdFile::from_bytes(&a).unwrap(), PsdFile::from_bytes(&b).unwrap()); + assert_eq!(fa.layers(), fb.layers()); +} + +#[test] +fn text_shape_smart_raw_blocks_survive() { + use photocraft_doc::*; + use std::sync::Arc; + let mut d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); + let fmt = d.pixel_format(); + let cache = pattern(fmt, photocraft_geom::Rect::new(1, 1, 6, 4), 42, true); + let placed = Arc::new(vec![1u8, 2, 3, 4]); + let mut smart = Layer::new( + "Smart", + LayerContent::Smart(SmartObject { + source: SmartSource::Linked { path: String::new() }, + transform: photocraft_geom::Affine::IDENTITY, + smart_filters: vec![], + cache: Some(cache.clone()), + psd_raw: Some(placed.clone()), + }), + ); + smart.psd_blocks = vec![(*b"PlLd", Arc::new(vec![0; 4])), (*b"vmsk", Arc::new(vec![5; 8])), (*b"luni", Arc::new(vec![0; 8]))]; + d.layers.push(smart); + let mut shape = Layer::new("Shape", LayerContent::Shape(ShapeLayer { fill: None, cache: Some(cache.clone()), psd_raw: None })); + shape.psd_blocks = vec![(*b"vscg", Arc::new(vec![1, 2, 3, 4])), (*b"vmsk", Arc::new(vec![7; 8]))]; + d.layers.push(shape); + let back = roundtrip(&d); + let n = back.layers.len(); + let (bs, bsh) = (&back.layers[n - 2], &back.layers[n - 1]); + assert!(matches!(bs.content, LayerContent::Smart(_))); + assert!(matches!(bsh.content, LayerContent::Shape(_))); + // psd_raw overrides the PlLd entry; the stale luni is regenerated, not kept. + assert!(bs.psd_blocks.contains(&(*b"PlLd", placed.clone()))); + assert!(bs.psd_blocks.iter().any(|b| &b.0 == b"vmsk")); + assert!(!bs.psd_blocks.iter().any(|b| &b.0 == b"luni")); + if let LayerContent::Smart(s) = &bs.content { + assert_eq!(s.psd_raw.as_deref(), Some(&*placed)); + } + if let LayerContent::Shape(s) = &bsh.content { + assert_eq!(s.psd_raw.as_deref(), Some(&vec![7u8; 8])); + } + assert_eq!(bsh.psd_blocks, d.layers[n - 1].psd_blocks); +} + +#[test] +fn raster_layer_blocks_preserved() { + let mut d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); + let blocks = vec![(*b"vmsk", std::sync::Arc::new(vec![0u8; 12])), (*b"clbl", std::sync::Arc::new(vec![0u8, 0, 0, 0])), (*b"Zzzz", std::sync::Arc::new(vec![1u8]))]; + d.layers[1].psd_blocks = blocks.clone(); + let back = roundtrip(&d); + assert_eq!(back.layers[1].psd_blocks, blocks); +} + +#[test] +fn psd_ids_are_kept_and_deduplicated() { + let mut d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); + d.layers[0].psd_id = Some(500); + d.layers[1].psd_id = Some(500); // duplicate: second gets a fresh id + d.layers[2].psd_id = Some(3); + let back = roundtrip(&d); + assert_eq!(back.layers[0].psd_id, Some(500)); + assert_ne!(back.layers[1].psd_id, Some(500)); + assert_eq!(back.layers[2].psd_id, Some(3)); + let ids: Vec = back.walk().iter().filter_map(|(_, _, l)| l.psd_id).collect(); + let set: std::collections::HashSet<_> = ids.iter().collect(); + assert_eq!(set.len(), ids.len(), "ids unique"); +} + +#[test] +fn edited_adjustment_regenerates_block_unedited_keeps_raw() { + use photocraft_doc::*; + use std::sync::Arc; + let mut d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); + // Levels block with non-default extra records (record 5 changed). + let base = Adjustment::Levels { master: adjust::LevelsChannel::default(), per_channel: Default::default() }; + let mut l = Layer::new("lv", LayerContent::Adjustment(base.clone())); + let psd = document_to_psd(&{ + let mut t = d.clone(); + t.layers.push(l.clone()); + t + }); + let rec = psd.layers().iter().find(|r| r.name() == "lv").unwrap(); + let mut raw = rec.block(b"levl").unwrap().data.clone(); + raw[2 + 5 * 10 + 1] = 7; // record 5 in_black = 7 (not modelled) + l.psd_blocks = vec![(*b"levl", Arc::new(raw.clone()))]; + d.layers.push(l); + let f = document_to_psd(&d); + assert_eq!(f.layers().iter().find(|r| r.name() == "lv").unwrap().block(b"levl").unwrap().data, raw, "unchanged: raw kept"); + if let LayerContent::Adjustment(Adjustment::Levels { master, .. }) = &mut d.layers.last_mut().unwrap().content { + master.in_black = 10.0 / 255.0; + } + let f = document_to_psd(&d); + let data = &f.layers().iter().find(|r| r.name() == "lv").unwrap().block(b"levl").unwrap().data; + assert_ne!(data, &raw, "edited: regenerated"); + assert_eq!(data[3], 10); +} + +#[test] +fn text_layer_roundtrip_with_tysh() { + use photocraft_doc::*; + use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value, VersionedDescriptor}; + // Minimal TySh: version, transform, text version, text descriptor, warp. + let mut tysh = 1u16.to_be_bytes().to_vec(); + for v in [1.0f64, 0.0, 0.0, 1.0, 3.0, 4.0] { + tysh.extend_from_slice(&v.to_be_bytes()); + } + tysh.extend_from_slice(&50u16.to_be_bytes()); + let text = Descriptor::new("TxLr").with("Txt ", Value::Text(UnicodeString::new_nul("Hello"))); + tysh.extend(VersionedDescriptor::new(text).to_bytes()); + tysh.extend_from_slice(&1u16.to_be_bytes()); + tysh.extend(VersionedDescriptor::new(Descriptor::new("warp")).to_bytes()); + tysh.extend_from_slice(&[0; 16]); + let mut d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); + let fmt = d.pixel_format(); + d.layers.push(Layer::new( + "Hello", + LayerContent::Text(TextLayer { + text: "Hello".into(), + font_family: String::new(), + size_pt: 0.0, + color: photocraft_color::Color::BLACK, + transform: photocraft_geom::Affine { m: [1.0, 0.0, 0.0, 1.0, 3.0, 4.0] }, + cache: Some(pattern(fmt, photocraft_geom::Rect::new(3, 4, 9, 8), 5, true)), + psd_raw: Some(std::sync::Arc::new(tysh)), + }), + )); + let back = roundtrip(&d); + let t = back.layers.last().unwrap(); + match &t.content { + LayerContent::Text(tl) => { + assert_eq!(tl.text, "Hello"); + assert_eq!(tl.transform.m, [1.0, 0.0, 0.0, 1.0, 3.0, 4.0]); + } + other => panic!("{}", other.kind_name()), + } + assert_layers_eq(d.layers.last().unwrap(), t, ""); +} + +#[test] +fn effects_raw_roundtrip() { + let mut d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); + let mut lfx = vec![0, 0, 0, 0]; + lfx.extend(photocraft_psd::testgen::sample_descriptor().to_bytes()); + // Typed items and the raw block must agree; the raw bytes are then kept. + let (master, items) = photocraft_io::effects_map::parse_lfx2(&lfx).unwrap(); + d.layers[1].effects.enabled = master; + d.layers[1].effects.items = items; + d.layers[1].effects.psd_raw = Some(std::sync::Arc::new(lfx)); + let back = roundtrip(&d); + assert_docs_eq(&d, &back); +} + +#[test] +fn gradient_and_pattern_fills_roundtrip() { + use photocraft_doc::*; + let mut d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); + d.layers.push(Layer::new( + "grad", + LayerContent::Fill(Fill::Gradient { + stops: vec![(0.0, photocraft_color::Color::rgb(1.0, 0.0, 0.0)), (1.0, photocraft_color::Color::rgb(0.0, 0.0, 1.0))], + angle: 30.0, + scale: 1.0, + style: GradientStyle::Diamond, + reverse: true, + }), + )); + d.layers.push(Layer::new("pat", LayerContent::Fill(Fill::Pattern { name: "Dots".into(), scale: 0.5 }))); + let back = roundtrip(&d); + assert_docs_eq(&d, &back); +} + +#[test] +fn psd_raw_resources_and_global_blocks_preserved() { + let mut d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); + d.metadata.psd_resources.push((4001, "named".into(), std::sync::Arc::new(vec![1]))); + d.metadata.psd_global_blocks.push((*b"8BIM", *b"Patt", std::sync::Arc::new(vec![7; 6]))); + d.metadata.psd_global_blocks.push((*b"8B64", *b"lnk2", std::sync::Arc::new(vec![1, 2]))); + let back = roundtrip(&d); + assert_eq!(back.metadata, d.metadata); +} + +#[test] +fn document_to_psd_direct_api() { + let d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::ALL); + let f = document_to_psd(&d); + assert_eq!(f.header.width, 24); + let (d2, _warnings) = psd_to_document(&f); + assert_docs_eq(&d, &d2); +} + +#[test] +fn big_document_goes_psb_with_warning() { + let d = photocraft_doc::Document::new("big", photocraft_geom::Size::new(30_001, 1), ColorMode::Grayscale, SampleType::U8); + let (f, w) = document_to_psd_with(&d, &PsdExportOptions::default()); + assert_eq!(f.header.version, photocraft_psd::Version::Psb); + assert!(w.iter().any(|w| w.contains("PSB"))); +} + +#[test] +fn effects_roundtrip_typed_and_raw() { + use photocraft_doc::*; + let mut d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); + d.global_light = GlobalLight { angle: 45.0, altitude: 25.0 }; + d.layers[1].effects.items = vec![ + Effect::default_drop_shadow(), + Effect::Stroke(StrokeFx { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), size: 3.0, position: StrokePosition::Center, paint: FxPaint::Color(photocraft_color::Color::rgb(0.0, 1.0, 0.0)) }), + Effect::Stroke(StrokeFx { common: FxCommon::new(photocraft_color::BlendMode::Normal, 0.5), size: 6.0, position: StrokePosition::Outside, paint: FxPaint::Color(photocraft_color::Color::rgb(0.0, 0.0, 1.0)) }), + ]; + let back = roundtrip(&d); + assert_eq!(back.global_light, d.global_light); + let fx = &back.layers[1].effects; + assert_eq!(fx.items.len(), 3); + let raw = fx.psd_raw.clone().expect("lfx2 kept"); + // Unchanged → the same lfx2 bytes are written again. + let f = document_to_psd(&back); + let rec = f.layers().iter().find(|r| r.name() == back.layers[1].name).unwrap(); + assert_eq!(rec.block(b"lfx2").unwrap().data, *raw); + // Edited → regenerated. + let mut edited = back.clone(); + if let Effect::DropShadow(s) = &mut edited.layers[1].effects.items[0] { + s.distance = 20.0; + } + let f = document_to_psd(&edited); + let rec = f.layers().iter().find(|r| r.name() == back.layers[1].name).unwrap(); + let data = &rec.block(b"lfx2").unwrap().data; + assert_ne!(data, &*raw); + let (_, items) = photocraft_io::effects_map::parse_lfx2(data).unwrap(); + assert!(matches!(&items[0], Effect::DropShadow(s) if s.distance == 20.0)); +} + +#[test] +fn group_effects_use_lfxs() { + use photocraft_doc::*; + let mut d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); + let n = d.layers.iter().position(|l| l.is_group()).unwrap(); + d.layers[n].effects.items.push(Effect::ColorOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), color: photocraft_color::Color::rgb(1.0, 0.0, 0.0) }); + let f = document_to_psd(&d); + let rec = f.layers().iter().find(|r| r.name() == d.layers[n].name && r.section_type().is_folder()).unwrap(); + assert!(rec.block(b"lfxs").is_some() && rec.block(b"lfx2").is_none()); + let (back, _) = psd_to_document(&f); + assert_eq!(back.layers[n].effects.items.len(), 1); +} + +#[test] +fn clearing_effects_drops_stale_blocks() { + use photocraft_doc::*; + let mut d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); + d.layers[1].effects.items.push(Effect::default_drop_shadow()); + let back = roundtrip(&d); + let mut cleared = back.clone(); + cleared.layers[1].effects = Effects { enabled: true, ..Default::default() }; + let f = document_to_psd(&cleared); + let rec = f.layers().iter().find(|r| r.name() == back.layers[1].name).unwrap(); + assert!(rec.block(b"lfx2").is_none()); +} diff --git a/crates/ops/Cargo.toml b/crates/ops/Cargo.toml new file mode 100644 index 00000000..7a15a111 --- /dev/null +++ b/crates/ops/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "photocraft-ops" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +photocraft-doc = { workspace = true } diff --git a/crates/ops/src/lib.rs b/crates/ops/src/lib.rs new file mode 100644 index 00000000..7ac11018 --- /dev/null +++ b/crates/ops/src/lib.rs @@ -0,0 +1,194 @@ +//! Undo/redo history. +//! +//! Because pixel tiles are `Arc`-shared copy-on-write (see `photocraft-raster`), a full +//! [`Document`] clone costs O(layers + tiles) pointer copies, not pixel copies. History therefore +//! stores whole-document snapshots per transaction, which is simple, obviously correct, and the +//! same approach Photoshop's History panel exposes to users (one state per step). +//! +//! Memory is bounded by `max_states` plus an approximate byte budget. Only tiles *not shared* +//! with the current document count against it. +#![forbid(unsafe_code)] + +use std::collections::{HashSet, VecDeque}; +use std::sync::Arc; + +use photocraft_doc::Document; + +#[derive(Clone, Debug)] +pub struct HistoryState { + pub label: String, + pub doc: Arc, +} + +#[derive(Clone, Debug)] +pub struct History { + /// Past states; the last one is the state *before* the current document. + undo: VecDeque, + redo: Vec, + pub max_states: usize, + /// Label of the step that produced the current document. + current_label: String, +} + +impl Default for History { + fn default() -> Self { + Self::new(50) + } +} + +impl History { + pub fn new(max_states: usize) -> Self { + Self { undo: VecDeque::new(), redo: Vec::new(), max_states: max_states.max(1), current_label: "Open".into() } + } + + /// Record that `before` was replaced by a new current document via step `label`. + pub fn record(&mut self, label: impl Into, before: Arc) { + let prev_label = std::mem::replace(&mut self.current_label, label.into()); + self.undo.push_back(HistoryState { label: prev_label, doc: before }); + self.redo.clear(); + while self.undo.len() > self.max_states { + self.undo.pop_front(); + } + } + + pub fn can_undo(&self) -> bool { + !self.undo.is_empty() + } + pub fn can_redo(&self) -> bool { + !self.redo.is_empty() + } + pub fn undo_label(&self) -> Option<&str> { + self.can_undo().then_some(self.current_label.as_str()) + } + pub fn redo_label(&self) -> Option<&str> { + self.redo.last().map(|s| s.label.as_str()) + } + + /// Undo: returns the document to make current. + pub fn undo(&mut self, current: Arc) -> Option> { + let prev = self.undo.pop_back()?; + let label = std::mem::replace(&mut self.current_label, prev.label); + self.redo.push(HistoryState { label, doc: current }); + Some(prev.doc) + } + + pub fn redo(&mut self, current: Arc) -> Option> { + let next = self.redo.pop()?; + let label = std::mem::replace(&mut self.current_label, next.label); + self.undo.push_back(HistoryState { label, doc: current }); + Some(next.doc) + } + + /// Entries for a History panel: past labels oldest→newest, then the current label. + pub fn entries(&self) -> Vec { + self.undo.iter().map(|s| s.label.clone()).chain(std::iter::once(self.current_label.clone())).collect() + } + + pub fn clear(&mut self) { + self.undo.clear(); + self.redo.clear(); + } + + /// Approximate unique pixel bytes held by history (tiles not shared with `current`). + pub fn unique_bytes(&self, current: &Document) -> usize { + fn collect(doc: &Document, out: &mut HashSet, bytes: &mut Vec<(usize, usize)>) { + for (_, _, l) in doc.walk() { + if let Some(s) = l.surface() { + for (_, t) in s.tiles() { + let p = Arc::as_ptr(t) as usize; + if out.insert(p) { + bytes.push((p, t.bytes().len())); + } + } + } + } + } + let mut cur = HashSet::new(); + collect(current, &mut cur, &mut Vec::new()); + let mut seen = cur.clone(); + let mut hist = Vec::new(); + for s in self.undo.iter().chain(self.redo.iter()) { + collect(&s.doc, &mut seen, &mut hist); + } + hist.iter().map(|(_, b)| b).sum() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_doc::{Color, ColorMode, Layer, SampleType, Size}; + + fn base() -> Document { + Document::with_background("h", Size::new(64, 64), ColorMode::Rgb, SampleType::U8, Color::WHITE) + } + + /// Apply an edit the way the engine does: snapshot, mutate a clone, record. + fn edit(h: &mut History, cur: &mut Arc, label: &str, f: impl FnOnce(&mut Document)) { + let before = cur.clone(); + let mut d = (**cur).clone(); + f(&mut d); + *cur = Arc::new(d); + h.record(label, before); + } + + #[test] + fn undo_redo_cycle() { + let mut h = History::default(); + let mut cur = Arc::new(base()); + edit(&mut h, &mut cur, "New Layer", |d| { + d.insert_above(None, Layer::raster("L", d.pixel_format())); + }); + assert_eq!(cur.layers.len(), 2); + assert_eq!(h.undo_label(), Some("New Layer")); + cur = h.undo(cur).unwrap(); + assert_eq!(cur.layers.len(), 1); + assert_eq!(h.redo_label(), Some("New Layer")); + cur = h.redo(cur).unwrap(); + assert_eq!(cur.layers.len(), 2); + assert!(h.redo(cur.clone()).is_none()); + } + + #[test] + fn new_edit_clears_redo() { + let mut h = History::default(); + let mut cur = Arc::new(base()); + edit(&mut h, &mut cur, "A", |d| d.name = "a".into()); + cur = h.undo(cur).unwrap(); + edit(&mut h, &mut cur, "B", |d| d.name = "b".into()); + assert!(!h.can_redo()); + assert_eq!(h.entries(), vec!["Open".to_string(), "B".to_string()]); + } + + #[test] + fn max_states_bound() { + let mut h = History::new(3); + let mut cur = Arc::new(base()); + for i in 0..10 { + edit(&mut h, &mut cur, &format!("step {i}"), |d| d.name = format!("{i}")); + } + let mut n = 0; + while let Some(d) = h.undo(cur.clone()) { + cur = d; + n += 1; + } + assert_eq!(n, 3); + assert_eq!(cur.name, "6"); + } + + #[test] + fn history_shares_untouched_tiles() { + let mut h = History::default(); + let mut cur = Arc::new(base()); + assert_eq!(h.unique_bytes(&cur), 0); + // rename only: no pixel changes, history holds no unique tiles + edit(&mut h, &mut cur, "Rename", |d| d.name = "x".into()); + assert_eq!(h.unique_bytes(&cur), 0); + // paint one pixel: exactly one tile becomes unique to history + edit(&mut h, &mut cur, "Paint", |d| { + let id = d.layers[0].id; + d.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(1, 1, &[0.0, 0.0, 0.0, 1.0]); + }); + assert_eq!(h.unique_bytes(&cur), 256 * 256 * 4); + } +} diff --git a/crates/paint/Cargo.toml b/crates/paint/Cargo.toml new file mode 100644 index 00000000..3fd02797 --- /dev/null +++ b/crates/paint/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "photocraft-paint" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +photocraft-geom = { workspace = true } +photocraft-color = { workspace = true } +photocraft-raster = { workspace = true } +serde = { workspace = true } diff --git a/crates/paint/src/lib.rs b/crates/paint/src/lib.rs new file mode 100644 index 00000000..1caef9d7 --- /dev/null +++ b/crates/paint/src/lib.rs @@ -0,0 +1,326 @@ +//! Brush engine v1: round dabs with hardness, spacing, pressure dynamics and stroke smoothing. +//! +//! Strokes are data ([`Stroke`]) so they can be recorded, replayed by automation, and tested +//! deterministically. Dabs composite with "build-up up to stroke opacity" semantics: within one stroke, +//! coverage accumulates with `max`, so overlapping dabs don't darken beyond the stroke's opacity +//! (Photoshop's behaviour for Opacity, versus Flow which builds up). +#![forbid(unsafe_code)] + +use photocraft_geom::{Point, Rect}; +use photocraft_raster::{Surface, from_rgba, to_rgba}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct StrokePoint { + pub x: f64, + pub y: f64, + /// 0..1, 1 for mouse. + pub pressure: f32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct BrushSettings { + /// Diameter in pixels. + pub size: f32, + /// 0 (soft) ..1 (hard). + pub hardness: f32, + /// Spacing between dabs as a fraction of the diameter. + pub spacing: f32, + /// Maximum coverage for the whole stroke. + pub opacity: f32, + /// Per-dab coverage. + pub flow: f32, + pub pressure_size: bool, + pub pressure_opacity: bool, + /// Straight RGBA colour (display RGB). + pub color: [f32; 4], + /// Erase instead of paint. + pub erase: bool, +} + +impl Default for BrushSettings { + fn default() -> Self { + Self { + size: 20.0, + hardness: 1.0, + spacing: 0.1, + opacity: 1.0, + flow: 1.0, + pressure_size: true, + pressure_opacity: false, + color: [0.0, 0.0, 0.0, 1.0], + erase: false, + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Stroke { + pub brush: BrushSettings, + pub points: Vec, +} + +/// A dab placed along the stroke. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Dab { + pub center: Point, + pub radius: f32, + pub alpha: f32, +} + +/// Place dabs along the polyline at `spacing × diameter` intervals, interpolating pressure. +pub fn dabs(stroke: &Stroke) -> Vec { + let b = &stroke.brush; + let mut out = Vec::new(); + let Some(first) = stroke.points.first() else { return out }; + let radius_at = |p: f32| { + let s = if b.pressure_size { b.size * p.clamp(0.0, 1.0) } else { b.size }; + (s / 2.0).max(0.5) + }; + let alpha_at = |p: f32| b.flow * if b.pressure_opacity { p.clamp(0.0, 1.0) } else { 1.0 }; + out.push(Dab { center: Point::new(first.x, first.y), radius: radius_at(first.pressure), alpha: alpha_at(first.pressure) }); + let mut carry = 0.0f64; + for w in stroke.points.windows(2) { + let (a, c) = (w[0], w[1]); + let (dx, dy) = (c.x - a.x, c.y - a.y); + let len = (dx * dx + dy * dy).sqrt(); + if len <= 0.0 { + continue; + } + let mut t = carry; + loop { + let pr = a.pressure + (c.pressure - a.pressure) * (t / len) as f32; + let step = (radius_at(pr) as f64 * 2.0 * b.spacing.max(0.01) as f64).max(0.5); + t += step; + if t > len { + carry = t - len - step; + carry = carry.max(0.0); + break; + } + let f = t / len; + let pr = a.pressure + (c.pressure - a.pressure) * f as f32; + out.push(Dab { center: Point::new(a.x + dx * f, a.y + dy * f), radius: radius_at(pr), alpha: alpha_at(pr) }); + } + } + out +} + +/// Coverage of a round dab at distance `d` from its centre (anti-aliased edge, hardness falloff). +#[inline] +pub fn dab_coverage(d: f32, radius: f32, hardness: f32) -> f32 { + if d >= radius + 0.5 { + return 0.0; + } + let edge = ((radius + 0.5 - d).clamp(0.0, 1.0)).min(1.0); + let h = hardness.clamp(0.0, 1.0); + let inner = radius * h; + let falloff = if d <= inner || radius - inner < 1e-3 { + 1.0 + } else { + let t = ((d - inner) / (radius - inner)).clamp(0.0, 1.0); + // smoothstep falloff + 1.0 - t * t * (3.0 - 2.0 * t) + }; + edge * falloff +} + +/// Rasterize a stroke onto `target`, optionally limited by a selection (grayscale coverage surface). +/// Returns the damaged rectangle. +pub fn apply_stroke(target: &mut Surface, stroke: &Stroke, selection: Option<&Surface>, lock_transparency: bool) -> Rect { + let b = &stroke.brush; + let ds = dabs(stroke); + if ds.is_empty() { + return Rect::EMPTY; + } + let bounds = ds.iter().fold(Rect::EMPTY, |r, d| { + let rr = d.radius.ceil() as i32 + 1; + r.union(&Rect::new(d.center.x as i32 - rr, d.center.y as i32 - rr, d.center.x as i32 + rr + 1, d.center.y as i32 + rr + 1)) + }); + // Accumulate stroke coverage (max-combined dabs × flow), then composite once at stroke opacity. + let w = bounds.width() as usize; + let mut cov = vec![0.0f32; w * bounds.height() as usize]; + for d in &ds { + let rr = d.radius.ceil() as i32 + 1; + let (cx, cy) = (d.center.x as f32, d.center.y as f32); + for y in (d.center.y as i32 - rr)..=(d.center.y as i32 + rr) { + for x in (d.center.x as i32 - rr)..=(d.center.x as i32 + rr) { + if !bounds.contains(x, y) { + continue; + } + let dist = ((x as f32 + 0.5 - cx).powi(2) + (y as f32 + 0.5 - cy).powi(2)).sqrt(); + let c = dab_coverage(dist, d.radius, b.hardness) * d.alpha; + let i = (y - bounds.y0) as usize * w + (x - bounds.x0) as usize; + // flow build-up within the stroke, capped at 1 + cov[i] = cov[i] + c * (1.0 - cov[i]); + } + } + } + let fmt = target.format(); + let mut region = target.read_region(bounds); + let n = fmt.channels(); + for (i, c) in cov.iter().enumerate() { + let x = bounds.x0 + (i % w) as i32; + let y = bounds.y0 + (i / w) as i32; + let sel = selection.map_or(1.0, |s| s.pixel(x, y)[0]); + let k = (c * b.opacity * sel).min(1.0); + if k <= 0.0 { + continue; + } + let px = &mut region[i * n..(i + 1) * n]; + let dst = to_rgba(&fmt, px); + let out = if b.erase { + [dst[0], dst[1], dst[2], dst[3] * (1.0 - k)] + } else { + let sa = b.color[3] * k; + let oa = sa + dst[3] * (1.0 - sa); + let mut o = [0.0f32; 4]; + if oa > 0.0 { + for ch in 0..3 { + o[ch] = (b.color[ch] * sa + dst[ch] * dst[3] * (1.0 - sa)) / oa; + } + } + o[3] = if lock_transparency { dst[3] } else { oa }; + if lock_transparency && dst[3] <= 0.0 { + continue; + } + o + }; + let enc = from_rgba(&fmt, out); + px.copy_from_slice(&enc[..n]); + } + target.write_region(bounds, ®ion); + bounds +} + +/// Exponential moving-average smoothing of input points (Photoshop's "Smoothing" %, simplified). +pub fn smooth(points: &[StrokePoint], amount: f32) -> Vec { + let a = (1.0 - amount.clamp(0.0, 0.95)) as f64; + let mut out = Vec::with_capacity(points.len()); + let mut cur: Option = None; + for p in points { + let n = match cur { + None => *p, + Some(c) => StrokePoint { x: c.x + (p.x - c.x) * a, y: c.y + (p.y - c.y) * a, pressure: p.pressure }, + }; + out.push(n); + cur = Some(n); + } + if let (Some(last), Some(end)) = (out.last_mut(), points.last()) { + *last = *end; + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_color::PixelFormat; + + fn stroke(points: &[(f64, f64)], size: f32) -> Stroke { + Stroke { + brush: BrushSettings { size, pressure_size: false, ..Default::default() }, + points: points.iter().map(|&(x, y)| StrokePoint { x, y, pressure: 1.0 }).collect(), + } + } + + #[test] + fn dab_spacing() { + let s = stroke(&[(0.0, 0.0), (100.0, 0.0)], 10.0); + let d = dabs(&s); + // spacing 0.1 * 10px = 1px → ~101 dabs + assert!((95..=105).contains(&d.len()), "{}", d.len()); + let s = Stroke { brush: BrushSettings { spacing: 1.0, pressure_size: false, size: 10.0, ..Default::default() }, ..s }; + assert_eq!(dabs(&s).len(), 11); + } + + #[test] + fn coverage_profile() { + assert_eq!(dab_coverage(0.0, 10.0, 1.0), 1.0); + assert_eq!(dab_coverage(20.0, 10.0, 1.0), 0.0); + let soft_mid = dab_coverage(5.0, 10.0, 0.0); + assert!(soft_mid > 0.2 && soft_mid < 0.8); + assert!(dab_coverage(9.9, 10.0, 1.0) > 0.5); + } + + #[test] + fn hard_brush_paints_solid_line() { + let mut s = Surface::new(PixelFormat::RGBA8); + let dmg = apply_stroke(&mut s, &stroke(&[(10.0, 10.0), (90.0, 10.0)], 8.0), None, false); + assert!(dmg.contains(50, 10)); + assert_eq!(s.pixel(50, 10), vec![0.0, 0.0, 0.0, 1.0]); + assert_eq!(s.pixel(50, 30)[3], 0.0); + } + + #[test] + fn opacity_caps_overlap() { + let mut s = Surface::new(PixelFormat::RGBA8); + let mut st = stroke(&[(10.0, 10.0), (60.0, 10.0), (10.0, 10.0)], 10.0); + st.brush.opacity = 0.5; + apply_stroke(&mut s, &st, None, false); + let a = s.pixel(30, 10)[3]; + assert!((a - 0.5).abs() < 0.02, "{a}"); + } + + #[test] + fn selection_limits_paint() { + let mut s = Surface::new(PixelFormat::RGBA8); + let mut sel = Surface::new(PixelFormat::GRAY8); + sel.fill_rect(Rect::new(0, 0, 50, 100), &[1.0]); + apply_stroke(&mut s, &stroke(&[(10.0, 10.0), (90.0, 10.0)], 8.0), Some(&sel), false); + assert_eq!(s.pixel(20, 10)[3], 1.0); + assert_eq!(s.pixel(70, 10)[3], 0.0); + } + + #[test] + fn eraser_removes_alpha() { + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 100, 20), &[1.0, 0.0, 0.0, 1.0]); + let mut st = stroke(&[(10.0, 10.0), (90.0, 10.0)], 8.0); + st.brush.erase = true; + apply_stroke(&mut s, &st, None, false); + assert_eq!(s.pixel(50, 10)[3], 0.0); + assert_eq!(s.pixel(50, 1)[3], 1.0); + } + + #[test] + fn lock_transparency_preserves_alpha() { + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 50, 20), &[1.0, 1.0, 1.0, 1.0]); + apply_stroke(&mut s, &stroke(&[(10.0, 10.0), (90.0, 10.0)], 8.0), None, true); + assert_eq!(s.pixel(20, 10), vec![0.0, 0.0, 0.0, 1.0]); + assert_eq!(s.pixel(70, 10)[3], 0.0); + } + + #[test] + fn pressure_changes_size() { + let mut st = stroke(&[(0.0, 0.0), (100.0, 0.0)], 20.0); + st.brush.pressure_size = true; + st.points[0].pressure = 0.1; + st.points[1].pressure = 1.0; + let d = dabs(&st); + assert!(d.first().unwrap().radius < d.last().unwrap().radius); + } + + #[test] + fn smoothing_keeps_endpoints() { + let pts: Vec = (0..20).map(|i| StrokePoint { x: i as f64, y: if i % 2 == 0 { 0.0 } else { 10.0 }, pressure: 1.0 }).collect(); + let s = smooth(&pts, 0.8); + assert_eq!(s.first(), pts.first()); + assert_eq!(s.last(), pts.last()); + let jitter: f64 = s[1..19].iter().map(|p| (p.y - 5.0).abs()).sum(); + let orig: f64 = pts[1..19].iter().map(|p| (p.y - 5.0).abs()).sum(); + assert!(jitter < orig); + } + + #[test] + fn works_at_all_depths() { + for f in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F, PixelFormat::GRAYA8, PixelFormat::CMYKA8] { + let mut s = Surface::new(f); + let mut st = stroke(&[(5.0, 5.0), (40.0, 5.0)], 6.0); + st.brush.color = [1.0, 1.0, 1.0, 1.0]; + apply_stroke(&mut s, &st, None, false); + let rgba = s.rgba(20, 5); + assert!(rgba[3] > 0.99 && rgba[0] > 0.98, "{f:?} {rgba:?}"); + } + } +} diff --git a/crates/psd/Cargo.toml b/crates/psd/Cargo.toml new file mode 100644 index 00000000..4c744090 --- /dev/null +++ b/crates/psd/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "photocraft-psd" +description = "Faithful, lossless reader and writer for Adobe Photoshop PSD (v1) and PSB (v2) files" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lib] +name = "photocraft_psd" +path = "src/lib.rs" + +[features] +default = [] +serde = ["dep:serde"] +testgen = [] + +[dependencies] +thiserror = { workspace = true } +flate2 = { workspace = true } +serde = { workspace = true, optional = true } + +[dev-dependencies] +proptest = { workspace = true } +photocraft-psd = { path = ".", features = ["testgen"] } + +[lints] +workspace = true diff --git a/crates/psd/README.md b/crates/psd/README.md new file mode 100644 index 00000000..4bb8a415 --- /dev/null +++ b/crates/psd/README.md @@ -0,0 +1,148 @@ +# photocraft-psd + +A standalone reader and writer for Adobe Photoshop **PSD** (version 1) and **PSB** (version 2, "large document format") files. It keeps everything it reads, so an unmodified file writes back byte for byte. It depends on no other photocraft crate. + +* Clean-room implementation from Adobe's public *Photoshop File Formats Specification*. Where the spec is silent, behavior follows MIT-licensed psd-tools / ag-psd documentation, and the code comments say so. +* `#![forbid(unsafe_code)]`. Builds for `wasm32-unknown-unknown`. The core API works on byte slices; the file helpers only exist on native targets. +* Parsing never panics on malformed input. Limits: dimensions ≤ 300 000; allocations are checked against the remaining input; a single decode produces at most `MAX_DECODED_BYTES` (2 GiB); descriptor nesting depth ≤ 64. + +## Guarantees + +| Property | How | +|---|---| +| `PsdFile::from_bytes(b)?.to_bytes()? == b` for unmodified files | Channel data keeps its original encoding. Unknown blocks and resources are stored raw. Exact padding is recorded wherever it can vary: tagged blocks, layer info, section trailers. | +| `from_bytes(to_bytes(f)) == f` for any model `f` | Canonical defaults: `padding: None` means "zero-pad to even", and the parser yields `None` whenever the file used that default. | +| Lazy decoding | The parser only copies encoded bytes. Channels are decoded when asked (`ChannelData::decode`, `ImageData::decode`). | +| No compositing | `PsdBuilder` takes the merged image from the caller. Without one, it writes a white placeholder and sets `has_real_merged_data = false` in resource 1057. | + +## Public API (overview) + +```rust +// Top level +PsdFile::from_bytes(&[u8]) -> Result +PsdFile::to_bytes(&self) -> Result> +PsdFile::open(path) / save(path) // native only +PsdFile { header, color_mode_data, resources, layer_info, layer_info_placement, + global_layer_mask, global_blocks, layer_mask_trailing, image_data } +PsdFile::layers() -> &[LayerRecord] // file order = bottom-most first +PsdFile::layers_mut() -> &mut Vec +PsdFile::layer(i) -> Option>; iter_layers() +PsdFile::layer_tree() -> Vec // nested groups, children bottom-to-top +PsdFile::decode_merged() -> Result> // planar big-endian samples +PsdFile::composite_rgba8() -> Result +PsdFile::resource(id), global_block(key), icc_profile(), resolution(), + has_real_merged_data(), merged_has_alpha(), validate() + +// Header +Header { version: Version::{Psd,Psb}, channels, width, height, depth, color_mode, reserved } +ColorMode::{Bitmap, Grayscale, Indexed, Rgb, Cmyk, Multichannel, Duotone, Lab, Unknown(u16)} + +// Layers +LayerInfo { merged_alpha, layers: Vec, padding } +LayerRecord { rect, channels: Vec, blend_mode, opacity, clipping, flags, + filler, mask: MaskData, blending_ranges, name, blocks, extra_trailing } +LayerRecord::name() // prefers `luni` +LayerRecord::section_divider(), section_type(), layer_id(), fill_opacity(), is_visible(), + block(key), block_mut(key), channel(id), channel_rect(id), decode_channel(id, depth, version) +ChannelData { id, compression: Option, data } // encoded bytes kept verbatim +ChannelData::encode(id, compression, decoded, w, h, depth, version) +ChannelData::decode(w, h, depth, version); set_decoded(...) +MaskData::{None, Mask(LayerMask), Raw(Vec)} +LayerMask { rect, default_color, flags, parameters: Option, real: Option, trailing } +BlendMode (27 modes + PassThrough + Unknown([u8;4])); BlendMode::key()/from_key() +Layer<'_>::rgba8() -> Result; user_mask() -> Option>; channel_bytes(id) + +// Tagged blocks +TaggedBlock { signature /* 8BIM | 8B64 */, key, data, padding: Option> } +TaggedBlock::parsed() -> Option> +BlockData::{UnicodeName, SectionDivider, LayerId, NameSource, BlendClippedAsGroup, + BlendInteriorElements, Knockout, Protection, SheetColor, FillOpacity, MetadataSetting} +constructors: unicode_name, section_divider, layer_id, name_source, blend_clipped_as_group, + blend_interior_elements, knockout, protection, sheet_color, fill_opacity +tagged::uses_long_length(version, key) // PSB 8-byte length keys + +// Image resources +ImageResource { signature, id, name, data }; ImageResource::parsed() -> Option> +ResourceData::{ResolutionInfo, LayerState, LayerGroupInfo, Thumbnail, GlobalAngle, IccProfile, + GlobalAltitude, VersionInfo, Exif, Xmp} + +// Compression +Compression::{Raw, Rle, Zip, ZipPrediction, Unknown(u16)} +compression::{encode_planes, decode_planes, PlaneLayout, packbits::{encode, decode}, predict, unpredict} +pixels::{samples_u16, samples_f32, u16_to_bytes, f32_to_bytes, unpack_bits, plane_to_u8} + +// Descriptors +descriptor::{Descriptor, VersionedDescriptor, Value, Id, Class, ReferenceItem, UnicodeString, ObjectArray} +Descriptor::from_bytes / to_bytes; VersionedDescriptor::from_bytes / to_bytes (version 16) + +// Builder +PsdBuilder::new(w, h).color_mode(..).depth(8|16).version(..).compression(..).resolution(dpi).icc_profile(..) +builder.push_layer(LayerSpec) / begin_group(GroupSpec) / end_group()? / composite(PixelData) +builder.build() -> Result; builder.to_bytes() +PixelData::{Rgba8, Rgba16, GrayA8, GrayA16, Cmyka8, Cmyka16} // CMYK given as ink, stored inverted + +// Test generator (feature `testgen`) +testgen::{all_cases, merged_only, layered, small, sample_descriptor, pattern_plane} +``` + +## Feature matrix + +| Area | Read | Write | Typed | Notes | +|---|:-:|:-:|:-:|---| +| Header, PSD v1 / PSB v2 | ✓ | ✓ | ✓ | depth 1/8/16/32; all 8 color modes plus `Unknown` | +| Color mode data | ✓ | ✓ | raw | indexed palette is used by `composite_rgba8` | +| Image resources | ✓ | ✓ | partial | 1005, 1024, 1026, 1033/1036 (raw), 1037, 1039, 1049, 1057, 1058, 1060 | +| Layer records | ✓ | ✓ | ✓ | flags, clipping, filler, blending ranges (raw + accessor) | +| Layer masks | ✓ | ✓ | ✓ | 20 / 36-byte forms, mask parameters, real mask; unparseable masks stay raw | +| Channel ids -1 / -2 / -3 | ✓ | ✓ | ✓ | per-channel rect via `channel_rect` | +| Raw / RLE / ZIP / ZIP+prediction | ✓ | ✓ | – | prediction at 8/16/32 bits (32: byte-plane shuffle + delta); RLE counts are u16 in PSD, u32 in PSB | +| Merged image data | ✓ | ✓ | – | validated at parse time (sizes, RLE counts, zlib stream incl. checksum) | +| Tagged blocks (raw passthrough) | ✓ | ✓ | – | keeps `8BIM` / `8B64` and exact padding | +| PSB 8-byte-length keys | ✓ | ✓ | – | LMsk Lr16 Lr32 Layr Mt16 Mt32 Mtrn Alph FMsk lnk2 FEid FXid PxSD | +| Lr16 / Lr32 / Layr layer info | ✓ | ✓ | ✓ | lifted into `layer_info`; re-inserted at its original position | +| luni lsct lsdk lyid lnsr clbl infx knko lspf lclr iOpa shmd | ✓ | ✓ | ✓ | `TaggedBlock::parsed()` | +| ActionDescriptor (all OSTypes) | ✓ | ✓ | ✓ | including references, `ObAr` (best effort), `UnFl`, `Pth `, version-16 wrapper | +| Layer tree (groups) | ✓ | ✓ | ✓ | open/closed folders plus bounding dividers; tolerant of malformed nesting | +| RGBA8 extraction | ✓ | – | – | RGB / gray / CMYK layers; merged image also supports indexed / bitmap / duotone | +| Builder | – | ✓ | – | RGB / gray / CMYK at 8/16 bit, masks, groups, blend modes, opacity, fill, visibility, clipping | + +### Preserved raw, not yet typed (parsers planned for M8) + +* Layer effects: `lfx2`, `lrFX`, `lmfx`. The descriptors inside can be parsed on demand with `VersionedDescriptor::from_bytes(&block.data[4..])`. +* Type layers: `TySh`, `tySh`, and the EngineData inside them. +* Smart objects: `SoLd`, `PlLd`, `SoLE`, `lnk2`, `lnkD`, `lnk3`. +* Vector masks and shapes: `vmsk`, `vsms`, `vogk`, `vscg`. +* Adjustment and fill layers: `curv`, `levl`, `hue2`, `SoCo`, `GdFl`, `PtFl`, …, plus `Patt`, `Txt2`, `FMsk`, 3D and video blocks. +* Global layer mask info (raw, with accessors), thumbnails (raw), slices, guides, paths, and every other resource id. + +## Testing + +`cargo test -p photocraft-psd --all-features` runs the unit tests, integration tests and proptests: + +* Byte stability and model round-trips for every generated case: all depths, color modes and compressions, PSD and PSB. +* PackBits edge cases. +* Known vectors for 8/16/32-bit prediction. +* PSD vs PSB length handling. +* Truncation sweeps: every prefix of several small files must return `Err`. +* Random-mutation and random-byte proptests: never panic. +* Layer tree cases. +* Builder → `rgba8` correctness. +* Unknown blend modes and unknown blocks pass through unchanged. + +`tests/corpus.rs` walks `corpus/psd/**/*.{psd,psb}` at the workspace root when that directory exists. For each file it asserts that parsing succeeds and the file round-trips byte for byte, and it prints a result per file. Without the directory it skips silently. + +A `cargo-fuzz` skeleton lives in `fuzz/`. It is its own workspace and is excluded from photocraft's. Run it with `cargo +nightly fuzz run parse` (or `descriptor`) from `crates/psd`. + +## Spec ambiguities and decisions + +* **Tagged block padding.** The spec says lengths are "rounded up to an even byte count". In practice writers either include the padding in the length or append it after the data, and pad to 2 or 4. The parser detects the padding that actually follows each block: the smallest k ≤ 3 after which the next signature or the end of the region appears. It stores that padding unless it matches the default. +* **Layer info padding** (2 vs 4) is recorded the same way. +* **16/32-bit layers** are read from global `Lr16` / `Lr32` blocks when the main layer info is empty. The block data is a layer-info body with no inner length field. +* **Mask record layout.** The order is: rect, default color, flags, parameters (when flag bit 4 is set), then the real flags, background and rect whenever at least 18 bytes remain. Anything left over (for example the 2 padding bytes of the 20-byte form) is kept in `trailing`. +* **Mask channel rects.** Channel -2 uses the mask rect. Channel -3 uses the real mask rect. +* **Merged ZIP data** is a single zlib stream over all planes. Prediction is applied per row across all planes. +* **ZIP prediction at depth 1** is treated as byte-wise delta. +* **`ObAr`** is read as a u32 prefix followed by a descriptor-shaped body, following ag-psd's reading. The spec does not document it. +* **Descriptor `bool`** bytes other than 0/1 are normalized to 1 on write. +* **Pascal names** have their pad bytes written as zeros. A layer & mask section that contains only a zero layer-info length is normalized to an empty section. These are the only known non-byte-exact cases, and both are pathological. +* **Header limits.** The parser accepts up to 300 000 × 300 000 for both versions. `PsdFile::validate()` enforces the spec's 30 000 limit for PSD. diff --git a/crates/psd/fuzz/.gitignore b/crates/psd/fuzz/.gitignore new file mode 100644 index 00000000..1a45eee7 --- /dev/null +++ b/crates/psd/fuzz/.gitignore @@ -0,0 +1,4 @@ +target +corpus +artifacts +coverage diff --git a/crates/psd/fuzz/Cargo.toml b/crates/psd/fuzz/Cargo.toml new file mode 100644 index 00000000..8d986413 --- /dev/null +++ b/crates/psd/fuzz/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "photocraft-psd-fuzz" +version = "0.0.0" +publish = false +edition = "2024" + +# Standalone: excluded from the photocraft workspace. +[workspace] + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +photocraft-psd = { path = ".." } + +[[bin]] +name = "parse" +path = "fuzz_targets/parse.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "descriptor" +path = "fuzz_targets/descriptor.rs" +test = false +doc = false +bench = false diff --git a/crates/psd/fuzz/fuzz_targets/descriptor.rs b/crates/psd/fuzz/fuzz_targets/descriptor.rs new file mode 100644 index 00000000..e4870815 --- /dev/null +++ b/crates/psd/fuzz/fuzz_targets/descriptor.rs @@ -0,0 +1,13 @@ +//! Fuzz the ActionDescriptor parser; anything that parses must be byte +//! stable after one normalization pass (e.g. `bool` bytes other than 0/1). +#![no_main] + +use libfuzzer_sys::fuzz_target; +use photocraft_psd::descriptor::Descriptor; + +fuzz_target!(|data: &[u8]| { + if let Ok(d) = Descriptor::from_bytes(data) { + let b = d.to_bytes(); + assert_eq!(Descriptor::from_bytes(&b).expect("re-parse").to_bytes(), b); + } +}); diff --git a/crates/psd/fuzz/fuzz_targets/parse.rs b/crates/psd/fuzz/fuzz_targets/parse.rs new file mode 100644 index 00000000..3dab9cd9 --- /dev/null +++ b/crates/psd/fuzz/fuzz_targets/parse.rs @@ -0,0 +1,27 @@ +//! Fuzz the full parser, and exercise writer/decoders on anything that parses. +#![no_main] + +use libfuzzer_sys::fuzz_target; +use photocraft_psd::PsdFile; + +fuzz_target!(|data: &[u8]| { + let Ok(f) = PsdFile::from_bytes(data) else { return }; + // Anything written must re-parse and be byte stable from then on. + // (Byte comparison rather than model equality, since NaN != NaN.) + if let Ok(out) = f.to_bytes() { + let g = PsdFile::from_bytes(&out).expect("re-parse of written file"); + assert_eq!(g.to_bytes().expect("re-write"), out); + } + // Keep decoding bounded: only small images. + if u64::from(f.header.width) * u64::from(f.header.height) <= 1 << 20 { + let _ = f.decode_merged(); + let _ = f.composite_rgba8(); + for l in f.iter_layers() { + if l.record.rect.width() * l.record.rect.height() <= 1 << 20 { + let _ = l.rgba8(); + let _ = l.user_mask(); + } + } + } + let _ = f.layer_tree(); +}); diff --git a/crates/psd/proptest-regressions/descriptor.txt b/crates/psd/proptest-regressions/descriptor.txt new file mode 100644 index 00000000..d28a250e --- /dev/null +++ b/crates/psd/proptest-regressions/descriptor.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 8089b21c70b7dfddf5697e3a0ec01fa8aeb5ea2e8e844f62ae6e8c2453382404 # shrinks to d = Descriptor { name: UnicodeString([]), class_id: Code([0, 0, 0, 0]), items: [(Code([0, 0, 0, 0]), Descriptor(Descriptor { name: UnicodeString([]), class_id: Code([0, 0, 0, 0]), items: [(Str([0]), Boolean(false))] }))] } diff --git a/crates/psd/src/blend.rs b/crates/psd/src/blend.rs new file mode 100644 index 00000000..d8d6d306 --- /dev/null +++ b/crates/psd/src/blend.rs @@ -0,0 +1,147 @@ +//! Layer blend modes (the 4-character keys stored after the `8BIM` signature). + +/// Photoshop blend mode keys. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[allow(missing_docs)] +pub enum BlendMode { + PassThrough, + #[default] + Normal, + Dissolve, + Darken, + Multiply, + ColorBurn, + LinearBurn, + DarkerColor, + Lighten, + Screen, + ColorDodge, + LinearDodge, + LighterColor, + Overlay, + SoftLight, + HardLight, + VividLight, + LinearLight, + PinLight, + HardMix, + Difference, + Exclusion, + Subtract, + Divide, + Hue, + Saturation, + Color, + Luminosity, + /// A key not known to this crate, preserved verbatim. + Unknown([u8; 4]), +} + +impl BlendMode { + /// All known modes (27 layer modes plus pass-through for groups). + pub const ALL: [BlendMode; 28] = [ + BlendMode::PassThrough, + BlendMode::Normal, + BlendMode::Dissolve, + BlendMode::Darken, + BlendMode::Multiply, + BlendMode::ColorBurn, + BlendMode::LinearBurn, + BlendMode::DarkerColor, + BlendMode::Lighten, + BlendMode::Screen, + BlendMode::ColorDodge, + BlendMode::LinearDodge, + BlendMode::LighterColor, + BlendMode::Overlay, + BlendMode::SoftLight, + BlendMode::HardLight, + BlendMode::VividLight, + BlendMode::LinearLight, + BlendMode::PinLight, + BlendMode::HardMix, + BlendMode::Difference, + BlendMode::Exclusion, + BlendMode::Subtract, + BlendMode::Divide, + BlendMode::Hue, + BlendMode::Saturation, + BlendMode::Color, + BlendMode::Luminosity, + ]; + + /// The stored 4-byte key. + pub fn key(self) -> [u8; 4] { + *match self { + BlendMode::PassThrough => b"pass", + BlendMode::Normal => b"norm", + BlendMode::Dissolve => b"diss", + BlendMode::Darken => b"dark", + BlendMode::Multiply => b"mul ", + BlendMode::ColorBurn => b"idiv", + BlendMode::LinearBurn => b"lbrn", + BlendMode::DarkerColor => b"dkCl", + BlendMode::Lighten => b"lite", + BlendMode::Screen => b"scrn", + BlendMode::ColorDodge => b"div ", + BlendMode::LinearDodge => b"lddg", + BlendMode::LighterColor => b"lgCl", + BlendMode::Overlay => b"over", + BlendMode::SoftLight => b"sLit", + BlendMode::HardLight => b"hLit", + BlendMode::VividLight => b"vLit", + BlendMode::LinearLight => b"lLit", + BlendMode::PinLight => b"pLit", + BlendMode::HardMix => b"hMix", + BlendMode::Difference => b"diff", + BlendMode::Exclusion => b"smud", + BlendMode::Subtract => b"fsub", + BlendMode::Divide => b"fdiv", + BlendMode::Hue => b"hue ", + BlendMode::Saturation => b"sat ", + BlendMode::Color => b"colr", + BlendMode::Luminosity => b"lum ", + BlendMode::Unknown(ref k) => k, + } + } + + /// Parses a stored key; unknown keys map to [`BlendMode::Unknown`]. + pub fn from_key(key: [u8; 4]) -> Self { + BlendMode::ALL + .iter() + .copied() + .find(|m| m.key() == key) + .unwrap_or(BlendMode::Unknown(key)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_keys_roundtrip_and_unique() { + let mut keys = std::collections::HashSet::new(); + for m in BlendMode::ALL { + assert_eq!(BlendMode::from_key(m.key()), m); + assert!(keys.insert(m.key())); + } + assert_eq!(keys.len(), 28); + } + + #[test] + fn unknown_preserved() { + let m = BlendMode::from_key(*b"zzzz"); + assert_eq!(m, BlendMode::Unknown(*b"zzzz")); + assert_eq!(m.key(), *b"zzzz"); + } + + #[test] + fn specific_keys() { + assert_eq!(BlendMode::Multiply.key(), *b"mul "); + assert_eq!(BlendMode::from_key(*b"smud"), BlendMode::Exclusion); + assert_eq!(BlendMode::from_key(*b"idiv"), BlendMode::ColorBurn); + assert_eq!(BlendMode::default(), BlendMode::Normal); + } +} diff --git a/crates/psd/src/builder.rs b/crates/psd/src/builder.rs new file mode 100644 index 00000000..b290c55d --- /dev/null +++ b/crates/psd/src/builder.rs @@ -0,0 +1,451 @@ +//! [`PsdBuilder`]: construct PSD/PSB files from pixel buffers. +//! +//! The builder never composites. Supply the merged image with +//! [`PsdBuilder::composite`]; otherwise a white placeholder is written and +//! resource 1057 records `has_real_merged_data = false`. + +use crate::blend::BlendMode; +use crate::compression::Compression; +use crate::error::{PsdError, Result}; +use crate::file::{GlobalLayerMask, LayerInfoPlacement, PsdFile}; +use crate::header::{ColorMode, Header, Version}; +use crate::image_data::ImageData; +use crate::io::encode_legacy_name; +use crate::layer::{BlendingRanges, ChannelData, LayerFlags, LayerInfo, LayerMask, LayerRecord, MaskData, Rect}; +use crate::resources::{ImageResource, ResolutionInfo, ids, version_info_resource}; +use crate::tagged::{SectionType, TaggedBlock}; + +/// Interleaved pixel data with alpha. CMYK values are *ink amounts* +/// (0 = no ink); the builder stores them inverted as Photoshop does. +#[derive(Debug, Clone, PartialEq)] +pub enum PixelData { + /// RGBA, 8-bit. + Rgba8(Vec), + /// RGBA, 16-bit. + Rgba16(Vec), + /// Gray + alpha, 8-bit. + GrayA8(Vec), + /// Gray + alpha, 16-bit. + GrayA16(Vec), + /// CMYK ink + alpha, 8-bit. + Cmyka8(Vec), + /// CMYK ink + alpha, 16-bit. + Cmyka16(Vec), +} + +impl PixelData { + fn mode_depth(&self) -> (ColorMode, u16) { + match self { + PixelData::Rgba8(_) => (ColorMode::Rgb, 8), + PixelData::Rgba16(_) => (ColorMode::Rgb, 16), + PixelData::GrayA8(_) => (ColorMode::Grayscale, 8), + PixelData::GrayA16(_) => (ColorMode::Grayscale, 16), + PixelData::Cmyka8(_) => (ColorMode::Cmyk, 8), + PixelData::Cmyka16(_) => (ColorMode::Cmyk, 16), + } + } + /// Samples per pixel including alpha. + fn spp(&self) -> usize { + match self.mode_depth().0 { + ColorMode::Rgb => 4, + ColorMode::Grayscale => 2, + _ => 5, + } + } + fn len(&self) -> usize { + match self { + PixelData::Rgba8(v) | PixelData::GrayA8(v) | PixelData::Cmyka8(v) => v.len(), + PixelData::Rgba16(v) | PixelData::GrayA16(v) | PixelData::Cmyka16(v) => v.len(), + } + } + /// Planar big-endian plane for interleaved component `c`, as stored + /// (CMYK inverted). + fn plane(&self, c: usize, n: usize) -> Vec { + let spp = self.spp(); + let invert = self.mode_depth().0 == ColorMode::Cmyk && c < 4; + match self { + PixelData::Rgba8(v) | PixelData::GrayA8(v) | PixelData::Cmyka8(v) => { + (0..n).map(|i| if invert { 255 - v[i * spp + c] } else { v[i * spp + c] }).collect() + } + PixelData::Rgba16(v) | PixelData::GrayA16(v) | PixelData::Cmyka16(v) => (0..n) + .flat_map(|i| { + let s = v[i * spp + c]; + (if invert { 65535 - s } else { s }).to_be_bytes() + }) + .collect(), + } + } + fn alpha_is_opaque(&self, n: usize) -> bool { + let spp = self.spp(); + match self { + PixelData::Rgba8(v) | PixelData::GrayA8(v) | PixelData::Cmyka8(v) => { + (0..n).all(|i| v[i * spp + spp - 1] == 255) + } + PixelData::Rgba16(v) | PixelData::GrayA16(v) | PixelData::Cmyka16(v) => { + (0..n).all(|i| v[i * spp + spp - 1] == 65535) + } + } + } +} + +/// A layer mask given as 8-bit samples. +#[derive(Debug, Clone, PartialEq)] +pub struct MaskSpec { + /// Mask rectangle in document coordinates. + pub rect: Rect, + /// `rect.width × rect.height` samples (0 = hidden, 255 = shown). + pub data: Vec, + /// Value outside the rectangle. + pub default_color: u8, + /// Mask disabled. + pub disabled: bool, +} + +/// A raster layer. +#[derive(Debug, Clone, PartialEq)] +pub struct LayerSpec { + /// Name (written both as legacy Pascal name and `luni`). + pub name: String, + /// Left edge. + pub left: i32, + /// Top edge. + pub top: i32, + /// Width. + pub width: u32, + /// Height. + pub height: u32, + /// Pixels (must match the builder's color mode and depth). + pub pixels: PixelData, + /// Blend mode. + pub blend_mode: BlendMode, + /// Opacity 0..=255. + pub opacity: u8, + /// Fill opacity (`iOpa`), if not 255. + pub fill_opacity: Option, + /// Visible. + pub visible: bool, + /// Clipped to the layer below. + pub clipping: bool, + /// Optional user mask. + pub mask: Option, + /// Additional blocks appended verbatim. + pub extra_blocks: Vec, +} + +impl LayerSpec { + /// A visible normal layer at full opacity. + pub fn new(name: impl Into, left: i32, top: i32, width: u32, height: u32, pixels: PixelData) -> Self { + LayerSpec { + name: name.into(), + left, + top, + width, + height, + pixels, + blend_mode: BlendMode::Normal, + opacity: 255, + fill_opacity: None, + visible: true, + clipping: false, + mask: None, + extra_blocks: Vec::new(), + } + } +} + +/// A group (folder). +#[derive(Debug, Clone, PartialEq)] +pub struct GroupSpec { + /// Name. + pub name: String, + /// Blend mode (default pass-through). + pub blend_mode: BlendMode, + /// Opacity. + pub opacity: u8, + /// Visible. + pub visible: bool, + /// Open (expanded) in the layers panel. + pub open: bool, +} + +impl GroupSpec { + /// A visible, open, pass-through group. + pub fn new(name: impl Into) -> Self { + GroupSpec { name: name.into(), blend_mode: BlendMode::PassThrough, opacity: 255, visible: true, open: true } + } +} + +#[derive(Debug, Clone)] +enum Entry { + Layer(LayerSpec), + GroupStart, + GroupEnd(GroupSpec), +} + +/// Builds [`PsdFile`]s. Layers are pushed bottom-to-top. +#[derive(Debug, Clone)] +pub struct PsdBuilder { + width: u32, + height: u32, + color_mode: ColorMode, + depth: u16, + version: Version, + compression: Compression, + entries: Vec, + open_groups: Vec, + composite: Option, + resources: Vec, +} + +impl PsdBuilder { + /// An 8-bit RGB PSD using RLE compression. + pub fn new(width: u32, height: u32) -> Self { + PsdBuilder { + width, + height, + color_mode: ColorMode::Rgb, + depth: 8, + version: Version::Psd, + compression: Compression::Rle, + entries: Vec::new(), + open_groups: Vec::new(), + composite: None, + resources: Vec::new(), + } + } + /// Sets the color mode (RGB, Grayscale or CMYK). + pub fn color_mode(mut self, mode: ColorMode) -> Self { + self.color_mode = mode; + self + } + /// Sets the depth (8 or 16). + pub fn depth(mut self, depth: u16) -> Self { + self.depth = depth; + self + } + /// Sets PSD or PSB. + pub fn version(mut self, v: Version) -> Self { + self.version = v; + self + } + /// Sets the compression used for all channels and the merged image. + pub fn compression(mut self, c: Compression) -> Self { + self.compression = c; + self + } + /// Adds a resolution resource. + pub fn resolution(mut self, dpi: f64) -> Self { + self.resources.push(ImageResource::new(ids::RESOLUTION_INFO, ResolutionInfo::from_dpi(dpi).to_bytes())); + self + } + /// Adds an ICC profile resource. + pub fn icc_profile(mut self, icc: Vec) -> Self { + self.resources.push(ImageResource::new(ids::ICC_PROFILE, icc)); + self + } + /// Adds an arbitrary image resource. + pub fn resource(mut self, r: ImageResource) -> Self { + self.resources.push(r); + self + } + /// Pushes a layer above the previous one. + pub fn push_layer(&mut self, spec: LayerSpec) -> &mut Self { + self.entries.push(Entry::Layer(spec)); + self + } + /// Opens a group; subsequent layers go inside until [`Self::end_group`]. + pub fn begin_group(&mut self, spec: GroupSpec) -> &mut Self { + self.entries.push(Entry::GroupStart); + self.open_groups.push(spec); + self + } + /// Closes the innermost open group. + pub fn end_group(&mut self) -> Result<&mut Self> { + let g = self.open_groups.pop().ok_or_else(|| PsdError::invalid("end_group without begin_group"))?; + self.entries.push(Entry::GroupEnd(g)); + Ok(self) + } + /// Supplies the merged composite (same pixel format as layers, full canvas). + pub fn composite(&mut self, pixels: PixelData) -> &mut Self { + self.composite = Some(pixels); + self + } + + fn color_channels(&self) -> Result { + match self.color_mode { + ColorMode::Rgb => Ok(3), + ColorMode::Grayscale => Ok(1), + ColorMode::Cmyk => Ok(4), + m => Err(PsdError::Unsupported(format!("builder color mode {m:?}"))), + } + } + + fn check_pixels(&self, p: &PixelData, n: usize) -> Result<()> { + if p.mode_depth() != (self.color_mode, self.depth) { + return Err(PsdError::invalid(format!( + "pixel format {:?} does not match document {:?}/{}", + p.mode_depth(), + self.color_mode, + self.depth + ))); + } + if p.len() != n * p.spp() { + return Err(PsdError::invalid(format!("pixel buffer has {} samples, expected {}", p.len(), n * p.spp()))); + } + Ok(()) + } + + fn channel(&self, id: i16, plane: &[u8], w: usize, h: usize) -> Result { + ChannelData::encode(id, self.compression, plane, w, h, self.depth, self.version) + } + + fn empty_channels(&self, cc: usize) -> Result> { + let mut v = vec![self.channel(-1, &[], 0, 0)?]; + for c in 0..cc { + v.push(self.channel(c as i16, &[], 0, 0)?); + } + Ok(v) + } + + fn layer_record(&self, spec: &LayerSpec, cc: usize, id: u32) -> Result { + let (w, h) = (spec.width as usize, spec.height as usize); + let n = w * h; + self.check_pixels(&spec.pixels, n)?; + let mut channels = vec![self.channel(-1, &spec.pixels.plane(cc, n), w, h)?]; + for c in 0..cc { + channels.push(self.channel(c as i16, &spec.pixels.plane(c, n), w, h)?); + } + let mut mask = MaskData::None; + if let Some(m) = &spec.mask { + let (mw, mh) = m.rect.size()?; + if m.data.len() != mw * mh { + return Err(PsdError::invalid("mask buffer size mismatch")); + } + let plane: Vec = if self.depth == 16 { + m.data.iter().flat_map(|&v| (u16::from(v) * 257).to_be_bytes()).collect() + } else { + m.data.clone() + }; + channels.push(self.channel(-2, &plane, mw, mh)?); + let flags = if m.disabled { LayerMask::FLAG_DISABLED } else { 0 }; + mask = MaskData::Mask(LayerMask::new(m.rect, m.default_color, flags)); + } + let mut flags = LayerFlags(0); + flags.set_hidden(!spec.visible); + let mut blocks = vec![TaggedBlock::unicode_name(&spec.name), TaggedBlock::layer_id(id)]; + if let Some(f) = spec.fill_opacity { + blocks.push(TaggedBlock::fill_opacity(f)); + } + blocks.extend(spec.extra_blocks.iter().cloned()); + Ok(LayerRecord { + rect: Rect::from_xywh(spec.left, spec.top, spec.width, spec.height), + channels, + blend_mode: spec.blend_mode, + opacity: spec.opacity, + clipping: u8::from(spec.clipping), + flags, + filler: 0, + mask, + blending_ranges: BlendingRanges::full(cc), + name: encode_legacy_name(&spec.name), + blocks, + extra_trailing: Vec::new(), + }) + } + + /// Builds the file model. + pub fn build(&self) -> Result { + if !self.open_groups.is_empty() { + return Err(PsdError::invalid("unclosed group")); + } + if !matches!(self.depth, 8 | 16) { + return Err(PsdError::Unsupported(format!("builder depth {}", self.depth))); + } + let cc = self.color_channels()?; + let mut layers = Vec::new(); + for (id, e) in (1u32..).zip(self.entries.iter()) { + match e { + Entry::Layer(spec) => layers.push(self.layer_record(spec, cc, id)?), + Entry::GroupStart => layers.push(LayerRecord { + channels: self.empty_channels(cc)?, + blending_ranges: BlendingRanges::full(cc), + name: b"".to_vec(), + blocks: vec![ + TaggedBlock::unicode_name(""), + TaggedBlock::layer_id(id), + TaggedBlock::section_divider(SectionType::BoundingDivider, None, None), + ], + ..Default::default() + }), + Entry::GroupEnd(g) => { + let kind = if g.open { SectionType::OpenFolder } else { SectionType::ClosedFolder }; + let mut flags = LayerFlags(0); + flags.set_hidden(!g.visible); + layers.push(LayerRecord { + channels: self.empty_channels(cc)?, + blend_mode: g.blend_mode, + opacity: g.opacity, + flags, + blending_ranges: BlendingRanges::full(cc), + name: encode_legacy_name(&g.name), + blocks: vec![ + TaggedBlock::unicode_name(&g.name), + TaggedBlock::layer_id(id), + TaggedBlock::section_divider(kind, Some(g.blend_mode), None), + ], + ..Default::default() + }); + } + } + } + + let (w, h) = (self.width as usize, self.height as usize); + let n = w * h; + let (real, planes, with_alpha) = match &self.composite { + Some(p) => { + self.check_pixels(p, n)?; + let with_alpha = !p.alpha_is_opaque(n); + let mut planes = Vec::new(); + for c in 0..cc + usize::from(with_alpha) { + planes.extend(p.plane(c, n)); + } + (true, planes, with_alpha) + } + None => { + let bps = usize::from(self.depth / 8); + // White: 0xff in RGB/gray; no ink (stored inverted as 0xff) in CMYK. + (false, vec![0xff; n * bps * cc], false) + } + }; + let channels = (cc + usize::from(with_alpha)) as u16; + let header = Header::new(self.version, self.width, self.height, channels, self.depth, self.color_mode); + header.validate()?; + let image_data = ImageData::encode(self.compression, &planes, &header)?; + let mut resources = self.resources.clone(); + resources.push(version_info_resource(real)); + + let has_layers = !layers.is_empty(); + let layer_info = has_layers.then_some(LayerInfo { merged_alpha: with_alpha, layers, padding: None }); + let layer_info_placement = if has_layers && self.depth == 16 { + LayerInfoPlacement::GlobalBlock { index: 0, signature: *b"8BIM", key: *b"Lr16", padding: None } + } else { + LayerInfoPlacement::Section + }; + Ok(PsdFile { + header, + color_mode_data: Vec::new(), + resources, + layer_info, + layer_info_placement, + global_layer_mask: has_layers.then(GlobalLayerMask::default), + global_blocks: Vec::new(), + layer_mask_trailing: Vec::new(), + image_data, + }) + } + + /// Builds and serializes. + pub fn to_bytes(&self) -> Result> { + self.build()?.to_bytes() + } +} diff --git a/crates/psd/src/compression.rs b/crates/psd/src/compression.rs new file mode 100644 index 00000000..e666f211 --- /dev/null +++ b/crates/psd/src/compression.rs @@ -0,0 +1,718 @@ +//! Channel image data codecs: Raw, RLE (PackBits), ZIP, and ZIP with prediction. +//! +//! All decoded data is *planar* bytes in the file's native big-endian sample +//! order: `planes × height × row_bytes` where `row_bytes` depends on depth +//! (depth 1 packs 8 pixels per byte, MSB first). + +use std::io::{Read, Write}; + +use crate::error::{PsdError, Result}; +use crate::header::{Version, row_bytes}; +use crate::io::{Reader, WriteExt}; + +/// Maximum number of decoded bytes a single decode call may produce. Guards +/// against decompression bombs from tiny inputs. +pub const MAX_DECODED_BYTES: u64 = 1 << 31; + +/// Compression method stored before channel / image data. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum Compression { + /// 0: uncompressed. + Raw, + /// 1: PackBits RLE with a per-row byte-count table. + Rle, + /// 2: zlib without prediction. + Zip, + /// 3: zlib with horizontal delta prediction. + ZipPrediction, + /// Unknown method; data is preserved but cannot be decoded. + Unknown(u16), +} + +impl Compression { + /// All four decodable methods. + pub const ALL: [Compression; 4] = [Compression::Raw, Compression::Rle, Compression::Zip, Compression::ZipPrediction]; + + /// From the stored value. + pub fn from_u16(v: u16) -> Self { + match v { + 0 => Compression::Raw, + 1 => Compression::Rle, + 2 => Compression::Zip, + 3 => Compression::ZipPrediction, + v => Compression::Unknown(v), + } + } + /// Stored value. + pub fn as_u16(self) -> u16 { + match self { + Compression::Raw => 0, + Compression::Rle => 1, + Compression::Zip => 2, + Compression::ZipPrediction => 3, + Compression::Unknown(v) => v, + } + } +} + +/// PackBits (Apple / TIFF) run-length coding as used by Photoshop RLE. +pub mod packbits { + use crate::error::{PsdError, Result}; + + /// Encodes one row, appending to `out`. Never emits the `-128` no-op. + pub fn encode(src: &[u8], out: &mut Vec) { + let n = src.len(); + let mut i = 0; + while i < n { + // Length of the run starting at i. + let mut run = 1; + while i + run < n && run < 128 && src[i + run] == src[i] { + run += 1; + } + // A literal never stops before a 2-byte run, so any run of >= 2 + // seen here starts a packet boundary: a run packet is never larger. + if run >= 2 { + out.push((1i16 - run as i16) as i8 as u8); + out.push(src[i]); + i += run; + continue; + } + // Literal: extend until a run of >= 3 begins or 128 bytes. + let start = i; + let mut len = 0; + while i < n && len < 128 { + if i + 2 < n && src[i] == src[i + 1] && src[i] == src[i + 2] { + break; + } + i += 1; + len += 1; + } + out.push((len - 1) as u8); + out.extend_from_slice(&src[start..start + len]); + } + } + + /// Returns the encoded form of `src`. + pub fn encode_vec(src: &[u8]) -> Vec { + let mut v = Vec::with_capacity(src.len() + src.len() / 128 + 1); + encode(src, &mut v); + v + } + + /// Decodes `src` into exactly `expected` bytes appended to `out`. + /// + /// Errors if the input is exhausted early or a packet would overflow the + /// row. Trailing input after the row is complete is ignored except for + /// `-128` no-op bytes (which are always permitted). + pub fn decode_into(src: &[u8], expected: usize, out: &mut Vec) -> Result<()> { + let target = out.len() + expected; + let mut i = 0; + while out.len() < target { + let Some(&h) = src.get(i) else { + return Err(PsdError::Decompress("PackBits row ended early".into())); + }; + i += 1; + let h = h as i8; + if h >= 0 { + let len = h as usize + 1; + let Some(lit) = src.get(i..i + len) else { + return Err(PsdError::Decompress("PackBits literal truncated".into())); + }; + if out.len() + len > target { + return Err(PsdError::Decompress("PackBits literal overflows row".into())); + } + out.extend_from_slice(lit); + i += len; + } else if h != -128 { + let len = 1 - h as isize; + let len = len as usize; + let Some(&b) = src.get(i) else { + return Err(PsdError::Decompress("PackBits run truncated".into())); + }; + i += 1; + if out.len() + len > target { + return Err(PsdError::Decompress("PackBits run overflows row".into())); + } + out.extend(std::iter::repeat_n(b, len)); + } + } + Ok(()) + } + + /// Decodes into a new vector of exactly `expected` bytes. + pub fn decode(src: &[u8], expected: usize) -> Result> { + let mut v = Vec::with_capacity(expected.min(src.len().saturating_mul(64))); + decode_into(src, expected, &mut v)?; + Ok(v) + } +} + +/// Geometry of a set of planes to encode/decode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PlaneLayout { + /// Number of planes (1 for a layer channel, channel count for the merged image). + pub planes: usize, + /// Pixels per row. + pub width: usize, + /// Rows per plane. + pub height: usize, + /// Bits per sample (1, 8, 16, 32). + pub depth: u16, + /// PSD vs PSB (affects RLE count width). + pub version: Version, +} + +impl PlaneLayout { + /// Bytes per row. + pub fn row_bytes(&self) -> usize { + row_bytes(self.width, self.depth) + } + /// Total rows over all planes. + pub fn rows(&self) -> Result { + self.planes.checked_mul(self.height).ok_or(PsdError::LimitExceeded("row count overflow")) + } + /// Total decoded bytes without the [`MAX_DECODED_BYTES`] cap. + pub fn total_bytes(&self) -> Result { + (self.planes as u64) + .checked_mul(self.height as u64) + .and_then(|v| v.checked_mul(self.row_bytes() as u64)) + .ok_or(PsdError::LimitExceeded("decoded size overflow")) + } + + /// Total decoded bytes, checked against [`MAX_DECODED_BYTES`]. + pub fn decoded_len(&self) -> Result { + let total = self.total_bytes()?; + if total > MAX_DECODED_BYTES { + return Err(PsdError::LimitExceeded("decoded channel data exceeds MAX_DECODED_BYTES")); + } + usize::try_from(total).map_err(|_| PsdError::LimitExceeded("decoded size exceeds address space")) + } + fn count_size(&self) -> usize { + if self.version.is_psb() { 4 } else { 2 } + } +} + +/// Decodes planar data (excluding the 2-byte compression field). +pub fn decode_planes(compression: Compression, data: &[u8], layout: &PlaneLayout) -> Result> { + let total = layout.decoded_len()?; + match compression { + Compression::Raw => { + if data.len() < total { + return Err(PsdError::UnexpectedEof { offset: data.len(), needed: total - data.len() }); + } + Ok(data[..total].to_vec()) + } + Compression::Rle => decode_rle(data, layout, total), + Compression::Zip => zip_decompress(data, total), + Compression::ZipPrediction => { + let mut v = zip_decompress(data, total)?; + unpredict(&mut v, layout)?; + Ok(v) + } + Compression::Unknown(c) => Err(PsdError::Unsupported(format!("compression method {c}"))), + } +} + +/// Encodes planar data (excluding the 2-byte compression field). +pub fn encode_planes(compression: Compression, decoded: &[u8], layout: &PlaneLayout) -> Result> { + let total = layout.decoded_len()?; + if decoded.len() != total { + return Err(PsdError::invalid(format!( + "decoded buffer has {} bytes, layout requires {}", + decoded.len(), + total + ))); + } + match compression { + Compression::Raw => Ok(decoded.to_vec()), + Compression::Rle => encode_rle(decoded, layout), + Compression::Zip => Ok(zip_compress(decoded)), + Compression::ZipPrediction => { + let mut v = decoded.to_vec(); + predict(&mut v, layout)?; + Ok(zip_compress(&v)) + } + Compression::Unknown(c) => Err(PsdError::Unsupported(format!("compression method {c}"))), + } +} + +/// Returns the number of leading bytes of `data` that a decoder would consume +/// (validating structure). For RLE this is the count table plus the counted +/// bytes; for Raw it is the decoded size; for ZIP the zlib stream is +/// decompressed (without keeping output) and the whole input is consumed. +pub(crate) fn validate_planes(compression: Compression, data: &[u8], layout: &PlaneLayout) -> Result<()> { + let total = layout.total_bytes()?; + match compression { + Compression::Raw => { + if (data.len() as u64) < total { + return Err(PsdError::UnexpectedEof { + offset: data.len(), + needed: usize::try_from(total - data.len() as u64).unwrap_or(usize::MAX), + }); + } + Ok(()) + } + Compression::Rle => { + let rows = layout.rows()?; + let mut r = Reader::new(data); + r.check_count(rows as u64, layout.count_size() as u64)?; + let mut sum: u64 = 0; + for _ in 0..rows { + sum += read_count(&mut r, layout.version)? as u64; + } + if sum > r.remaining() as u64 { + return Err(PsdError::UnexpectedEof { + offset: data.len(), + needed: usize::try_from(sum - r.remaining() as u64).unwrap_or(usize::MAX), + }); + } + Ok(()) + } + Compression::Zip | Compression::ZipPrediction => { + // Decompress without keeping the output, through the end of the + // stream so the Adler-32 checksum is verified too. + let mut dec = flate2::read::ZlibDecoder::new(data); + let mut buf = [0u8; 8192]; + let mut got: u64 = 0; + loop { + let n = dec.read(&mut buf).map_err(|e| PsdError::Decompress(e.to_string()))?; + if n == 0 { + break; + } + got += n as u64; + if got > total.saturating_add(1 << 20) { + // Far more output than needed; stop (bomb guard). + break; + } + } + if got < total { + return Err(PsdError::Decompress(format!("zlib stream produced {got} of {total} bytes"))); + } + Ok(()) + } + Compression::Unknown(_) => Ok(()), + } +} + +fn read_count(r: &mut Reader<'_>, v: Version) -> Result { + Ok(if v.is_psb() { r.u32()? as usize } else { r.u16()? as usize }) +} + +fn decode_rle(data: &[u8], layout: &PlaneLayout, total: usize) -> Result> { + let rows = layout.rows()?; + let rb = layout.row_bytes(); + let mut r = Reader::new(data); + r.check_count(rows as u64, layout.count_size() as u64)?; + let mut counts = Vec::with_capacity(rows); + for _ in 0..rows { + counts.push(read_count(&mut r, layout.version)?); + } + // PackBits expands at most 64x (2 input bytes -> 128 output bytes). + if (total as u64) > (r.remaining() as u64).saturating_mul(64) { + return Err(PsdError::Decompress("RLE data too short for declared size".into())); + } + let mut out = Vec::with_capacity(total); + for c in counts { + let row = r.bytes(c)?; + packbits::decode_into(row, rb, &mut out)?; + } + Ok(out) +} + +fn encode_rle(decoded: &[u8], layout: &PlaneLayout) -> Result> { + let rows = layout.rows()?; + let rb = layout.row_bytes(); + let cs = layout.count_size(); + let mut out = vec![0u8; rows * cs]; + let mut enc = Vec::new(); + for row in 0..rows { + let start = enc.len(); + packbits::encode(&decoded[row * rb..(row + 1) * rb], &mut enc); + let n = enc.len() - start; + if cs == 2 { + let n = u16::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row exceeds 65535 bytes (use PSB)"))?; + out[row * 2..row * 2 + 2].copy_from_slice(&n.to_be_bytes()); + } else { + let n = u32::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row too large"))?; + out[row * 4..row * 4 + 4].copy_from_slice(&n.to_be_bytes()); + } + } + out.put(&enc); + Ok(out) +} + +/// zlib-compresses `data` at the default level. +pub fn zip_compress(data: &[u8]) -> Vec { + let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + // Writing into a Vec cannot fail. + let _ = e.write_all(data); + e.finish().unwrap_or_default() +} + +/// Decompresses a zlib stream, requiring at least `expected` output bytes and +/// returning exactly `expected`. +pub fn zip_decompress(data: &[u8], expected: usize) -> Result> { + let dec = flate2::read::ZlibDecoder::new(data); + let mut out = Vec::with_capacity(expected.min(data.len().saturating_mul(1032))); + dec.take(expected as u64) + .read_to_end(&mut out) + .map_err(|e| PsdError::Decompress(e.to_string()))?; + if out.len() < expected { + return Err(PsdError::Decompress(format!("zlib stream produced {} of {} bytes", out.len(), expected))); + } + Ok(out) +} + +/// Applies horizontal delta prediction in place (encoder side). +/// +/// * 1/8-bit: byte-wise delta per row. +/// * 16-bit: big-endian u16 sample delta per row. +/// * 32-bit: each row's samples are split into four byte planes (MSB plane +/// first) and the resulting row is byte-wise delta coded, as Photoshop does. +pub fn predict(buf: &mut [u8], layout: &PlaneLayout) -> Result<()> { + let rb = layout.row_bytes(); + if rb == 0 { + return Ok(()); + } + let w = layout.width; + let mut scratch = Vec::new(); + for row in buf.chunks_exact_mut(rb) { + match layout.depth { + 1 | 8 => { + for i in (1..row.len()).rev() { + row[i] = row[i].wrapping_sub(row[i - 1]); + } + } + 16 => { + for i in (1..w).rev() { + let a = u16::from_be_bytes([row[2 * i], row[2 * i + 1]]); + let b = u16::from_be_bytes([row[2 * i - 2], row[2 * i - 1]]); + row[2 * i..2 * i + 2].copy_from_slice(&a.wrapping_sub(b).to_be_bytes()); + } + } + 32 => { + scratch.clear(); + scratch.resize(rb, 0); + for i in 0..w { + for k in 0..4 { + scratch[k * w + i] = row[4 * i + k]; + } + } + for i in (1..rb).rev() { + scratch[i] = scratch[i].wrapping_sub(scratch[i - 1]); + } + row.copy_from_slice(&scratch); + } + d => return Err(PsdError::Unsupported(format!("prediction for depth {d}"))), + } + } + Ok(()) +} + +/// Reverses [`predict`] in place (decoder side). +pub fn unpredict(buf: &mut [u8], layout: &PlaneLayout) -> Result<()> { + let rb = layout.row_bytes(); + if rb == 0 { + return Ok(()); + } + let w = layout.width; + let mut scratch = Vec::new(); + for row in buf.chunks_exact_mut(rb) { + match layout.depth { + 1 | 8 => { + for i in 1..row.len() { + row[i] = row[i].wrapping_add(row[i - 1]); + } + } + 16 => { + for i in 1..w { + let a = u16::from_be_bytes([row[2 * i], row[2 * i + 1]]); + let b = u16::from_be_bytes([row[2 * i - 2], row[2 * i - 1]]); + row[2 * i..2 * i + 2].copy_from_slice(&a.wrapping_add(b).to_be_bytes()); + } + } + 32 => { + for i in 1..rb { + row[i] = row[i].wrapping_add(row[i - 1]); + } + scratch.clear(); + scratch.extend_from_slice(row); + for i in 0..w { + for k in 0..4 { + row[4 * i + k] = scratch[k * w + i]; + } + } + } + d => return Err(PsdError::Unsupported(format!("prediction for depth {d}"))), + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn layout(planes: usize, width: usize, height: usize, depth: u16, version: Version) -> PlaneLayout { + PlaneLayout { planes, width, height, depth, version } + } + + fn pb_roundtrip(src: &[u8]) -> Vec { + let enc = packbits::encode_vec(src); + let dec = packbits::decode(&enc, src.len()).unwrap(); + assert_eq!(dec, src, "encoded: {enc:?}"); + enc + } + + #[test] + fn packbits_empty() { + assert!(pb_roundtrip(&[]).is_empty()); + } + + #[test] + fn packbits_single() { + assert_eq!(pb_roundtrip(&[7]), vec![0, 7]); + } + + #[test] + fn packbits_run_of_two() { + assert_eq!(pb_roundtrip(&[5, 5]), vec![0xff, 5]); + } + + #[test] + fn packbits_run_128() { + let src = vec![9u8; 128]; + assert_eq!(pb_roundtrip(&src), vec![0x81, 9]); + } + + #[test] + fn packbits_run_129() { + let src = vec![9u8; 129]; + assert_eq!(pb_roundtrip(&src), vec![0x81, 9, 0, 9]); + } + + #[test] + fn packbits_run_130_and_256() { + pb_roundtrip(&[1u8; 130]); + let enc = pb_roundtrip(&[1u8; 256]); + assert_eq!(enc, vec![0x81, 1, 0x81, 1]); + } + + #[test] + fn packbits_literal_128_and_129() { + let src: Vec = (0..128u32).map(|i| i as u8).collect(); + let enc = pb_roundtrip(&src); + assert_eq!(enc[0], 127); + assert_eq!(enc.len(), 129); + let src: Vec = (0..129u32).map(|i| i as u8).collect(); + let enc = pb_roundtrip(&src); + assert_eq!(enc[0], 127); + assert_eq!(enc[129], 0); + assert_eq!(enc.len(), 131); + } + + #[test] + fn packbits_literal_run_boundary() { + pb_roundtrip(&[1, 2, 3, 3, 3, 3, 4, 5]); + pb_roundtrip(&[1, 1, 2, 2, 3, 3]); + pb_roundtrip(&[1, 2, 2, 3]); + pb_roundtrip(&[0, 0, 0, 1, 0, 0, 0]); + let enc = pb_roundtrip(&[1, 2, 3, 3, 3]); + assert_eq!(enc, vec![1, 1, 2, 0xfe, 3]); + } + + #[test] + fn packbits_never_emits_noop() { + for n in 0..300usize { + let src: Vec = (0..n).map(|i| ((i / 3) % 5) as u8).collect(); + let enc = pb_roundtrip(&src); + // Walk the packets to find headers. + let mut i = 0; + while i < enc.len() { + let h = enc[i] as i8; + assert_ne!(h, -128); + i += if h >= 0 { h as usize + 2 } else { 2 }; + } + } + } + + #[test] + fn packbits_decode_noop_skipped() { + assert_eq!(packbits::decode(&[0x80, 0, 42], 1).unwrap(), vec![42]); + } + + #[test] + fn packbits_decode_errors() { + assert!(packbits::decode(&[], 1).is_err()); + assert!(packbits::decode(&[2, 1, 2], 3).is_err()); // literal truncated + assert!(packbits::decode(&[0xfe], 3).is_err()); // run truncated + assert!(packbits::decode(&[0xfd, 1], 3).is_err()); // run overflows (4 > 3) + assert!(packbits::decode(&[3, 1, 2, 3, 4], 3).is_err()); // literal overflows + } + + #[test] + fn packbits_zero_expected_ignores_input() { + assert_eq!(packbits::decode(&[0xff, 1], 0).unwrap(), Vec::::new()); + } + + #[test] + fn compression_values() { + for v in 0..6 { + assert_eq!(Compression::from_u16(v).as_u16(), v); + } + assert_eq!(Compression::from_u16(9), Compression::Unknown(9)); + } + + fn sample_data(l: &PlaneLayout) -> Vec { + let n = l.decoded_len().unwrap(); + (0..n).map(|i| ((i * 7 + i / 5) % 251) as u8).collect() + } + + #[test] + fn all_codecs_roundtrip_all_depths() { + for version in [Version::Psd, Version::Psb] { + for depth in [1u16, 8, 16, 32] { + for (w, h) in [(0, 0), (1, 1), (3, 2), (17, 5), (0, 3), (4, 0)] { + for planes in [1, 3] { + let l = layout(planes, w, h, depth, version); + let data = sample_data(&l); + for c in Compression::ALL { + let enc = encode_planes(c, &data, &l).unwrap(); + validate_planes(c, &enc, &l).unwrap(); + let dec = decode_planes(c, &enc, &l).unwrap(); + assert_eq!(dec, data, "{c:?} {depth} {w}x{h} {version:?}"); + } + } + } + } + } + } + + #[test] + fn rle_count_width_psd_vs_psb() { + let data = vec![0u8; 4]; + let psd = encode_planes(Compression::Rle, &data, &layout(1, 2, 2, 8, Version::Psd)).unwrap(); + let psb = encode_planes(Compression::Rle, &data, &layout(1, 2, 2, 8, Version::Psb)).unwrap(); + assert_eq!(psd, vec![0, 2, 0, 2, 0xff, 0, 0xff, 0]); + assert_eq!(psb, vec![0, 0, 0, 2, 0, 0, 0, 2, 0xff, 0, 0xff, 0]); + assert!(decode_planes(Compression::Rle, &psd, &layout(1, 2, 2, 8, Version::Psb)).is_err()); + } + + #[test] + fn rle_odd_width() { + let l = layout(1, 7, 3, 16, Version::Psd); + let data = sample_data(&l); + let enc = encode_planes(Compression::Rle, &data, &l).unwrap(); + assert_eq!(decode_planes(Compression::Rle, &enc, &l).unwrap(), data); + } + + #[test] + fn rle_truncated_fails() { + let l = layout(1, 10, 10, 8, Version::Psd); + let data = sample_data(&l); + let enc = encode_planes(Compression::Rle, &data, &l).unwrap(); + for cut in 0..enc.len() { + assert!(decode_planes(Compression::Rle, &enc[..cut], &l).is_err(), "cut {cut}"); + assert!(validate_planes(Compression::Rle, &enc[..cut], &l).is_err(), "cut {cut}"); + } + } + + #[test] + fn zip_truncated_fails() { + let l = layout(1, 10, 10, 8, Version::Psd); + let data = sample_data(&l); + let enc = encode_planes(Compression::Zip, &data, &l).unwrap(); + for cut in 0..enc.len() - 4 { + assert!(decode_planes(Compression::Zip, &enc[..cut], &l).is_err(), "cut {cut}"); + assert!(validate_planes(Compression::Zip, &enc[..cut], &l).is_err(), "cut {cut}"); + } + } + + #[test] + fn raw_short_fails() { + let l = layout(2, 3, 3, 8, Version::Psd); + assert!(decode_planes(Compression::Raw, &[0; 17], &l).is_err()); + assert!(decode_planes(Compression::Raw, &[0; 18], &l).is_ok()); + } + + #[test] + fn unknown_compression_errors() { + let l = layout(1, 1, 1, 8, Version::Psd); + assert!(decode_planes(Compression::Unknown(7), &[0], &l).is_err()); + assert!(encode_planes(Compression::Unknown(7), &[0], &l).is_err()); + } + + #[test] + fn encode_rejects_wrong_size() { + let l = layout(1, 2, 2, 8, Version::Psd); + assert!(encode_planes(Compression::Raw, &[0; 3], &l).is_err()); + } + + #[test] + fn decoded_len_limits() { + let l = layout(56, 300_000, 300_000, 32, Version::Psb); + assert!(matches!(l.decoded_len(), Err(PsdError::LimitExceeded(_)))); + assert!(decode_planes(Compression::Rle, &[0; 16], &l).is_err()); + } + + #[test] + fn prediction_8bit_known_vector() { + let l = layout(1, 4, 1, 8, Version::Psd); + let mut v = vec![10, 12, 11, 255]; + predict(&mut v, &l).unwrap(); + assert_eq!(v, vec![10, 2, 255, 244]); + unpredict(&mut v, &l).unwrap(); + assert_eq!(v, vec![10, 12, 11, 255]); + } + + #[test] + fn prediction_16bit_known_vector() { + let l = layout(1, 3, 1, 16, Version::Psd); + let mut v = vec![0x01, 0x00, 0x01, 0x05, 0x00, 0x00]; + predict(&mut v, &l).unwrap(); + assert_eq!(v, vec![0x01, 0x00, 0x00, 0x05, 0xfe, 0xfb]); + unpredict(&mut v, &l).unwrap(); + assert_eq!(v, vec![0x01, 0x00, 0x01, 0x05, 0x00, 0x00]); + } + + #[test] + fn prediction_32bit_plane_shuffle() { + // Two samples: AABBCCDD and 11223344 → planes [AA,11][BB,22][CC,33][DD,44] + let l = layout(1, 2, 1, 32, Version::Psd); + let orig = vec![0xAA, 0xBB, 0xCC, 0xDD, 0x11, 0x22, 0x33, 0x44]; + let mut v = orig.clone(); + predict(&mut v, &l).unwrap(); + let planes = [0xAAu8, 0x11, 0xBB, 0x22, 0xCC, 0x33, 0xDD, 0x44]; + let mut expect = planes; + for i in (1..8).rev() { + expect[i] = expect[i].wrapping_sub(expect[i - 1]); + } + assert_eq!(v, expect); + unpredict(&mut v, &l).unwrap(); + assert_eq!(v, orig); + } + + #[test] + fn prediction_32bit_floats_roundtrip() { + let l = layout(1, 5, 2, 32, Version::Psd); + let mut v = Vec::new(); + for i in 0..10 { + v.extend_from_slice(&(i as f32 * 0.37 - 1.0).to_be_bytes()); + } + let enc = encode_planes(Compression::ZipPrediction, &v, &l).unwrap(); + assert_eq!(decode_planes(Compression::ZipPrediction, &enc, &l).unwrap(), v); + } + + #[test] + fn zip_bomb_is_bounded() { + let big = zip_compress(&vec![0u8; 1 << 20]); + let out = zip_decompress(&big, 10).unwrap(); + assert_eq!(out.len(), 10); + } + + #[test] + fn zip_garbage_errors() { + assert!(zip_decompress(&[1, 2, 3, 4], 4).is_err()); + } +} diff --git a/crates/psd/src/descriptor.rs b/crates/psd/src/descriptor.rs new file mode 100644 index 00000000..50358861 --- /dev/null +++ b/crates/psd/src/descriptor.rs @@ -0,0 +1,817 @@ +//! Photoshop ActionDescriptor structures (OSType-tagged key/value trees). +//! +//! These appear inside many tagged blocks (`lfx2`, `SoCo`, `GdFl`, `TySh`, +//! `PlLd`, …), usually wrapped in a [`VersionedDescriptor`] (version 16). +//! Parsing and writing are exact: `write(parse(b)) == b` for well-formed input. + +use crate::error::{PsdError, Result}; +use crate::io::{Reader, WriteExt, read_unicode_units, write_unicode_units}; + +/// Maximum nesting depth accepted when parsing (fuzz safety). +pub const MAX_DEPTH: usize = 64; + +/// A Photoshop Unicode string: UTF-16 code units, preserved exactly +/// (including any trailing NUL and unpaired surrogates). +#[derive(Debug, Clone, PartialEq, Eq, Default, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct UnicodeString(pub Vec); + +impl UnicodeString { + /// Encodes `s` as UTF-16 without a trailing NUL. + pub fn new(s: &str) -> Self { + UnicodeString(s.encode_utf16().collect()) + } + /// Encodes `s` as UTF-16 followed by a NUL terminator (as Photoshop + /// commonly writes in descriptors). + pub fn new_nul(s: &str) -> Self { + let mut v: Vec = s.encode_utf16().collect(); + v.push(0); + UnicodeString(v) + } + /// Lossy conversion, stripping trailing NULs. + pub fn to_string_lossy(&self) -> String { + let mut end = self.0.len(); + while end > 0 && self.0[end - 1] == 0 { + end -= 1; + } + String::from_utf16_lossy(&self.0[..end]) + } + pub(crate) fn read(r: &mut Reader<'_>) -> Result { + Ok(UnicodeString(read_unicode_units(r)?)) + } + pub(crate) fn write(&self, out: &mut Vec) { + write_unicode_units(out, &self.0); + } +} + +/// A class / key / type identifier: a length-prefixed string, where a stored +/// length of 0 means a 4-character code follows. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum Id { + /// Stored as length 0 + 4 bytes. + Code([u8; 4]), + /// Stored as explicit length + bytes (e.g. `"artboardRect"`). + Str(Vec), +} + +impl Id { + /// Convenience constructor: 4-byte strings become [`Id::Code`], others + /// [`Id::Str`]. + pub fn new(s: &str) -> Self { + let b = s.as_bytes(); + if b.len() == 4 { + Id::Code([b[0], b[1], b[2], b[3]]) + } else { + Id::Str(b.to_vec()) + } + } + /// The identifier's bytes. + pub fn as_bytes(&self) -> &[u8] { + match self { + Id::Code(c) => c, + Id::Str(s) => s, + } + } + /// `true` if the identifier's bytes equal `s`. + pub fn is(&self, s: &str) -> bool { + self.as_bytes() == s.as_bytes() + } + pub(crate) fn read(r: &mut Reader<'_>) -> Result { + let n = r.u32()?; + if n == 0 { + Ok(Id::Code(r.array()?)) + } else { + Ok(Id::Str(r.bytes_u64(u64::from(n))?.to_vec())) + } + } + pub(crate) fn write(&self, out: &mut Vec) { + match self { + Id::Code(c) => { + out.put_u32(0); + out.put(c); + } + Id::Str(s) => { + out.put_u32(s.len() as u32); + out.put(s); + } + } + } +} + +/// A class reference: display name + class id. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Class { + /// Display name. + pub name: UnicodeString, + /// Class identifier. + pub class_id: Id, +} + +impl Class { + fn read(r: &mut Reader<'_>) -> Result { + Ok(Class { name: UnicodeString::read(r)?, class_id: Id::read(r)? }) + } + fn write(&self, out: &mut Vec) { + self.name.write(out); + self.class_id.write(out); + } +} + +/// One element of an `'obj '` reference. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum ReferenceItem { + /// `prop` + Property { + /// Class. + class: Class, + /// Property key. + key: Id, + }, + /// `Clss` + Class(Class), + /// `Enmr` + Enumerated { + /// Class. + class: Class, + /// Enum type. + type_id: Id, + /// Enum value. + value: Id, + }, + /// `rele` + Offset { + /// Class. + class: Class, + /// Offset. + offset: i32, + }, + /// `Idnt` + Identifier(u32), + /// `indx` + Index(u32), + /// `name` + Name { + /// Class. + class: Class, + /// Name. + name: UnicodeString, + }, +} + +/// A descriptor value, tagged by its OSType. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum Value { + /// `obj ` + Reference(Vec), + /// `Objc` + Descriptor(Descriptor), + /// `GlbO` + GlobalObject(Descriptor), + /// `VlLs` + List(Vec), + /// `doub` + Double(f64), + /// `UntF`: unit (`#Ang`, `#Rsl`, `#Rlt`, `#Nne`, `#Prc`, `#Pxl`, …) + value. + UnitFloat { + /// Unit code. + unit: [u8; 4], + /// Value. + value: f64, + }, + /// `UnFl`: unit + array of doubles (used in `ObAr`). + UnitFloats { + /// Unit code. + unit: [u8; 4], + /// Values. + values: Vec, + }, + /// `TEXT` + Text(UnicodeString), + /// `enum` + Enumerated { + /// Enum type. + type_id: Id, + /// Enum value. + value: Id, + }, + /// `long` + Integer(i32), + /// `comp` + LargeInteger(i64), + /// `bool` + Boolean(bool), + /// `type` + Class(Class), + /// `GlbC` + GlobalClass(Class), + /// `alis`: raw alias data. + Alias(Vec), + /// `Pth `: raw file path data (length-prefixed). + Path(Vec), + /// `tdta`: raw data. + RawData(Vec), + /// `ObAr`: object array (best effort; see [`ObjectArray`]). + ObjectArray(ObjectArray), +} + +/// `ObAr` object array. Undocumented in the Adobe spec; the layout follows +/// ag-psd (MIT): a u32 (observed as the element count / version 16) followed +/// by a descriptor-shaped body whose values are usually `UnFl`. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct ObjectArray { + /// Leading u32, preserved. + pub prefix: u32, + /// Body. + pub body: Descriptor, +} + +/// An ActionDescriptor: class plus ordered key/value items. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Descriptor { + /// Display name of the class (often empty). + pub name: UnicodeString, + /// Class id. + pub class_id: Id, + /// Items in stored order. + pub items: Vec<(Id, Value)>, +} + +impl Default for Descriptor { + fn default() -> Self { + Descriptor::new("null") + } +} + +impl Descriptor { + /// Creates an empty descriptor of the given class. + pub fn new(class_id: &str) -> Self { + Descriptor { name: UnicodeString::default(), class_id: Id::new(class_id), items: Vec::new() } + } + + /// Builder-style item append. + pub fn with(mut self, key: &str, value: Value) -> Self { + self.items.push((Id::new(key), value)); + self + } + + /// Looks up the first item with key `key`. + pub fn get(&self, key: &str) -> Option<&Value> { + self.items.iter().find(|(k, _)| k.is(key)).map(|(_, v)| v) + } + + /// Parses a descriptor from `data`, which must be consumed entirely. + pub fn from_bytes(data: &[u8]) -> Result { + let mut r = Reader::new(data); + let d = Self::read(&mut r)?; + if !r.is_empty() { + return Err(PsdError::invalid("trailing bytes after descriptor")); + } + Ok(d) + } + + /// Serializes the descriptor. + pub fn to_bytes(&self) -> Vec { + let mut v = Vec::new(); + self.write(&mut v); + v + } + + pub(crate) fn read(r: &mut Reader<'_>) -> Result { + read_descriptor(r, 0) + } + + pub(crate) fn write(&self, out: &mut Vec) { + self.name.write(out); + self.class_id.write(out); + out.put_u32(self.items.len() as u32); + for (k, v) in &self.items { + k.write(out); + write_value(v, out); + } + } +} + +/// A descriptor prefixed by a u32 version (16 in all current Photoshop files). +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct VersionedDescriptor { + /// Descriptor version (16). + pub version: u32, + /// The descriptor. + pub descriptor: Descriptor, +} + +impl VersionedDescriptor { + /// Wraps a descriptor with version 16. + pub fn new(descriptor: Descriptor) -> Self { + VersionedDescriptor { version: 16, descriptor } + } + /// Parses from bytes (must be fully consumed). + pub fn from_bytes(data: &[u8]) -> Result { + let mut r = Reader::new(data); + let v = Self::read(&mut r)?; + if !r.is_empty() { + return Err(PsdError::invalid("trailing bytes after versioned descriptor")); + } + Ok(v) + } + /// Parses a versioned descriptor at the start of `data`, returning it and + /// the number of bytes consumed (trailing data is allowed, e.g. the warp + /// descriptor after the text descriptor in `TySh`). + pub fn parse_prefix(data: &[u8]) -> Result<(Self, usize)> { + let mut r = Reader::new(data); + let v = Self::read(&mut r)?; + Ok((v, r.pos())) + } + /// Serializes. + pub fn to_bytes(&self) -> Vec { + let mut v = Vec::new(); + self.write(&mut v); + v + } + pub(crate) fn read(r: &mut Reader<'_>) -> Result { + let version = r.u32()?; + if version != 16 { + return Err(PsdError::invalid(format!("descriptor version {version}, expected 16"))); + } + Ok(VersionedDescriptor { version, descriptor: Descriptor::read(r)? }) + } + pub(crate) fn write(&self, out: &mut Vec) { + out.put_u32(self.version); + self.descriptor.write(out); + } +} + +fn read_descriptor(r: &mut Reader<'_>, depth: usize) -> Result { + if depth > MAX_DEPTH { + return Err(PsdError::LimitExceeded("descriptor nesting too deep")); + } + let name = UnicodeString::read(r)?; + let class_id = Id::read(r)?; + let n = r.u32()?; + // Each item needs at least 5 (1-byte string key) + 4 (type) + 1 (bool). + r.check_count(u64::from(n), 10)?; + let mut items = Vec::with_capacity(n as usize); + for _ in 0..n { + let key = Id::read(r)?; + let v = read_value(r, depth + 1)?; + items.push((key, v)); + } + Ok(Descriptor { name, class_id, items }) +} + +fn read_value(r: &mut Reader<'_>, depth: usize) -> Result { + if depth > MAX_DEPTH { + return Err(PsdError::LimitExceeded("descriptor nesting too deep")); + } + let ty = r.array::<4>()?; + read_typed_value(r, &ty, depth) +} + +fn read_typed_value(r: &mut Reader<'_>, ty: &[u8; 4], depth: usize) -> Result { + Ok(match ty { + b"obj " => { + let n = r.u32()?; + r.check_count(u64::from(n), 8)?; + let mut items = Vec::with_capacity(n as usize); + for _ in 0..n { + items.push(read_reference_item(r)?); + } + Value::Reference(items) + } + b"Objc" => Value::Descriptor(read_descriptor(r, depth + 1)?), + b"GlbO" => Value::GlobalObject(read_descriptor(r, depth + 1)?), + b"VlLs" => { + let n = r.u32()?; + r.check_count(u64::from(n), 4)?; + let mut items = Vec::with_capacity(n as usize); + for _ in 0..n { + items.push(read_value(r, depth + 1)?); + } + Value::List(items) + } + b"doub" => Value::Double(r.f64()?), + b"UntF" => Value::UnitFloat { unit: r.array()?, value: r.f64()? }, + b"UnFl" => { + let unit = r.array()?; + let n = r.u32()?; + r.check_count(u64::from(n), 8)?; + let mut values = Vec::with_capacity(n as usize); + for _ in 0..n { + values.push(r.f64()?); + } + Value::UnitFloats { unit, values } + } + b"TEXT" => Value::Text(UnicodeString::read(r)?), + b"enum" => Value::Enumerated { type_id: Id::read(r)?, value: Id::read(r)? }, + b"long" => Value::Integer(r.i32()?), + b"comp" => Value::LargeInteger(r.i64()?), + b"bool" => Value::Boolean(r.u8()? != 0), + b"type" => Value::Class(Class::read(r)?), + b"GlbC" => Value::GlobalClass(Class::read(r)?), + b"alis" => { + let n = r.u32()?; + Value::Alias(r.bytes_u64(u64::from(n))?.to_vec()) + } + b"Pth " => { + let n = r.u32()?; + Value::Path(r.bytes_u64(u64::from(n))?.to_vec()) + } + b"tdta" => { + let n = r.u32()?; + Value::RawData(r.bytes_u64(u64::from(n))?.to_vec()) + } + b"ObAr" => { + let prefix = r.u32()?; + Value::ObjectArray(ObjectArray { prefix, body: read_descriptor(r, depth + 1)? }) + } + other => { + return Err(PsdError::Unsupported(format!( + "descriptor OSType {:?}", + String::from_utf8_lossy(other) + ))); + } + }) +} + +fn read_reference_item(r: &mut Reader<'_>) -> Result { + let ty = r.array::<4>()?; + Ok(match &ty { + b"prop" => ReferenceItem::Property { class: Class::read(r)?, key: Id::read(r)? }, + b"Clss" => ReferenceItem::Class(Class::read(r)?), + b"Enmr" => ReferenceItem::Enumerated { class: Class::read(r)?, type_id: Id::read(r)?, value: Id::read(r)? }, + b"rele" => ReferenceItem::Offset { class: Class::read(r)?, offset: r.i32()? }, + b"Idnt" => ReferenceItem::Identifier(r.u32()?), + b"indx" => ReferenceItem::Index(r.u32()?), + b"name" => ReferenceItem::Name { class: Class::read(r)?, name: UnicodeString::read(r)? }, + other => { + return Err(PsdError::Unsupported(format!( + "reference item type {:?}", + String::from_utf8_lossy(other) + ))); + } + }) +} + +fn value_type(v: &Value) -> &'static [u8; 4] { + match v { + Value::Reference(_) => b"obj ", + Value::Descriptor(_) => b"Objc", + Value::GlobalObject(_) => b"GlbO", + Value::List(_) => b"VlLs", + Value::Double(_) => b"doub", + Value::UnitFloat { .. } => b"UntF", + Value::UnitFloats { .. } => b"UnFl", + Value::Text(_) => b"TEXT", + Value::Enumerated { .. } => b"enum", + Value::Integer(_) => b"long", + Value::LargeInteger(_) => b"comp", + Value::Boolean(_) => b"bool", + Value::Class(_) => b"type", + Value::GlobalClass(_) => b"GlbC", + Value::Alias(_) => b"alis", + Value::Path(_) => b"Pth ", + Value::RawData(_) => b"tdta", + Value::ObjectArray(_) => b"ObAr", + } +} + +fn write_value(v: &Value, out: &mut Vec) { + out.put(value_type(v)); + match v { + Value::Reference(items) => { + out.put_u32(items.len() as u32); + for it in items { + write_reference_item(it, out); + } + } + Value::Descriptor(d) | Value::GlobalObject(d) => d.write(out), + Value::List(items) => { + out.put_u32(items.len() as u32); + for it in items { + write_value(it, out); + } + } + Value::Double(d) => out.put_f64(*d), + Value::UnitFloat { unit, value } => { + out.put(unit); + out.put_f64(*value); + } + Value::UnitFloats { unit, values } => { + out.put(unit); + out.put_u32(values.len() as u32); + for v in values { + out.put_f64(*v); + } + } + Value::Text(s) => s.write(out), + Value::Enumerated { type_id, value } => { + type_id.write(out); + value.write(out); + } + Value::Integer(i) => out.put_i32(*i), + Value::LargeInteger(i) => out.put_i64(*i), + Value::Boolean(b) => out.put_u8(u8::from(*b)), + Value::Class(c) | Value::GlobalClass(c) => c.write(out), + Value::Alias(b) | Value::Path(b) | Value::RawData(b) => { + out.put_u32(b.len() as u32); + out.put(b); + } + Value::ObjectArray(o) => { + out.put_u32(o.prefix); + o.body.write(out); + } + } +} + +fn write_reference_item(it: &ReferenceItem, out: &mut Vec) { + match it { + ReferenceItem::Property { class, key } => { + out.put(b"prop"); + class.write(out); + key.write(out); + } + ReferenceItem::Class(c) => { + out.put(b"Clss"); + c.write(out); + } + ReferenceItem::Enumerated { class, type_id, value } => { + out.put(b"Enmr"); + class.write(out); + type_id.write(out); + value.write(out); + } + ReferenceItem::Offset { class, offset } => { + out.put(b"rele"); + class.write(out); + out.put_i32(*offset); + } + ReferenceItem::Identifier(v) => { + out.put(b"Idnt"); + out.put_u32(*v); + } + ReferenceItem::Index(v) => { + out.put(b"indx"); + out.put_u32(*v); + } + ReferenceItem::Name { class, name } => { + out.put(b"name"); + class.write(out); + name.write(out); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn class(name: &str, id: &str) -> Class { + Class { name: UnicodeString::new(name), class_id: Id::new(id) } + } + + pub(crate) fn sample() -> Descriptor { + Descriptor::new("null") + .with("Nm ", Value::Text(UnicodeString::new_nul("Hello \u{1F600}"))) + .with("Opct", Value::UnitFloat { unit: *b"#Prc", value: 75.5 }) + .with("enab", Value::Boolean(true)) + .with("long", Value::Integer(-5)) + .with("comp", Value::LargeInteger(1 << 40)) + .with("doub", Value::Double(0.25)) + .with("Md ", Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new("Mltp") }) + .with("artboardRect", Value::Descriptor(Descriptor::new("classFloatRect").with("Top ", Value::Double(1.0)))) + .with("list", Value::List(vec![Value::Integer(1), Value::Text(UnicodeString::new("x"))])) + .with( + "null", + Value::Reference(vec![ + ReferenceItem::Property { class: class("", "Lyr "), key: Id::new("Nm ") }, + ReferenceItem::Class(class("", "Dcmn")), + ReferenceItem::Enumerated { class: class("", "Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") }, + ReferenceItem::Offset { class: class("", "Lyr "), offset: -1 }, + ReferenceItem::Identifier(7), + ReferenceItem::Index(2), + ReferenceItem::Name { class: class("", "Lyr "), name: UnicodeString::new("Bg") }, + ]), + ) + .with("type", Value::Class(class("c", "Clr "))) + .with("glbc", Value::GlobalClass(class("c", "RGBC"))) + .with("glbo", Value::GlobalObject(Descriptor::default())) + .with("alis", Value::Alias(vec![1, 2, 3])) + .with("tdta", Value::RawData(vec![0xde, 0xad])) + .with("pth ", Value::Path(vec![9, 9])) + .with( + "ObAr", + Value::ObjectArray(ObjectArray { + prefix: 16, + body: Descriptor::new("rationalPoint") + .with("Hrzn", Value::UnitFloats { unit: *b"#Pxl", values: vec![1.0, 2.0] }), + }), + ) + } + + #[test] + fn roundtrip_all_types() { + let d = sample(); + let b = d.to_bytes(); + let p = Descriptor::from_bytes(&b).unwrap(); + assert_eq!(p, d); + assert_eq!(p.to_bytes(), b); + } + + #[test] + fn versioned_roundtrip() { + let v = VersionedDescriptor::new(sample()); + let b = v.to_bytes(); + assert_eq!(&b[..4], &[0, 0, 0, 16]); + assert_eq!(VersionedDescriptor::from_bytes(&b).unwrap(), v); + } + + #[test] + fn versioned_parse_prefix() { + let v = VersionedDescriptor::new(sample()); + let mut b = v.to_bytes(); + let n = b.len(); + b.extend_from_slice(&[1, 2, 3]); + let (p, used) = VersionedDescriptor::parse_prefix(&b).unwrap(); + assert_eq!(p, v); + assert_eq!(used, n); + } + + #[test] + fn versioned_rejects_other_version() { + let mut b = VersionedDescriptor::new(Descriptor::new("null")).to_bytes(); + b[3] = 15; + assert!(VersionedDescriptor::from_bytes(&b).is_err()); + } + + #[test] + fn id_encoding() { + let mut v = Vec::new(); + Id::new("Nm ").write(&mut v); + assert_eq!(v, [0, 0, 0, 0, b'N', b'm', b' ', b' ']); + let mut v = Vec::new(); + Id::new("abc").write(&mut v); + assert_eq!(v, [0, 0, 0, 3, b'a', b'b', b'c']); + // A 4-byte explicit-length id stays explicit. + let data = [0, 0, 0, 4, b'a', b'b', b'c', b'd']; + let id = Id::read(&mut Reader::new(&data)).unwrap(); + assert_eq!(id, Id::Str(b"abcd".to_vec())); + let mut v = Vec::new(); + id.write(&mut v); + assert_eq!(v, data); + } + + #[test] + fn get_and_lookup() { + let d = sample(); + assert_eq!(d.get("long"), Some(&Value::Integer(-5))); + assert!(d.get("nope").is_none()); + } + + #[test] + fn unicode_lossy() { + assert_eq!(UnicodeString::new_nul("ab").to_string_lossy(), "ab"); + assert_eq!(UnicodeString(vec![0xd800, 0x41]).to_string_lossy(), "\u{fffd}A"); + } + + #[test] + fn truncations_error() { + let b = sample().to_bytes(); + for cut in 0..b.len() { + assert!(Descriptor::from_bytes(&b[..cut]).is_err(), "cut {cut}"); + } + } + + #[test] + fn trailing_bytes_error() { + let mut b = sample().to_bytes(); + b.push(0); + assert!(Descriptor::from_bytes(&b).is_err()); + } + + #[test] + fn unknown_ostype_errors() { + let mut b = Descriptor::new("null").to_bytes(); + let n = b.len(); + b[n - 1] = 1; // one item + b.extend_from_slice(&[0, 0, 0, 0, b'k', b'e', b'y', b' ']); + b.extend_from_slice(b"????"); + assert!(matches!(Descriptor::from_bytes(&b), Err(PsdError::Unsupported(_)))); + } + + #[test] + fn deep_nesting_is_limited() { + let mut d = Descriptor::new("null"); + for _ in 0..(MAX_DEPTH + 5) { + d = Descriptor::new("null").with("in ", Value::Descriptor(d)); + } + let b = d.to_bytes(); + assert!(matches!(Descriptor::from_bytes(&b), Err(PsdError::LimitExceeded(_)))); + } + + #[test] + fn huge_counts_rejected_without_alloc() { + let mut b = Vec::new(); + b.put_u32(0); + b.put_u32(0); + b.put(b"null"); + b.put_u32(u32::MAX); + assert!(Descriptor::from_bytes(&b).is_err()); + } +} + +#[cfg(test)] +pub(crate) mod proptests { + use super::*; + use proptest::prelude::*; + + fn arb_id() -> impl Strategy { + prop_oneof![ + any::<[u8; 4]>().prop_map(Id::Code), + proptest::collection::vec(any::(), 1..12).prop_map(Id::Str), + ] + } + fn arb_ustr() -> impl Strategy { + proptest::collection::vec(any::(), 0..8).prop_map(UnicodeString) + } + fn arb_class() -> impl Strategy { + (arb_ustr(), arb_id()).prop_map(|(name, class_id)| Class { name, class_id }) + } + fn arb_ref() -> impl Strategy { + prop_oneof![ + (arb_class(), arb_id()).prop_map(|(class, key)| ReferenceItem::Property { class, key }), + arb_class().prop_map(ReferenceItem::Class), + (arb_class(), arb_id(), arb_id()) + .prop_map(|(class, type_id, value)| ReferenceItem::Enumerated { class, type_id, value }), + (arb_class(), any::()).prop_map(|(class, offset)| ReferenceItem::Offset { class, offset }), + any::().prop_map(ReferenceItem::Identifier), + any::().prop_map(ReferenceItem::Index), + (arb_class(), arb_ustr()).prop_map(|(class, name)| ReferenceItem::Name { class, name }), + ] + } + fn finite() -> impl Strategy { + -1e12f64..1e12 + } + fn arb_leaf() -> impl Strategy { + prop_oneof![ + finite().prop_map(Value::Double), + (any::<[u8; 4]>(), finite()).prop_map(|(unit, value)| Value::UnitFloat { unit, value }), + (any::<[u8; 4]>(), proptest::collection::vec(finite(), 0..4)) + .prop_map(|(unit, values)| Value::UnitFloats { unit, values }), + arb_ustr().prop_map(Value::Text), + (arb_id(), arb_id()).prop_map(|(type_id, value)| Value::Enumerated { type_id, value }), + any::().prop_map(Value::Integer), + any::().prop_map(Value::LargeInteger), + any::().prop_map(Value::Boolean), + arb_class().prop_map(Value::Class), + arb_class().prop_map(Value::GlobalClass), + proptest::collection::vec(any::(), 0..8).prop_map(Value::Alias), + proptest::collection::vec(any::(), 0..8).prop_map(Value::RawData), + proptest::collection::vec(any::(), 0..8).prop_map(Value::Path), + proptest::collection::vec(arb_ref(), 0..3).prop_map(Value::Reference), + ] + } + pub(crate) fn arb_descriptor() -> impl Strategy { + let leaf = arb_leaf(); + let value = leaf.prop_recursive(3, 24, 4, |inner| { + prop_oneof![ + proptest::collection::vec(inner.clone(), 0..4).prop_map(Value::List), + (arb_ustr(), arb_id(), proptest::collection::vec((arb_id(), inner.clone()), 0..4)) + .prop_map(|(name, id, items)| Value::Descriptor(Descriptor { name, class_id: id, items })), + (arb_ustr(), arb_id(), proptest::collection::vec((arb_id(), inner.clone()), 0..3)).prop_map( + |(name, id, items)| Value::GlobalObject(Descriptor { name, class_id: id, items }) + ), + (any::(), arb_id(), proptest::collection::vec((arb_id(), inner), 0..3)).prop_map( + |(prefix, id, items)| Value::ObjectArray(ObjectArray { + prefix, + body: Descriptor { name: UnicodeString::default(), class_id: id, items } + }) + ), + ] + }); + (arb_ustr(), arb_id(), proptest::collection::vec((arb_id(), value), 0..6)) + .prop_map(|(name, id, items)| Descriptor { name, class_id: id, items }) + } + + proptest! { + #[test] + fn descriptor_roundtrip(d in arb_descriptor()) { + let b = d.to_bytes(); + let p = Descriptor::from_bytes(&b).unwrap(); + prop_assert_eq!(&p, &d); + prop_assert_eq!(p.to_bytes(), b); + } + + #[test] + fn descriptor_parse_never_panics(data in proptest::collection::vec(any::(), 0..256)) { + let _ = Descriptor::from_bytes(&data); + let _ = VersionedDescriptor::from_bytes(&data); + } + } +} diff --git a/crates/psd/src/error.rs b/crates/psd/src/error.rs new file mode 100644 index 00000000..da519e66 --- /dev/null +++ b/crates/psd/src/error.rs @@ -0,0 +1,52 @@ +//! Error type for every fallible operation in this crate. + +use thiserror::Error; + +/// Errors produced while parsing, decoding, encoding or writing PSD/PSB data. +/// +/// Parsers never panic on malformed input; every failure is reported through +/// this type. +#[derive(Debug, Error, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum PsdError { + /// The input ended before a structure was complete. + #[error("unexpected end of data at offset {offset} (needed {needed} more bytes)")] + UnexpectedEof { + /// Offset (relative to the current section) where the read started. + offset: usize, + /// Number of bytes that were requested but not available. + needed: usize, + }, + /// A four-byte signature did not match. + #[error("invalid signature {found:?}, expected {expected}")] + InvalidSignature { + /// Human-readable description of the expected signature(s). + expected: &'static str, + /// The bytes that were found. + found: [u8; 4], + }, + /// Header version is neither 1 (PSD) nor 2 (PSB). + #[error("unsupported file version {0}")] + UnsupportedVersion(u16), + /// A dimension, count or allocation exceeded the configured safety limits. + #[error("limit exceeded: {0}")] + LimitExceeded(&'static str), + /// Structurally invalid data. + #[error("invalid data: {0}")] + Invalid(String), + /// A compressed stream could not be decoded. + #[error("decompression failed: {0}")] + Decompress(String), + /// An operation is not supported for this data (e.g. unknown compression). + #[error("unsupported: {0}")] + Unsupported(String), +} + +impl PsdError { + pub(crate) fn invalid(msg: impl Into) -> Self { + PsdError::Invalid(msg.into()) + } +} + +/// Convenience alias. +pub type Result = std::result::Result; diff --git a/crates/psd/src/file.rs b/crates/psd/src/file.rs new file mode 100644 index 00000000..c907c469 --- /dev/null +++ b/crates/psd/src/file.rs @@ -0,0 +1,283 @@ +//! The top-level [`PsdFile`] model, parser and writer. + +use crate::error::{PsdError, Result}; +use crate::header::{ColorMode, Header, Version}; +use crate::image_data::ImageData; +use crate::io::{Reader, WriteExt}; +use crate::layer::{LayerInfo, LayerRecord}; +use crate::resources::{self, ImageResource, ResourceData, ids}; +use crate::tagged::{TaggedBlock, read_blocks, write_blocks}; + +/// Where the layer info was stored in the file. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum LayerInfoPlacement { + /// In the layer info field of the layer and mask section (8-bit files). + #[default] + Section, + /// In a global `Lr16` / `Lr32` / `Layr` tagged block (16/32-bit files). + /// The block is re-inserted at `index` among `global_blocks` on write. + GlobalBlock { + /// Position among the global blocks. + index: usize, + /// Block signature (`8BIM` / `8B64`). + signature: [u8; 4], + /// Block key. + key: [u8; 4], + /// Padding after the block (see [`TaggedBlock::padding`]). + padding: Option>, + }, +} + +/// Global layer mask info, stored raw with typed accessors. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct GlobalLayerMask { + /// Raw bytes (without the length field). + pub data: Vec, +} + +impl GlobalLayerMask { + fn be16(&self, at: usize) -> Option { + self.data.get(at..at + 2).map(|b| u16::from_be_bytes([b[0], b[1]])) + } + /// Overlay color space. + pub fn overlay_color_space(&self) -> Option { + self.be16(0) + } + /// Four color components. + pub fn color_components(&self) -> Option<[u16; 4]> { + Some([self.be16(2)?, self.be16(4)?, self.be16(6)?, self.be16(8)?]) + } + /// Opacity 0..=100. + pub fn opacity(&self) -> Option { + self.be16(10) + } + /// Kind: 0 color selected, 1 color protected, 128 per layer. + pub fn kind(&self) -> Option { + self.data.get(12).copied() + } +} + +/// A complete PSD/PSB file. +/// +/// `PsdFile::from_bytes(b)?.to_bytes()? == b` for unmodified well-formed +/// files, and `from_bytes(to_bytes(f)) == f` for any model `f`. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct PsdFile { + /// Header. + pub header: Header, + /// Color mode data (palette for indexed, duotone spec, …). + pub color_mode_data: Vec, + /// Image resources in stored order. + pub resources: Vec, + /// Layer info (`None` when the layer info length is 0 and no + /// `Lr16`/`Lr32` block holds layers). + pub layer_info: Option, + /// Where [`Self::layer_info`] is stored. + pub layer_info_placement: LayerInfoPlacement, + /// Global layer mask (`None` when the field is absent entirely). + pub global_layer_mask: Option, + /// Global additional layer information blocks. + pub global_blocks: Vec, + /// Bytes at the end of the layer and mask section that do not form a block. + pub layer_mask_trailing: Vec, + /// Merged image data. + pub image_data: ImageData, +} + +impl PsdFile { + /// Parses a PSD or PSB file. + pub fn from_bytes(data: &[u8]) -> Result { + let mut r = Reader::new(data); + let header = Header::read(&mut r)?; + let psb = header.version.is_psb(); + let cmd_len = r.u32()?; + let color_mode_data = r.bytes_u64(u64::from(cmd_len))?.to_vec(); + let resources = resources::read_section(&mut r)?; + + let lm_len = r.len_field(psb)?; + let mut s = r.sub(lm_len)?; + let mut layer_info = None; + let mut global_layer_mask = None; + let mut global_blocks = Vec::new(); + let mut layer_mask_trailing = Vec::new(); + let mut layer_info_placement = LayerInfoPlacement::Section; + if !s.is_empty() { + let li_len = s.len_field(psb)?; + if li_len > 0 { + layer_info = Some(LayerInfo::read_body(s.bytes_u64(li_len)?, header.version)?); + } + if s.remaining() >= 4 { + let n = s.u32()?; + global_layer_mask = Some(GlobalLayerMask { data: s.bytes_u64(u64::from(n))?.to_vec() }); + let (blocks, trailing) = read_blocks(&mut s, header.version)?; + global_blocks = blocks; + layer_mask_trailing = trailing; + } else { + layer_mask_trailing = s.peek_rest().to_vec(); + } + } + // 16/32-bit files keep their layers in a global Lr16/Lr32 block. + if layer_info.is_none() + && let Some(i) = global_blocks.iter().position(|b| matches!(&b.key, b"Lr16" | b"Lr32" | b"Layr")) + && let Ok(info) = LayerInfo::read_body(&global_blocks[i].data, header.version) + { + let b = global_blocks.remove(i); + layer_info = Some(info); + layer_info_placement = + LayerInfoPlacement::GlobalBlock { index: i, signature: b.signature, key: b.key, padding: b.padding }; + } + let image_data = ImageData::read(&mut r, &header)?; + Ok(PsdFile { + header, + color_mode_data, + resources, + layer_info, + layer_info_placement, + global_layer_mask, + global_blocks, + layer_mask_trailing, + image_data, + }) + } + + /// Serializes the file. + pub fn to_bytes(&self) -> Result> { + let v = self.header.version; + let psb = v.is_psb(); + let mut out = Vec::new(); + self.header.write(&mut out); + out.put_len(self.color_mode_data.len() as u64, false)?; + out.put(&self.color_mode_data); + resources::write_section(&mut out, &self.resources)?; + + let in_block = matches!(self.layer_info_placement, LayerInfoPlacement::GlobalBlock { .. }); + // An entirely empty section is written as a zero length. (A section + // containing only a zero layer-info length cannot be distinguished in + // the model and is normalized to this form.) + let section_empty = self.layer_info.is_none() + && self.global_layer_mask.is_none() + && self.global_blocks.is_empty() + && self.layer_mask_trailing.is_empty(); + let at = out.begin_len(psb); + if !section_empty { + let li = out.begin_len(psb); + if let (Some(info), false) = (&self.layer_info, in_block) { + info.write_body(&mut out, v)?; + } + out.end_len(li, psb)?; + let blocks: std::borrow::Cow<'_, [TaggedBlock]> = match (&self.layer_info, &self.layer_info_placement) { + (Some(info), LayerInfoPlacement::GlobalBlock { index, signature, key, padding }) => { + let mut data = Vec::new(); + info.write_body(&mut data, v)?; + let mut blocks = self.global_blocks.clone(); + let idx = (*index).min(blocks.len()); + blocks.insert(idx, TaggedBlock { signature: *signature, key: *key, data, padding: padding.clone() }); + blocks.into() + } + _ => (&self.global_blocks[..]).into(), + }; + let blocks_ref: &[TaggedBlock] = &blocks; + let need_mask_field = self.global_layer_mask.is_some() || !blocks_ref.is_empty(); + if need_mask_field { + let gm = self.global_layer_mask.as_ref().map_or(&[][..], |g| &g.data[..]); + out.put_len(gm.len() as u64, false)?; + out.put(gm); + } + write_blocks(&mut out, blocks_ref, v)?; + out.put(&self.layer_mask_trailing); + } + out.end_len(at, psb)?; + + out.put_u16(self.image_data.compression.as_u16()); + out.put(&self.image_data.data); + Ok(out) + } + + /// Layer records in file order (bottom-most first); empty if none. + pub fn layers(&self) -> &[LayerRecord] { + self.layer_info.as_ref().map_or(&[], |i| &i.layers) + } + + /// Mutable layer records (creates an empty layer info if absent). + pub fn layers_mut(&mut self) -> &mut Vec { + &mut self.layer_info.get_or_insert_with(LayerInfo::default).layers + } + + /// First resource with the given id. + pub fn resource(&self, id: u16) -> Option<&ImageResource> { + self.resources.iter().find(|r| r.id == id) + } + + /// First global block with the given key. + pub fn global_block(&self, key: &[u8; 4]) -> Option<&TaggedBlock> { + self.global_blocks.iter().find(|b| &b.key == key) + } + + /// ICC profile bytes (resource 1039). + pub fn icc_profile(&self) -> Option<&[u8]> { + self.resource(ids::ICC_PROFILE).map(|r| &r.data[..]) + } + + /// Resolution info (resource 1005). + pub fn resolution(&self) -> Option { + match self.resource(ids::RESOLUTION_INFO)?.parsed()? { + Ok(ResourceData::ResolutionInfo(r)) => Some(r), + _ => None, + } + } + + /// The "has real merged data" flag of resource 1057, if present. + pub fn has_real_merged_data(&self) -> Option { + match self.resource(ids::VERSION_INFO)?.parsed()? { + Ok(ResourceData::VersionInfo(v)) => Some(v.has_real_merged_data), + _ => None, + } + } + + /// Decodes the merged image into planar big-endian samples. + pub fn decode_merged(&self) -> Result> { + self.image_data.decode(&self.header) + } + + /// `true` if the merged image carries a transparency channel. + pub fn merged_has_alpha(&self) -> bool { + let Some(color) = self.header.color_mode.color_channels() else { + return false; + }; + if self.header.channels <= color { + return false; + } + match &self.layer_info { + Some(li) => li.merged_alpha, + None => true, + } + } + + /// Checks structural invariants that the writer relies on. + pub fn validate(&self) -> Result<()> { + self.header.validate()?; + if self.header.version == Version::Psd && (self.header.width > 30_000 || self.header.height > 30_000) { + return Err(PsdError::LimitExceeded("PSD dimensions exceed 30000; use PSB")); + } + if self.header.color_mode == ColorMode::Indexed && self.color_mode_data.len() != 768 { + return Err(PsdError::invalid("indexed color mode requires a 768-byte palette")); + } + Ok(()) + } + + /// Reads a file from disk (native targets only). + #[cfg(not(target_arch = "wasm32"))] + pub fn open(path: impl AsRef) -> std::io::Result> { + Ok(Self::from_bytes(&std::fs::read(path)?)) + } + + /// Writes the file to disk (native targets only). + #[cfg(not(target_arch = "wasm32"))] + pub fn save(&self, path: impl AsRef) -> std::io::Result<()> { + let b = self.to_bytes().map_err(std::io::Error::other)?; + std::fs::write(path, b) + } +} diff --git a/crates/psd/src/header.rs b/crates/psd/src/header.rs new file mode 100644 index 00000000..229a0c7d --- /dev/null +++ b/crates/psd/src/header.rs @@ -0,0 +1,248 @@ +//! File header: signature, version, channels, dimensions, depth and color mode. + +use crate::error::{PsdError, Result}; +use crate::io::{Reader, WriteExt}; + +/// Maximum width/height accepted by the parser (PSB limit). +pub const MAX_DIMENSION: u32 = 300_000; +/// Maximum number of channels accepted (spec: 1..=56). +pub const MAX_CHANNELS: u16 = 56; + +/// File format version. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum Version { + /// Version 1: Photoshop Document (32-bit section lengths). + Psd, + /// Version 2: Large Document Format (64-bit lengths for some sections). + Psb, +} + +impl Version { + /// `true` for PSB. + pub fn is_psb(self) -> bool { + matches!(self, Version::Psb) + } + /// Numeric version as stored in the header. + pub fn as_u16(self) -> u16 { + match self { + Version::Psd => 1, + Version::Psb => 2, + } + } +} + +/// Document color mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum ColorMode { + /// 0 + Bitmap, + /// 1 + Grayscale, + /// 2 + Indexed, + /// 3 + Rgb, + /// 4 + Cmyk, + /// 7 + Multichannel, + /// 8 + Duotone, + /// 9 + Lab, + /// Any other value, preserved. + Unknown(u16), +} + +impl ColorMode { + /// Converts from the stored numeric value. + pub fn from_u16(v: u16) -> Self { + match v { + 0 => ColorMode::Bitmap, + 1 => ColorMode::Grayscale, + 2 => ColorMode::Indexed, + 3 => ColorMode::Rgb, + 4 => ColorMode::Cmyk, + 7 => ColorMode::Multichannel, + 8 => ColorMode::Duotone, + 9 => ColorMode::Lab, + other => ColorMode::Unknown(other), + } + } + /// Numeric value as stored. + pub fn as_u16(self) -> u16 { + match self { + ColorMode::Bitmap => 0, + ColorMode::Grayscale => 1, + ColorMode::Indexed => 2, + ColorMode::Rgb => 3, + ColorMode::Cmyk => 4, + ColorMode::Multichannel => 7, + ColorMode::Duotone => 8, + ColorMode::Lab => 9, + ColorMode::Unknown(v) => v, + } + } + /// Number of color (non-alpha) channels for the mode, when fixed. + pub fn color_channels(self) -> Option { + match self { + ColorMode::Bitmap | ColorMode::Grayscale | ColorMode::Indexed | ColorMode::Duotone => Some(1), + ColorMode::Rgb | ColorMode::Lab => Some(3), + ColorMode::Cmyk => Some(4), + ColorMode::Multichannel | ColorMode::Unknown(_) => None, + } + } +} + +/// The 26-byte file header. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Header { + /// PSD or PSB. + pub version: Version, + /// Reserved bytes (spec: must be zero). Preserved for exact round-trip. + pub reserved: [u8; 6], + /// Number of channels in the merged image, including alpha channels (1..=56). + pub channels: u16, + /// Height in pixels. + pub height: u32, + /// Width in pixels. + pub width: u32, + /// Bits per channel: 1, 8, 16 or 32. + pub depth: u16, + /// Color mode. + pub color_mode: ColorMode, +} + +impl Header { + /// Creates a header with zeroed reserved bytes. + pub fn new(version: Version, width: u32, height: u32, channels: u16, depth: u16, color_mode: ColorMode) -> Self { + Header { version, reserved: [0; 6], channels, height, width, depth, color_mode } + } + + /// Bytes per row of one channel plane. + pub fn row_bytes(&self) -> usize { + row_bytes(self.width as usize, self.depth) + } + + pub(crate) fn read(r: &mut Reader<'_>) -> Result { + let sig = r.array::<4>()?; + if &sig != b"8BPS" { + return Err(PsdError::InvalidSignature { expected: "8BPS", found: sig }); + } + let version = match r.u16()? { + 1 => Version::Psd, + 2 => Version::Psb, + v => return Err(PsdError::UnsupportedVersion(v)), + }; + let reserved = r.array::<6>()?; + let channels = r.u16()?; + let height = r.u32()?; + let width = r.u32()?; + let depth = r.u16()?; + let color_mode = ColorMode::from_u16(r.u16()?); + let h = Header { version, reserved, channels, height, width, depth, color_mode }; + h.validate()?; + Ok(h) + } + + /// Checks the header against spec constraints and safety limits. + pub fn validate(&self) -> Result<()> { + if self.channels == 0 || self.channels > MAX_CHANNELS { + return Err(PsdError::invalid(format!("channel count {} out of range 1..=56", self.channels))); + } + if self.width > MAX_DIMENSION || self.height > MAX_DIMENSION { + return Err(PsdError::LimitExceeded("image dimensions exceed 300000")); + } + if !matches!(self.depth, 1 | 8 | 16 | 32) { + return Err(PsdError::invalid(format!("unsupported bit depth {}", self.depth))); + } + Ok(()) + } + + pub(crate) fn write(&self, out: &mut Vec) { + out.put(b"8BPS"); + out.put_u16(self.version.as_u16()); + out.put(&self.reserved); + out.put_u16(self.channels); + out.put_u32(self.height); + out.put_u32(self.width); + out.put_u16(self.depth); + out.put_u16(self.color_mode.as_u16()); + } +} + +/// Bytes per row for `width` samples at `depth` bits. +pub fn row_bytes(width: usize, depth: u16) -> usize { + match depth { + 1 => width.div_ceil(8), + d => width.saturating_mul(usize::from(d / 8).max(1)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bytes(h: &Header) -> Vec { + let mut v = Vec::new(); + h.write(&mut v); + v + } + + #[test] + fn header_roundtrip() { + let h = Header::new(Version::Psb, 1000, 20, 4, 16, ColorMode::Cmyk); + let b = bytes(&h); + assert_eq!(b.len(), 26); + assert_eq!(Header::read(&mut Reader::new(&b)).unwrap(), h); + } + + #[test] + fn bad_signature() { + let mut b = bytes(&Header::new(Version::Psd, 1, 1, 1, 8, ColorMode::Grayscale)); + b[0] = b'X'; + assert!(matches!(Header::read(&mut Reader::new(&b)), Err(PsdError::InvalidSignature { .. }))); + } + + #[test] + fn bad_version() { + let mut b = bytes(&Header::new(Version::Psd, 1, 1, 1, 8, ColorMode::Grayscale)); + b[5] = 3; + assert_eq!(Header::read(&mut Reader::new(&b)), Err(PsdError::UnsupportedVersion(3))); + } + + #[test] + fn limits() { + for h in [ + Header::new(Version::Psd, 300_001, 1, 1, 8, ColorMode::Rgb), + Header::new(Version::Psd, 1, 1, 0, 8, ColorMode::Rgb), + Header::new(Version::Psd, 1, 1, 57, 8, ColorMode::Rgb), + Header::new(Version::Psd, 1, 1, 3, 12, ColorMode::Rgb), + ] { + let b = bytes(&h); + assert!(Header::read(&mut Reader::new(&b)).is_err(), "{h:?}"); + } + } + + #[test] + fn color_modes_roundtrip() { + for v in 0..20u16 { + assert_eq!(ColorMode::from_u16(v).as_u16(), v); + } + assert_eq!(ColorMode::from_u16(5), ColorMode::Unknown(5)); + assert_eq!(ColorMode::Rgb.color_channels(), Some(3)); + } + + #[test] + fn row_bytes_values() { + assert_eq!(row_bytes(9, 1), 2); + assert_eq!(row_bytes(8, 1), 1); + assert_eq!(row_bytes(0, 1), 0); + assert_eq!(row_bytes(3, 8), 3); + assert_eq!(row_bytes(3, 16), 6); + assert_eq!(row_bytes(3, 32), 12); + } +} diff --git a/crates/psd/src/image_data.rs b/crates/psd/src/image_data.rs new file mode 100644 index 00000000..515b2f92 --- /dev/null +++ b/crates/psd/src/image_data.rs @@ -0,0 +1,59 @@ +//! Merged (composite) image data section. + +use crate::compression::{Compression, PlaneLayout, decode_planes, encode_planes, validate_planes}; +use crate::error::Result; +use crate::header::Header; +use crate::io::Reader; + +/// The merged image: compression plus the encoded bytes of all channels +/// (to the end of the file), kept verbatim. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct ImageData { + /// Compression method for the whole image. + pub compression: Compression, + /// Encoded data. For RLE: the byte-count table for every row of every + /// channel followed by the rows. + pub data: Vec, +} + +impl ImageData { + /// Plane layout for the merged image described by `header`. + pub fn layout(header: &Header) -> PlaneLayout { + PlaneLayout { + planes: usize::from(header.channels), + width: header.width as usize, + height: header.height as usize, + depth: header.depth, + version: header.version, + } + } + + /// Encodes planar data (`channels × height × row_bytes`). + pub fn encode(compression: Compression, decoded: &[u8], header: &Header) -> Result { + Ok(ImageData { compression, data: encode_planes(compression, decoded, &Self::layout(header))? }) + } + + /// Decodes all channels into planar big-endian samples. + pub fn decode(&self, header: &Header) -> Result> { + decode_planes(self.compression, &self.data, &Self::layout(header)) + } + + /// Decodes and returns a single channel plane. + pub fn decode_channel(&self, header: &Header, index: usize) -> Result> { + let all = self.decode(header)?; + let plane = header.row_bytes() * header.height as usize; + all.get(index * plane..(index + 1) * plane) + .map(<[u8]>::to_vec) + .ok_or_else(|| crate::error::PsdError::invalid(format!("no merged channel {index}"))) + } + + pub(crate) fn read(r: &mut Reader<'_>, header: &Header) -> Result { + let compression = Compression::from_u16(r.u16()?); + let data = r.peek_rest(); + validate_planes(compression, data, &Self::layout(header))?; + let data = data.to_vec(); + r.skip(data.len())?; + Ok(ImageData { compression, data }) + } +} diff --git a/crates/psd/src/io.rs b/crates/psd/src/io.rs new file mode 100644 index 00000000..31e7ed76 --- /dev/null +++ b/crates/psd/src/io.rs @@ -0,0 +1,327 @@ +//! Bounds-checked big-endian reader and writer helpers. + +use crate::error::{PsdError, Result}; + +/// A cursor over a byte slice. Every read is bounds checked. +#[derive(Debug, Clone)] +pub(crate) struct Reader<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> Reader<'a> { + pub(crate) fn new(data: &'a [u8]) -> Self { + Reader { data, pos: 0 } + } + + pub(crate) fn pos(&self) -> usize { + self.pos + } + + pub(crate) fn remaining(&self) -> usize { + self.data.len() - self.pos + } + + pub(crate) fn is_empty(&self) -> bool { + self.remaining() == 0 + } + + /// Bytes from the current position without consuming. + pub(crate) fn peek_rest(&self) -> &'a [u8] { + &self.data[self.pos..] + } + + pub(crate) fn bytes(&mut self, n: usize) -> Result<&'a [u8]> { + if n > self.remaining() { + return Err(PsdError::UnexpectedEof { + offset: self.pos, + needed: n - self.remaining(), + }); + } + let s = &self.data[self.pos..self.pos + n]; + self.pos += n; + Ok(s) + } + + /// Reads `n` bytes where `n` comes from a (possibly 64-bit) length field. + pub(crate) fn bytes_u64(&mut self, n: u64) -> Result<&'a [u8]> { + let n = usize::try_from(n).map_err(|_| PsdError::LimitExceeded("length exceeds address space"))?; + self.bytes(n) + } + + pub(crate) fn sub(&mut self, n: u64) -> Result> { + Ok(Reader::new(self.bytes_u64(n)?)) + } + + pub(crate) fn skip(&mut self, n: usize) -> Result<()> { + self.bytes(n).map(|_| ()) + } + + pub(crate) fn array(&mut self) -> Result<[u8; N]> { + let s = self.bytes(N)?; + let mut out = [0u8; N]; + out.copy_from_slice(s); + Ok(out) + } + + pub(crate) fn u8(&mut self) -> Result { + Ok(self.array::<1>()?[0]) + } + pub(crate) fn u16(&mut self) -> Result { + Ok(u16::from_be_bytes(self.array()?)) + } + pub(crate) fn i16(&mut self) -> Result { + Ok(i16::from_be_bytes(self.array()?)) + } + pub(crate) fn u32(&mut self) -> Result { + Ok(u32::from_be_bytes(self.array()?)) + } + pub(crate) fn i32(&mut self) -> Result { + Ok(i32::from_be_bytes(self.array()?)) + } + pub(crate) fn u64(&mut self) -> Result { + Ok(u64::from_be_bytes(self.array()?)) + } + pub(crate) fn i64(&mut self) -> Result { + Ok(i64::from_be_bytes(self.array()?)) + } + pub(crate) fn f64(&mut self) -> Result { + Ok(f64::from_be_bytes(self.array()?)) + } + + /// Reads a length field that is 4 bytes, or 8 bytes when `long`. + pub(crate) fn len_field(&mut self, long: bool) -> Result { + if long { + self.u64() + } else { + Ok(u64::from(self.u32()?)) + } + } + + /// Returns `Err` unless `count * min_item_size` bytes remain. Used to + /// validate element counts before allocating. + pub(crate) fn check_count(&self, count: u64, min_item_size: u64) -> Result<()> { + let need = count.saturating_mul(min_item_size); + if need > self.remaining() as u64 { + return Err(PsdError::UnexpectedEof { + offset: self.pos, + needed: usize::try_from(need - self.remaining() as u64).unwrap_or(usize::MAX), + }); + } + Ok(()) + } +} + +/// Big-endian writing helpers on `Vec`. +pub(crate) trait WriteExt { + fn put_u8(&mut self, v: u8); + fn put_u16(&mut self, v: u16); + fn put_i16(&mut self, v: i16); + fn put_u32(&mut self, v: u32); + fn put_i32(&mut self, v: i32); + fn put_u64(&mut self, v: u64); + fn put_i64(&mut self, v: i64); + fn put_f64(&mut self, v: f64); + fn put(&mut self, b: &[u8]); + /// Writes a length field (4 or 8 bytes). + fn put_len(&mut self, v: u64, long: bool) -> Result<()>; + /// Reserves a length placeholder, returning its position. + fn begin_len(&mut self, long: bool) -> usize; + /// Back-patches a placeholder with the number of bytes written since. + fn end_len(&mut self, at: usize, long: bool) -> Result<()>; +} + +impl WriteExt for Vec { + fn put_u8(&mut self, v: u8) { + self.push(v); + } + fn put_u16(&mut self, v: u16) { + self.extend_from_slice(&v.to_be_bytes()); + } + fn put_i16(&mut self, v: i16) { + self.extend_from_slice(&v.to_be_bytes()); + } + fn put_u32(&mut self, v: u32) { + self.extend_from_slice(&v.to_be_bytes()); + } + fn put_i32(&mut self, v: i32) { + self.extend_from_slice(&v.to_be_bytes()); + } + fn put_u64(&mut self, v: u64) { + self.extend_from_slice(&v.to_be_bytes()); + } + fn put_i64(&mut self, v: i64) { + self.extend_from_slice(&v.to_be_bytes()); + } + fn put_f64(&mut self, v: f64) { + self.extend_from_slice(&v.to_be_bytes()); + } + fn put(&mut self, b: &[u8]) { + self.extend_from_slice(b); + } + fn put_len(&mut self, v: u64, long: bool) -> Result<()> { + if long { + self.put_u64(v); + } else { + let v = u32::try_from(v).map_err(|_| PsdError::LimitExceeded("section too large for 32-bit length (use PSB)"))?; + self.put_u32(v); + } + Ok(()) + } + fn begin_len(&mut self, long: bool) -> usize { + let at = self.len(); + self.extend_from_slice(if long { &[0u8; 8] } else { &[0u8; 4] }); + at + } + fn end_len(&mut self, at: usize, long: bool) -> Result<()> { + let hdr = if long { 8 } else { 4 }; + let n = (self.len() - at - hdr) as u64; + if long { + self[at..at + 8].copy_from_slice(&n.to_be_bytes()); + } else { + let v = u32::try_from(n).map_err(|_| PsdError::LimitExceeded("section too large for 32-bit length (use PSB)"))?; + self[at..at + 4].copy_from_slice(&v.to_be_bytes()); + } + Ok(()) + } +} + +/// Reads a Pascal string (length byte + bytes) whose total size is padded to +/// a multiple of `align`. Returns the raw name bytes. +pub(crate) fn read_pascal(r: &mut Reader<'_>, align: usize) -> Result> { + let n = r.u8()? as usize; + let name = r.bytes(n)?.to_vec(); + let total = 1 + n; + let pad = (align - total % align) % align; + r.skip(pad)?; + Ok(name) +} + +pub(crate) fn write_pascal(out: &mut Vec, name: &[u8], align: usize) { + let n = name.len().min(255); + out.put_u8(n as u8); + out.put(&name[..n]); + let total = 1 + n; + let pad = (align - total % align) % align; + out.extend(std::iter::repeat_n(0u8, pad)); +} + +/// Decodes legacy (Pascal) names: UTF-8 when valid, otherwise Latin-1. +pub(crate) fn decode_legacy_name(b: &[u8]) -> String { + match std::str::from_utf8(b) { + Ok(s) => s.to_owned(), + Err(_) => b.iter().map(|&c| c as char).collect(), + } +} + +/// Encodes a name for a legacy Pascal field: non-ASCII chars become `?`. +pub(crate) fn encode_legacy_name(s: &str) -> Vec { + s.chars() + .map(|c| if c.is_ascii() && !c.is_ascii_control() { c as u8 } else { b'?' }) + .take(255) + .collect() +} + +/// Reads a Photoshop Unicode string: u32 code-unit count + UTF-16BE units. +pub(crate) fn read_unicode_units(r: &mut Reader<'_>) -> Result> { + let n = r.u32()? as u64; + r.check_count(n, 2)?; + let mut v = Vec::with_capacity(n as usize); + for _ in 0..n { + v.push(r.u16()?); + } + Ok(v) +} + +pub(crate) fn write_unicode_units(out: &mut Vec, units: &[u16]) { + out.put_u32(units.len() as u32); + for &u in units { + out.put_u16(u); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reader_reads_big_endian() { + let data = [0x00, 0x01, 0xff, 0xfe, 0x00, 0x00, 0x00, 0x02]; + let mut r = Reader::new(&data); + assert_eq!(r.u16().unwrap(), 1); + assert_eq!(r.i16().unwrap(), -2); + assert_eq!(r.u32().unwrap(), 2); + assert!(r.is_empty()); + assert!(matches!(r.u8(), Err(PsdError::UnexpectedEof { .. }))); + } + + #[test] + fn reader_eof_reports_needed() { + let mut r = Reader::new(&[1, 2]); + match r.u32() { + Err(PsdError::UnexpectedEof { offset, needed }) => { + assert_eq!(offset, 0); + assert_eq!(needed, 2); + } + other => panic!("{other:?}"), + } + } + + #[test] + fn check_count_rejects_huge() { + let r = Reader::new(&[0; 8]); + assert!(r.check_count(u64::MAX, 4).is_err()); + assert!(r.check_count(2, 4).is_ok()); + assert!(r.check_count(3, 4).is_err()); + } + + #[test] + fn len_patch_roundtrip() { + let mut v = Vec::new(); + let at = v.begin_len(false); + v.put(&[1, 2, 3]); + v.end_len(at, false).unwrap(); + assert_eq!(v, [0, 0, 0, 3, 1, 2, 3]); + let mut v = Vec::new(); + let at = v.begin_len(true); + v.put(&[9]); + v.end_len(at, true).unwrap(); + assert_eq!(v, [0, 0, 0, 0, 0, 0, 0, 1, 9]); + } + + #[test] + fn pascal_padding() { + for (name, align, total) in [ + (&b""[..], 2, 2), + (&b"a"[..], 2, 2), + (&b"ab"[..], 2, 4), + (&b""[..], 4, 4), + (&b"abc"[..], 4, 4), + (&b"abcd"[..], 4, 8), + ] { + let mut v = Vec::new(); + write_pascal(&mut v, name, align); + assert_eq!(v.len(), total, "{name:?} align {align}"); + let mut r = Reader::new(&v); + assert_eq!(read_pascal(&mut r, align).unwrap(), name); + assert!(r.is_empty()); + } + } + + #[test] + fn legacy_names() { + assert_eq!(decode_legacy_name(b"Layer 1"), "Layer 1"); + assert_eq!(decode_legacy_name(&[0x41, 0xe9]), "A\u{e9}"); + assert_eq!(encode_legacy_name("h\u{e9}llo"), b"h?llo"); + } + + #[test] + fn unicode_units_roundtrip() { + let mut v = Vec::new(); + write_unicode_units(&mut v, &[0x48, 0x69, 0]); + let mut r = Reader::new(&v); + assert_eq!(read_unicode_units(&mut r).unwrap(), vec![0x48, 0x69, 0]); + let mut r = Reader::new(&[0xff, 0xff, 0xff, 0xff]); + assert!(read_unicode_units(&mut r).is_err()); + } +} diff --git a/crates/psd/src/layer.rs b/crates/psd/src/layer.rs new file mode 100644 index 00000000..7ea1f309 --- /dev/null +++ b/crates/psd/src/layer.rs @@ -0,0 +1,915 @@ +//! Layer records, layer masks, blending ranges and per-layer channel data. + +use crate::blend::BlendMode; +use crate::compression::{Compression, PlaneLayout, decode_planes, encode_planes}; +use crate::error::{PsdError, Result}; +use crate::header::{MAX_DIMENSION, Version}; +use crate::io::{Reader, WriteExt, decode_legacy_name, read_pascal, write_pascal}; +use crate::tagged::{BlockData, SectionDivider, SectionType, TaggedBlock, read_blocks, write_blocks}; + +/// Channel id of the transparency mask. +pub const CHANNEL_TRANSPARENCY: i16 = -1; +/// Channel id of the user-supplied layer mask. +pub const CHANNEL_USER_MASK: i16 = -2; +/// Channel id of the real user-supplied layer mask (when both a user mask and +/// a vector mask are present). +pub const CHANNEL_REAL_USER_MASK: i16 = -3; + +/// Maximum number of channels per layer accepted by the parser. +pub const MAX_LAYER_CHANNELS: u16 = 64; + +/// A rectangle as stored in PSD files (top, left, bottom, right). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Rect { + /// Top edge (inclusive). + pub top: i32, + /// Left edge (inclusive). + pub left: i32, + /// Bottom edge (exclusive). + pub bottom: i32, + /// Right edge (exclusive). + pub right: i32, +} + +impl Rect { + /// Rectangle from origin and size. + pub fn from_xywh(left: i32, top: i32, width: u32, height: u32) -> Self { + Rect { + top, + left, + bottom: top.saturating_add(height.min(i32::MAX as u32) as i32), + right: left.saturating_add(width.min(i32::MAX as u32) as i32), + } + } + /// Width (may be negative for malformed data). + pub fn width(&self) -> i64 { + i64::from(self.right) - i64::from(self.left) + } + /// Height (may be negative for malformed data). + pub fn height(&self) -> i64 { + i64::from(self.bottom) - i64::from(self.top) + } + /// `true` when width or height is zero or negative. + pub fn is_empty(&self) -> bool { + self.width() <= 0 || self.height() <= 0 + } + /// Validated `(width, height)` as `usize`. + pub fn size(&self) -> Result<(usize, usize)> { + let (w, h) = (self.width(), self.height()); + if w < 0 || h < 0 { + return Err(PsdError::invalid("rectangle has negative size")); + } + if w > i64::from(MAX_DIMENSION) || h > i64::from(MAX_DIMENSION) { + return Err(PsdError::LimitExceeded("layer dimensions exceed 300000")); + } + Ok((w as usize, h as usize)) + } + fn read(r: &mut Reader<'_>) -> Result { + Ok(Rect { top: r.i32()?, left: r.i32()?, bottom: r.i32()?, right: r.i32()? }) + } + fn write(&self, out: &mut Vec) { + out.put_i32(self.top); + out.put_i32(self.left); + out.put_i32(self.bottom); + out.put_i32(self.right); + } +} + +/// Layer record flag byte. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct LayerFlags(pub u8); + +impl LayerFlags { + /// Bit 0. + pub const TRANSPARENCY_PROTECTED: u8 = 1 << 0; + /// Bit 1 (set = hidden). + pub const HIDDEN: u8 = 1 << 1; + /// Bit 2 (obsolete). + pub const OBSOLETE: u8 = 1 << 2; + /// Bit 3: bit 4 carries useful information. + pub const BIT4_USEFUL: u8 = 1 << 3; + /// Bit 4: pixel data irrelevant to the appearance of the document. + pub const PIXEL_DATA_IRRELEVANT: u8 = 1 << 4; + + fn bit(self, b: u8) -> bool { + self.0 & b != 0 + } + fn set(&mut self, b: u8, on: bool) { + if on { + self.0 |= b; + } else { + self.0 &= !b; + } + } + /// Transparency protected. + pub fn transparency_protected(self) -> bool { + self.bit(Self::TRANSPARENCY_PROTECTED) + } + /// Hidden. + pub fn hidden(self) -> bool { + self.bit(Self::HIDDEN) + } + /// Bit 4 is meaningful. + pub fn bit4_useful(self) -> bool { + self.bit(Self::BIT4_USEFUL) + } + /// Pixel data irrelevant (only meaningful when `bit4_useful`). + pub fn pixel_data_irrelevant(self) -> bool { + self.bit(Self::PIXEL_DATA_IRRELEVANT) + } + /// Sets hidden. + pub fn set_hidden(&mut self, v: bool) { + self.set(Self::HIDDEN, v); + } + /// Sets transparency protection. + pub fn set_transparency_protected(&mut self, v: bool) { + self.set(Self::TRANSPARENCY_PROTECTED, v); + } + /// Sets pixel-data-irrelevant (also sets bit 3). + pub fn set_pixel_data_irrelevant(&mut self, v: bool) { + self.set(Self::BIT4_USEFUL, true); + self.set(Self::PIXEL_DATA_IRRELEVANT, v); + } +} + +/// Optional mask parameters (present when mask flag bit 4 is set). +#[derive(Debug, Clone, Copy, PartialEq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct MaskParameters { + /// Parameter flags byte (bit 0 user density, 1 user feather, 2 vector + /// density, 3 vector feather). + pub flags: u8, + /// User mask density. + pub user_density: Option, + /// User mask feather. + pub user_feather: Option, + /// Vector mask density. + pub vector_density: Option, + /// Vector mask feather. + pub vector_feather: Option, +} + +/// "Real" user mask fields (present in 36+-byte mask records). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct RealMask { + /// Real flags. + pub flags: u8, + /// Real user mask background (0 or 255). + pub background: u8, + /// Real mask rectangle. + pub rect: Rect, +} + +/// Layer mask / adjustment layer data. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct LayerMask { + /// Mask rectangle. + pub rect: Rect, + /// Default color outside the rectangle (0 or 255). + pub default_color: u8, + /// Flags: bit 0 position relative to layer, 1 disabled, 2 invert + /// (obsolete), 3 user mask came from rendering other data, 4 parameters + /// applied. + pub flags: u8, + /// Mask parameters (when flag bit 4 is set). + pub parameters: Option, + /// Real user mask fields. + pub real: Option, + /// Remaining bytes (the 2 padding bytes of the 20-byte form, etc.). + pub trailing: Vec, +} + +impl LayerMask { + /// Mask flag: position relative to layer. + pub const FLAG_RELATIVE: u8 = 1 << 0; + /// Mask flag: disabled. + pub const FLAG_DISABLED: u8 = 1 << 1; + /// Mask flag: invert when blending (obsolete). + pub const FLAG_INVERT: u8 = 1 << 2; + /// Mask flag: parameters present. + pub const FLAG_PARAMETERS: u8 = 1 << 4; + + /// The simple 20-byte form. + pub fn new(rect: Rect, default_color: u8, flags: u8) -> Self { + LayerMask { rect, default_color, flags: flags & !Self::FLAG_PARAMETERS, parameters: None, real: None, trailing: vec![0, 0] } + } + /// `true` if the disabled flag is set. + pub fn disabled(&self) -> bool { + self.flags & Self::FLAG_DISABLED != 0 + } + + fn parse(d: &[u8]) -> Result { + let mut r = Reader::new(d); + let rect = Rect::read(&mut r)?; + let default_color = r.u8()?; + let flags = r.u8()?; + let parameters = if flags & Self::FLAG_PARAMETERS != 0 { + let pf = r.u8()?; + Some(MaskParameters { + flags: pf, + user_density: if pf & 1 != 0 { Some(r.u8()?) } else { None }, + user_feather: if pf & 2 != 0 { Some(r.f64()?) } else { None }, + vector_density: if pf & 4 != 0 { Some(r.u8()?) } else { None }, + vector_feather: if pf & 8 != 0 { Some(r.f64()?) } else { None }, + }) + } else { + None + }; + let real = if r.remaining() >= 18 { + Some(RealMask { flags: r.u8()?, background: r.u8()?, rect: Rect::read(&mut r)? }) + } else { + None + }; + Ok(LayerMask { rect, default_color, flags, parameters, real, trailing: r.peek_rest().to_vec() }) + } + + fn write(&self, out: &mut Vec) { + self.rect.write(out); + out.put_u8(self.default_color); + out.put_u8(self.flags); + if let Some(p) = &self.parameters { + out.put_u8(p.flags); + if let Some(v) = p.user_density { + out.put_u8(v); + } + if let Some(v) = p.user_feather { + out.put_f64(v); + } + if let Some(v) = p.vector_density { + out.put_u8(v); + } + if let Some(v) = p.vector_feather { + out.put_f64(v); + } + } + if let Some(real) = &self.real { + out.put_u8(real.flags); + out.put_u8(real.background); + real.rect.write(out); + } + out.put(&self.trailing); + } +} + +/// The layer mask data field of a layer record. +#[derive(Debug, Clone, PartialEq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum MaskData { + /// Length 0. + #[default] + None, + /// Parsed mask. + Mask(LayerMask), + /// Unparseable mask data, preserved. + Raw(Vec), +} + +/// Layer blending ranges ("Blend If"), stored raw. The data is a sequence of +/// 8-byte entries: composite gray first, then one per channel; each entry is +/// source (black lo, black hi, white lo, white hi) then destination. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct BlendingRanges { + /// Raw bytes. + pub data: Vec, +} + +/// One blending range entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BlendRange { + /// Source range: black low, black high, white low, white high. + pub source: [u8; 4], + /// Destination range. + pub dest: [u8; 4], +} + +impl BlendingRanges { + /// Default "full" ranges for composite + `channels` channels. + pub fn full(channels: usize) -> Self { + let mut data = Vec::with_capacity((channels + 1) * 8); + for _ in 0..=channels { + data.extend_from_slice(&[0, 0, 255, 255, 0, 0, 255, 255]); + } + BlendingRanges { data } + } + /// Parsed entries (incomplete trailing bytes are ignored). + pub fn ranges(&self) -> Vec { + self.data + .chunks_exact(8) + .map(|c| BlendRange { source: [c[0], c[1], c[2], c[3]], dest: [c[4], c[5], c[6], c[7]] }) + .collect() + } +} + +/// Encoded image data for one layer channel. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct ChannelData { + /// Channel id: 0.. color channels, -1 transparency, -2 user mask, + /// -3 real user mask. + pub id: i16, + /// Compression; `None` when the stored length was 0 (no compression + /// field at all). + pub compression: Option, + /// Encoded bytes following the compression field, kept verbatim. + pub data: Vec, +} + +impl ChannelData { + /// Encodes planar samples (`height × row_bytes`). + pub fn encode( + id: i16, + compression: Compression, + decoded: &[u8], + width: usize, + height: usize, + depth: u16, + version: Version, + ) -> Result { + let layout = PlaneLayout { planes: 1, width, height, depth, version }; + Ok(ChannelData { id, compression: Some(compression), data: encode_planes(compression, decoded, &layout)? }) + } + + /// Decodes into planar big-endian samples. + pub fn decode(&self, width: usize, height: usize, depth: u16, version: Version) -> Result> { + let layout = PlaneLayout { planes: 1, width, height, depth, version }; + match self.compression { + Some(c) => decode_planes(c, &self.data, &layout), + None => { + if layout.decoded_len()? == 0 { + Ok(Vec::new()) + } else { + Err(PsdError::invalid("channel has no data")) + } + } + } + } + + /// Replaces the channel contents, re-encoding with `compression`. + #[allow(clippy::too_many_arguments)] + pub fn set_decoded( + &mut self, + compression: Compression, + decoded: &[u8], + width: usize, + height: usize, + depth: u16, + version: Version, + ) -> Result<()> { + *self = Self::encode(self.id, compression, decoded, width, height, depth, version)?; + Ok(()) + } + + fn stored_len(&self) -> u64 { + match self.compression { + Some(_) => 2 + self.data.len() as u64, + None => 0, + } + } +} + +/// A layer record together with its channel image data. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct LayerRecord { + /// Layer bounds. + pub rect: Rect, + /// Channels in stored order, including encoded image data. + pub channels: Vec, + /// Blend mode. + pub blend_mode: BlendMode, + /// Opacity 0..=255. + pub opacity: u8, + /// Clipping: 0 base, 1 non-base (clipped to layer below). + pub clipping: u8, + /// Flags. + pub flags: LayerFlags, + /// Filler byte (zero), preserved. + pub filler: u8, + /// Layer mask data. + pub mask: MaskData, + /// Blending ranges. + pub blending_ranges: BlendingRanges, + /// Legacy Pascal name bytes (padded to 4 in the file). + pub name: Vec, + /// Additional layer information blocks. + pub blocks: Vec, + /// Bytes at the end of the extra-data field that do not form a block. + pub extra_trailing: Vec, +} + +impl Default for LayerRecord { + fn default() -> Self { + LayerRecord { + rect: Rect::default(), + channels: Vec::new(), + blend_mode: BlendMode::Normal, + opacity: 255, + clipping: 0, + flags: LayerFlags(0), + filler: 0, + mask: MaskData::None, + blending_ranges: BlendingRanges::default(), + name: Vec::new(), + blocks: Vec::new(), + extra_trailing: Vec::new(), + } + } +} + +impl LayerRecord { + /// Layer name, preferring the Unicode `luni` block over the legacy name. + pub fn name(&self) -> String { + for b in &self.blocks { + if &b.key == b"luni" + && let Some(Ok(BlockData::UnicodeName(n))) = b.parsed() + { + return n; + } + } + decode_legacy_name(&self.name) + } + + /// First block with the given key. + pub fn block(&self, key: &[u8; 4]) -> Option<&TaggedBlock> { + self.blocks.iter().find(|b| &b.key == key) + } + + /// Mutable access to the first block with the given key. + pub fn block_mut(&mut self, key: &[u8; 4]) -> Option<&mut TaggedBlock> { + self.blocks.iter_mut().find(|b| &b.key == key) + } + + /// Section divider (`lsct`, falling back to `lsdk`), if present and valid. + pub fn section_divider(&self) -> Option { + for key in [b"lsct", b"lsdk"] { + if let Some(Ok(BlockData::SectionDivider(s))) = self.block(key).and_then(TaggedBlock::parsed) { + return Some(s); + } + } + None + } + + /// Section type (Other if no divider). + pub fn section_type(&self) -> SectionType { + self.section_divider().map_or(SectionType::Other, |s| s.kind) + } + + /// `true` unless the hidden flag is set. + pub fn is_visible(&self) -> bool { + !self.flags.hidden() + } + + /// Layer id from `lyid`. + pub fn layer_id(&self) -> Option { + match self.block(b"lyid")?.parsed()? { + Ok(BlockData::LayerId(v)) => Some(v), + _ => None, + } + } + + /// Fill opacity from `iOpa` (255 if absent). + pub fn fill_opacity(&self) -> u8 { + match self.block(b"iOpa").and_then(TaggedBlock::parsed) { + Some(Ok(BlockData::FillOpacity(v))) => v, + _ => 255, + } + } + + /// Parsed layer mask, if any. + pub fn layer_mask(&self) -> Option<&LayerMask> { + match &self.mask { + MaskData::Mask(m) => Some(m), + _ => None, + } + } + + /// Channel with the given id. + pub fn channel(&self, id: i16) -> Option<&ChannelData> { + self.channels.iter().find(|c| c.id == id) + } + + /// Rectangle that a channel's pixels cover: the layer rect for color and + /// transparency channels, the mask rect for -2, the real mask rect for -3. + pub fn channel_rect(&self, id: i16) -> Rect { + match (id, &self.mask) { + (CHANNEL_USER_MASK, MaskData::Mask(m)) => m.rect, + (CHANNEL_REAL_USER_MASK, MaskData::Mask(m)) => m.real.map_or(m.rect, |r| r.rect), + _ => self.rect, + } + } + + /// Decodes a channel's samples (planar, big-endian). + pub fn decode_channel(&self, id: i16, depth: u16, version: Version) -> Result> { + let ch = self.channel(id).ok_or_else(|| PsdError::invalid(format!("layer has no channel {id}")))?; + let (w, h) = self.channel_rect(id).size()?; + ch.decode(w, h, depth, version) + } + + fn read(r: &mut Reader<'_>, version: Version) -> Result<(Self, Vec)> { + let rect = Rect::read(r)?; + let nch = r.u16()?; + if nch > MAX_LAYER_CHANNELS { + return Err(PsdError::LimitExceeded("too many channels in layer")); + } + let mut channels = Vec::with_capacity(usize::from(nch)); + let mut lens = Vec::with_capacity(usize::from(nch)); + for _ in 0..nch { + let id = r.i16()?; + let len = r.len_field(version.is_psb())?; + channels.push(ChannelData { id, compression: None, data: Vec::new() }); + lens.push(len); + } + let sig = r.array::<4>()?; + if &sig != b"8BIM" { + return Err(PsdError::InvalidSignature { expected: "8BIM (layer blend mode)", found: sig }); + } + let blend_mode = BlendMode::from_key(r.array()?); + let opacity = r.u8()?; + let clipping = r.u8()?; + let flags = LayerFlags(r.u8()?); + let filler = r.u8()?; + let extra_len = r.u32()?; + let mut x = r.sub(u64::from(extra_len))?; + let mask_len = x.u32()?; + let mask_bytes = x.bytes_u64(u64::from(mask_len))?; + let mask = if mask_bytes.is_empty() { + MaskData::None + } else { + match LayerMask::parse(mask_bytes) { + Ok(m) => MaskData::Mask(m), + Err(_) => MaskData::Raw(mask_bytes.to_vec()), + } + }; + let br_len = x.u32()?; + let blending_ranges = BlendingRanges { data: x.bytes_u64(u64::from(br_len))?.to_vec() }; + let name = read_pascal(&mut x, 4)?; + let (blocks, extra_trailing) = read_blocks(&mut x, version)?; + Ok(( + LayerRecord { + rect, + channels, + blend_mode, + opacity, + clipping, + flags, + filler, + mask, + blending_ranges, + name, + blocks, + extra_trailing, + }, + lens, + )) + } + + fn write(&self, out: &mut Vec, version: Version) -> Result<()> { + self.rect.write(out); + let nch = u16::try_from(self.channels.len()).map_err(|_| PsdError::LimitExceeded("too many channels"))?; + out.put_u16(nch); + for c in &self.channels { + out.put_i16(c.id); + out.put_len(c.stored_len(), version.is_psb())?; + } + out.put(b"8BIM"); + out.put(&self.blend_mode.key()); + out.put_u8(self.opacity); + out.put_u8(self.clipping); + out.put_u8(self.flags.0); + out.put_u8(self.filler); + let at = out.begin_len(false); + let m = out.begin_len(false); + match &self.mask { + MaskData::None => {} + MaskData::Mask(mask) => mask.write(out), + MaskData::Raw(b) => out.put(b), + } + out.end_len(m, false)?; + out.put_len(self.blending_ranges.data.len() as u64, false)?; + out.put(&self.blending_ranges.data); + write_pascal(out, &self.name, 4); + write_blocks(out, &self.blocks, version)?; + out.put(&self.extra_trailing); + out.end_len(at, false) + } +} + +/// Layer info: layer records plus their channel image data. +#[derive(Debug, Clone, PartialEq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct LayerInfo { + /// The layer count was stored negative: the first alpha channel of the + /// merged image contains the transparency of the merged result. + pub merged_alpha: bool, + /// Layer records, bottom-most first (file order). + pub layers: Vec, + /// Bytes after the channel data. `None` = default (zero pad to even). + pub padding: Option>, +} + +impl LayerInfo { + /// Parses a layer info body (the bytes covered by its length field). + pub(crate) fn read_body(data: &[u8], version: Version) -> Result { + let mut r = Reader::new(data); + let count = r.i16()?; + let merged_alpha = count < 0; + let n = count.unsigned_abs(); + // Minimum record size: 16 rect + 2 + 4 sig + 4 key + 4 + 4 extra len. + r.check_count(u64::from(n), 34)?; + let mut layers = Vec::with_capacity(usize::from(n)); + let mut all_lens = Vec::with_capacity(usize::from(n)); + for _ in 0..n { + let (rec, lens) = LayerRecord::read(&mut r, version)?; + layers.push(rec); + all_lens.push(lens); + } + for (rec, lens) in layers.iter_mut().zip(all_lens) { + for (ch, len) in rec.channels.iter_mut().zip(lens) { + if len == 0 { + continue; + } + if len == 1 { + return Err(PsdError::invalid("channel data length 1")); + } + ch.compression = Some(Compression::from_u16(r.u16()?)); + ch.data = r.bytes_u64(len - 2)?.to_vec(); + } + } + let body_len = r.pos(); + let rest = r.peek_rest(); + let padding = if rest.len() == body_len % 2 && rest.iter().all(|&b| b == 0) { None } else { Some(rest.to_vec()) }; + Ok(LayerInfo { merged_alpha, layers, padding }) + } + + /// Serializes the body (without a length field). + pub(crate) fn write_body(&self, out: &mut Vec, version: Version) -> Result<()> { + let start = out.len(); + let n = i16::try_from(self.layers.len()).map_err(|_| PsdError::LimitExceeded("more than 32767 layers"))?; + out.put_i16(if self.merged_alpha { -n } else { n }); + for l in &self.layers { + l.write(out, version)?; + } + for l in &self.layers { + for c in &l.channels { + if let Some(comp) = c.compression { + out.put_u16(comp.as_u16()); + out.put(&c.data); + } + } + } + match &self.padding { + Some(p) => out.put(p), + None => { + if (out.len() - start) % 2 == 1 { + out.put_u8(0); + } + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_record(version: Version) -> LayerRecord { + let rect = Rect::from_xywh(-3, 2, 5, 4); + let px: Vec = (0..20).collect(); + let mut channels = Vec::new(); + for (id, comp) in [(-1, Compression::Rle), (0, Compression::Raw), (1, Compression::Zip), (2, Compression::ZipPrediction)] { + channels.push(ChannelData::encode(id, comp, &px, 5, 4, 8, version).unwrap()); + } + let mask = LayerMask::new(Rect::from_xywh(0, 0, 2, 2), 255, LayerMask::FLAG_RELATIVE); + channels.push(ChannelData::encode(-2, Compression::Rle, &[1, 2, 3, 4], 2, 2, 8, version).unwrap()); + LayerRecord { + rect, + channels, + blend_mode: BlendMode::Screen, + opacity: 200, + clipping: 1, + flags: LayerFlags(LayerFlags::HIDDEN), + mask: MaskData::Mask(mask), + blending_ranges: BlendingRanges::full(3), + name: b"abc".to_vec(), + blocks: vec![TaggedBlock::unicode_name("\u{e4}bc"), TaggedBlock::layer_id(5)], + ..Default::default() + } + } + + fn rt(info: &LayerInfo, version: Version) -> Vec { + let mut out = Vec::new(); + info.write_body(&mut out, version).unwrap(); + let back = LayerInfo::read_body(&out, version).unwrap(); + assert_eq!(&back, info); + let mut again = Vec::new(); + back.write_body(&mut again, version).unwrap(); + assert_eq!(again, out); + out + } + + #[test] + fn empty_layer_info() { + let out = rt(&LayerInfo::default(), Version::Psd); + assert_eq!(out, vec![0, 0]); + } + + #[test] + fn record_roundtrip_psd_psb() { + for v in [Version::Psd, Version::Psb] { + let info = LayerInfo { merged_alpha: true, layers: vec![sample_record(v), LayerRecord::default()], padding: None }; + let out = rt(&info, v); + assert_eq!(out.len() % 2, 0); + assert_eq!(&out[..2], &(-2i16).to_be_bytes()); + } + } + + #[test] + fn psb_channel_lengths_are_8_bytes() { + let mut rec = LayerRecord::default(); + rec.channels.push(ChannelData { id: 0, compression: Some(Compression::Raw), data: vec![] }); + let info = LayerInfo { layers: vec![rec], ..Default::default() }; + let psd = rt(&info, Version::Psd); + let psb = rt(&info, Version::Psb); + assert_eq!(psb.len(), psd.len() + 4); + } + + #[test] + fn channel_decode_via_record() { + let rec = sample_record(Version::Psd); + let px: Vec = (0..20).collect(); + for id in [-1, 0, 1, 2] { + assert_eq!(rec.decode_channel(id, 8, Version::Psd).unwrap(), px); + } + assert_eq!(rec.decode_channel(-2, 8, Version::Psd).unwrap(), vec![1, 2, 3, 4]); + assert!(rec.decode_channel(7, 8, Version::Psd).is_err()); + } + + #[test] + fn record_accessors() { + let rec = sample_record(Version::Psd); + assert_eq!(rec.name(), "\u{e4}bc"); + assert!(!rec.is_visible()); + assert_eq!(rec.layer_id(), Some(5)); + assert_eq!(rec.fill_opacity(), 255); + assert_eq!(rec.section_type(), SectionType::Other); + assert!(rec.layer_mask().is_some()); + assert_eq!(rec.channel_rect(-2), Rect::from_xywh(0, 0, 2, 2)); + assert_eq!(rec.channel_rect(0), rec.rect); + let mut plain = LayerRecord { name: b"legacy".to_vec(), ..Default::default() }; + assert_eq!(plain.name(), "legacy"); + plain.blocks.push(TaggedBlock::fill_opacity(10)); + assert_eq!(plain.fill_opacity(), 10); + plain.block_mut(b"iOpa").unwrap().data[0] = 11; + assert_eq!(plain.fill_opacity(), 11); + } + + #[test] + fn mask_variants_roundtrip() { + let base = LayerMask::new(Rect::from_xywh(1, 2, 3, 4), 0, 0); + let with_real = LayerMask { + real: Some(RealMask { flags: 1, background: 255, rect: Rect::from_xywh(0, 0, 9, 9) }), + trailing: vec![], + ..base.clone() + }; + let with_params = LayerMask { + flags: LayerMask::FLAG_PARAMETERS, + parameters: Some(MaskParameters { + flags: 0b1111, + user_density: Some(128), + user_feather: Some(2.5), + vector_density: Some(64), + vector_feather: Some(0.5), + }), + real: Some(RealMask::default()), + trailing: vec![0, 0], + ..base.clone() + }; + let partial_params = LayerMask { + flags: LayerMask::FLAG_PARAMETERS, + parameters: Some(MaskParameters { flags: 0b0010, user_feather: Some(1.0), ..Default::default() }), + trailing: vec![], + ..base.clone() + }; + for m in [base, with_real, with_params, partial_params] { + let mut out = Vec::new(); + m.write(&mut out); + assert_eq!(LayerMask::parse(&out).unwrap(), m); + } + } + + #[test] + fn mask_sizes() { + let mut out = Vec::new(); + LayerMask::new(Rect::default(), 0, 0).write(&mut out); + assert_eq!(out.len(), 20); + let m = LayerMask { + real: Some(RealMask::default()), + trailing: vec![], + ..LayerMask::new(Rect::default(), 0, 0) + }; + let mut out = Vec::new(); + m.write(&mut out); + assert_eq!(out.len(), 36); + } + + #[test] + fn short_mask_kept_raw() { + let mut rec = LayerRecord { mask: MaskData::Raw(vec![1, 2, 3]), ..Default::default() }; + rec.channels.clear(); + let info = LayerInfo { layers: vec![rec], ..Default::default() }; + rt(&info, Version::Psd); + } + + #[test] + fn flags_helpers() { + let mut f = LayerFlags::default(); + f.set_hidden(true); + f.set_transparency_protected(true); + assert!(f.hidden() && f.transparency_protected()); + f.set_pixel_data_irrelevant(true); + assert!(f.bit4_useful() && f.pixel_data_irrelevant()); + f.set_hidden(false); + assert!(!f.hidden()); + } + + #[test] + fn blending_ranges_parse() { + let br = BlendingRanges::full(3); + assert_eq!(br.data.len(), 32); + let r = br.ranges(); + assert_eq!(r.len(), 4); + assert_eq!(r[0].source, [0, 0, 255, 255]); + } + + #[test] + fn rect_helpers() { + let r = Rect::from_xywh(-5, -6, 10, 3); + assert_eq!((r.width(), r.height()), (10, 3)); + assert_eq!(r.size().unwrap(), (10, 3)); + assert!(!r.is_empty()); + let bad = Rect { top: 5, left: 0, bottom: 0, right: 1 }; + assert!(bad.size().is_err()); + assert!(bad.is_empty()); + let huge = Rect { top: 0, left: i32::MIN, bottom: 1, right: i32::MAX }; + assert!(huge.size().is_err()); + } + + #[test] + fn truncation_errors() { + let info = LayerInfo { layers: vec![sample_record(Version::Psd)], ..Default::default() }; + let mut out = Vec::new(); + info.write_body(&mut out, Version::Psd).unwrap(); + // The final byte may be the (optional) even padding; stop before it. + for cut in 0..out.len() - 1 { + assert!(LayerInfo::read_body(&out[..cut], Version::Psd).is_err(), "cut {cut}"); + } + } + + #[test] + fn bad_blend_signature() { + let info = LayerInfo { layers: vec![LayerRecord::default()], ..Default::default() }; + let mut out = Vec::new(); + info.write_body(&mut out, Version::Psd).unwrap(); + // count(2) + rect(16) + nch(2) = 20 → signature + out[20] = b'X'; + assert!(matches!(LayerInfo::read_body(&out, Version::Psd), Err(PsdError::InvalidSignature { .. }))); + } + + #[test] + fn too_many_layers_rejected() { + let out = 1000i16.to_be_bytes(); + assert!(LayerInfo::read_body(&out, Version::Psd).is_err()); + } + + #[test] + fn zero_length_channel() { + let mut rec = LayerRecord::default(); + rec.channels.push(ChannelData { id: 0, compression: None, data: vec![] }); + let info = LayerInfo { layers: vec![rec], ..Default::default() }; + rt(&info, Version::Psd); + assert_eq!(info.layers[0].decode_channel(0, 8, Version::Psd).unwrap(), Vec::::new()); + } + + #[test] + fn nondefault_padding_kept() { + let info = LayerInfo { padding: Some(vec![0, 0]), ..Default::default() }; + let out = rt(&info, Version::Psd); + assert_eq!(out.len(), 4); + } + + #[test] + fn unknown_blend_and_blocks_passthrough() { + let rec = LayerRecord { + blend_mode: BlendMode::Unknown(*b"wxyz"), + blocks: vec![TaggedBlock::new(*b"Zzzz", vec![1, 2, 3, 4, 5])], + ..Default::default() + }; + rt(&LayerInfo { layers: vec![rec], ..Default::default() }, Version::Psd); + } +} diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs new file mode 100644 index 00000000..7fcd0ce0 --- /dev/null +++ b/crates/psd/src/lib.rs @@ -0,0 +1,73 @@ +//! # photocraft-psd +//! +//! Faithful, lossless reader and writer for Adobe Photoshop PSD (version 1) +//! and PSB (version 2, "large document format") files. +//! +//! * [`PsdFile::from_bytes`] parses a file into a format-level model; +//! [`PsdFile::to_bytes`] writes it back. Unmodified files round-trip byte +//! for byte: channel data is kept in its original encoding, unknown tagged +//! blocks and resources are kept raw, and padding is preserved. +//! * Channel data is decoded lazily ([`ChannelData::decode`], +//! [`ImageData::decode`]) with all four compression methods supported for +//! both decoding and encoding. +//! * [`PsdBuilder`] constructs new files from RGBA/gray/CMYK buffers; the +//! caller supplies the merged composite (this crate never composites). +//! * [`descriptor`] parses and writes ActionDescriptors. +//! +//! Implemented clean-room from Adobe's public "Photoshop File Formats +//! Specification". Undocumented details that follow MIT-licensed psd-tools +//! or ag-psd behavior are noted in comments. +//! +//! The parser never panics on malformed input and enforces limits +//! (dimensions ≤ 300000, bounded allocations, bounded descriptor nesting). +//! +//! ``` +//! use photocraft_psd::{LayerSpec, PixelData, PsdBuilder, PsdFile}; +//! +//! let mut b = PsdBuilder::new(2, 1); +//! b.push_layer(LayerSpec::new("Red", 0, 0, 2, 1, PixelData::Rgba8(vec![255, 0, 0, 255, 255, 0, 0, 128]))); +//! b.composite(PixelData::Rgba8(vec![255, 0, 0, 255, 255, 0, 0, 128])); +//! let bytes = b.to_bytes()?; +//! +//! let file = PsdFile::from_bytes(&bytes)?; +//! assert_eq!(file.to_bytes()?, bytes); // byte-stable +//! let layer = file.layer(0).unwrap(); +//! assert_eq!(layer.name(), "Red"); +//! assert_eq!(layer.rgba8()?.data[7], 128); +//! # Ok::<(), photocraft_psd::PsdError>(()) +//! ``` +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +pub mod blend; +pub mod builder; +pub mod compression; +pub mod descriptor; +pub mod error; +pub mod file; +pub mod header; +pub mod image_data; +mod io; +pub mod layer; +pub mod pixels; +pub mod resources; +pub mod tagged; +#[cfg(any(test, feature = "testgen"))] +pub mod testgen; +pub mod tree; + +pub use blend::BlendMode; +pub use builder::{GroupSpec, LayerSpec, MaskSpec, PixelData, PsdBuilder}; +pub use compression::{Compression, PlaneLayout}; +pub use descriptor::{Descriptor, VersionedDescriptor}; +pub use error::{PsdError, Result}; +pub use file::{GlobalLayerMask, LayerInfoPlacement, PsdFile}; +pub use header::{ColorMode, Header, Version}; +pub use image_data::ImageData; +pub use layer::{ + BlendingRanges, ChannelData, LayerFlags, LayerInfo, LayerMask, LayerRecord, MaskData, MaskParameters, RealMask, Rect, +}; +pub use pixels::{GrayImage, Layer, RgbaImage}; +pub use resources::{ImageResource, ResolutionInfo, ResourceData}; +pub use tagged::{BlockData, SectionDivider, SectionType, TaggedBlock}; +pub use tree::LayerNode; diff --git a/crates/psd/src/pixels.rs b/crates/psd/src/pixels.rs new file mode 100644 index 00000000..23ff25c4 --- /dev/null +++ b/crates/psd/src/pixels.rs @@ -0,0 +1,273 @@ +//! Sample helpers and RGBA8 convenience extraction. +//! +//! These are conveniences for previews and tests; they perform no color +//! management. 16-bit samples are scaled with rounding, 32-bit float samples +//! are clamped to 0..=1 (no tone mapping or gamma), CMYK is converted +//! naively (`r = c' * k' / 255` using the stored, inverted values). + +use crate::error::{PsdError, Result}; +use crate::file::PsdFile; +use crate::header::{ColorMode, Header, Version}; +use crate::layer::{CHANNEL_REAL_USER_MASK, CHANNEL_TRANSPARENCY, CHANNEL_USER_MASK, LayerRecord, Rect}; + +/// Interprets planar big-endian bytes as u16 samples. +pub fn samples_u16(bytes: &[u8]) -> Vec { + bytes.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect() +} + +/// Interprets planar big-endian bytes as f32 samples. +pub fn samples_f32(bytes: &[u8]) -> Vec { + bytes.chunks_exact(4).map(|c| f32::from_be_bytes([c[0], c[1], c[2], c[3]])).collect() +} + +/// Encodes u16 samples as big-endian bytes. +pub fn u16_to_bytes(samples: &[u16]) -> Vec { + samples.iter().flat_map(|s| s.to_be_bytes()).collect() +} + +/// Encodes f32 samples as big-endian bytes. +pub fn f32_to_bytes(samples: &[f32]) -> Vec { + samples.iter().flat_map(|s| s.to_be_bytes()).collect() +} + +/// Expands a 1-bit plane (MSB first, rows padded to bytes) to one byte per +/// pixel (0 or 1). +pub fn unpack_bits(bytes: &[u8], width: usize, height: usize) -> Vec { + let rb = width.div_ceil(8); + let mut out = Vec::with_capacity(width * height); + for y in 0..height { + for x in 0..width { + let b = bytes.get(y * rb + x / 8).copied().unwrap_or(0); + out.push((b >> (7 - (x % 8))) & 1); + } + } + out +} + +/// Converts one plane of samples at `depth` to 8-bit. +pub fn plane_to_u8(bytes: &[u8], depth: u16, width: usize, height: usize) -> Result> { + Ok(match depth { + 1 => unpack_bits(bytes, width, height).into_iter().map(|b| if b == 1 { 0 } else { 255 }).collect(), + 8 => bytes.to_vec(), + 16 => samples_u16(bytes).into_iter().map(|v| ((u32::from(v) * 255 + 32767) / 65535) as u8).collect(), + 32 => samples_f32(bytes) + .into_iter() + .map(|v| if v.is_nan() { 0 } else { (v.clamp(0.0, 1.0) * 255.0).round() as u8 }) + .collect(), + d => return Err(PsdError::Unsupported(format!("depth {d}"))), + }) +} + +/// An 8-bit RGBA image positioned in document coordinates. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RgbaImage { + /// Left edge in document coordinates. + pub left: i32, + /// Top edge in document coordinates. + pub top: i32, + /// Width. + pub width: u32, + /// Height. + pub height: u32, + /// Interleaved RGBA, row-major. + pub data: Vec, +} + +/// An 8-bit single-channel image (e.g. a layer mask). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GrayImage { + /// Left edge. + pub left: i32, + /// Top edge. + pub top: i32, + /// Width. + pub width: u32, + /// Height. + pub height: u32, + /// Samples. + pub data: Vec, + /// Value outside the rectangle (masks only). + pub default_value: u8, +} + +fn interleave( + mode: ColorMode, + color: &[Vec], + alpha: Option<&[u8]>, + palette: &[u8], + n: usize, +) -> Result> { + let mut out = vec![0u8; n * 4]; + for i in 0..n { + let (r, g, b) = match mode { + ColorMode::Rgb => (color[0][i], color[1][i], color[2][i]), + ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone => (color[0][i], color[0][i], color[0][i]), + ColorMode::Indexed => { + let idx = usize::from(color[0][i]); + let p = |k: usize| palette.get(k * 256 + idx).copied().unwrap_or(0); + (p(0), p(1), p(2)) + } + ColorMode::Cmyk => { + let k = u32::from(color[3][i]); + let f = |c: u8| ((u32::from(c) * k + 127) / 255) as u8; + (f(color[0][i]), f(color[1][i]), f(color[2][i])) + } + m => return Err(PsdError::Unsupported(format!("rgba8 for color mode {m:?}"))), + }; + out[i * 4] = r; + out[i * 4 + 1] = g; + out[i * 4 + 2] = b; + out[i * 4 + 3] = alpha.map_or(255, |a| a[i]); + } + Ok(out) +} + +fn color_channel_count(mode: ColorMode) -> Result { + match mode { + ColorMode::Rgb => Ok(3), + ColorMode::Cmyk => Ok(4), + ColorMode::Grayscale | ColorMode::Indexed | ColorMode::Bitmap | ColorMode::Duotone => Ok(1), + m => Err(PsdError::Unsupported(format!("rgba8 for color mode {m:?}"))), + } +} + +/// A borrowed view of a layer with the file context needed to decode it. +#[derive(Debug, Clone, Copy)] +pub struct Layer<'a> { + /// Index in [`PsdFile::layers`]. + pub index: usize, + /// The record. + pub record: &'a LayerRecord, + header: &'a Header, +} + +impl<'a> Layer<'a> { + /// Name, preferring `luni`. + pub fn name(&self) -> String { + self.record.name() + } + + /// PSD/PSB version of the containing file. + pub fn version(&self) -> Version { + self.header.version + } + + /// Decodes a channel (planar, big-endian samples). + pub fn channel_bytes(&self, id: i16) -> Result> { + self.record.decode_channel(id, self.header.depth, self.header.version) + } + + /// Layer pixels as RGBA8 (transparency channel as alpha; the user mask is + /// *not* applied, see [`Self::user_mask`]). Supports RGB, grayscale and + /// CMYK at 8/16/32 bits. + pub fn rgba8(&self) -> Result { + let rect = self.record.rect; + let (w, h) = rect.size()?; + let n = w * h; + let depth = self.header.depth; + let mode = self.header.color_mode; + let cc = color_channel_count(mode)?; + let mut color = Vec::with_capacity(cc); + for id in 0..cc as i16 { + let plane = if self.record.channel(id).is_some() { + plane_to_u8(&self.channel_bytes(id)?, depth, w, h)? + } else { + vec![if mode == ColorMode::Cmyk { 255 } else { 0 }; n] + }; + if plane.len() != n { + return Err(PsdError::invalid("channel size mismatch")); + } + color.push(plane); + } + let alpha = match self.record.channel(CHANNEL_TRANSPARENCY) { + Some(_) => Some(plane_to_u8(&self.channel_bytes(CHANNEL_TRANSPARENCY)?, depth, w, h)?), + None => None, + }; + let data = interleave(mode, &color, alpha.as_deref(), &[], n)?; + Ok(RgbaImage { left: rect.left, top: rect.top, width: w as u32, height: h as u32, data }) + } + + /// The user mask (channel -3 if present, else -2) as 8-bit gray, with its + /// rectangle and default color. `None` if the layer has no mask channel. + pub fn user_mask(&self) -> Option> { + let id = if self.record.channel(CHANNEL_REAL_USER_MASK).is_some() { + CHANNEL_REAL_USER_MASK + } else if self.record.channel(CHANNEL_USER_MASK).is_some() { + CHANNEL_USER_MASK + } else { + return None; + }; + Some((|| { + let rect: Rect = self.record.channel_rect(id); + let (w, h) = rect.size()?; + let data = plane_to_u8(&self.channel_bytes(id)?, self.header.depth, w, h)?; + let default_value = match (self.record.layer_mask(), id) { + (Some(m), CHANNEL_REAL_USER_MASK) => m.real.map_or(m.default_color, |r| r.background), + (Some(m), _) => m.default_color, + (None, _) => 0, + }; + Ok(GrayImage { left: rect.left, top: rect.top, width: w as u32, height: h as u32, data, default_value }) + })()) + } +} + +impl PsdFile { + /// Borrowed view of layer `index`. + pub fn layer(&self, index: usize) -> Option> { + self.layers().get(index).map(|record| Layer { index, record, header: &self.header }) + } + + /// Iterates over layers in file order (bottom-most first). + pub fn iter_layers(&self) -> impl Iterator> { + self.layers().iter().enumerate().map(|(index, record)| Layer { index, record, header: &self.header }) + } + + /// The merged composite as RGBA8. Supports RGB, grayscale, CMYK, indexed, + /// duotone (as gray) and bitmap at all depths. Alpha comes from the first + /// extra channel when [`PsdFile::merged_has_alpha`] is true. + pub fn composite_rgba8(&self) -> Result { + let h = &self.header; + let (w, hh) = (h.width as usize, h.height as usize); + let n = w * hh; + let cc = color_channel_count(h.color_mode)?; + if usize::from(h.channels) < cc { + return Err(PsdError::invalid("fewer channels than the color mode requires")); + } + let all = self.decode_merged()?; + let plane = h.row_bytes() * hh; + let get = |i: usize| -> Result> { plane_to_u8(&all[i * plane..(i + 1) * plane], h.depth, w, hh) }; + let mut color = Vec::with_capacity(cc); + for i in 0..cc { + color.push(get(i)?); + } + let alpha = if self.merged_has_alpha() { Some(get(cc)?) } else { None }; + let data = interleave(h.color_mode, &color, alpha.as_deref(), &self.color_mode_data, n)?; + Ok(RgbaImage { left: 0, top: 0, width: h.width, height: h.height, data }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sample_conversions() { + assert_eq!(samples_u16(&[0x12, 0x34, 0xff, 0xff]), vec![0x1234, 0xffff]); + assert_eq!(u16_to_bytes(&[0x1234]), vec![0x12, 0x34]); + let f = f32_to_bytes(&[0.5, -1.0]); + assert_eq!(samples_f32(&f), vec![0.5, -1.0]); + } + + #[test] + fn unpack_bits_rows() { + assert_eq!(unpack_bits(&[0b1010_0000, 0b1000_0000], 3, 2), vec![1, 0, 1, 1, 0, 0]); + } + + #[test] + fn plane_to_u8_depths() { + assert_eq!(plane_to_u8(&[0xff, 0xff, 0, 0, 0x80, 0x00], 16, 3, 1).unwrap(), vec![255, 0, 128]); + assert_eq!(plane_to_u8(&f32_to_bytes(&[0.0, 1.0, 2.0, -1.0, f32::NAN]), 32, 5, 1).unwrap(), vec![0, 255, 255, 0, 0]); + assert_eq!(plane_to_u8(&[0b1000_0000], 1, 2, 1).unwrap(), vec![0, 255]); + assert!(plane_to_u8(&[], 12, 0, 0).is_err()); + } +} diff --git a/crates/psd/src/resources.rs b/crates/psd/src/resources.rs new file mode 100644 index 00000000..cbc7ef1f --- /dev/null +++ b/crates/psd/src/resources.rs @@ -0,0 +1,370 @@ +//! Image resources section (`8BIM` resource blocks). + +use crate::descriptor::UnicodeString; +use crate::error::{PsdError, Result}; +use crate::io::{Reader, WriteExt, read_pascal, write_pascal}; + +/// Well-known resource ids. +pub mod ids { + /// Resolution info (`ResolutionInfo` structure). + pub const RESOLUTION_INFO: u16 = 1005; + /// Target layer index. + pub const LAYER_STATE: u16 = 1024; + /// Layer group ids (one u16 per layer). + pub const LAYER_GROUP_INFO: u16 = 1026; + /// Legacy (Photoshop 4) thumbnail, BGR. + pub const THUMBNAIL_PS4: u16 = 1033; + /// Thumbnail (JFIF). + pub const THUMBNAIL: u16 = 1036; + /// Global lighting angle. + pub const GLOBAL_ANGLE: u16 = 1037; + /// ICC profile. + pub const ICC_PROFILE: u16 = 1039; + /// Global altitude. + pub const GLOBAL_ALTITUDE: u16 = 1049; + /// Version info (has real merged data flag). + pub const VERSION_INFO: u16 = 1057; + /// EXIF data 1. + pub const EXIF: u16 = 1058; + /// XMP metadata. + pub const XMP: u16 = 1060; +} + +/// One image resource block. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct ImageResource { + /// Signature, normally `8BIM` (also `MeSa`, `AgHg`, `PHUT`, `DCSR` seen in the wild). + pub signature: [u8; 4], + /// Resource id. + pub id: u16, + /// Pascal name bytes (usually empty). + pub name: Vec, + /// Resource data (without the even padding). + pub data: Vec, +} + +/// Fixed-point resolution info (resource 1005). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct ResolutionInfo { + /// Horizontal resolution, 16.16 fixed point (pixels per inch/cm). + pub h_res_fixed: u32, + /// 1 = pixels per inch, 2 = pixels per cm. + pub h_res_unit: u16, + /// Display unit for width (1 in, 2 cm, 3 pt, 4 pica, 5 column). + pub width_unit: u16, + /// Vertical resolution, 16.16 fixed. + pub v_res_fixed: u32, + /// Vertical resolution unit. + pub v_res_unit: u16, + /// Display unit for height. + pub height_unit: u16, +} + +impl ResolutionInfo { + /// Resolution info for `dpi` pixels per inch in both directions. + pub fn from_dpi(dpi: f64) -> Self { + let f = (dpi * 65536.0).round().clamp(0.0, u32::MAX as f64) as u32; + ResolutionInfo { h_res_fixed: f, h_res_unit: 1, width_unit: 1, v_res_fixed: f, v_res_unit: 1, height_unit: 1 } + } + /// Horizontal resolution as float. + pub fn h_res(&self) -> f64 { + f64::from(self.h_res_fixed) / 65536.0 + } + /// Vertical resolution as float. + pub fn v_res(&self) -> f64 { + f64::from(self.v_res_fixed) / 65536.0 + } + /// Parses 16 bytes. + pub fn from_bytes(b: &[u8]) -> Result { + let mut r = Reader::new(b); + Ok(ResolutionInfo { + h_res_fixed: r.u32()?, + h_res_unit: r.u16()?, + width_unit: r.u16()?, + v_res_fixed: r.u32()?, + v_res_unit: r.u16()?, + height_unit: r.u16()?, + }) + } + /// Serializes to 16 bytes. + pub fn to_bytes(&self) -> Vec { + let mut v = Vec::with_capacity(16); + v.put_u32(self.h_res_fixed); + v.put_u16(self.h_res_unit); + v.put_u16(self.width_unit); + v.put_u32(self.v_res_fixed); + v.put_u16(self.v_res_unit); + v.put_u16(self.height_unit); + v + } +} + +/// Version info (resource 1057). +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct VersionInfo { + /// Version (1). + pub version: u32, + /// `false` when the merged image is a placeholder. + pub has_real_merged_data: bool, + /// Writer application name. + pub writer: UnicodeString, + /// Reader application name. + pub reader: UnicodeString, + /// File version. + pub file_version: u32, +} + +impl VersionInfo { + /// Parses resource data. + pub fn from_bytes(b: &[u8]) -> Result { + let mut r = Reader::new(b); + Ok(VersionInfo { + version: r.u32()?, + has_real_merged_data: r.u8()? != 0, + writer: UnicodeString::read(&mut r)?, + reader: UnicodeString::read(&mut r)?, + file_version: r.u32()?, + }) + } + /// Serializes. + pub fn to_bytes(&self) -> Vec { + let mut v = Vec::new(); + v.put_u32(self.version); + v.put_u8(u8::from(self.has_real_merged_data)); + self.writer.write(&mut v); + self.reader.write(&mut v); + v.put_u32(self.file_version); + v + } +} + +/// Typed view of a known resource. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum ResourceData<'a> { + /// 1005 + ResolutionInfo(ResolutionInfo), + /// 1024: index of the target layer. + LayerState(u16), + /// 1026: group id per layer. + LayerGroupInfo(Vec), + /// 1036 / 1033: raw thumbnail resource (28-byte header + JFIF/raw data). + Thumbnail(&'a [u8]), + /// 1037: global lighting angle in degrees. + GlobalAngle(i32), + /// 1039: ICC profile bytes. + IccProfile(&'a [u8]), + /// 1049: global altitude. + GlobalAltitude(i32), + /// 1057 + VersionInfo(VersionInfo), + /// 1058: EXIF bytes. + Exif(&'a [u8]), + /// 1060: XMP packet (UTF-8). + Xmp(&'a [u8]), +} + +impl ImageResource { + /// A new `8BIM` resource with an empty name. + pub fn new(id: u16, data: Vec) -> Self { + ImageResource { signature: *b"8BIM", id, name: Vec::new(), data } + } + + /// Typed parse of well-known resources. Returns `None` for unknown ids, + /// `Some(Err)` if the data is malformed. + pub fn parsed(&self) -> Option>> { + let d = &self.data[..]; + let be_i32 = |d: &[u8]| -> Result { Reader::new(d).i32() }; + Some(match self.id { + ids::RESOLUTION_INFO => ResolutionInfo::from_bytes(d).map(ResourceData::ResolutionInfo), + ids::LAYER_STATE => Reader::new(d).u16().map(ResourceData::LayerState), + ids::LAYER_GROUP_INFO => { + if !d.len().is_multiple_of(2) { + Err(PsdError::invalid("layer group info has odd length")) + } else { + Ok(ResourceData::LayerGroupInfo( + d.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect(), + )) + } + } + ids::THUMBNAIL | ids::THUMBNAIL_PS4 => Ok(ResourceData::Thumbnail(d)), + ids::GLOBAL_ANGLE => be_i32(d).map(ResourceData::GlobalAngle), + ids::ICC_PROFILE => Ok(ResourceData::IccProfile(d)), + ids::GLOBAL_ALTITUDE => be_i32(d).map(ResourceData::GlobalAltitude), + ids::VERSION_INFO => VersionInfo::from_bytes(d).map(ResourceData::VersionInfo), + ids::EXIF => Ok(ResourceData::Exif(d)), + ids::XMP => Ok(ResourceData::Xmp(d)), + _ => return None, + }) + } + + /// XMP packet as a string (resource 1060). + pub fn xmp_str(&self) -> Option<&str> { + (self.id == ids::XMP).then(|| std::str::from_utf8(&self.data).ok()).flatten() + } + + pub(crate) fn read(r: &mut Reader<'_>) -> Result { + let signature = r.array::<4>()?; + if !matches!(&signature, b"8BIM" | b"MeSa" | b"AgHg" | b"PHUT" | b"DCSR") { + return Err(PsdError::InvalidSignature { expected: "8BIM resource", found: signature }); + } + let id = r.u16()?; + let name = read_pascal(r, 2)?; + let len = r.u32()?; + let data = r.bytes_u64(u64::from(len))?.to_vec(); + if len % 2 == 1 { + r.skip(1)?; + } + Ok(ImageResource { signature, id, name, data }) + } + + pub(crate) fn write(&self, out: &mut Vec) -> Result<()> { + out.put(&self.signature); + out.put_u16(self.id); + write_pascal(out, &self.name, 2); + out.put_len(self.data.len() as u64, false)?; + out.put(&self.data); + if self.data.len() % 2 == 1 { + out.put_u8(0); + } + Ok(()) + } +} + +pub(crate) fn read_section(r: &mut Reader<'_>) -> Result> { + let len = r.u32()?; + let mut s = r.sub(u64::from(len))?; + let mut v = Vec::new(); + while !s.is_empty() { + v.push(ImageResource::read(&mut s)?); + } + Ok(v) +} + +pub(crate) fn write_section(out: &mut Vec, res: &[ImageResource]) -> Result<()> { + let at = out.begin_len(false); + for r in res { + r.write(out)?; + } + out.end_len(at, false) +} + +/// Builds the data for a 1057 version info resource. +pub fn version_info_resource(has_real_merged_data: bool) -> ImageResource { + ImageResource::new( + ids::VERSION_INFO, + VersionInfo { + version: 1, + has_real_merged_data, + writer: UnicodeString::new_nul("Adobe Photoshop"), + reader: UnicodeString::new_nul("Adobe Photoshop CS6"), + file_version: 1, + } + .to_bytes(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn roundtrip(res: &[ImageResource]) -> Vec { + let mut out = Vec::new(); + write_section(&mut out, res).unwrap(); + let back = read_section(&mut Reader::new(&out)).unwrap(); + assert_eq!(back, res); + out + } + + #[test] + fn empty_section() { + assert_eq!(roundtrip(&[]), vec![0, 0, 0, 0]); + } + + #[test] + fn odd_data_is_padded() { + let out = roundtrip(&[ImageResource::new(1000, vec![1, 2, 3])]); + // len(4) + sig(4) + id(2) + name(2) + size(4) + data(3) + pad(1) + assert_eq!(out.len(), 4 + 4 + 2 + 2 + 4 + 4); + assert_eq!(*out.last().unwrap(), 0); + } + + #[test] + fn named_resources() { + let mut r = ImageResource::new(2000, vec![9; 4]); + r.name = b"path".to_vec(); + let mut r2 = ImageResource::new(2001, vec![]); + r2.name = b"abc".to_vec(); + r2.signature = *b"MeSa"; + roundtrip(&[r, r2]); + } + + #[test] + fn bad_signature() { + let mut out = Vec::new(); + write_section(&mut out, &[ImageResource::new(1, vec![])]).unwrap(); + out[4] = b'X'; + assert!(read_section(&mut Reader::new(&out)).is_err()); + } + + #[test] + fn truncation_errors() { + let mut out = Vec::new(); + write_section(&mut out, &[ImageResource::new(1005, ResolutionInfo::from_dpi(72.0).to_bytes())]).unwrap(); + for cut in 0..out.len() { + assert!(read_section(&mut Reader::new(&out[..cut])).is_err()); + } + } + + #[test] + fn typed_resolution() { + let ri = ResolutionInfo::from_dpi(300.0); + let r = ImageResource::new(ids::RESOLUTION_INFO, ri.to_bytes()); + match r.parsed() { + Some(Ok(ResourceData::ResolutionInfo(p))) => { + assert_eq!(p, ri); + assert!((p.h_res() - 300.0).abs() < 1e-9); + assert!((p.v_res() - 300.0).abs() < 1e-9); + } + other => panic!("{other:?}"), + } + } + + #[test] + fn typed_misc() { + let r = ImageResource::new(ids::GLOBAL_ANGLE, 120i32.to_be_bytes().to_vec()); + assert_eq!(r.parsed(), Some(Ok(ResourceData::GlobalAngle(120)))); + let r = ImageResource::new(ids::GLOBAL_ALTITUDE, 30i32.to_be_bytes().to_vec()); + assert_eq!(r.parsed(), Some(Ok(ResourceData::GlobalAltitude(30)))); + let r = ImageResource::new(ids::LAYER_STATE, vec![0, 2]); + assert_eq!(r.parsed(), Some(Ok(ResourceData::LayerState(2)))); + let r = ImageResource::new(ids::LAYER_GROUP_INFO, vec![0, 1, 0, 2]); + assert_eq!(r.parsed(), Some(Ok(ResourceData::LayerGroupInfo(vec![1, 2])))); + let r = ImageResource::new(ids::LAYER_GROUP_INFO, vec![0]); + assert!(matches!(r.parsed(), Some(Err(_)))); + let r = ImageResource::new(ids::ICC_PROFILE, vec![1]); + assert_eq!(r.parsed(), Some(Ok(ResourceData::IccProfile(&[1])))); + let r = ImageResource::new(ids::EXIF, vec![2]); + assert_eq!(r.parsed(), Some(Ok(ResourceData::Exif(&[2])))); + let r = ImageResource::new(ids::THUMBNAIL, vec![3]); + assert_eq!(r.parsed(), Some(Ok(ResourceData::Thumbnail(&[3])))); + let r = ImageResource::new(ids::XMP, b"".to_vec()); + assert_eq!(r.xmp_str(), Some("")); + assert_eq!(ImageResource::new(4242, vec![]).parsed(), None); + } + + #[test] + fn version_info_roundtrip() { + let r = version_info_resource(false); + match r.parsed() { + Some(Ok(ResourceData::VersionInfo(v))) => { + assert!(!v.has_real_merged_data); + assert_eq!(v.to_bytes(), r.data); + } + other => panic!("{other:?}"), + } + } +} diff --git a/crates/psd/src/tagged.rs b/crates/psd/src/tagged.rs new file mode 100644 index 00000000..ce67c45d --- /dev/null +++ b/crates/psd/src/tagged.rs @@ -0,0 +1,455 @@ +//! Additional layer information ("tagged blocks"). +//! +//! Every block is stored raw (signature, key, data and the exact padding that +//! followed it in the file) so unknown and partially-understood blocks +//! round-trip byte for byte. [`TaggedBlock::parsed`] offers typed views of +//! simple, well-known keys. + +use crate::blend::BlendMode; +use crate::descriptor::UnicodeString; +use crate::error::{PsdError, Result}; +use crate::header::Version; +use crate::io::{Reader, WriteExt}; + +/// Keys whose length field is 8 bytes in PSB files. +pub const PSB_LONG_KEYS: [&[u8; 4]; 13] = [ + b"LMsk", b"Lr16", b"Lr32", b"Layr", b"Mt16", b"Mt32", b"Mtrn", b"Alph", b"FMsk", b"lnk2", b"FEid", b"FXid", b"PxSD", +]; + +/// Returns `true` if `key` uses an 8-byte length in `version`. +pub fn uses_long_length(version: Version, key: &[u8; 4]) -> bool { + version.is_psb() && PSB_LONG_KEYS.contains(&key) +} + +/// A tagged block, stored raw. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct TaggedBlock { + /// `8BIM` or `8B64`. + pub signature: [u8; 4], + /// Four-character key. + pub key: [u8; 4], + /// Block data exactly as covered by the length field. + pub data: Vec, + /// Bytes that followed the data before the next block. `None` means + /// "default": zero-pad to an even length. Parsing yields `None` whenever + /// the stored padding equals the default, so the model stays canonical. + pub padding: Option>, +} + +/// Layer section divider type (`lsct`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum SectionType { + /// 0: any other type of layer. + Other, + /// 1: open folder. + OpenFolder, + /// 2: closed folder. + ClosedFolder, + /// 3: bounding section divider (hidden in the UI; marks the group end + /// in bottom-to-top order). + BoundingDivider, + /// Unknown value. + Unknown(u32), +} + +impl SectionType { + /// From stored value. + pub fn from_u32(v: u32) -> Self { + match v { + 0 => SectionType::Other, + 1 => SectionType::OpenFolder, + 2 => SectionType::ClosedFolder, + 3 => SectionType::BoundingDivider, + v => SectionType::Unknown(v), + } + } + /// Stored value. + pub fn as_u32(self) -> u32 { + match self { + SectionType::Other => 0, + SectionType::OpenFolder => 1, + SectionType::ClosedFolder => 2, + SectionType::BoundingDivider => 3, + SectionType::Unknown(v) => v, + } + } + /// `true` for open or closed folders. + pub fn is_folder(self) -> bool { + matches!(self, SectionType::OpenFolder | SectionType::ClosedFolder) + } +} + +/// Section divider setting (`lsct` / `lsdk`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct SectionDivider { + /// Divider type. + pub kind: SectionType, + /// Group blend mode (present if length >= 12). + pub blend_mode: Option, + /// Sub type: 0 normal, 1 scene group (present if length >= 16). + pub sub_type: Option, +} + +/// Typed view of a known block. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum BlockData<'a> { + /// `luni` + UnicodeName(String), + /// `lsct` / `lsdk` + SectionDivider(SectionDivider), + /// `lyid` + LayerId(u32), + /// `lnsr` + NameSource([u8; 4]), + /// `clbl` + BlendClippedAsGroup(bool), + /// `infx` + BlendInteriorElements(bool), + /// `knko` + Knockout(u8), + /// `lspf`: bit 0 transparency, bit 1 composite, bit 2 position locked. + Protection(u32), + /// `lclr`: sheet color index (0 none, 1 red, 2 orange, 3 yellow, 4 green, + /// 5 blue, 6 violet, 7 gray). + SheetColor(u16), + /// `iOpa` + FillOpacity(u8), + /// `shmd`: metadata setting, raw. + MetadataSetting(&'a [u8]), +} + +impl TaggedBlock { + /// A new `8BIM` block with default padding. + pub fn new(key: [u8; 4], data: Vec) -> Self { + TaggedBlock { signature: *b"8BIM", key, data, padding: None } + } + + /// Key as a string (lossy). + pub fn key_str(&self) -> String { + String::from_utf8_lossy(&self.key).into_owned() + } + + fn default_padding(len: usize) -> usize { + len % 2 + } + + /// Typed view for known keys. `None` for keys without a typed parser, + /// `Some(Err)` if a known key's data is malformed. + pub fn parsed(&self) -> Option>> { + let d = &self.data[..]; + let mut r = Reader::new(d); + Some(match &self.key { + b"luni" => UnicodeString::read(&mut r).map(|s| BlockData::UnicodeName(s.to_string_lossy())), + b"lsct" | b"lsdk" => parse_section(d).map(BlockData::SectionDivider), + b"lyid" => r.u32().map(BlockData::LayerId), + b"lnsr" => r.array().map(BlockData::NameSource), + b"clbl" => r.u8().map(|v| BlockData::BlendClippedAsGroup(v != 0)), + b"infx" => r.u8().map(|v| BlockData::BlendInteriorElements(v != 0)), + b"knko" => r.u8().map(BlockData::Knockout), + b"lspf" => r.u32().map(BlockData::Protection), + b"lclr" => r.u16().map(BlockData::SheetColor), + b"iOpa" => r.u8().map(BlockData::FillOpacity), + b"shmd" => Ok(BlockData::MetadataSetting(d)), + _ => return None, + }) + } + + /// `luni` block. Data is padded to a multiple of 4, as Photoshop does. + pub fn unicode_name(name: &str) -> Self { + let mut d = Vec::new(); + UnicodeString::new(name).write(&mut d); + while d.len() % 4 != 0 { + d.push(0); + } + Self::new(*b"luni", d) + } + + /// `lsct` block with all fields. + pub fn section_divider(kind: SectionType, blend_mode: Option, sub_type: Option) -> Self { + let mut d = Vec::new(); + d.put_u32(kind.as_u32()); + if let Some(b) = blend_mode { + d.put(b"8BIM"); + d.put(&b.key()); + if let Some(s) = sub_type { + d.put_u32(s); + } + } + Self::new(*b"lsct", d) + } + + /// `lyid` block. + pub fn layer_id(id: u32) -> Self { + Self::new(*b"lyid", id.to_be_bytes().to_vec()) + } + /// `lnsr` block. + pub fn name_source(src: [u8; 4]) -> Self { + Self::new(*b"lnsr", src.to_vec()) + } + fn flag(key: [u8; 4], v: u8) -> Self { + Self::new(key, vec![v, 0, 0, 0]) + } + /// `clbl` block. + pub fn blend_clipped_as_group(v: bool) -> Self { + Self::flag(*b"clbl", u8::from(v)) + } + /// `infx` block. + pub fn blend_interior_elements(v: bool) -> Self { + Self::flag(*b"infx", u8::from(v)) + } + /// `knko` block. + pub fn knockout(v: u8) -> Self { + Self::flag(*b"knko", v) + } + /// `lspf` block. + pub fn protection(flags: u32) -> Self { + Self::new(*b"lspf", flags.to_be_bytes().to_vec()) + } + /// `lclr` block. + pub fn sheet_color(color: u16) -> Self { + let mut d = color.to_be_bytes().to_vec(); + d.extend_from_slice(&[0; 6]); + Self::new(*b"lclr", d) + } + /// `iOpa` block. + pub fn fill_opacity(v: u8) -> Self { + Self::flag(*b"iOpa", v) + } + + pub(crate) fn write(&self, out: &mut Vec, version: Version) -> Result<()> { + out.put(&self.signature); + out.put(&self.key); + out.put_len(self.data.len() as u64, uses_long_length(version, &self.key))?; + out.put(&self.data); + match &self.padding { + Some(p) => out.put(p), + None => out.extend(std::iter::repeat_n(0u8, Self::default_padding(self.data.len()))), + } + Ok(()) + } + + /// Serialized size in bytes. + pub fn encoded_len(&self, version: Version) -> usize { + let l = if uses_long_length(version, &self.key) { 8 } else { 4 }; + let p = self.padding.as_ref().map_or(Self::default_padding(self.data.len()), Vec::len); + 8 + l + self.data.len() + p + } +} + +fn parse_section(d: &[u8]) -> Result { + let mut r = Reader::new(d); + let kind = SectionType::from_u32(r.u32()?); + let mut blend_mode = None; + let mut sub_type = None; + if r.remaining() >= 8 { + let sig = r.array::<4>()?; + if &sig != b"8BIM" { + return Err(PsdError::InvalidSignature { expected: "8BIM", found: sig }); + } + blend_mode = Some(BlendMode::from_key(r.array()?)); + if r.remaining() >= 4 { + sub_type = Some(r.u32()?); + } + } + Ok(SectionDivider { kind, blend_mode, sub_type }) +} + +fn is_sig(b: &[u8]) -> bool { + b.starts_with(b"8BIM") || b.starts_with(b"8B64") +} + +/// Reads tagged blocks until the reader is exhausted. Bytes that do not form +/// a block (fewer than 12 bytes or no valid signature) are returned as +/// trailing data. +pub(crate) fn read_blocks(r: &mut Reader<'_>, version: Version) -> Result<(Vec, Vec)> { + let mut blocks = Vec::new(); + loop { + let rest = r.peek_rest(); + if rest.len() < 12 || !is_sig(rest) { + let trailing = rest.to_vec(); + r.skip(rest.len())?; + return Ok((blocks, trailing)); + } + let signature = r.array::<4>()?; + let key = r.array::<4>()?; + let len = r.len_field(uses_long_length(version, &key))?; + let data = r.bytes_u64(len)?.to_vec(); + // Detect padding: the smallest k in 0..=3 such that the next block + // signature (or the end of the region) follows k bytes later. + let rest = r.peek_rest(); + let mut pad = None; + for k in 0..=3usize { + if k == rest.len() || (k < rest.len() && is_sig(&rest[k..])) { + pad = Some(k); + break; + } + } + let pad = pad.unwrap_or(0); + let pad_bytes = r.bytes(pad)?.to_vec(); + let padding = if pad_bytes.len() == TaggedBlock::default_padding(data.len()) && pad_bytes.iter().all(|&b| b == 0) + { + None + } else { + Some(pad_bytes) + }; + blocks.push(TaggedBlock { signature, key, data, padding }); + } +} + +pub(crate) fn write_blocks(out: &mut Vec, blocks: &[TaggedBlock], version: Version) -> Result<()> { + for b in blocks { + b.write(out, version)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rt(blocks: &[TaggedBlock], version: Version) -> Vec { + let mut out = Vec::new(); + write_blocks(&mut out, blocks, version).unwrap(); + let (back, trailing) = read_blocks(&mut Reader::new(&out), version).unwrap(); + assert_eq!(back, blocks); + assert!(trailing.is_empty()); + out + } + + #[test] + fn unicode_name_typed() { + let b = TaggedBlock::unicode_name("Ebene \u{e4}\u{1F600}"); + assert_eq!(b.data.len() % 4, 0); + assert_eq!(b.parsed(), Some(Ok(BlockData::UnicodeName("Ebene \u{e4}\u{1F600}".into())))); + rt(&[b], Version::Psd); + } + + #[test] + fn section_divider_variants() { + for (kind, bm, st, len) in [ + (SectionType::OpenFolder, None, None, 4), + (SectionType::ClosedFolder, Some(BlendMode::PassThrough), None, 12), + (SectionType::BoundingDivider, Some(BlendMode::Multiply), Some(1), 16), + ] { + let b = TaggedBlock::section_divider(kind, bm, st); + assert_eq!(b.data.len(), len); + match b.parsed() { + Some(Ok(BlockData::SectionDivider(s))) => { + assert_eq!(s.kind, kind); + assert_eq!(s.blend_mode, bm); + assert_eq!(s.sub_type, st); + } + other => panic!("{other:?}"), + } + } + let mut b = TaggedBlock::section_divider(SectionType::OpenFolder, Some(BlendMode::Normal), None); + b.data[4] = b'X'; + assert!(matches!(b.parsed(), Some(Err(_)))); + let mut lsdk = TaggedBlock::section_divider(SectionType::OpenFolder, None, None); + lsdk.key = *b"lsdk"; + assert!(matches!(lsdk.parsed(), Some(Ok(BlockData::SectionDivider(_))))); + } + + #[test] + fn section_type_values() { + for v in 0..6 { + assert_eq!(SectionType::from_u32(v).as_u32(), v); + } + assert!(SectionType::OpenFolder.is_folder()); + assert!(!SectionType::BoundingDivider.is_folder()); + } + + #[test] + fn simple_typed_blocks() { + assert_eq!(TaggedBlock::layer_id(7).parsed(), Some(Ok(BlockData::LayerId(7)))); + assert_eq!(TaggedBlock::name_source(*b"cont").parsed(), Some(Ok(BlockData::NameSource(*b"cont")))); + assert_eq!(TaggedBlock::blend_clipped_as_group(true).parsed(), Some(Ok(BlockData::BlendClippedAsGroup(true)))); + assert_eq!( + TaggedBlock::blend_interior_elements(false).parsed(), + Some(Ok(BlockData::BlendInteriorElements(false))) + ); + assert_eq!(TaggedBlock::knockout(2).parsed(), Some(Ok(BlockData::Knockout(2)))); + assert_eq!(TaggedBlock::protection(0x8000_0000).parsed(), Some(Ok(BlockData::Protection(0x8000_0000)))); + assert_eq!(TaggedBlock::sheet_color(3).parsed(), Some(Ok(BlockData::SheetColor(3)))); + assert_eq!(TaggedBlock::fill_opacity(128).parsed(), Some(Ok(BlockData::FillOpacity(128)))); + let shmd = TaggedBlock::new(*b"shmd", vec![0, 0, 0, 0]); + assert_eq!(shmd.parsed(), Some(Ok(BlockData::MetadataSetting(&[0, 0, 0, 0])))); + assert_eq!(TaggedBlock::new(*b"lfx2", vec![]).parsed(), None); + assert!(matches!(TaggedBlock::new(*b"lyid", vec![1]).parsed(), Some(Err(_)))); + } + + #[test] + fn default_padding_odd_data() { + let b = TaggedBlock::new(*b"abcd", vec![1, 2, 3]); + let out = rt(&[b.clone(), b], Version::Psd); + assert_eq!(out.len(), 2 * (12 + 4)); + } + + #[test] + fn explicit_padding_preserved() { + let mut a = TaggedBlock::new(*b"abcd", vec![1, 2, 3]); + a.padding = Some(vec![0; 3]); // pad-to-4 in a file that aligned to 4 + let mut b = TaggedBlock::new(*b"efgh", vec![1, 2]); + b.padding = Some(vec![0, 0]); + let mut c = TaggedBlock::new(*b"ijkl", vec![1]); + c.padding = Some(vec![]); // no padding at all + rt(&[a, b, c], Version::Psd); + } + + #[test] + fn signature_8b64_preserved() { + let mut b = TaggedBlock::new(*b"Lr16", vec![0; 6]); + b.signature = *b"8B64"; + rt(&[b], Version::Psb); + } + + #[test] + fn psb_long_keys() { + for key in PSB_LONG_KEYS { + assert!(uses_long_length(Version::Psb, key)); + assert!(!uses_long_length(Version::Psd, key)); + let b = TaggedBlock::new(*key, vec![7; 4]); + let out = rt(std::slice::from_ref(&b), Version::Psb); + assert_eq!(out.len(), 8 + 8 + 4); + assert_eq!(b.encoded_len(Version::Psb), out.len()); + let out = rt(std::slice::from_ref(&b), Version::Psd); + assert_eq!(out.len(), 8 + 4 + 4); + } + assert!(!uses_long_length(Version::Psb, b"luni")); + } + + #[test] + fn trailing_bytes_kept() { + let mut out = Vec::new(); + write_blocks(&mut out, &[TaggedBlock::layer_id(1)], Version::Psd).unwrap(); + out.extend_from_slice(&[0; 8]); + let (blocks, trailing) = read_blocks(&mut Reader::new(&out), Version::Psd).unwrap(); + assert_eq!(blocks.len(), 1); + // The detector attributes up to 3 bytes of zeros to padding only when + // they reach the end; 8 zeros are not padding. + assert_eq!(trailing, vec![0; 8]); + } + + #[test] + fn short_trailing_pad_attributed_to_block() { + let mut out = Vec::new(); + write_blocks(&mut out, &[TaggedBlock::layer_id(1)], Version::Psd).unwrap(); + out.extend_from_slice(&[0; 2]); + let (blocks, trailing) = read_blocks(&mut Reader::new(&out), Version::Psd).unwrap(); + assert_eq!(blocks[0].padding, Some(vec![0, 0])); + assert!(trailing.is_empty()); + } + + #[test] + fn overlong_length_errors() { + let mut out = Vec::new(); + out.put(b"8BIMabcd"); + out.put_u32(100); + out.put(&[0; 4]); + assert!(read_blocks(&mut Reader::new(&out), Version::Psd).is_err()); + } +} diff --git a/crates/psd/src/testgen.rs b/crates/psd/src/testgen.rs new file mode 100644 index 00000000..f3ccf32f --- /dev/null +++ b/crates/psd/src/testgen.rs @@ -0,0 +1,429 @@ +//! Deterministic synthetic PSD/PSB generator for tests and fuzz seeds. +//! +//! Covers every depth (1, 8, 16, 32), color mode (Bitmap, Grayscale, +//! Indexed, RGB, CMYK, Lab, Multichannel, Duotone), every compression, +//! layers with masks (incl. real masks and parameters), nested groups, all +//! blend modes, Unicode names, empty (0×0) layers, large-ish bounds, +//! negative offsets, many tagged blocks, descriptors and image resources. + +use crate::blend::BlendMode; +use crate::compression::Compression; +use crate::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; +use crate::file::{GlobalLayerMask, LayerInfoPlacement, PsdFile}; +use crate::header::{ColorMode, Header, Version, row_bytes}; +use crate::image_data::ImageData; +use crate::layer::{ + BlendingRanges, ChannelData, LayerFlags, LayerInfo, LayerMask, LayerRecord, MaskData, MaskParameters, RealMask, Rect, +}; +use crate::resources::{ImageResource, ResolutionInfo, ids, version_info_resource}; +use crate::tagged::{SectionType, TaggedBlock}; + +/// A named generated file. +#[derive(Debug, Clone)] +pub struct Case { + /// Descriptive name. + pub name: String, + /// The model. + pub file: PsdFile, +} + +/// Deterministic pattern of planar samples for a `w × h` plane. +pub fn pattern_plane(w: usize, h: usize, depth: u16, seed: u32) -> Vec { + let mut out = Vec::with_capacity(row_bytes(w, depth) * h); + let s = seed as usize; + for y in 0..h { + match depth { + 1 => { + for bx in 0..row_bytes(w, 1) { + out.push(((bx * 37 + y * 11 + s) as u8) ^ 0x5a); + } + } + 8 => { + for x in 0..w { + // Mix smooth gradients and runs so RLE sees both. + let v = if (x / 4 + y) % 3 == 0 { (s * 17) as u8 } else { (x * 3 + y * 7 + s) as u8 }; + out.push(v); + } + } + 16 => { + for x in 0..w { + let v = ((x * 997 + y * 131 + s * 7919) % 65536) as u16; + out.extend_from_slice(&v.to_be_bytes()); + } + } + _ => { + for x in 0..w { + let v = ((x + y + s) % 17) as f32 / 16.0; + out.extend_from_slice(&v.to_be_bytes()); + } + } + } + } + out +} + +/// Number of channels used for a color mode in generated files. +pub fn mode_channels(mode: ColorMode) -> u16 { + match mode { + ColorMode::Multichannel => 2, + m => m.color_channels().unwrap_or(1), + } +} + +/// Valid depths for a color mode. +pub fn mode_depths(mode: ColorMode) -> &'static [u16] { + match mode { + ColorMode::Bitmap => &[1], + ColorMode::Indexed | ColorMode::Duotone => &[8], + ColorMode::Multichannel | ColorMode::Cmyk | ColorMode::Lab => &[8, 16], + _ => &[8, 16, 32], + } +} + +/// All color modes covered. +pub const MODES: [ColorMode; 8] = [ + ColorMode::Bitmap, + ColorMode::Grayscale, + ColorMode::Indexed, + ColorMode::Rgb, + ColorMode::Cmyk, + ColorMode::Lab, + ColorMode::Multichannel, + ColorMode::Duotone, +]; + +fn color_mode_data(mode: ColorMode) -> Vec { + match mode { + ColorMode::Indexed => (0..768u32).map(|i| (i * 7 % 256) as u8).collect(), + // Duotone data is undocumented; any bytes are preserved. + ColorMode::Duotone => (0..24u8).collect(), + _ => Vec::new(), + } +} + +fn base_resources() -> Vec { + let mut named = ImageResource::new(2000, vec![1, 2, 3]); + named.name = b"Path 1".to_vec(); + vec![ + ImageResource::new(ids::RESOLUTION_INFO, ResolutionInfo::from_dpi(72.0).to_bytes()), + ImageResource::new(ids::GLOBAL_ANGLE, 30i32.to_be_bytes().to_vec()), + ImageResource::new(ids::GLOBAL_ALTITUDE, 30i32.to_be_bytes().to_vec()), + ImageResource::new(ids::ICC_PROFILE, vec![0x42; 13]), + ImageResource::new(ids::XMP, b"".to_vec()), + named, + version_info_resource(true), + ] +} + +/// A flattened file (no layer section) with a patterned merged image. +pub fn merged_only(version: Version, mode: ColorMode, depth: u16, compression: Compression, w: u32, h: u32) -> PsdFile { + let channels = mode_channels(mode); + let header = Header::new(version, w, h, channels, depth, mode); + let mut planes = Vec::new(); + for c in 0..channels { + planes.extend(pattern_plane(w as usize, h as usize, depth, u32::from(c))); + } + let image_data = ImageData::encode(compression, &planes, &header).unwrap_or(ImageData { compression, data: Vec::new() }); + PsdFile { + header, + color_mode_data: color_mode_data(mode), + resources: base_resources(), + layer_info: None, + layer_info_placement: LayerInfoPlacement::Section, + global_layer_mask: None, + global_blocks: Vec::new(), + layer_mask_trailing: Vec::new(), + image_data, + } +} + +/// Parameters for a generated raster layer. +struct LayerGen<'a> { + header: &'a Header, + compression: Compression, + rect: Rect, + name: &'a str, + blend: BlendMode, + mask: Option, + seed: u32, +} + +fn raster_layer(g: LayerGen<'_>) -> LayerRecord { + let (w, h) = g.rect.size().unwrap_or((0, 0)); + let depth = g.header.depth; + let v = g.header.version; + let cc = mode_channels(g.header.color_mode) as i16; + let mut channels = Vec::new(); + for id in -1..cc { + let plane = pattern_plane(w, h, depth, g.seed + (id + 1) as u32); + let comp = if id == -1 && g.compression == Compression::Rle { Compression::Zip } else { g.compression }; + channels.push(ChannelData::encode(id, comp, &plane, w, h, depth, v).expect("encode")); + } + let mut mask_data = MaskData::None; + if let Some(m) = g.mask { + let (mw, mh) = m.rect.size().unwrap_or((0, 0)); + channels.push( + ChannelData::encode(-2, g.compression, &pattern_plane(mw, mh, depth, g.seed + 99), mw, mh, depth, v) + .expect("encode"), + ); + if let Some(real) = m.real { + let (rw, rh) = real.rect.size().unwrap_or((0, 0)); + channels.push( + ChannelData::encode(-3, g.compression, &pattern_plane(rw, rh, depth, g.seed + 7), rw, rh, depth, v) + .expect("encode"), + ); + } + mask_data = MaskData::Mask(m); + } + let mut flags = LayerFlags(0); + flags.set_hidden(g.seed % 5 == 4); + LayerRecord { + rect: g.rect, + channels, + blend_mode: g.blend, + opacity: (255 - (g.seed * 13) % 128) as u8, + clipping: u8::from(g.seed % 7 == 3), + flags, + filler: 0, + mask: mask_data, + blending_ranges: BlendingRanges::full(cc as usize), + name: crate::io::encode_legacy_name(g.name), + blocks: vec![TaggedBlock::unicode_name(g.name), TaggedBlock::layer_id(g.seed + 1)], + extra_trailing: Vec::new(), + } +} + +fn group_record(header: &Header, name: &str, kind: SectionType, blend: BlendMode, id: u32) -> LayerRecord { + let cc = mode_channels(header.color_mode) as i16; + let channels = (-1..cc) + .map(|c| ChannelData { id: c, compression: Some(Compression::Raw), data: Vec::new() }) + .collect(); + let lsct = if kind == SectionType::BoundingDivider { + TaggedBlock::section_divider(kind, None, None) + } else { + TaggedBlock::section_divider(kind, Some(blend), Some(0)) + }; + LayerRecord { + channels, + blend_mode: blend, + name: crate::io::encode_legacy_name(name), + blending_ranges: BlendingRanges::full(cc as usize), + blocks: vec![TaggedBlock::unicode_name(name), TaggedBlock::layer_id(id), lsct], + ..Default::default() + } +} + +/// A sample versioned descriptor as used by `lfx2`/`SoCo`-style blocks. +pub fn sample_descriptor() -> VersionedDescriptor { + VersionedDescriptor::new( + Descriptor::new("null") + .with("Scl ", Value::UnitFloat { unit: *b"#Prc", value: 100.0 }) + .with("masterFXSwitch", Value::Boolean(true)) + .with( + "DrSh", + Value::Descriptor( + Descriptor::new("DrSh") + .with("enab", Value::Boolean(true)) + .with("Md ", Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new("Mltp") }) + .with("Clr ", Value::Descriptor(Descriptor::new("RGBC").with("Rd ", Value::Double(0.0)))) + .with("lagl", Value::UnitFloat { unit: *b"#Ang", value: 120.0 }) + .with("Nm ", Value::Text(UnicodeString::new_nul("Shadow"))), + ), + ) + .with("list", Value::List(vec![Value::Integer(1), Value::LargeInteger(-2)])), + ) +} + +fn extra_blocks(seed: u32) -> Vec { + let mut lfx2 = vec![0, 0, 0, 0]; + lfx2.extend(sample_descriptor().to_bytes()); + let mut odd = TaggedBlock::new(*b"zOdd", vec![1, 2, 3]); + odd.padding = Some(vec![0, 0, 0]); + let mut unpadded = TaggedBlock::new(*b"zNop", vec![9]); + unpadded.padding = Some(vec![]); + vec![ + TaggedBlock::name_source(*b"cont"), + TaggedBlock::blend_clipped_as_group(true), + TaggedBlock::blend_interior_elements(false), + TaggedBlock::knockout(0), + TaggedBlock::protection(seed % 8), + TaggedBlock::sheet_color((seed % 8) as u16), + TaggedBlock::fill_opacity(200), + TaggedBlock::new(*b"shmd", vec![0, 0, 0, 0]), + TaggedBlock::new(*b"lfx2", lfx2), + TaggedBlock::new(*b"fxrp", vec![0; 16]), + odd, + unpadded, + ] +} + +/// A layered file exercising masks, nested groups, blend modes, names, +/// empty layers, negative offsets and many tagged blocks. +pub fn layered(version: Version, mode: ColorMode, depth: u16, compression: Compression) -> PsdFile { + let (w, h) = (24u32, 16u32); + let channels = mode_channels(mode) + 1; + let header = Header::new(version, w, h, channels, depth, mode); + let mut layers = Vec::new(); + let mut seed = 0u32; + let mut next = |layers: &mut Vec, rect: Rect, name: &str, blend: BlendMode, mask: Option| { + seed += 1; + let mut rec = raster_layer(LayerGen { header: &header, compression, rect, name, blend, mask, seed }); + if seed.is_multiple_of(3) { + rec.blocks.extend(extra_blocks(seed)); + } + layers.push(rec); + }; + // Background. + next(&mut layers, Rect::from_xywh(0, 0, w, h), "Background", BlendMode::Normal, None); + // Negative offsets + simple mask. + next( + &mut layers, + Rect::from_xywh(-5, -3, 10, 7), + "Neg offset \u{1F600}", + BlendMode::Multiply, + Some(LayerMask::new(Rect::from_xywh(-2, -2, 6, 5), 0, LayerMask::FLAG_RELATIVE)), + ); + // Empty layer. + next(&mut layers, Rect::default(), "Empty", BlendMode::Screen, None); + // Large-ish bounds beyond the canvas. + next(&mut layers, Rect::from_xywh(-100, 5, 400, 2), "Wide", BlendMode::Overlay, None); + // Nested groups: outer [ layer, inner [ masked layer (real mask + params) ] ]. + let id0 = 1000; + layers.push(group_record(&header, "", SectionType::BoundingDivider, BlendMode::Normal, id0)); + next(&mut layers, Rect::from_xywh(2, 2, 5, 5), "In outer", BlendMode::Darken, None); + layers.push(group_record(&header, "", SectionType::BoundingDivider, BlendMode::Normal, id0 + 1)); + let real_mask = LayerMask { + rect: Rect::from_xywh(1, 1, 4, 3), + default_color: 255, + flags: LayerMask::FLAG_PARAMETERS, + parameters: Some(MaskParameters { + flags: 0b0011, + user_density: Some(200), + user_feather: Some(1.5), + vector_density: None, + vector_feather: None, + }), + real: Some(RealMask { flags: 0, background: 255, rect: Rect::from_xywh(0, 0, 3, 3) }), + trailing: Vec::new(), + }; + next(&mut layers, Rect::from_xywh(3, 3, 6, 4), "Grüße", BlendMode::ColorDodge, Some(real_mask)); + layers.push(group_record(&header, "Inner", SectionType::ClosedFolder, BlendMode::Normal, id0 + 2)); + layers.push(group_record(&header, "Outer", SectionType::OpenFolder, BlendMode::PassThrough, id0 + 3)); + // One small layer per blend mode. + for (i, m) in BlendMode::ALL.iter().enumerate() { + let name = format!("blend {:?}", m); + next(&mut layers, Rect::from_xywh(i as i32 % 20, i as i32 % 12, 3, 2), &name, *m, None); + } + next(&mut layers, Rect::from_xywh(0, 0, 2, 2), "unknown blend", BlendMode::Unknown(*b"zzzz"), None); + + let mut planes = Vec::new(); + for c in 0..channels { + planes.extend(pattern_plane(w as usize, h as usize, depth, 50 + u32::from(c))); + } + let image_data = ImageData::encode(compression, &planes, &header).expect("encode merged"); + let layer_info = LayerInfo { merged_alpha: true, layers, padding: None }; + let (placement, blocks) = match depth { + 16 | 32 => ( + LayerInfoPlacement::GlobalBlock { + index: 1, + signature: *b"8BIM", + key: if depth == 16 { *b"Lr16" } else { *b"Lr32" }, + padding: None, + }, + vec![TaggedBlock::new(*b"Patt", vec![]), TaggedBlock::new(*b"Txt2", vec![0, 1, 2, 3])], + ), + _ => ( + LayerInfoPlacement::Section, + vec![TaggedBlock::new(*b"Patt", vec![]), TaggedBlock::new(*b"FMsk", vec![0; 10])], + ), + }; + PsdFile { + header, + color_mode_data: color_mode_data(mode), + resources: base_resources(), + layer_info: Some(layer_info), + layer_info_placement: placement, + global_layer_mask: Some(GlobalLayerMask { data: vec![0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 50, 128, 0] }), + global_blocks: blocks, + layer_mask_trailing: Vec::new(), + image_data, + } +} + +/// A tiny layered RGB file (useful for truncation sweeps). +pub fn small(version: Version, compression: Compression) -> PsdFile { + let header = Header::new(version, 4, 3, 4, 8, ColorMode::Rgb); + let mask = LayerMask::new(Rect::from_xywh(0, 0, 2, 2), 0, 0); + let layers = vec![ + raster_layer(LayerGen { + header: &header, + compression, + rect: Rect::from_xywh(0, 0, 4, 3), + name: "a", + blend: BlendMode::Normal, + mask: Some(mask), + seed: 1, + }), + group_record(&header, "", SectionType::BoundingDivider, BlendMode::Normal, 9), + raster_layer(LayerGen { + header: &header, + compression, + rect: Rect::from_xywh(-1, 1, 2, 2), + name: "b", + blend: BlendMode::Screen, + mask: None, + seed: 2, + }), + group_record(&header, "g", SectionType::OpenFolder, BlendMode::PassThrough, 10), + ]; + let mut planes = Vec::new(); + for c in 0..4 { + planes.extend(pattern_plane(4, 3, 8, c)); + } + PsdFile { + header: header.clone(), + color_mode_data: Vec::new(), + resources: vec![ImageResource::new(ids::RESOLUTION_INFO, ResolutionInfo::from_dpi(72.0).to_bytes())], + layer_info: Some(LayerInfo { merged_alpha: true, layers, padding: None }), + layer_info_placement: LayerInfoPlacement::Section, + global_layer_mask: Some(GlobalLayerMask::default()), + global_blocks: vec![TaggedBlock::new(*b"Patt", vec![1, 2])], + layer_mask_trailing: Vec::new(), + image_data: ImageData::encode(compression, &planes, &header).expect("encode"), + } +} + +/// Every generated case. +pub fn all_cases() -> Vec { + let mut v = Vec::new(); + for version in [Version::Psd, Version::Psb] { + for mode in MODES { + for &depth in mode_depths(mode) { + for c in Compression::ALL { + v.push(Case { + name: format!("merged {version:?} {mode:?} {depth}bit {c:?}"), + file: merged_only(version, mode, depth, c, 13, 7), + }); + } + } + } + for mode in [ColorMode::Grayscale, ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Lab] { + for &depth in mode_depths(mode).iter().filter(|&&d| d >= 8) { + for c in Compression::ALL { + v.push(Case { + name: format!("layered {version:?} {mode:?} {depth}bit {c:?}"), + file: layered(version, mode, depth, c), + }); + } + } + } + for c in Compression::ALL { + v.push(Case { name: format!("small {version:?} {c:?}"), file: small(version, c) }); + } + v.push(Case { + name: format!("empty canvas {version:?}"), + file: merged_only(version, ColorMode::Rgb, 8, Compression::Rle, 0, 0), + }); + } + v +} diff --git a/crates/psd/src/tree.rs b/crates/psd/src/tree.rs new file mode 100644 index 00000000..156699f8 --- /dev/null +++ b/crates/psd/src/tree.rs @@ -0,0 +1,89 @@ +//! Nested layer tree reconstruction from section dividers. +//! +//! In the file, layers are stored bottom-to-top. A group appears as a +//! *bounding section divider* (`lsct` type 3, usually named +//! ``) below its children, followed by the children, followed +//! by the group's own record (`lsct` type 1 = open or 2 = closed folder). + +use crate::file::PsdFile; +use crate::tagged::SectionType; + +/// A node of the layer tree. Children are in file order (bottom-to-top). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LayerNode { + /// A non-group layer. + Layer { + /// Index into [`PsdFile::layers`]. + index: usize, + }, + /// A group (folder). + Group { + /// Index of the group's own record (the folder, top of the group). + index: usize, + /// Index of the bounding section divider, if one was found. + divider: Option, + /// Children, bottom-to-top. + children: Vec, + }, +} + +impl LayerNode { + /// Record index of this node. + pub fn index(&self) -> usize { + match self { + LayerNode::Layer { index } | LayerNode::Group { index, .. } => *index, + } + } + /// Children (empty for layers). + pub fn children(&self) -> &[LayerNode] { + match self { + LayerNode::Layer { .. } => &[], + LayerNode::Group { children, .. } => children, + } + } + /// `true` for groups. + pub fn is_group(&self) -> bool { + matches!(self, LayerNode::Group { .. }) + } + /// Total number of nodes in this subtree (including self). + pub fn count(&self) -> usize { + 1 + self.children().iter().map(LayerNode::count).sum::() + } +} + +impl PsdFile { + /// Builds the nested layer tree. Root nodes and children are ordered + /// bottom-to-top (file order). + /// + /// Malformed nesting is tolerated: a folder without a matching divider + /// becomes an empty group; unclosed dividers at the end are dropped and + /// their children are hoisted to the enclosing level. + pub fn layer_tree(&self) -> Vec { + // Stack of (divider index, children). + let mut stack: Vec<(Option, Vec)> = vec![(None, Vec::new())]; + for (i, rec) in self.layers().iter().enumerate() { + match rec.section_type() { + SectionType::BoundingDivider => stack.push((Some(i), Vec::new())), + SectionType::OpenFolder | SectionType::ClosedFolder => { + let (divider, children) = + if stack.len() > 1 { stack.pop().unwrap_or_default() } else { (None, Vec::new()) }; + if let Some(top) = stack.last_mut() { + top.1.push(LayerNode::Group { index: i, divider, children }); + } + } + _ => { + if let Some(top) = stack.last_mut() { + top.1.push(LayerNode::Layer { index: i }); + } + } + } + } + while stack.len() > 1 { + let (_, children) = stack.pop().unwrap_or_default(); + if let Some(top) = stack.last_mut() { + top.1.extend(children); + } + } + stack.pop().map(|(_, c)| c).unwrap_or_default() + } +} diff --git a/crates/psd/tests/builder.rs b/crates/psd/tests/builder.rs new file mode 100644 index 00000000..c1f17992 --- /dev/null +++ b/crates/psd/tests/builder.rs @@ -0,0 +1,313 @@ +//! Builder and pixel extraction tests. + +use photocraft_psd::*; + +fn rgba_pattern(w: usize, h: usize, seed: u8) -> Vec { + let mut v = Vec::with_capacity(w * h * 4); + for y in 0..h { + for x in 0..w { + v.extend_from_slice(&[ + (x * 10) as u8 ^ seed, + (y * 20) as u8, + (x + y) as u8 ^ seed, + if (x + y) % 3 == 0 { 0 } else { 200 + (x % 50) as u8 }, + ]); + } + } + v +} + +fn built(b: &PsdBuilder) -> PsdFile { + let bytes = b.to_bytes().unwrap(); + let f = PsdFile::from_bytes(&bytes).unwrap(); + assert_eq!(f.to_bytes().unwrap(), bytes, "byte stable"); + assert_eq!(f, b.build().unwrap(), "model stable"); + f +} + +macro_rules! rgba8_case { + ($name:ident, $ver:expr, $comp:expr) => { + #[test] + fn $name() { + let px = rgba_pattern(7, 5, 3); + let mut b = PsdBuilder::new(10, 8).version($ver).compression($comp); + b.push_layer(LayerSpec::new("L", -2, 1, 7, 5, PixelData::Rgba8(px.clone()))); + let f = built(&b); + let img = f.layer(0).unwrap().rgba8().unwrap(); + assert_eq!((img.left, img.top, img.width, img.height), (-2, 1, 7, 5)); + assert_eq!(img.data, px); + } + }; +} + +rgba8_case!(rgba8_psd_raw, Version::Psd, Compression::Raw); +rgba8_case!(rgba8_psd_rle, Version::Psd, Compression::Rle); +rgba8_case!(rgba8_psd_zip, Version::Psd, Compression::Zip); +rgba8_case!(rgba8_psd_zipp, Version::Psd, Compression::ZipPrediction); +rgba8_case!(rgba8_psb_raw, Version::Psb, Compression::Raw); +rgba8_case!(rgba8_psb_rle, Version::Psb, Compression::Rle); +rgba8_case!(rgba8_psb_zip, Version::Psb, Compression::Zip); +rgba8_case!(rgba8_psb_zipp, Version::Psb, Compression::ZipPrediction); + +#[test] +fn rgba16_layers_scale_to_8bit() { + let n = 4 * 3; + let px: Vec = (0..n * 4).map(|i| (i as u16).wrapping_mul(5003)).collect(); + for comp in Compression::ALL { + let mut b = PsdBuilder::new(4, 3).depth(16).compression(comp); + b.push_layer(LayerSpec::new("x", 0, 0, 4, 3, PixelData::Rgba16(px.clone()))); + let f = built(&b); + assert!(matches!(f.layer_info_placement, LayerInfoPlacement::GlobalBlock { key: [b'L', b'r', b'1', b'6'], .. })); + let l = f.layer(0).unwrap(); + let img = l.rgba8().unwrap(); + let expect: Vec = px.iter().map(|&v| ((u32::from(v) * 255 + 32767) / 65535) as u8).collect(); + assert_eq!(img.data, expect); + // Raw 16-bit samples are exact. + let red = pixels::samples_u16(&l.channel_bytes(0).unwrap()); + assert_eq!(red, px.iter().step_by(4).copied().collect::>()); + } +} + +#[test] +fn grayscale_builder() { + let px: Vec = (0..6).flat_map(|i| [i * 40, 255 - i]).collect(); + let mut b = PsdBuilder::new(3, 2).color_mode(ColorMode::Grayscale); + b.push_layer(LayerSpec::new("g", 0, 0, 3, 2, PixelData::GrayA8(px.clone()))); + b.composite(PixelData::GrayA8(px.clone())); + let f = built(&b); + assert_eq!(f.header.color_mode, ColorMode::Grayscale); + let img = f.layer(0).unwrap().rgba8().unwrap(); + for i in 0..6 { + assert_eq!(&img.data[i * 4..i * 4 + 4], &[px[i * 2], px[i * 2], px[i * 2], px[i * 2 + 1]]); + } + let c = f.composite_rgba8().unwrap(); + assert_eq!(c.data, img.data); + assert_eq!(f.header.channels, 2); +} + +#[test] +fn grayscale16_builder() { + let px: Vec = vec![0, 65535, 32768, 65535]; + let mut b = PsdBuilder::new(2, 1).color_mode(ColorMode::Grayscale).depth(16); + b.push_layer(LayerSpec::new("g", 0, 0, 2, 1, PixelData::GrayA16(px))); + let f = built(&b); + assert_eq!(f.layer(0).unwrap().rgba8().unwrap().data, vec![0, 0, 0, 255, 128, 128, 128, 255]); +} + +#[test] +fn cmyk_builder_inverts_ink() { + // One pixel: C=0 M=0 Y=0 K=0 ink (white), one: K=255 (black). + let px = vec![0, 0, 0, 0, 255, 0, 0, 0, 255, 255]; + let mut b = PsdBuilder::new(2, 1).color_mode(ColorMode::Cmyk); + b.push_layer(LayerSpec::new("c", 0, 0, 2, 1, PixelData::Cmyka8(px.clone()))); + b.composite(PixelData::Cmyka8(px)); + let f = built(&b); + assert_eq!(f.header.channels, 4); + let l = f.layer(0).unwrap(); + assert_eq!(l.channel_bytes(0).unwrap(), vec![255, 255]); // stored inverted + assert_eq!(l.channel_bytes(3).unwrap(), vec![255, 0]); + assert_eq!(l.rgba8().unwrap().data, vec![255, 255, 255, 255, 0, 0, 0, 255]); + assert_eq!(f.composite_rgba8().unwrap().data, vec![255, 255, 255, 255, 0, 0, 0, 255]); +} + +#[test] +fn cmyk16_builder() { + let px = vec![0u16, 0, 0, 0, 65535]; + let mut b = PsdBuilder::new(1, 1).color_mode(ColorMode::Cmyk).depth(16); + b.push_layer(LayerSpec::new("c", 0, 0, 1, 1, PixelData::Cmyka16(px))); + let f = built(&b); + assert_eq!(f.layer(0).unwrap().rgba8().unwrap().data, vec![255, 255, 255, 255]); +} + +#[test] +fn layer_properties_roundtrip() { + let mut b = PsdBuilder::new(4, 4); + let mut spec = LayerSpec::new("Props \u{1F3A8}", 1, 1, 2, 2, PixelData::Rgba8(vec![9; 16])); + spec.blend_mode = BlendMode::SoftLight; + spec.opacity = 128; + spec.fill_opacity = Some(64); + spec.visible = false; + spec.clipping = true; + spec.extra_blocks.push(TaggedBlock::sheet_color(4)); + b.push_layer(spec); + let f = built(&b); + let r = &f.layers()[0]; + assert_eq!(r.name(), "Props \u{1F3A8}"); + assert_eq!(r.name, b"Props ?"); + assert_eq!(r.blend_mode, BlendMode::SoftLight); + assert_eq!(r.opacity, 128); + assert_eq!(r.fill_opacity(), 64); + assert!(!r.is_visible()); + assert_eq!(r.clipping, 1); + assert_eq!(r.layer_id(), Some(1)); + assert_eq!(r.block(b"lclr").unwrap().parsed(), Some(Ok(BlockData::SheetColor(4)))); +} + +#[test] +fn all_blend_modes_via_builder() { + let mut b = PsdBuilder::new(1, 1); + for m in BlendMode::ALL { + let mut s = LayerSpec::new(format!("{m:?}"), 0, 0, 1, 1, PixelData::Rgba8(vec![1, 2, 3, 4])); + s.blend_mode = m; + b.push_layer(s); + } + let f = built(&b); + let modes: Vec<_> = f.layers().iter().map(|l| l.blend_mode).collect(); + assert_eq!(modes, BlendMode::ALL.to_vec()); +} + +#[test] +fn masks_roundtrip() { + let mask_px: Vec = (0..12).map(|i| i * 20).collect(); + let mut s = LayerSpec::new("m", 0, 0, 3, 3, PixelData::Rgba8(vec![100; 36])); + s.mask = Some(MaskSpec { rect: Rect::from_xywh(-1, 0, 4, 3), data: mask_px.clone(), default_color: 255, disabled: true }); + for depth in [8u16, 16] { + let mut b = PsdBuilder::new(3, 3).depth(depth); + let mut s = s.clone(); + if depth == 16 { + s.pixels = PixelData::Rgba16(vec![25700; 36]); + } + b.push_layer(s); + let f = built(&b); + let l = f.layer(0).unwrap(); + let m = l.user_mask().unwrap().unwrap(); + assert_eq!((m.left, m.top, m.width, m.height), (-1, 0, 4, 3)); + assert_eq!(m.data, mask_px); + assert_eq!(m.default_value, 255); + assert!(l.record.layer_mask().unwrap().disabled()); + // Mask is not applied to rgba8. + assert!(l.rgba8().unwrap().data.chunks(4).all(|p| p == [100, 100, 100, 100])); + } +} + +#[test] +fn no_mask_returns_none() { + let mut b = PsdBuilder::new(1, 1); + b.push_layer(LayerSpec::new("x", 0, 0, 1, 1, PixelData::Rgba8(vec![0; 4]))); + let f = built(&b); + assert!(f.layer(0).unwrap().user_mask().is_none()); +} + +#[test] +fn empty_layer() { + let mut b = PsdBuilder::new(2, 2); + b.push_layer(LayerSpec::new("empty", 0, 0, 0, 0, PixelData::Rgba8(vec![]))); + let f = built(&b); + let img = f.layer(0).unwrap().rgba8().unwrap(); + assert_eq!((img.width, img.height), (0, 0)); + assert!(img.data.is_empty()); +} + +#[test] +fn composite_supplied_with_alpha() { + let px = rgba_pattern(3, 2, 0); + let mut b = PsdBuilder::new(3, 2); + b.push_layer(LayerSpec::new("x", 0, 0, 3, 2, PixelData::Rgba8(px.clone()))); + b.composite(PixelData::Rgba8(px.clone())); + let f = built(&b); + assert_eq!(f.header.channels, 4); + assert!(f.layer_info.as_ref().unwrap().merged_alpha); + assert!(f.merged_has_alpha()); + assert_eq!(f.has_real_merged_data(), Some(true)); + assert_eq!(f.composite_rgba8().unwrap().data, px); +} + +#[test] +fn composite_opaque_drops_alpha() { + let px: Vec = (0..6).flat_map(|i| [i, i, i, 255]).collect(); + let mut b = PsdBuilder::new(3, 2); + b.composite(PixelData::Rgba8(px.clone())); + let f = built(&b); + assert_eq!(f.header.channels, 3); + assert!(f.layer_info.is_none()); + assert_eq!(f.composite_rgba8().unwrap().data, px); +} + +#[test] +fn composite_16bit() { + let px: Vec = vec![65535, 0, 0, 65535, 0, 65535, 0, 32768]; + let mut b = PsdBuilder::new(2, 1).depth(16); + b.composite(PixelData::Rgba16(px)); + let f = built(&b); + assert_eq!(f.composite_rgba8().unwrap().data, vec![255, 0, 0, 255, 0, 255, 0, 128]); +} + +#[test] +fn placeholder_composite_is_flagged() { + let mut b = PsdBuilder::new(2, 2); + b.push_layer(LayerSpec::new("x", 0, 0, 1, 1, PixelData::Rgba8(vec![0; 4]))); + let f = built(&b); + assert_eq!(f.has_real_merged_data(), Some(false)); + assert!(f.composite_rgba8().unwrap().data.iter().all(|&v| v == 255)); +} + +#[test] +fn builder_resources() { + let b = PsdBuilder::new(1, 1).resolution(300.0).icc_profile(vec![1, 2, 3]); + let f = built(&b); + assert!((f.resolution().unwrap().v_res() - 300.0).abs() < 1e-9); + assert_eq!(f.icc_profile(), Some(&[1u8, 2, 3][..])); +} + +#[test] +fn builder_errors() { + let mut b = PsdBuilder::new(2, 2); + b.push_layer(LayerSpec::new("bad", 0, 0, 2, 2, PixelData::Rgba8(vec![0; 3]))); + assert!(b.build().is_err()); + let mut b = PsdBuilder::new(2, 2); + b.push_layer(LayerSpec::new("wrong fmt", 0, 0, 1, 1, PixelData::GrayA8(vec![0; 2]))); + assert!(b.build().is_err()); + let mut b = PsdBuilder::new(2, 2); + b.begin_group(GroupSpec::new("open")); + assert!(b.build().is_err()); + let mut b = PsdBuilder::new(2, 2); + assert!(b.end_group().is_err()); + assert!(PsdBuilder::new(1, 1).depth(32).build().is_err()); + assert!(PsdBuilder::new(1, 1).color_mode(ColorMode::Lab).build().is_err()); + let mut b = PsdBuilder::new(2, 2); + b.composite(PixelData::Rgba8(vec![0; 4])); + assert!(b.build().is_err()); + let mut b = PsdBuilder::new(2, 2); + let mut s = LayerSpec::new("m", 0, 0, 1, 1, PixelData::Rgba8(vec![0; 4])); + s.mask = Some(MaskSpec { rect: Rect::from_xywh(0, 0, 2, 2), data: vec![0; 3], default_color: 0, disabled: false }); + b.push_layer(s); + assert!(b.build().is_err()); +} + +#[test] +fn group_properties() { + let mut b = PsdBuilder::new(1, 1); + b.begin_group(GroupSpec { blend_mode: BlendMode::Multiply, opacity: 77, visible: false, ..GroupSpec::new("G") }); + b.push_layer(LayerSpec::new("x", 0, 0, 1, 1, PixelData::Rgba8(vec![0; 4]))); + b.end_group().unwrap(); + let f = built(&b); + let g = &f.layers()[2]; + assert_eq!(g.name(), "G"); + assert_eq!(g.opacity, 77); + assert!(!g.is_visible()); + let sd = g.section_divider().unwrap(); + assert_eq!(sd.kind, SectionType::OpenFolder); + assert_eq!(sd.blend_mode, Some(BlendMode::Multiply)); + assert_eq!(f.layers()[0].section_type(), SectionType::BoundingDivider); +} + +#[test] +fn psb_builder_large_canvas_header() { + let b = PsdBuilder::new(40_000, 1).version(Version::Psb).compression(Compression::Rle); + let f = built(&b); + assert_eq!(f.header.width, 40_000); + assert!(f.validate().is_ok()); +} + +#[test] +fn iter_layers_indices() { + let mut b = PsdBuilder::new(1, 1); + for i in 0..3 { + b.push_layer(LayerSpec::new(format!("l{i}"), 0, 0, 1, 1, PixelData::Rgba8(vec![0; 4]))); + } + let f = built(&b); + let v: Vec<_> = f.iter_layers().map(|l| (l.index, l.name())).collect(); + assert_eq!(v, vec![(0, "l0".into()), (1, "l1".into()), (2, "l2".into())]); + assert!(f.layer(3).is_none()); + assert_eq!(f.layer(0).unwrap().version(), Version::Psd); +} diff --git a/crates/psd/tests/corpus.rs b/crates/psd/tests/corpus.rs new file mode 100644 index 00000000..1bf2d60f --- /dev/null +++ b/crates/psd/tests/corpus.rs @@ -0,0 +1,71 @@ +//! Optional corpus test: iterates `corpus/psd/**/*.{psd,psb}` at the +//! workspace root when present (fetched by `xtask corpus`; never committed). +//! Skips silently when the directory does not exist. + +use std::path::{Path, PathBuf}; + +use photocraft_psd::PsdFile; + +fn collect(dir: &Path, out: &mut Vec) { + let Ok(rd) = std::fs::read_dir(dir) else { return }; + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + collect(&p, out); + } else if p + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("psd") || e.eq_ignore_ascii_case("psb")) + { + out.push(p); + } + } +} + +/// Files known to be invalid upstream, with the reason. They are reported but +/// do not fail the test. +const KNOWN_BAD: &[(&str, &str)] = + &[("group-divider-blend-mode.psd", "psd-tools fixture stripped to 1906 bytes: merged image data missing")]; + +#[test] +fn corpus_parse_and_byte_stable() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd"); + if !root.is_dir() { + return; + } + let mut files = Vec::new(); + collect(&root, &mut files); + files.sort(); + let mut failures = Vec::new(); + for p in &files { + let name = p.strip_prefix(&root).unwrap_or(p).display().to_string(); + let bytes = match std::fs::read(p) { + Ok(b) => b, + Err(e) => { + failures.push(format!("{name}: read error {e}")); + continue; + } + }; + match PsdFile::from_bytes(&bytes) { + Err(e) => { + if let Some((_, why)) = KNOWN_BAD.iter().find(|(f, _)| name.ends_with(f)) { + eprintln!("KNOWN {name}: parse error: {e} ({why})"); + } else { + eprintln!("FAIL {name}: parse error: {e}"); + failures.push(format!("{name}: parse: {e}")); + } + } + Ok(f) => match f.to_bytes() { + Ok(out) if out == bytes => eprintln!("ok {name} ({} layers)", f.layers().len()), + Ok(out) => { + let first = out.iter().zip(&bytes).position(|(a, b)| a != b).unwrap_or(out.len().min(bytes.len())); + eprintln!("DIFF {name}: first difference at {first} (len {} vs {})", out.len(), bytes.len()); + failures.push(format!("{name}: not byte stable (first diff at {first})")); + } + Err(e) => failures.push(format!("{name}: write: {e}")), + }, + } + } + eprintln!("corpus: {} files, {} failures", files.len(), failures.len()); + assert!(failures.is_empty(), "corpus failures:\n{}", failures.join("\n")); +} diff --git a/crates/psd/tests/malformed.rs b/crates/psd/tests/malformed.rs new file mode 100644 index 00000000..68ee8701 --- /dev/null +++ b/crates/psd/tests/malformed.rs @@ -0,0 +1,267 @@ +//! Malformed input handling: every error path must return `Err`, never panic. + +use photocraft_psd::testgen; +use photocraft_psd::*; +use proptest::prelude::*; + +fn truncation_sweep(bytes: &[u8], name: &str) { + for cut in 0..bytes.len() { + let r = PsdFile::from_bytes(&bytes[..cut]); + assert!(r.is_err(), "{name}: truncation at {cut}/{} parsed successfully", bytes.len()); + } + assert!(PsdFile::from_bytes(bytes).is_ok(), "{name}: full file must parse"); +} + +macro_rules! trunc { + ($name:ident, $ver:expr, $comp:expr) => { + #[test] + fn $name() { + let b = testgen::small($ver, $comp).to_bytes().unwrap(); + truncation_sweep(&b, stringify!($name)); + } + }; +} + +trunc!(truncate_small_psd_raw, Version::Psd, Compression::Raw); +trunc!(truncate_small_psd_rle, Version::Psd, Compression::Rle); +trunc!(truncate_small_psd_zip, Version::Psd, Compression::Zip); +trunc!(truncate_small_psd_zipp, Version::Psd, Compression::ZipPrediction); +trunc!(truncate_small_psb_raw, Version::Psb, Compression::Raw); +trunc!(truncate_small_psb_rle, Version::Psb, Compression::Rle); +trunc!(truncate_small_psb_zip, Version::Psb, Compression::Zip); +trunc!(truncate_small_psb_zipp, Version::Psb, Compression::ZipPrediction); + +#[test] +fn truncate_merged_only_all_modes() { + for mode in testgen::MODES { + for &depth in testgen::mode_depths(mode) { + let b = testgen::merged_only(Version::Psd, mode, depth, Compression::Rle, 5, 3).to_bytes().unwrap(); + truncation_sweep(&b, &format!("{mode:?} {depth}")); + } + } +} + +#[test] +fn truncate_builder_16bit_lr16() { + let mut b = PsdBuilder::new(3, 2).depth(16); + b.push_layer(LayerSpec::new("x", 0, 0, 3, 2, PixelData::Rgba16(vec![1000; 24]))); + let bytes = b.to_bytes().unwrap(); + truncation_sweep(&bytes, "builder 16"); +} + +#[test] +fn empty_input() { + assert!(matches!(PsdFile::from_bytes(&[]), Err(PsdError::UnexpectedEof { .. }))); +} + +#[test] +fn bad_file_signature() { + let mut b = testgen::small(Version::Psd, Compression::Raw).to_bytes().unwrap(); + b[..4].copy_from_slice(b"8BPX"); + assert!(matches!(PsdFile::from_bytes(&b), Err(PsdError::InvalidSignature { .. }))); +} + +#[test] +fn bad_version() { + let mut b = testgen::small(Version::Psd, Compression::Raw).to_bytes().unwrap(); + for v in [0u16, 3, 0xffff] { + b[4..6].copy_from_slice(&v.to_be_bytes()); + assert_eq!(PsdFile::from_bytes(&b), Err(PsdError::UnsupportedVersion(v))); + } +} + +#[test] +fn bad_depth_and_channels() { + let good = testgen::small(Version::Psd, Compression::Raw).to_bytes().unwrap(); + let mut b = good.clone(); + b[22..24].copy_from_slice(&7u16.to_be_bytes()); + assert!(PsdFile::from_bytes(&b).is_err()); + let mut b = good.clone(); + b[12..14].copy_from_slice(&0u16.to_be_bytes()); + assert!(PsdFile::from_bytes(&b).is_err()); + let mut b = good; + b[12..14].copy_from_slice(&57u16.to_be_bytes()); + assert!(PsdFile::from_bytes(&b).is_err()); +} + +#[test] +fn oversized_dimensions_rejected() { + let mut b = testgen::small(Version::Psb, Compression::Raw).to_bytes().unwrap(); + b[14..18].copy_from_slice(&300_001u32.to_be_bytes()); + assert!(matches!(PsdFile::from_bytes(&b), Err(PsdError::LimitExceeded(_)))); +} + +#[test] +fn huge_declared_dimensions_small_data() { + // 300000² RLE image with 16 bytes of data must fail fast, not allocate. + let mut f = testgen::merged_only(Version::Psb, ColorMode::Grayscale, 8, Compression::Rle, 1, 1); + f.header.width = 300_000; + f.header.height = 300_000; + let b = f.to_bytes().unwrap(); + assert!(PsdFile::from_bytes(&b).is_err()); + f.image_data.compression = Compression::Zip; + f.image_data.data = compression::zip_compress(&[0; 100]); + let b = f.to_bytes().unwrap(); + assert!(PsdFile::from_bytes(&b).is_err()); +} + +#[test] +fn bad_resource_signature() { + let f = testgen::small(Version::Psd, Compression::Raw); + let mut b = f.to_bytes().unwrap(); + // header 26 + color mode len 4 + resources len 4 → first resource sig. + b[34..38].copy_from_slice(b"XXXX"); + assert!(matches!(PsdFile::from_bytes(&b), Err(PsdError::InvalidSignature { .. }))); +} + +#[test] +fn bad_layer_blend_signature() { + let f = testgen::small(Version::Psd, Compression::Raw); + let b = f.to_bytes().unwrap(); + let pos = b.windows(8).position(|w| w == b"8BIMnorm").expect("blend sig"); + let mut b2 = b.clone(); + b2[pos..pos + 4].copy_from_slice(b"XBIM"); + assert!(PsdFile::from_bytes(&b2).is_err()); +} + +#[test] +fn oversized_section_lengths() { + let f = testgen::small(Version::Psd, Compression::Raw); + let b = f.to_bytes().unwrap(); + // Color mode data length. + let mut b2 = b.clone(); + b2[26..30].copy_from_slice(&u32::MAX.to_be_bytes()); + assert!(PsdFile::from_bytes(&b2).is_err()); + // Resource section length. + let mut b3 = b.clone(); + b3[30..34].copy_from_slice(&u32::MAX.to_be_bytes()); + assert!(PsdFile::from_bytes(&b3).is_err()); +} + +#[test] +fn corrupt_zip_merged_rejected() { + let mut f = testgen::small(Version::Psd, Compression::Zip); + let n = f.image_data.data.len(); + f.image_data.data[n / 2] ^= 0xff; + f.image_data.data[n / 2 + 1] ^= 0x55; + let b = f.to_bytes().unwrap(); + assert!(PsdFile::from_bytes(&b).is_err()); +} + +#[test] +fn corrupt_layer_channel_is_lazy_error() { + let mut f = testgen::small(Version::Psd, Compression::Rle); + let ch = &mut f.layers_mut()[0].channels[1]; + let id = ch.id; + for b in ch.data.iter_mut() { + *b = 0x7f; + } + let bytes = f.to_bytes().unwrap(); + let p = PsdFile::from_bytes(&bytes).expect("lazy: parse succeeds"); + assert!(p.layers()[0].decode_channel(id, 8, Version::Psd).is_err()); + assert!(p.layer(0).unwrap().rgba8().is_err()); +} + +#[test] +fn negative_layer_rect_decode_errors() { + let mut f = testgen::small(Version::Psd, Compression::Raw); + f.layers_mut()[0].rect = Rect { top: 10, left: 0, bottom: 0, right: 4 }; + let b = f.to_bytes().unwrap(); + let p = PsdFile::from_bytes(&b).unwrap(); + assert!(p.layers()[0].decode_channel(0, 8, Version::Psd).is_err()); + assert!(p.layer(0).unwrap().rgba8().is_err()); +} + +#[test] +fn too_many_layer_channels() { + let f = testgen::small(Version::Psd, Compression::Raw); + let b = f.to_bytes().unwrap(); + let li = b.windows(4).position(|w| w == [0, 0xff, 0xff, 0xff]).is_some(); + let _ = li; + // Construct directly: layer count 1, rect, channel count 0xffff. + let mut f2 = testgen::merged_only(Version::Psd, ColorMode::Rgb, 8, Compression::Raw, 1, 1); + f2.layer_info = Some(LayerInfo::default()); + let mut b2 = f2.to_bytes().unwrap(); + // Find layer info: after resources. Locate the 2-byte count (0) and + // replace with a malformed record. + let res_len = u32::from_be_bytes(b2[30..34].try_into().unwrap()) as usize; + let lm = 34 + res_len; + let mut rec = vec![0, 1]; + rec.extend_from_slice(&[0; 16]); + rec.extend_from_slice(&0xffffu16.to_be_bytes()); + let li_len = rec.len() as u32; + let mut section = li_len.to_be_bytes().to_vec(); + section.extend_from_slice(&rec); + let mut out = b2[..lm].to_vec(); + out.extend_from_slice(&(section.len() as u32).to_be_bytes()); + out.extend_from_slice(§ion); + let old_lm_len = u32::from_be_bytes(b2[lm..lm + 4].try_into().unwrap()) as usize; + out.extend_from_slice(&b2[lm + 4 + old_lm_len..]); + b2 = out; + assert!(matches!(PsdFile::from_bytes(&b2), Err(PsdError::LimitExceeded(_)) | Err(PsdError::UnexpectedEof { .. }))); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + #[test] + fn random_bytes_never_panic(data in proptest::collection::vec(any::(), 0..512)) { + let _ = PsdFile::from_bytes(&data); + } + + #[test] + fn random_bytes_with_valid_header_never_panic(tail in proptest::collection::vec(any::(), 0..512)) { + let mut data = testgen::small(Version::Psd, Compression::Raw).to_bytes().unwrap()[..26].to_vec(); + data.extend(tail); + let _ = PsdFile::from_bytes(&data); + } + + #[test] + fn mutations_never_panic( + idx in proptest::collection::vec(any::(), 1..8), + vals in proptest::collection::vec(any::(), 8), + psb in any::(), + comp in 0usize..4, + ) { + let v = if psb { Version::Psb } else { Version::Psd }; + let mut b = testgen::small(v, Compression::ALL[comp]).to_bytes().unwrap(); + for (i, ix) in idx.iter().enumerate() { + let at = ix.index(b.len()); + b[at] = vals[i]; + } + if let Ok(f) = PsdFile::from_bytes(&b) { + // Anything that parses must also be writable and decodable + // without panicking. + let _ = f.to_bytes(); + let _ = f.decode_merged(); + let _ = f.composite_rgba8(); + let _ = f.layer_tree(); + for l in f.iter_layers() { + let _ = l.rgba8(); + let _ = l.user_mask(); + let _ = l.name(); + for bl in &l.record.blocks { + let _ = bl.parsed(); + } + } + for r in &f.resources { + let _ = r.parsed(); + } + } + } + + #[test] + fn parsed_mutants_are_byte_stable( + idx in any::(), + val in any::(), + ) { + let mut b = testgen::small(Version::Psd, Compression::Rle).to_bytes().unwrap(); + let at = idx.index(b.len()); + b[at] = val; + if let Ok(f) = PsdFile::from_bytes(&b) { + let out = f.to_bytes().unwrap(); + let g = PsdFile::from_bytes(&out).unwrap(); + prop_assert_eq!(g, f); + } + } +} diff --git a/crates/psd/tests/proptests.rs b/crates/psd/tests/proptests.rs new file mode 100644 index 00000000..6ca4d6c8 --- /dev/null +++ b/crates/psd/tests/proptests.rs @@ -0,0 +1,155 @@ +//! Property-based round-trip tests. + +use photocraft_psd::compression::{PlaneLayout, decode_planes, encode_planes, packbits}; +use photocraft_psd::testgen; +use photocraft_psd::*; +use proptest::prelude::*; + +fn version() -> impl Strategy { + prop_oneof![Just(Version::Psd), Just(Version::Psb)] +} +fn compression() -> impl Strategy { + prop_oneof![ + Just(Compression::Raw), + Just(Compression::Rle), + Just(Compression::Zip), + Just(Compression::ZipPrediction) + ] +} +fn depth() -> impl Strategy { + prop_oneof![Just(1u16), Just(8), Just(16), Just(32)] +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + + #[test] + fn packbits_roundtrip(data in proptest::collection::vec(any::(), 0..600)) { + let enc = packbits::encode_vec(&data); + prop_assert_eq!(packbits::decode(&enc, data.len()).unwrap(), data.clone()); + // Worst case expansion is 1 header byte per 128 literal bytes. + prop_assert!(enc.len() <= data.len() + data.len().div_ceil(128)); + } + + #[test] + fn packbits_runs_roundtrip(runs in proptest::collection::vec((any::(), 1usize..300), 0..20)) { + let data: Vec = runs.iter().flat_map(|&(b, n)| std::iter::repeat_n(b, n)).collect(); + let enc = packbits::encode_vec(&data); + prop_assert_eq!(packbits::decode(&enc, data.len()).unwrap(), data); + } + + #[test] + fn packbits_decode_garbage_never_panics(data in proptest::collection::vec(any::(), 0..64), n in 0usize..512) { + let _ = packbits::decode(&data, n); + } + + #[test] + fn channel_codecs_roundtrip( + w in 0usize..40, h in 0usize..12, planes in 1usize..4, + d in depth(), v in version(), c in compression(), seed in any::(), + ) { + let l = PlaneLayout { planes, width: w, height: h, depth: d, version: v }; + let n = l.decoded_len().unwrap(); + let mut x = seed; + let data: Vec = (0..n).map(|_| { x = x.wrapping_mul(6364136223846793005).wrapping_add(1); if x >> 62 == 0 { 0 } else { (x >> 33) as u8 } }).collect(); + let enc = encode_planes(c, &data, &l).unwrap(); + prop_assert_eq!(decode_planes(c, &enc, &l).unwrap(), data); + } + + #[test] + fn channel_decode_garbage_never_panics( + data in proptest::collection::vec(any::(), 0..128), + w in 0usize..20, h in 0usize..8, d in depth(), v in version(), c in 0u16..5, + ) { + let l = PlaneLayout { planes: 1, width: w, height: h, depth: d, version: v }; + let _ = decode_planes(Compression::from_u16(c), &data, &l); + } + + #[test] + fn generated_small_files_roundtrip(v in version(), c in compression()) { + let f = testgen::small(v, c); + let b = f.to_bytes().unwrap(); + let p = PsdFile::from_bytes(&b).unwrap(); + prop_assert_eq!(&p, &f); + prop_assert_eq!(p.to_bytes().unwrap(), b); + } + + #[test] + fn merged_only_random_sizes_roundtrip( + w in 0u32..30, h in 0u32..10, v in version(), c in compression(), mode_ix in 0usize..8, depth_ix in 0usize..3, + ) { + let mode = testgen::MODES[mode_ix]; + let depths = testgen::mode_depths(mode); + let d = depths[depth_ix % depths.len()]; + let f = testgen::merged_only(v, mode, d, c, w, h); + let b = f.to_bytes().unwrap(); + let p = PsdFile::from_bytes(&b).unwrap(); + prop_assert_eq!(&p, &f); + prop_assert_eq!(p.to_bytes().unwrap(), b); + } + + #[test] + fn builder_random_layers_roundtrip( + layers in proptest::collection::vec( + (-20i32..20, -20i32..20, 0u32..9, 0u32..9, any::(), 0usize..28, any::(), "[a-zA-Z0-9 äöü😀]{0,12}"), + 0..6, + ), + v in version(), c in compression(), sixteen in any::(), + ) { + let depth = if sixteen { 16 } else { 8 }; + let mut b = PsdBuilder::new(16, 16).version(v).compression(c).depth(depth); + let mut expected = Vec::new(); + for (i, (x, y, w, h, op, bm, vis, name)) in layers.iter().enumerate() { + let n = (*w * *h) as usize; + let px8: Vec = (0..n * 4).map(|k| (k * 31 + i * 7) as u8).collect(); + let pixels = if sixteen { + PixelData::Rgba16(px8.iter().map(|&p| u16::from(p) * 257).collect()) + } else { + PixelData::Rgba8(px8.clone()) + }; + let mut s = LayerSpec::new(name.clone(), *x, *y, *w, *h, pixels); + s.opacity = *op; + s.blend_mode = BlendMode::ALL[*bm]; + s.visible = *vis; + b.push_layer(s); + expected.push((name.clone(), px8, *op, BlendMode::ALL[*bm], *vis)); + } + let bytes = b.to_bytes().unwrap(); + let f = PsdFile::from_bytes(&bytes).unwrap(); + prop_assert_eq!(f.to_bytes().unwrap(), bytes); + prop_assert_eq!(f.layers().len(), expected.len()); + for (l, (name, px, op, bm, vis)) in f.iter_layers().zip(expected) { + prop_assert_eq!(l.name(), name); + prop_assert_eq!(l.rgba8().unwrap().data, px); + prop_assert_eq!(l.record.opacity, op); + prop_assert_eq!(l.record.blend_mode, bm); + prop_assert_eq!(l.record.is_visible(), vis); + } + } + + #[test] + fn tagged_blocks_roundtrip( + blocks in proptest::collection::vec( + (any::<[u8; 4]>(), proptest::collection::vec(any::(), 0..40), any::(), 0usize..4), + 0..8, + ), + v in version(), + ) { + let mut f = testgen::small(v, Compression::Raw); + for (key, data, b64, pad) in blocks { + let mut tb = TaggedBlock::new(key, data); + if b64 { tb.signature = *b"8B64"; } + // Only padding patterns the detector can recover are modeled: + // zeros up to 3 bytes. + tb.padding = match pad { 0 => None, n => Some(vec![0; n - 1]) }; + if tb.padding.as_ref().is_some_and(|p| p.len() == tb.data.len() % 2) { + tb.padding = None; + } + f.global_blocks.push(tb); + } + let b = f.to_bytes().unwrap(); + let p = PsdFile::from_bytes(&b).unwrap(); + prop_assert_eq!(&p, &f); + prop_assert_eq!(p.to_bytes().unwrap(), b); + } +} diff --git a/crates/psd/tests/roundtrip.rs b/crates/psd/tests/roundtrip.rs new file mode 100644 index 00000000..a425bfbc --- /dev/null +++ b/crates/psd/tests/roundtrip.rs @@ -0,0 +1,289 @@ +//! Round-trip and byte-stability tests over generated files. + +use photocraft_psd::testgen::{self, MODES, mode_channels, mode_depths, pattern_plane}; +use photocraft_psd::*; + +fn assert_stable(file: &PsdFile, name: &str) { + let bytes = file.to_bytes().unwrap_or_else(|e| panic!("{name}: write failed: {e}")); + let parsed = PsdFile::from_bytes(&bytes).unwrap_or_else(|e| panic!("{name}: parse failed: {e}")); + assert_eq!(&parsed, file, "{name}: model round trip"); + let again = parsed.to_bytes().unwrap(); + assert_eq!(again, bytes, "{name}: byte stability"); +} + +#[test] +fn all_generated_cases_roundtrip() { + let cases = testgen::all_cases(); + assert!(cases.len() > 100, "only {} cases", cases.len()); + for c in &cases { + assert_stable(&c.file, &c.name); + } +} + +#[test] +fn all_generated_channels_decode() { + for c in testgen::all_cases() { + let f = &c.file; + let merged = f.decode_merged().unwrap_or_else(|e| panic!("{}: {e}", c.name)); + let (w, h) = (f.header.width as usize, f.header.height as usize); + assert_eq!(merged.len(), f.header.row_bytes() * h * usize::from(f.header.channels)); + for (i, layer) in f.layers().iter().enumerate() { + for ch in &layer.channels { + layer + .decode_channel(ch.id, f.header.depth, f.header.version) + .unwrap_or_else(|e| panic!("{} layer {i} ch {}: {e}", c.name, ch.id)); + } + } + let _ = (w, h); + } +} + +#[test] +fn merged_only_decodes_to_pattern() { + for mode in MODES { + for &depth in mode_depths(mode) { + for c in Compression::ALL { + let f = testgen::merged_only(Version::Psd, mode, depth, c, 9, 5); + let dec = f.decode_merged().unwrap(); + let mut expect = Vec::new(); + for ch in 0..mode_channels(mode) { + expect.extend(pattern_plane(9, 5, depth, u32::from(ch))); + } + assert_eq!(dec, expect, "{mode:?} {depth} {c:?}"); + } + } + } +} + +macro_rules! layered_case { + ($name:ident, $ver:expr, $mode:expr, $depth:expr, $comp:expr) => { + #[test] + fn $name() { + let f = testgen::layered($ver, $mode, $depth, $comp); + assert_stable(&f, stringify!($name)); + assert!(f.layers().len() > 30); + let expect_placement = $depth != 8; + assert_eq!(matches!(f.layer_info_placement, LayerInfoPlacement::GlobalBlock { .. }), expect_placement); + } + }; +} + +layered_case!(layered_psd_rgb8_raw, Version::Psd, ColorMode::Rgb, 8, Compression::Raw); +layered_case!(layered_psd_rgb8_rle, Version::Psd, ColorMode::Rgb, 8, Compression::Rle); +layered_case!(layered_psd_rgb8_zip, Version::Psd, ColorMode::Rgb, 8, Compression::Zip); +layered_case!(layered_psd_rgb8_zipp, Version::Psd, ColorMode::Rgb, 8, Compression::ZipPrediction); +layered_case!(layered_psd_rgb16_raw, Version::Psd, ColorMode::Rgb, 16, Compression::Raw); +layered_case!(layered_psd_rgb16_rle, Version::Psd, ColorMode::Rgb, 16, Compression::Rle); +layered_case!(layered_psd_rgb16_zip, Version::Psd, ColorMode::Rgb, 16, Compression::Zip); +layered_case!(layered_psd_rgb16_zipp, Version::Psd, ColorMode::Rgb, 16, Compression::ZipPrediction); +layered_case!(layered_psd_rgb32_raw, Version::Psd, ColorMode::Rgb, 32, Compression::Raw); +layered_case!(layered_psd_rgb32_rle, Version::Psd, ColorMode::Rgb, 32, Compression::Rle); +layered_case!(layered_psd_rgb32_zip, Version::Psd, ColorMode::Rgb, 32, Compression::Zip); +layered_case!(layered_psd_rgb32_zipp, Version::Psd, ColorMode::Rgb, 32, Compression::ZipPrediction); +layered_case!(layered_psb_rgb8_raw, Version::Psb, ColorMode::Rgb, 8, Compression::Raw); +layered_case!(layered_psb_rgb8_rle, Version::Psb, ColorMode::Rgb, 8, Compression::Rle); +layered_case!(layered_psb_rgb8_zip, Version::Psb, ColorMode::Rgb, 8, Compression::Zip); +layered_case!(layered_psb_rgb8_zipp, Version::Psb, ColorMode::Rgb, 8, Compression::ZipPrediction); +layered_case!(layered_psb_rgb16_rle, Version::Psb, ColorMode::Rgb, 16, Compression::Rle); +layered_case!(layered_psb_rgb32_zipp, Version::Psb, ColorMode::Rgb, 32, Compression::ZipPrediction); +layered_case!(layered_psd_gray8_rle, Version::Psd, ColorMode::Grayscale, 8, Compression::Rle); +layered_case!(layered_psd_gray16_zipp, Version::Psd, ColorMode::Grayscale, 16, Compression::ZipPrediction); +layered_case!(layered_psd_gray32_zip, Version::Psd, ColorMode::Grayscale, 32, Compression::Zip); +layered_case!(layered_psd_cmyk8_rle, Version::Psd, ColorMode::Cmyk, 8, Compression::Rle); +layered_case!(layered_psd_cmyk16_raw, Version::Psd, ColorMode::Cmyk, 16, Compression::Raw); +layered_case!(layered_psb_cmyk8_zip, Version::Psb, ColorMode::Cmyk, 8, Compression::Zip); +layered_case!(layered_psd_lab8_rle, Version::Psd, ColorMode::Lab, 8, Compression::Rle); +layered_case!(layered_psb_lab16_zipp, Version::Psb, ColorMode::Lab, 16, Compression::ZipPrediction); + +macro_rules! merged_case { + ($name:ident, $mode:expr, $depth:expr) => { + #[test] + fn $name() { + for v in [Version::Psd, Version::Psb] { + for c in Compression::ALL { + let f = testgen::merged_only(v, $mode, $depth, c, 31, 3); + assert_stable(&f, stringify!($name)); + assert!(f.layer_info.is_none()); + } + } + } + }; +} + +merged_case!(merged_bitmap_1, ColorMode::Bitmap, 1); +merged_case!(merged_gray_8, ColorMode::Grayscale, 8); +merged_case!(merged_gray_16, ColorMode::Grayscale, 16); +merged_case!(merged_gray_32, ColorMode::Grayscale, 32); +merged_case!(merged_indexed_8, ColorMode::Indexed, 8); +merged_case!(merged_rgb_8, ColorMode::Rgb, 8); +merged_case!(merged_rgb_16, ColorMode::Rgb, 16); +merged_case!(merged_rgb_32, ColorMode::Rgb, 32); +merged_case!(merged_cmyk_8, ColorMode::Cmyk, 8); +merged_case!(merged_cmyk_16, ColorMode::Cmyk, 16); +merged_case!(merged_lab_8, ColorMode::Lab, 8); +merged_case!(merged_lab_16, ColorMode::Lab, 16); +merged_case!(merged_multichannel_8, ColorMode::Multichannel, 8); +merged_case!(merged_multichannel_16, ColorMode::Multichannel, 16); +merged_case!(merged_duotone_8, ColorMode::Duotone, 8); + +#[test] +fn psb_uses_long_section_lengths() { + let psd = testgen::small(Version::Psd, Compression::Raw).to_bytes().unwrap(); + let psb = testgen::small(Version::Psb, Compression::Raw).to_bytes().unwrap(); + // Layer & mask length (+4), layer info length (+4), 4 layers × channels × 4. + let f = testgen::small(Version::Psd, Compression::Raw); + let nch: usize = f.layers().iter().map(|l| l.channels.len()).sum(); + assert_eq!(psb.len(), psd.len() + 8 + nch * 4); + assert_eq!(psb[5], 2); +} + +#[test] +fn psb_rle_counts_are_u32() { + let psd = testgen::merged_only(Version::Psd, ColorMode::Grayscale, 8, Compression::Rle, 5, 4); + let psb = testgen::merged_only(Version::Psb, ColorMode::Grayscale, 8, Compression::Rle, 5, 4); + assert_eq!(psb.image_data.data.len(), psd.image_data.data.len() + 4 * 2); +} + +#[test] +fn psb_long_key_global_block() { + let mut f = testgen::small(Version::Psb, Compression::Rle); + f.global_blocks.push(TaggedBlock::new(*b"FMsk", vec![1, 2, 3, 4])); + f.global_blocks.push(TaggedBlock::new(*b"Alph", vec![5, 6])); + let b = f.to_bytes().unwrap(); + let p = PsdFile::from_bytes(&b).unwrap(); + assert_eq!(p, f); + // Reading the PSB bytes while pretending PSD must not silently succeed + // with the same structure. + let mut as_psd = b.clone(); + as_psd[5] = 1; + if let Ok(q) = PsdFile::from_bytes(&as_psd) { + assert_ne!(q, f); + } +} + +#[test] +fn edits_are_reencoded() { + let mut f = testgen::small(Version::Psd, Compression::Rle); + let v = f.header.version; + let layer = &mut f.layers_mut()[0]; + let (w, h) = layer.rect.size().unwrap(); + let new = vec![7u8; w * h]; + let ch = layer.channels.iter_mut().find(|c| c.id == 0).unwrap(); + ch.set_decoded(Compression::ZipPrediction, &new, w, h, 8, v).unwrap(); + let b = f.to_bytes().unwrap(); + let p = PsdFile::from_bytes(&b).unwrap(); + assert_eq!(p.layers()[0].decode_channel(0, 8, v).unwrap(), new); + assert_eq!(p.layers()[0].channel(0).unwrap().compression, Some(Compression::ZipPrediction)); +} + +#[test] +fn renaming_changes_luni_and_roundtrips() { + let mut f = testgen::small(Version::Psd, Compression::Raw); + let l = &mut f.layers_mut()[0]; + *l.block_mut(b"luni").unwrap() = TaggedBlock::unicode_name("Renamed \u{263a}"); + let p = PsdFile::from_bytes(&f.to_bytes().unwrap()).unwrap(); + assert_eq!(p.layers()[0].name(), "Renamed \u{263a}"); +} + +#[test] +fn unknown_blocks_and_resources_pass_through() { + let mut f = testgen::small(Version::Psd, Compression::Rle); + f.resources.push(ImageResource::new(9999, vec![1, 2, 3, 4, 5])); + f.global_blocks.push(TaggedBlock::new(*b"Q3Dx", (0..=255).collect())); + f.layers_mut()[0].blocks.push(TaggedBlock::new(*b"Vid!", vec![0xab; 7])); + f.layers_mut()[0].blend_mode = BlendMode::Unknown(*b"new!"); + assert_stable(&f, "unknown passthrough"); + let p = PsdFile::from_bytes(&f.to_bytes().unwrap()).unwrap(); + assert_eq!(p.layers()[0].blend_mode, BlendMode::Unknown(*b"new!")); + assert_eq!(p.global_block(b"Q3Dx").unwrap().data.len(), 256); +} + +#[test] +fn unknown_color_mode_passthrough() { + let mut f = testgen::merged_only(Version::Psd, ColorMode::Grayscale, 8, Compression::Raw, 2, 2); + f.header.color_mode = ColorMode::Unknown(5); + assert_stable(&f, "unknown mode"); +} + +#[test] +fn unknown_compression_passthrough() { + let mut f = testgen::small(Version::Psd, Compression::Raw); + f.layers_mut()[0].channels[0].compression = Some(Compression::Unknown(9)); + assert_stable(&f, "unknown channel compression"); + assert!(f.layers()[0].decode_channel(f.layers()[0].channels[0].id, 8, Version::Psd).is_err()); +} + +#[test] +fn trailing_section_bytes_preserved() { + let mut f = testgen::small(Version::Psd, Compression::Raw); + f.layer_mask_trailing = vec![0; 6]; + assert_stable(&f, "layer mask trailing"); + f.layers_mut()[1].extra_trailing = vec![0; 5]; + assert_stable(&f, "record trailing"); + f.layer_info.as_mut().unwrap().padding = Some(vec![0, 0, 0, 0]); + f.layers_mut()[1].extra_trailing.clear(); + assert_stable(&f, "layer info padding"); +} + +#[test] +fn global_mask_accessors() { + let f = testgen::layered(Version::Psd, ColorMode::Rgb, 8, Compression::Raw); + let gm = f.global_layer_mask.as_ref().unwrap(); + assert_eq!(gm.overlay_color_space(), Some(0)); + assert_eq!(gm.color_components(), Some([0xffff, 0, 0, 0])); + assert_eq!(gm.opacity(), Some(50)); + assert_eq!(gm.kind(), Some(128)); + assert_eq!(GlobalLayerMask::default().kind(), None); +} + +#[test] +fn file_accessors() { + let f = testgen::layered(Version::Psd, ColorMode::Rgb, 8, Compression::Rle); + assert!((f.resolution().unwrap().h_res() - 72.0).abs() < 1e-9); + assert_eq!(f.icc_profile().unwrap().len(), 13); + assert_eq!(f.has_real_merged_data(), Some(true)); + assert!(f.merged_has_alpha()); + assert!(f.validate().is_ok()); + let lfx = f.layers().iter().find_map(|l| l.block(b"lfx2")).expect("lfx2"); + let d = descriptor::VersionedDescriptor::from_bytes(&lfx.data[4..]).unwrap(); + assert_eq!(d, testgen::sample_descriptor()); +} + +#[test] +fn validate_rejects_bad_models() { + let mut f = testgen::merged_only(Version::Psd, ColorMode::Indexed, 8, Compression::Raw, 2, 2); + assert!(f.validate().is_ok()); + f.color_mode_data.pop(); + assert!(f.validate().is_err()); + let mut g = testgen::merged_only(Version::Psd, ColorMode::Rgb, 8, Compression::Raw, 2, 2); + g.header.width = 40_000; + assert!(g.validate().is_err()); + g.header.version = Version::Psb; + assert!(g.validate().is_ok()); +} + +#[test] +fn psd_too_large_for_32bit_errors_on_write() { + let mut f = testgen::small(Version::Psd, Compression::Raw); + // A resource larger than u32 can't be constructed cheaply; instead check + // RLE rows > 65535 bytes are rejected for PSD but allowed for PSB. + let l = photocraft_psd::compression::PlaneLayout { planes: 1, width: 70_000, height: 1, depth: 8, version: Version::Psd }; + let row: Vec = (0..70_000u32).map(|i| (i % 251) as u8).collect(); + assert!(compression::encode_planes(Compression::Rle, &row, &l).is_err()); + let l = compression::PlaneLayout { version: Version::Psb, ..l }; + assert!(compression::encode_planes(Compression::Rle, &row, &l).is_ok()); + f.header.version = Version::Psd; +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn save_and_open() { + let f = testgen::small(Version::Psd, Compression::Rle); + let dir = std::env::temp_dir().join(format!("photocraft-psd-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let p = dir.join("x.psd"); + f.save(&p).unwrap(); + let g = PsdFile::open(&p).unwrap().unwrap(); + assert_eq!(g, f); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/psd/tests/tree.rs b/crates/psd/tests/tree.rs new file mode 100644 index 00000000..8d06f0f1 --- /dev/null +++ b/crates/psd/tests/tree.rs @@ -0,0 +1,188 @@ +//! Layer tree reconstruction. + +use photocraft_psd::testgen; +use photocraft_psd::*; + +fn rec(name: &str, kind: Option) -> LayerRecord { + let mut r = LayerRecord { name: name.as_bytes().to_vec(), ..Default::default() }; + if let Some(k) = kind { + r.blocks.push(TaggedBlock::section_divider(k, Some(BlendMode::PassThrough), None)); + } + r +} + +fn file_with(records: Vec) -> PsdFile { + let mut f = testgen::merged_only(Version::Psd, ColorMode::Rgb, 8, Compression::Raw, 1, 1); + f.layer_info = Some(LayerInfo { layers: records, ..Default::default() }); + f +} + +fn names(f: &PsdFile, nodes: &[LayerNode]) -> String { + nodes + .iter() + .map(|n| match n { + LayerNode::Layer { index } => f.layers()[*index].name(), + LayerNode::Group { index, children, .. } => { + format!("{}[{}]", f.layers()[*index].name(), names(f, children)) + } + }) + .collect::>() + .join(",") +} + +const END: Option = Some(SectionType::BoundingDivider); +const OPEN: Option = Some(SectionType::OpenFolder); +const CLOSED: Option = Some(SectionType::ClosedFolder); + +#[test] +fn flat_layers() { + let f = file_with(vec![rec("a", None), rec("b", None), rec("c", None)]); + let t = f.layer_tree(); + assert_eq!(names(&f, &t), "a,b,c"); + assert!(t.iter().all(|n| !n.is_group())); +} + +#[test] +fn empty_file_has_empty_tree() { + let f = testgen::merged_only(Version::Psd, ColorMode::Rgb, 8, Compression::Raw, 1, 1); + assert!(f.layer_tree().is_empty()); +} + +#[test] +fn single_group() { + let f = file_with(vec![rec("bg", None), rec("", END), rec("x", None), rec("y", None), rec("G", OPEN)]); + let t = f.layer_tree(); + assert_eq!(names(&f, &t), "bg,G[x,y]"); + match &t[1] { + LayerNode::Group { index, divider, children } => { + assert_eq!(*index, 4); + assert_eq!(*divider, Some(1)); + assert_eq!(children.len(), 2); + } + _ => panic!(), + } +} + +#[test] +fn closed_group() { + let f = file_with(vec![rec("", END), rec("x", None), rec("G", CLOSED)]); + assert_eq!(names(&f, &f.layer_tree()), "G[x]"); +} + +#[test] +fn nested_groups() { + let f = file_with(vec![ + rec("", END), + rec("a", None), + rec("", END), + rec("b", None), + rec("Inner", CLOSED), + rec("c", None), + rec("Outer", OPEN), + rec("top", None), + ]); + let t = f.layer_tree(); + assert_eq!(names(&f, &t), "Outer[a,Inner[b],c],top"); + assert_eq!(t[0].count(), 5); +} + +#[test] +fn empty_group() { + let f = file_with(vec![rec("", END), rec("G", OPEN)]); + let t = f.layer_tree(); + assert_eq!(names(&f, &t), "G[]"); + assert!(t[0].children().is_empty()); +} + +#[test] +fn sibling_groups() { + let f = file_with(vec![ + rec("", END), + rec("a", None), + rec("G1", OPEN), + rec("", END), + rec("b", None), + rec("G2", OPEN), + ]); + assert_eq!(names(&f, &f.layer_tree()), "G1[a],G2[b]"); +} + +#[test] +fn deeply_nested() { + let mut v = Vec::new(); + for _ in 0..50 { + v.push(rec("", END)); + } + v.push(rec("leaf", None)); + for i in 0..50 { + v.push(rec(&format!("g{i}"), OPEN)); + } + let f = file_with(v); + let t = f.layer_tree(); + assert_eq!(t.len(), 1); + assert_eq!(t[0].count(), 51); + let mut n = &t[0]; + let mut depth = 1; + while let Some(c) = n.children().first() { + n = c; + depth += 1; + } + assert_eq!(depth, 51); +} + +#[test] +fn folder_without_divider_is_empty_group() { + let f = file_with(vec![rec("a", None), rec("G", OPEN), rec("b", None)]); + assert_eq!(names(&f, &f.layer_tree()), "a,G[],b"); +} + +#[test] +fn unclosed_divider_hoists_children() { + let f = file_with(vec![rec("a", None), rec("", END), rec("b", None), rec("c", None)]); + assert_eq!(names(&f, &f.layer_tree()), "a,b,c"); +} + +#[test] +fn lsdk_is_honored() { + let mut end = rec("", None); + let mut b = TaggedBlock::section_divider(SectionType::BoundingDivider, None, None); + b.key = *b"lsdk"; + end.blocks.push(b); + let f = file_with(vec![end, rec("x", None), rec("G", OPEN)]); + assert_eq!(names(&f, &f.layer_tree()), "G[x]"); +} + +#[test] +fn other_section_type_is_layer() { + let f = file_with(vec![rec("a", Some(SectionType::Other)), rec("b", Some(SectionType::Unknown(9)))]); + assert_eq!(names(&f, &f.layer_tree()), "a,b"); +} + +#[test] +fn generated_layered_tree() { + let f = testgen::layered(Version::Psd, ColorMode::Rgb, 8, Compression::Rle); + let t = f.layer_tree(); + let outer = t.iter().find(|n| n.is_group()).expect("group"); + assert_eq!(f.layers()[outer.index()].name(), "Outer"); + assert_eq!(outer.children().len(), 2); + let inner = &outer.children()[1]; + assert!(inner.is_group()); + assert_eq!(f.layers()[inner.index()].name(), "Inner"); + assert_eq!(f.layers()[inner.index()].section_type(), SectionType::ClosedFolder); + assert_eq!(f.layers()[inner.children()[0].index()].name(), "Grüße"); +} + +#[test] +fn builder_groups_tree() { + let px = |n: usize| PixelData::Rgba8(vec![255; n * 4]); + let mut b = PsdBuilder::new(2, 2); + b.push_layer(LayerSpec::new("bg", 0, 0, 2, 2, px(4))); + b.begin_group(GroupSpec::new("A")); + b.push_layer(LayerSpec::new("a1", 0, 0, 1, 1, px(1))); + b.begin_group(GroupSpec { open: false, ..GroupSpec::new("B") }); + b.push_layer(LayerSpec::new("b1", 0, 0, 1, 1, px(1))); + b.end_group().unwrap(); + b.end_group().unwrap(); + let f = PsdFile::from_bytes(&b.to_bytes().unwrap()).unwrap(); + assert_eq!(names(&f, &f.layer_tree()), "bg,A[a1,B[b1]]"); +} diff --git a/crates/raster/Cargo.toml b/crates/raster/Cargo.toml new file mode 100644 index 00000000..956d1c95 --- /dev/null +++ b/crates/raster/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "photocraft-raster" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +photocraft-geom = { workspace = true } +photocraft-color = { workspace = true } +serde = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs new file mode 100644 index 00000000..731dc2db --- /dev/null +++ b/crates/raster/src/lib.rs @@ -0,0 +1,516 @@ +//! Sparse, copy-on-write tiled surfaces in any [`PixelFormat`]. +//! +//! A [`Surface`] is an infinite plane of 256×256 tiles. Missing tiles read as the surface's +//! default pixel (transparent for layers, white for "reveal all" masks). Tiles are `Arc`-shared, +//! so cloning a surface is O(tiles) and mutation copies only the touched tiles. That makes +//! undo snapshots, background jobs and autosave cheap. +#![forbid(unsafe_code)] + +use std::collections::BTreeMap; +use std::sync::Arc; + +use photocraft_color::{ColorMode, PixelFormat, SampleType, read_sample, write_sample}; +use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; + +/// Pixel storage for one tile: `TILE_SIZE² × bytes_per_pixel`, row-major, interleaved channels. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Tile { + data: Box<[u8]>, +} + +impl Tile { + fn filled(format: &PixelFormat, pixel: &[u8]) -> Self { + let n = (TILE_SIZE * TILE_SIZE) as usize; + let mut data = vec![0u8; n * format.bytes_per_pixel()]; + if pixel.iter().any(|&b| b != 0) { + for px in data.chunks_exact_mut(pixel.len()) { + px.copy_from_slice(pixel); + } + } + Tile { data: data.into_boxed_slice() } + } + pub fn bytes(&self) -> &[u8] { + &self.data + } + pub fn bytes_mut(&mut self) -> &mut [u8] { + &mut self.data + } +} + +#[derive(Clone, Debug)] +pub struct Surface { + format: PixelFormat, + default_pixel: Box<[u8]>, + tiles: BTreeMap>, +} + +impl PartialEq for Surface { + fn eq(&self, other: &Self) -> bool { + self.format == other.format && self.default_pixel == other.default_pixel && self.tiles == other.tiles + } +} + +impl Surface { + /// Transparent/zero surface. + pub fn new(format: PixelFormat) -> Self { + Self { format, default_pixel: vec![0u8; format.bytes_per_pixel()].into_boxed_slice(), tiles: BTreeMap::new() } + } + + /// Surface whose untouched pixels read as `pixel` (normalised channel values). + pub fn with_default(format: PixelFormat, pixel: &[f32]) -> Self { + let mut s = Self::new(format); + encode_pixel(&format, pixel, &mut s.default_pixel); + s + } + + pub fn format(&self) -> PixelFormat { + self.format + } + pub fn channels(&self) -> usize { + self.format.channels() + } + pub fn tile_count(&self) -> usize { + self.tiles.len() + } + pub fn tiles(&self) -> impl Iterator)> { + self.tiles.iter() + } + pub fn tile(&self, c: TileCoord) -> Option<&Arc> { + self.tiles.get(&c) + } + pub fn default_pixel(&self) -> Vec { + decode_pixel(&self.format, &self.default_pixel) + } + + /// Mutable access to a tile, allocating it (filled with the default pixel) or un-sharing it. + pub fn tile_mut(&mut self, c: TileCoord) -> &mut Tile { + let fmt = self.format; + let dp = self.default_pixel.clone(); + let arc = self.tiles.entry(c).or_insert_with(|| Arc::new(Tile::filled(&fmt, &dp))); + Arc::make_mut(arc) + } + + /// Coarse bounds: union of allocated tile rects. + pub fn tile_bounds(&self) -> Rect { + self.tiles.keys().fold(Rect::EMPTY, |r, c| r.union(&c.rect())) + } + + /// Exact bounds of pixels that differ from the default pixel. + pub fn content_bounds(&self) -> Rect { + let bpp = self.format.bytes_per_pixel(); + let mut out = Rect::EMPTY; + for (c, t) in &self.tiles { + let origin = c.rect(); + let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN); + for (i, px) in t.data.chunks_exact(bpp).enumerate() { + if px != &*self.default_pixel { + let x = (i as i32) % TILE_SIZE; + let y = (i as i32) / TILE_SIZE; + x0 = x0.min(x); + y0 = y0.min(y); + x1 = x1.max(x + 1); + y1 = y1.max(y + 1); + } + } + if x0 <= x1 && x0 != i32::MAX { + out = out.union(&Rect::new(x0, y0, x1, y1).translate(origin.x0, origin.y0)); + } + } + out + } + + /// Drop tiles that are entirely the default pixel. + pub fn prune(&mut self) { + let dp = self.default_pixel.clone(); + let bpp = dp.len(); + self.tiles.retain(|_, t| t.data.chunks_exact(bpp).any(|px| px != &*dp)); + } + + #[inline] + fn locate(&self, x: i32, y: i32) -> (TileCoord, usize) { + let c = TileCoord::containing(x, y); + let lx = x.rem_euclid(TILE_SIZE) as usize; + let ly = y.rem_euclid(TILE_SIZE) as usize; + (c, (ly * TILE_SIZE as usize + lx) * self.format.channels()) + } + + /// Read one pixel as normalised channel values (`channels()` floats). + pub fn pixel(&self, x: i32, y: i32) -> Vec { + let mut out = vec![0.0; self.channels()]; + self.read_pixel(x, y, &mut out); + out + } + + pub fn read_pixel(&self, x: i32, y: i32, out: &mut [f32]) { + let (c, base) = self.locate(x, y); + let n = self.channels(); + match self.tiles.get(&c) { + Some(t) => { + for (i, o) in out.iter_mut().enumerate().take(n) { + *o = read_sample(&t.data, self.format.sample, base + i); + } + } + None => { + for (i, o) in out.iter_mut().enumerate().take(n) { + *o = read_sample(&self.default_pixel, self.format.sample, i); + } + } + } + } + + pub fn write_pixel(&mut self, x: i32, y: i32, px: &[f32]) { + let (c, base) = self.locate(x, y); + let n = self.channels(); + let sample = self.format.sample; + let t = self.tile_mut(c); + for (i, v) in px.iter().enumerate().take(n) { + write_sample(&mut t.data, sample, base + i, *v); + } + } + + /// Read a rectangle as interleaved normalised floats (`w*h*channels`). + pub fn read_region(&self, r: Rect) -> Vec { + let n = self.channels(); + let w = r.width() as usize; + let mut out = vec![0.0f32; w * r.height() as usize * n]; + let dp = self.default_pixel(); + for tc in r.tiles() { + let tr = tc.rect().intersect(&r); + let tile = self.tiles.get(&tc); + for y in tr.y0..tr.y1 { + for x in tr.x0..tr.x1 { + let o = (((y - r.y0) as usize) * w + (x - r.x0) as usize) * n; + match tile { + Some(t) => { + let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (x - tc.tx * TILE_SIZE) as usize) * n; + for i in 0..n { + out[o + i] = read_sample(&t.data, self.format.sample, base + i); + } + } + None => out[o..o + n].copy_from_slice(&dp), + } + } + } + } + out + } + + /// Write interleaved normalised floats into a rectangle. + pub fn write_region(&mut self, r: Rect, data: &[f32]) { + let n = self.channels(); + let w = r.width() as usize; + assert_eq!(data.len(), w * r.height() as usize * n, "region data length mismatch"); + let sample = self.format.sample; + for tc in r.tiles() { + let tr = tc.rect().intersect(&r); + let t = self.tile_mut(tc); + for y in tr.y0..tr.y1 { + for x in tr.x0..tr.x1 { + let o = (((y - r.y0) as usize) * w + (x - r.x0) as usize) * n; + let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (x - tc.tx * TILE_SIZE) as usize) * n; + for i in 0..n { + write_sample(&mut t.data, sample, base + i, data[o + i]); + } + } + } + } + } + + /// Fill a rectangle with one pixel value. + pub fn fill_rect(&mut self, r: Rect, px: &[f32]) { + let n = self.channels(); + let mut enc = vec![0u8; self.format.bytes_per_pixel()]; + encode_pixel(&self.format, &px[..n], &mut enc); + let bpp = enc.len(); + for tc in r.tiles() { + let tr = tc.rect().intersect(&r); + let t = self.tile_mut(tc); + for y in tr.y0..tr.y1 { + let ly = (y - tc.ty * TILE_SIZE) as usize; + let lx0 = (tr.x0 - tc.tx * TILE_SIZE) as usize; + let lx1 = (tr.x1 - tc.tx * TILE_SIZE) as usize; + let row = &mut t.data[(ly * TILE_SIZE as usize + lx0) * bpp..(ly * TILE_SIZE as usize + lx1) * bpp]; + for p in row.chunks_exact_mut(bpp) { + p.copy_from_slice(&enc); + } + } + } + } + + /// Build from interleaved *encoded* bytes of `format` covering `r`. + pub fn from_interleaved(format: PixelFormat, r: Rect, bytes: &[u8]) -> Self { + let mut s = Surface::new(format); + s.write_interleaved(r, bytes); + s + } + + /// Write interleaved encoded bytes (same format as the surface) into `r`. + pub fn write_interleaved(&mut self, r: Rect, bytes: &[u8]) { + let bpp = self.format.bytes_per_pixel(); + let w = r.width() as usize; + assert_eq!(bytes.len(), w * r.height() as usize * bpp, "interleaved length mismatch"); + for tc in r.tiles() { + let tr = tc.rect().intersect(&r); + let t = self.tile_mut(tc); + let span = tr.width() as usize * bpp; + for y in tr.y0..tr.y1 { + let src = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * bpp; + let dst = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * bpp; + t.data[dst..dst + span].copy_from_slice(&bytes[src..src + span]); + } + } + } + + /// Read `r` as interleaved encoded bytes in the surface's format. + pub fn to_interleaved(&self, r: Rect) -> Vec { + let bpp = self.format.bytes_per_pixel(); + let w = r.width() as usize; + let mut out = vec![0u8; w * r.height() as usize * bpp]; + for tc in r.tiles() { + let tr = tc.rect().intersect(&r); + let span = tr.width() as usize * bpp; + for y in tr.y0..tr.y1 { + let dst = (((y - r.y0) as usize) * w + (tr.x0 - r.x0) as usize) * bpp; + match self.tiles.get(&tc) { + Some(t) => { + let src = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * bpp; + out[dst..dst + span].copy_from_slice(&t.data[src..src + span]); + } + None => { + for p in out[dst..dst + span].chunks_exact_mut(bpp) { + p.copy_from_slice(&self.default_pixel); + } + } + } + } + } + out + } + + /// Convert to another pixel format (depth and/or model, naive colour conversion). + pub fn convert(&self, to: PixelFormat) -> Surface { + let mut out = Surface::with_default(to, &convert_pixel(&self.format, &to, &self.default_pixel())); + let from_n = self.channels(); + let mut src = vec![0.0f32; from_n]; + for (c, t) in &self.tiles { + let dst = out.tile_mut(*c); + let px_count = (TILE_SIZE * TILE_SIZE) as usize; + for i in 0..px_count { + for (k, v) in src.iter_mut().enumerate() { + *v = read_sample(&t.data, self.format.sample, i * from_n + k); + } + let px = convert_pixel(&self.format, &to, &src); + for (k, v) in px.iter().enumerate() { + write_sample(&mut dst.data, to.sample, i * to.channels() + k, *v); + } + } + } + out + } + + /// Composite-friendly RGBA read (converts from the surface's model). + pub fn rgba(&self, x: i32, y: i32) -> [f32; 4] { + let p = self.pixel(x, y); + to_rgba(&self.format, &p) + } +} + +/// Encode normalised floats into one pixel's bytes. +pub fn encode_pixel(format: &PixelFormat, px: &[f32], out: &mut [u8]) { + for (i, v) in px.iter().enumerate().take(format.channels()) { + write_sample(out, format.sample, i, *v); + } +} + +pub fn decode_pixel(format: &PixelFormat, bytes: &[u8]) -> Vec { + (0..format.channels()).map(|i| read_sample(bytes, format.sample, i)).collect() +} + +/// Convert one pixel between formats via RGBA (naive colour conversion, no ICC). +pub fn convert_pixel(from: &PixelFormat, to: &PixelFormat, px: &[f32]) -> Vec { + if from.mode == to.mode { + let n = from.mode.color_channels(); + let mut out: Vec = px[..n].to_vec(); + if to.alpha { + out.push(if from.alpha { px[n] } else { 1.0 }); + } + return out; + } + let rgba = to_rgba(from, px); + from_rgba(to, rgba) +} + +pub fn to_rgba(format: &PixelFormat, px: &[f32]) -> [f32; 4] { + let n = format.mode.color_channels(); + let a = if format.alpha { px[n] } else { 1.0 }; + let rgb = match format.mode { + ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone => [px[0]; 3], + ColorMode::Cmyk => photocraft_color::convert::cmyk_to_rgb_naive([px[0], px[1], px[2], px[3]]), + ColorMode::Lab => photocraft_color::convert::lab_to_srgb([px[0] * 100.0, px[1] * 255.0 - 128.0, px[2] * 255.0 - 128.0]), + _ => [px[0], px[1], px[2]], + }; + [rgb[0], rgb[1], rgb[2], a] +} + +pub fn from_rgba(format: &PixelFormat, rgba: [f32; 4]) -> Vec { + let rgb = [rgba[0], rgba[1], rgba[2]]; + let mut out: Vec = match format.mode { + ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone => vec![photocraft_color::convert::rgb_to_gray(rgb)], + ColorMode::Cmyk => photocraft_color::convert::rgb_to_cmyk_naive(rgb).to_vec(), + ColorMode::Lab => { + let l = photocraft_color::convert::srgb_to_lab(rgb); + vec![l[0] / 100.0, (l[1] + 128.0) / 255.0, (l[2] + 128.0) / 255.0] + } + _ => rgb.to_vec(), + }; + if format.alpha { + out.push(rgba[3]); + } + out +} + +/// A simple owned RGBA8 image, used for display and thumbnails. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Rgba8Image { + pub width: u32, + pub height: u32, + pub pixels: Vec, +} + +impl Rgba8Image { + pub fn new(width: u32, height: u32) -> Self { + Self { width, height, pixels: vec![0; width as usize * height as usize * 4] } + } +} + +/// Is this sample type able to hold values above 1.0 (HDR)? +pub fn is_hdr(sample: SampleType) -> bool { + matches!(sample, SampleType::F32) +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_color::PixelFormat; + use proptest::prelude::*; + + #[test] + fn empty_surface_reads_default() { + let s = Surface::new(PixelFormat::RGBA8); + assert_eq!(s.pixel(10, -500), vec![0.0; 4]); + let m = Surface::with_default(PixelFormat::GRAY8, &[1.0]); + assert_eq!(m.pixel(123_456, -9), vec![1.0]); + assert_eq!(m.tile_count(), 0); + } + + #[test] + fn write_read_pixel_every_depth() { + for sample in SampleType::ALL { + let f = PixelFormat::RGBA8.with_sample(sample); + let mut s = Surface::new(f); + s.write_pixel(-3, 700, &[0.2, 0.4, 0.6, 1.0]); + let p = s.pixel(-3, 700); + for (a, b) in p.iter().zip([0.2, 0.4, 0.6, 1.0]) { + assert!((a - b).abs() <= 1.0 / 255.0, "{sample:?}"); + } + assert_eq!(s.tile_count(), 1); + assert_eq!(s.tile_bounds(), TileCoord::containing(-3, 700).rect()); + } + } + + #[test] + fn copy_on_write_shares_untouched_tiles() { + let mut a = Surface::new(PixelFormat::RGBA8); + a.fill_rect(Rect::new(0, 0, 512, 512), &[1.0, 0.0, 0.0, 1.0]); + let mut b = a.clone(); + b.write_pixel(10, 10, &[0.0, 1.0, 0.0, 1.0]); + // a unchanged + assert_eq!(a.pixel(10, 10), vec![1.0, 0.0, 0.0, 1.0]); + assert_eq!(b.pixel(10, 10), vec![0.0, 1.0, 0.0, 1.0]); + // only tile (0,0) was copied + let shared = a.tiles().filter(|(c, t)| Arc::ptr_eq(t, b.tile(**c).unwrap())).count(); + assert_eq!(shared, 3); + } + + #[test] + fn content_bounds_and_prune() { + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(300, 10, 310, 20), &[1.0, 1.0, 1.0, 1.0]); + s.write_pixel(-5, -5, &[0.0, 0.0, 0.0, 1.0]); + assert_eq!(s.content_bounds(), Rect::new(-5, -5, 310, 20)); + // erase the single pixel -> its tile becomes default and is pruned + s.write_pixel(-5, -5, &[0.0, 0.0, 0.0, 0.0]); + s.prune(); + assert_eq!(s.tile_count(), 1); + assert_eq!(s.content_bounds(), Rect::new(300, 10, 310, 20)); + } + + #[test] + fn interleaved_roundtrip_crossing_tiles() { + let r = Rect::new(-20, 250, 280, 262); + let bpp = PixelFormat::RGBA16.bytes_per_pixel(); + let bytes: Vec = (0..r.width() as usize * r.height() as usize * bpp).map(|i| (i * 31 % 251) as u8).collect(); + let s = Surface::from_interleaved(PixelFormat::RGBA16, r, &bytes); + assert_eq!(s.to_interleaved(r), bytes); + assert_eq!(s.tile_count(), 6); // x tiles -1..=1, y tiles 0..=1 + } + + #[test] + fn region_outside_reads_default() { + let s = Surface::with_default(PixelFormat::GRAY8, &[1.0]); + let v = s.read_region(Rect::new(0, 0, 3, 3)); + assert_eq!(v, vec![1.0; 9]); + } + + #[test] + fn convert_depths_and_models() { + let mut s = Surface::new(PixelFormat::RGBA8); + s.write_pixel(1, 1, &[1.0, 1.0, 1.0, 1.0]); + let g = s.convert(PixelFormat::GRAYA8); + assert!((g.pixel(1, 1)[0] - 1.0).abs() < 1e-2); + let f = s.convert(PixelFormat::RGBA32F); + assert_eq!(f.pixel(1, 1), vec![1.0, 1.0, 1.0, 1.0]); + let c = s.convert(PixelFormat::CMYKA8); + let p = c.pixel(1, 1); + assert!(p[3] < 0.01 && (p[4] - 1.0).abs() < 1e-6); + let back = c.convert(PixelFormat::RGBA8); + assert_eq!(back.pixel(1, 1), vec![1.0, 1.0, 1.0, 1.0]); + } + + #[test] + fn rgba_from_gray_and_cmyk() { + let mut g = Surface::new(PixelFormat::GRAYA8); + g.write_pixel(0, 0, &[0.5, 1.0]); + let v = g.rgba(0, 0); + assert!((v[0] - v[2]).abs() < 1e-6 && (v[3] - 1.0).abs() < 1e-6); + } + + proptest! { + #[test] + fn region_write_read_roundtrip(x in -600i32..600, y in -600i32..600, w in 1u32..90, h in 1u32..90, seed in any::()) { + let r = Rect::from_xywh(x, y, w, h); + let n = 4usize; + let data: Vec = (0..(w * h) as usize * n).map(|i| ((i as u32).wrapping_mul(2654435761).wrapping_add(seed) % 256) as f32 / 255.0).collect(); + let mut s = Surface::new(PixelFormat::RGBA8); + s.write_region(r, &data); + let back = s.read_region(r); + for (a, b) in back.iter().zip(&data) { + prop_assert!((a - b).abs() < 1e-6); + } + prop_assert_eq!(s.content_bounds().union(&r), r); + } + + #[test] + fn clone_mutation_isolated(x in -300i32..300, y in -300i32..300) { + let mut a = Surface::new(PixelFormat::RGBA8); + a.fill_rect(Rect::new(-256, -256, 256, 256), &[0.5, 0.5, 0.5, 1.0]); + let snapshot = a.clone(); + a.write_pixel(x, y, &[1.0, 0.0, 0.0, 1.0]); + let expect = if Rect::new(-256, -256, 256, 256).contains(x, y) { vec![0.5f32, 0.5, 0.5, 1.0] } else { vec![0.0; 4] }; + let got = snapshot.pixel(x, y); + for (g, e) in got.iter().zip(&expect) { + prop_assert!((g - e).abs() <= 1.0 / 255.0); + } + } + } +} diff --git a/crates/testkit/Cargo.toml b/crates/testkit/Cargo.toml new file mode 100644 index 00000000..cf54ef16 --- /dev/null +++ b/crates/testkit/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "photocraft-testkit" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +photocraft-geom = { workspace = true } +photocraft-color = { workspace = true } +photocraft-raster = { workspace = true } diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs new file mode 100644 index 00000000..5851b6ff --- /dev/null +++ b/crates/testkit/src/lib.rs @@ -0,0 +1 @@ +//! photocraft-testkit diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml new file mode 100644 index 00000000..69e42631 --- /dev/null +++ b/crates/ui-egui/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "photocraft-ui-egui" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +photocraft-geom = { workspace = true } +photocraft-color = { workspace = true } +photocraft-raster = { workspace = true } +photocraft-doc = { workspace = true } +photocraft-compose = { workspace = true } +photocraft-engine = { workspace = true } +egui = { workspace = true } +egui_extras = { version = "0.36", default-features = false, features = ["svg"] } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } +serde = { workspace = true } +serde_json = { workspace = true } +log = { workspace = true } + +[dev-dependencies] +egui_kittest = { workspace = true, features = ["wgpu", "snapshot", "eframe"] } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs new file mode 100644 index 00000000..945e3c69 --- /dev/null +++ b/crates/ui-egui/src/canvas.rs @@ -0,0 +1,869 @@ +//! Document canvas: display texture cache, view transform, tool input, extra document windows. +//! +//! Rendering is CPU (`photocraft-compose`) for now, uploaded into an egui texture. Brush strokes +//! update only their damage rectangle (`set_partial`). The wgpu compositor (M5) will replace this +//! with direct GPU rendering behind the same `CanvasCache` interface. + +use egui::{Color32, Pos2, Rect, Sense, Stroke, TextureOptions, Vec2, pos2, vec2}; +use photocraft_doc::{Document, LayerContent}; +use photocraft_geom::Rect as DRect; +use serde_json::json; + +use crate::state::{Tool, View}; +use crate::PhotocraftApp; + +/// Largest texture side we upload; bigger documents display downsampled until the GPU path lands. +pub const MAX_TEXTURE: u32 = 4096; + +pub struct CanvasCache { + pub revision: u64, + pub texture: Option, + /// Display pixels per document pixel of the cached image (≤ 1). + pub scale: f32, + /// Hash of the live-adjust preview used for this render (0 = none). + pub preview_key: u64, + /// The current image lives in the GPU canvas (`gpu_canvas`) rather than `texture`. + pub on_gpu: bool, +} + +/// An in-progress pointer gesture on the canvas. +#[derive(Clone, Debug)] +pub struct Drag { + pub tool: Tool, + pub start: [f64; 2], + pub points: Vec<[f64; 3]>, + pub modifiers: egui::Modifiers, +} + +/// Abstract tool event, produced by the mouse or by automation (`ui.pointer`). +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ToolEvent { + Down { x: f64, y: f64, pressure: f32 }, + Move { x: f64, y: f64, pressure: f32 }, + Up { x: f64, y: f64 }, +} + +/// Document ↔ screen mapping for a canvas rect and a view. +#[derive(Clone, Copy, Debug)] +pub struct ViewXform { + pub rect: Rect, + pub zoom: f32, + pub center: [f32; 2], +} + +impl ViewXform { + pub fn to_screen(&self, x: f32, y: f32) -> Pos2 { + self.rect.center() + vec2((x - self.center[0]) * self.zoom, (y - self.center[1]) * self.zoom) + } + pub fn to_doc(&self, p: Pos2) -> [f64; 2] { + let d = (p - self.rect.center()) / self.zoom; + [(d.x + self.center[0]) as f64, (d.y + self.center[1]) as f64] + } + pub fn doc_rect(&self, r: DRect) -> Rect { + Rect::from_min_max(self.to_screen(r.x0 as f32, r.y0 as f32), self.to_screen(r.x1 as f32, r.y1 as f32)) + } +} + +pub fn fit_view(view: &mut View, doc: &Document, area: Vec2) { + let (w, h) = (doc.size.width as f32, doc.size.height as f32); + let zoom = ((area.x - 40.0) / w).min((area.y - 40.0) / h).clamp(0.01, 1.0); + view.zoom = zoom; + view.center = [w / 2.0, h / 2.0]; + view.fit_pending = false; +} + +/// Zoom steps like Photoshop's (⌘+ / ⌘−). +pub fn zoom_step(z: f32, dir: i32) -> f32 { + const STEPS: [f32; 22] = [0.01, 0.02, 0.03, 0.05, 0.0667, 0.1, 0.125, 0.1667, 0.25, 0.333, 0.5, 0.6667, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 8.0, 12.0, 16.0, 32.0]; + if dir > 0 { + STEPS.iter().copied().find(|s| *s > z * 1.001).unwrap_or(32.0) + } else { + STEPS.iter().rev().copied().find(|s| *s < z * 0.999).unwrap_or(0.01) + } +} + +fn checker(app: &mut PhotocraftApp, ctx: &egui::Context) -> egui::TextureId { + app.checker + .get_or_insert_with(|| { + let (a, b) = (Color32::from_gray(255), Color32::from_gray(204)); + let img = egui::ColorImage::new([2, 2], vec![a, b, b, a]); + let opts = TextureOptions { magnification: egui::TextureFilter::Nearest, minification: egui::TextureFilter::Nearest, wrap_mode: egui::TextureWrapMode::Repeat, mipmap_mode: None }; + ctx.load_texture("checker", img, opts) + }) + .id() +} + +fn buffer_to_image(buf: &photocraft_compose::Buffer) -> egui::ColorImage { + let img = buf.to_rgba8(); + egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.pixels) +} + +/// Box-downsample a composite for display. +fn downsample(buf: &photocraft_compose::Buffer, factor: u32) -> photocraft_compose::Buffer { + let (w, h) = (buf.rect.width(), buf.rect.height()); + let (nw, nh) = ((w / factor).max(1), (h / factor).max(1)); + let mut out = photocraft_compose::Buffer::transparent(DRect::from_xywh(0, 0, nw, nh)); + for y in 0..nh { + for x in 0..nw { + let mut acc = [0.0f32; 4]; + let mut n = 0.0; + for dy in 0..factor { + for dx in 0..factor { + let (sx, sy) = (x * factor + dx, y * factor + dy); + if sx < w && sy < h { + let p = buf.px[(sy * w + sx) as usize]; + // premultiplied average + acc[0] += p[0] * p[3]; + acc[1] += p[1] * p[3]; + acc[2] += p[2] * p[3]; + acc[3] += p[3]; + n += 1.0; + } + } + } + let a = acc[3] / n; + let px = if acc[3] > 0.0 { [acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], a] } else { [0.0; 4] }; + out.px[(y * nw + x) as usize] = px; + } + } + out +} + +/// The document to render: the committed one, or a clone with the live adjustment preview applied. +fn display_doc(app: &PhotocraftApp, idx: usize) -> (std::sync::Arc, u64) { + let st = &app.session.documents()[idx]; + if let Some((layer, params)) = &app.live_adjust + && let Some(l) = st.doc.layer(*layer) + && let LayerContent::Adjustment(a) = &l.content + { + let kind = photocraft_engine::commands::adjustment_kind(a); + let mut doc = (*st.doc).clone(); + if let Some(lm) = doc.layer_mut(*layer) { + lm.content = LayerContent::Adjustment(photocraft_engine::commands::adjustment_from_params(kind, params)); + } + let key = 1 + params.to_string().bytes().fold(0u64, |h, b| h.wrapping_mul(31).wrapping_add(b as u64)); + return (std::sync::Arc::new(doc), key); + } + (st.doc.clone(), 0) +} + +/// Make sure the canvas texture for document `idx` is current; returns (texture id, scale). +pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Option<(egui::TextureId, f32)> { + let (revision, last_damage, id) = { + let st = app.session.documents().get(idx)?; + (st.revision, st.last_damage, st.doc.id) + }; + let (doc, preview_key) = display_doc(app, idx); + let cache = app.canvases.entry(id).or_insert(CanvasCache { revision: 0, texture: None, scale: 1.0, preview_key: 0, on_gpu: false }); + if cache.revision != revision || cache.texture.is_none() || cache.preview_key != preview_key { + let partial = cache.texture.is_some() && cache.preview_key == preview_key && cache.revision + 1 == revision && cache.scale == 1.0 && last_damage.is_some(); + let t0 = crate::gpu_canvas::now_ms(); + if partial { + let r = last_damage.unwrap().intersect(&doc.bounds()); + if !r.is_empty() { + let buf = photocraft_compose::render(&doc, r); + let t1 = crate::gpu_canvas::now_ms(); + if let Some(t) = cache.texture.as_mut() { + t.set_partial([r.x0 as usize, r.y0 as usize], buf_image(&buf), TextureOptions::LINEAR); + } + app.perf.record("rect", r.width() as u64 * r.height() as u64, t1 - t0, crate::gpu_canvas::now_ms() - t1); + } + } else { + let full = photocraft_compose::flatten(&doc); + let t1 = crate::gpu_canvas::now_ms(); + let longest = doc.size.width.max(doc.size.height); + let factor = longest.div_ceil(MAX_TEXTURE).max(1); + let (img, scale) = if factor > 1 { (buffer_to_image(&downsample(&full, factor)), 1.0 / factor as f32) } else { (buffer_to_image(&full), 1.0) }; + match cache.texture.as_mut() { + Some(t) if t.size() == img.size => t.set(img, TextureOptions::LINEAR), + _ => cache.texture = Some(ctx.load_texture(format!("canvas-{}", id.0), img, TextureOptions::LINEAR)), + } + cache.scale = scale; + app.perf.record("full", doc.size.width as u64 * doc.size.height as u64, t1 - t0, crate::gpu_canvas::now_ms() - t1); + } + cache.revision = revision; + cache.preview_key = preview_key; + cache.on_gpu = false; + } + Some((cache.texture.as_ref()?.id(), cache.scale)) +} + +/// GPU path: make sure document `idx` is current in the GPU canvas. Brush strokes re-composite +/// and upload only their damage rect; everything else re-composites the whole document. +/// Returns false if there is no GPU canvas. +fn ensure_gpu(app: &mut PhotocraftApp, idx: usize) -> bool { + let Some(gpu) = app.gpu.clone() else { return false }; + let Some((revision, last_damage, id)) = app.session.documents().get(idx).map(|st| (st.revision, st.last_damage, st.doc.id)) else { return false }; + let (doc, preview_key) = display_doc(app, idx); + let size = [doc.size.width, doc.size.height]; + let cache = app.canvases.entry(id).or_insert(CanvasCache { revision: 0, texture: None, scale: 1.0, preview_key: 0, on_gpu: false }); + let present = cache.on_gpu && gpu.has(id.0, size); + if present && cache.revision == revision && cache.preview_key == preview_key { + return true; + } + let t0 = crate::gpu_canvas::now_ms(); + let partial = present && cache.preview_key == preview_key && cache.revision + 1 == revision && last_damage.is_some(); + let mut done = false; + if partial { + let r = last_damage.unwrap().intersect(&doc.bounds()); + if r.is_empty() { + done = true; + } else { + let buf = photocraft_compose::render(&doc, r); + let t1 = crate::gpu_canvas::now_ms(); + done = gpu.upload_buffer_rect(id.0, &buf); + app.perf.record("rect", r.width() as u64 * r.height() as u64, t1 - t0, crate::gpu_canvas::now_ms() - t1); + } + } + if !done { + // TODO(M5): render only the visible region at display resolution when zoomed out; the + // wgpu compositor will make this CPU flatten go away entirely. + let full = photocraft_compose::flatten(&doc); + let t1 = crate::gpu_canvas::now_ms(); + gpu.upload_buffer_full(id.0, &full); + app.perf.record("full", size[0] as u64 * size[1] as u64, t1 - t0, crate::gpu_canvas::now_ms() - t1); + } + let cache = app.canvases.get_mut(&id).expect("inserted above"); + cache.revision = revision; + cache.preview_key = preview_key; + cache.on_gpu = true; + cache.texture = None; + true +} + +/// If a live adjustment preview is active on a large document, composite it on the proxy and upload +/// it under its own GPU key. Returns (factor, gpu key) when the proxy should be drawn. +fn ensure_proxy_preview(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u64)> { + let (layer, params) = app.live_adjust.clone()?; + let (doc_id, revision, doc) = { + let st = app.session.documents().get(idx)?; + (st.doc.id, st.revision, st.doc.clone()) + }; + let k = crate::proxy::factor(&doc); + if k <= 1 { + return None; + } + let fresh = matches!(&app.proxy, Some((d, r, kk, _)) if *d == doc_id && *r == revision && *kk == k); + if !fresh { + app.proxy = Some((doc_id, revision, k, std::sync::Arc::new(crate::proxy::proxy_document(&doc, k)))); + } + let proxy = app.proxy.as_ref()?.3.clone(); + let key = doc_id.0 ^ (1u64 << 62); + let hash = params.to_string().bytes().fold(k as u64, |h, b| h.wrapping_mul(31).wrapping_add(b as u64)); + if app.proxy_uploaded != Some((doc_id, hash)) { + let l = proxy.layer(layer)?; + let LayerContent::Adjustment(a) = &l.content else { return None }; + let kind = photocraft_engine::commands::adjustment_kind(a); + let mut p = (*proxy).clone(); + if let Some(lm) = p.layer_mut(layer) { + lm.content = LayerContent::Adjustment(photocraft_engine::commands::adjustment_from_params(kind, ¶ms)); + } + let t0 = crate::gpu_canvas::now_ms(); + let buf = photocraft_compose::flatten(&p); + let t1 = crate::gpu_canvas::now_ms(); + app.gpu.as_ref()?.upload_buffer_full(key, &buf); + app.perf.record("proxy", p.size.area(), t1 - t0, crate::gpu_canvas::now_ms() - t1); + app.proxy_uploaded = Some((doc_id, hash)); + } + Some((k, key)) +} + +fn buf_image(buf: &photocraft_compose::Buffer) -> egui::ColorImage { + buffer_to_image(buf) +} + +/// Tabs + canvas for the active document, or the start screen. +pub fn document_area(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + if let Some(gpu) = &app.gpu { + let live: Vec = app.session.documents().iter().map(|st| st.doc.id.0).collect(); + gpu.retain(&live); + } + if app.session.documents().is_empty() { + start_screen(app, ui); + return; + } + tabs(app, ui); + let Some(idx) = app.session.active_index() else { return }; + let rect = ui.available_rect_before_wrap(); + app.last_canvas_rect = rect; + let view = app.ui.views[idx].clone(); + canvas_view(app, ui, idx, rect, view, true); +} + +fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let t = crate::theme::Tokens::get(ui.ctx()); + if t.pro { + return pro_tabs(app, ui); + } + let active = app.session.active_index(); + let mut activate = None; + let mut close = None; + egui::Frame::NONE.fill(t.canvas).inner_margin(egui::Margin { left: 8, right: 8, top: 6, bottom: 4 }).show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (i, st) in app.session.documents().iter().enumerate() { + let sel = Some(i) == active; + let name = format!("{}{}", st.doc.name, if st.is_dirty() { " *" } else { "" }); + let meta = format!("{}/{}", mode_label(&st.doc), st.doc.depth.bits()); + let name_g = ui.painter().layout_no_wrap(name, crate::theme::medium(12.5), t.text); + let meta_g = ui.painter().layout_no_wrap(meta, egui::FontId::proportional(10.5), t.text_faint); + let w = name_g.size().x + meta_g.size().x + 44.0; + let (r, resp) = ui.allocate_exact_size(egui::vec2(w, 26.0), Sense::click()); + if sel { + ui.painter().rect_filled(r, t.radius_sm, t.card); + ui.painter().rect_stroke(r, t.radius_sm, Stroke::new(1.0, t.card_border), egui::StrokeKind::Inside); + } else if resp.hovered() { + ui.painter().rect_filled(r, t.radius_sm, t.hover.gamma_multiply(0.5)); + } + let color = if sel { t.text } else { t.text_dim }; + let ny = r.center().y - name_g.size().y / 2.0; + ui.painter().galley_with_override_text_color(egui::pos2(r.left() + 10.0, ny), name_g.clone(), color); + ui.painter().galley(egui::pos2(r.left() + 16.0 + name_g.size().x, r.center().y - meta_g.size().y / 2.0), meta_g, t.text_faint); + let xr = Rect::from_center_size(egui::pos2(r.right() - 12.0, r.center().y), egui::vec2(16.0, 16.0)); + let xresp = ui.interact(xr, ui.id().with(("tabx", i)), Sense::click()); + if xresp.hovered() { + ui.painter().rect_filled(xr, 4.0, t.hover); + } + crate::icons::paint(ui, xr, "x", 11.0, if xresp.hovered() { t.text } else { t.text_faint }); + if xresp.clicked() { + close = Some(i); + } else if resp.clicked() { + activate = Some(i); + } + } + }); + }); + if let Some(i) = activate { + app.session.set_active(i); + } + if let Some(i) = close { + let _ = app.run("file.close", json!({"document": i})); + } +} + +/// Photoshop document tabs: "name @ 33.3% (RGB/8)" on a dark strip; active tab matches panels. +fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let t = crate::theme::Tokens::get(ui.ctx()); + let active = app.session.active_index(); + let (mut activate, mut close) = (None, None); + let (strip, _) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 26.0), Sense::hover()); + ui.painter().rect_filled(strip, 0.0, t.tab_strip); + let mut x = strip.left(); + for (i, st) in app.session.documents().iter().enumerate() { + let zoom = app.ui.views.get(i).map_or(100.0, |v| v.zoom * 100.0); + let title = format!("{} @ {}% ({}/{}){}", st.doc.name, fmt_zoom(zoom), mode_label(&st.doc), st.doc.depth.bits(), if st.is_dirty() { "*" } else { "" }); + let g = ui.painter().layout_no_wrap(title, egui::FontId::proportional(11.5), t.text); + let r = Rect::from_min_size(egui::pos2(x, strip.top()), egui::vec2(g.size().x + 42.0, strip.height())); + let resp = ui.interact(r, ui.id().with(("ptab", i)), Sense::click()); + let sel = Some(i) == active; + if sel { + ui.painter().rect_filled(r, 0.0, t.chrome); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.35)); + } + ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.separator)); + let xr = Rect::from_center_size(egui::pos2(r.left() + 13.0, r.center().y), egui::vec2(14.0, 14.0)); + let xresp = ui.interact(xr, ui.id().with(("ptabx", i)), Sense::click()); + crate::icons::paint(ui, xr, "x", 10.0, if xresp.hovered() { t.text } else { t.text_faint }); + ui.painter().galley_with_override_text_color(egui::pos2(r.left() + 26.0, r.center().y - g.size().y / 2.0), g, if sel { t.text } else { t.text_faint }); + if xresp.clicked() { + close = Some(i); + } else if resp.clicked() { + activate = Some(i); + } + x = r.right(); + } + if let Some(i) = activate { + app.session.set_active(i); + } + if let Some(i) = close { + let _ = app.run("file.close", json!({"document": i})); + } +} + +/// Photoshop-style zoom label: "33.3", "100", "12.5". +pub fn fmt_zoom(pct: f32) -> String { + if (pct - pct.round()).abs() < 0.05 { format!("{}", pct.round() as i64) } else { format!("{pct:.1}") } +} + +/// Repeating dot-grid texture for the canvas surround. +fn dots(ctx: &egui::Context, t: &crate::theme::Tokens) -> Option { + if t.bevel { + return None; + } + let key = egui::Id::new(("canvas-dots", format!("{:?}", t.kind))); + if let Some(tex) = ctx.data(|d| d.get_temp::(key)) { + return Some(tex.id()); + } + let n = 22usize; + let mut px = vec![Color32::TRANSPARENT; n * n]; + for (dx, dy, a) in [(0usize, 0usize, 255u8), (1, 0, 110), (0, 1, 110), (1, 1, 60)] { + let c = t.canvas_dot; + px[(n / 2 + dy) * n + n / 2 + dx] = Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), a); + } + let opts = TextureOptions { magnification: egui::TextureFilter::Linear, minification: egui::TextureFilter::Linear, wrap_mode: egui::TextureWrapMode::Repeat, mipmap_mode: None }; + let tex = ctx.load_texture("canvas-dots", egui::ColorImage::new([n, n], px), opts); + let id = tex.id(); + ctx.data_mut(|d| d.insert_temp(key, tex)); + Some(id) +} + +fn paint_dots(ui: &egui::Ui, rect: Rect) { + let t = crate::theme::Tokens::get(ui.ctx()); + if let Some(id) = dots(ui.ctx(), &t) { + let uv = Rect::from_min_max(Pos2::ZERO, pos2(rect.width() / 22.0, rect.height() / 22.0)); + ui.painter_at(rect).image(id, rect, uv, Color32::WHITE); + } +} + +pub fn mode_label(doc: &Document) -> &'static str { + match doc.mode { + photocraft_doc::ColorMode::Rgb => "RGB", + photocraft_doc::ColorMode::Grayscale => "Gray", + photocraft_doc::ColorMode::Cmyk => "CMYK", + photocraft_doc::ColorMode::Lab => "Lab", + photocraft_doc::ColorMode::Indexed => "Indexed", + photocraft_doc::ColorMode::Bitmap => "Bitmap", + photocraft_doc::ColorMode::Duotone => "Duotone", + photocraft_doc::ColorMode::Multichannel => "Multichannel", + } +} + +fn start_screen(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let t = crate::theme::Tokens::get(ui.ctx()); + let area = ui.available_rect_before_wrap(); + paint_dots(ui, area); + let card = Rect::from_center_size(area.center(), egui::vec2(460.0, 250.0)); + ui.scope_builder(egui::UiBuilder::new().max_rect(card), |ui| { + ui.vertical_centered(|ui| { + ui.horizontal(|ui| { + let title = ui.painter().layout_no_wrap("Photocraft".into(), crate::theme::semibold(38.0), t.text); + let by = ui.painter().layout_no_wrap("open source".into(), egui::FontId::proportional(13.0), t.text_faint); + let total = title.size().x + by.size().x + 10.0; + ui.add_space(((card.width() - total) / 2.0).max(0.0)); + let (r, _) = ui.allocate_exact_size(title.size(), Sense::hover()); + ui.painter().galley(r.min, title, t.text); + let (r2, _) = ui.allocate_exact_size(egui::vec2(by.size().x + 10.0, r.height()), Sense::hover()); + ui.painter().galley(egui::pos2(r2.left() + 10.0, r.bottom() - by.size().y - 8.0), by, t.text_faint); + }); + ui.add_space(6.0); + ui.label(egui::RichText::new("Create a new document or open an existing file.").color(t.text_dim).size(14.0)); + ui.add_space(22.0); + ui.horizontal(|ui| { + ui.add_space(((card.width() - 2.0 * 190.0 - 12.0) / 2.0).max(0.0)); + ui.spacing_mut().item_spacing.x = 12.0; + if crate::widgets::primary_button(ui, "New document… ⌘N", 190.0).clicked() { + app.ui.open_dialog(crate::state::DialogKind::NewDocument, crate::state::UiState::new_document_fields()); + } + if crate::widgets::secondary_button(ui, "Open… ⌘O", 190.0).clicked() { + app.open_dialog_file(); + } + }); + ui.add_space(22.0); + ui.horizontal(|ui| { + let msg = "Drop an image or PSD anywhere to open it."; + let g = ui.painter().layout_no_wrap(msg.into(), egui::FontId::proportional(12.5), t.text_faint); + ui.add_space(((card.width() - g.size().x - 24.0) / 2.0).max(0.0)); + let (r, _) = ui.allocate_exact_size(egui::vec2(18.0, 18.0), Sense::hover()); + crate::icons::paint(ui, r, "image", 15.0, t.text_faint); + ui.label(egui::RichText::new(msg).color(t.text_faint)); + }); + }); + }); +} + +/// Draw one canvas view and handle its input. `primary` = main window (tools active). +pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: Rect, mut view: View, primary: bool) -> View { + let ctx = ui.ctx().clone(); + let doc = app.session.documents()[idx].doc.clone(); + if view.fit_pending && rect.width() > 50.0 { + fit_view(&mut view, &doc, rect.size()); + } + let xf = ViewXform { rect, zoom: view.zoom, center: view.center }; + let response = ui.allocate_rect(rect, Sense::click_and_drag()); + let painter = ui.painter_at(rect); + + paint_dots(ui, rect); + // Drop shadow, checkerboard, document image. + let img_rect = xf.doc_rect(doc.bounds()); + // Live adjustment previews on big documents use a downsampled proxy (see proxy.rs). + let mut on_gpu = false; + if app.gpu.is_some() + && let Some((k, key)) = ensure_proxy_preview(app, idx) + { + on_gpu = true; + let params = crate::gpu_canvas::ViewParams { + doc: key, + doc_size: [doc.size.width.div_ceil(k), doc.size.height.div_ceil(k)], + zoom: view.zoom * k as f32, + center: [view.center[0] / k as f32, view.center[1] / k as f32], + shadow: { let t = crate::theme::Tokens::get(&ctx); !t.bevel && !t.pro }, + pixel_grid: false, + view_key: egui::Id::new(("pc-canvas-proxy", ctx.viewport_id(), idx)).value(), + }; + crate::gpu_canvas::GpuCanvas::paint(&painter, rect, params); + } else if ensure_gpu(app, idx) { + on_gpu = true; + app.perf.gpu = true; + // Shadow, checkerboard, document and pixel grid in one custom shader (gpu_canvas.rs). + let params = crate::gpu_canvas::ViewParams { + doc: doc.id.0, + doc_size: [doc.size.width, doc.size.height], + zoom: view.zoom, + center: view.center, + shadow: { let t = crate::theme::Tokens::get(&ctx); !t.bevel && !t.pro }, + pixel_grid: true, + view_key: egui::Id::new(("pc-canvas", ctx.viewport_id(), idx)).value(), + }; + crate::gpu_canvas::GpuCanvas::paint(&painter, rect, params); + } else { + if !crate::theme::Tokens::get(&ctx).bevel { + painter.add(egui::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(150) }.as_shape(img_rect, 0)); + } + let checker_id = checker(app, &ctx); + let tiles = img_rect.size() / 16.0; + painter.image(checker_id, img_rect, Rect::from_min_max(Pos2::ZERO, pos2(tiles.x, tiles.y)), Color32::WHITE); + if let Some((tex, _scale)) = ensure_texture(app, &ctx, idx) { + painter.image(tex, img_rect, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE); + } + } + + // Pixel grid at high zoom (the GPU path draws its own). + if !on_gpu && view.zoom >= 12.0 { + let vis = img_rect.intersect(rect); + let a = xf.to_doc(vis.min); + let b = xf.to_doc(vis.max); + let grid = Stroke::new(1.0, Color32::from_white_alpha(40)); + for x in (a[0].floor() as i32)..=(b[0].ceil() as i32) { + let sx = xf.to_screen(x as f32, 0.0).x; + painter.line_segment([pos2(sx, vis.top()), pos2(sx, vis.bottom())], grid); + } + for y in (a[1].floor() as i32)..=(b[1].ceil() as i32) { + let sy = xf.to_screen(0.0, y as f32).y; + painter.line_segment([pos2(vis.left(), sy), pos2(vis.right(), sy)], grid); + } + } + + // Selection outline: true boundary, animated marching ants (cached per revision). + if let Some(sel) = &doc.selection { + let rev = app.session.documents()[idx].revision; + let fresh = matches!(&app.outline_cache, Some((d, r, _)) if *d == doc.id && *r == rev); + if !fresh { + let b = app.cached_bounds(u64::MAX - doc.id.0, sel); + // Very large selections: fall back to the bounding box until the GPU path lands. + let segs = if b.width() as u64 * b.height() as u64 > 40_000_000 { + vec![([b.x0, b.y0], [b.x1, b.y0]), ([b.x1, b.y0], [b.x1, b.y1]), ([b.x1, b.y1], [b.x0, b.y1]), ([b.x0, b.y1], [b.x0, b.y0])] + } else { + crate::outline::outline(sel, b) + }; + app.outline_cache = Some((doc.id, rev, std::sync::Arc::new(segs))); + } + if let Some((_, _, segs)) = &app.outline_cache { + let time = ui.input(|i| i.time); + marching_ants_segments(&painter, &xf, segs, time); + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } + } + + // Navigation: scroll pans, pinch / ⌘-scroll zooms around the pointer. + if response.hovered() { + let (scroll, zoom_delta, pointer) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.pointer.hover_pos())); + if zoom_delta != 1.0 + && let Some(p) = pointer + { + let nz = (view.zoom * zoom_delta).clamp(0.01, 64.0); + zoom_about(&mut view, &xf, p, nz); + } else if scroll != Vec2::ZERO { + view.center[0] -= scroll.x / view.zoom; + view.center[1] -= scroll.y / view.zoom; + } + } + + let space_pan = ui.input(|i| i.key_down(egui::Key::Space)); + let middle = ui.input(|i| i.pointer.middle_down()); + let tool = if space_pan || middle { Tool::Hand } else { app.ui.tool }; + + if tool == Tool::Hand && response.dragged() { + let d = response.drag_delta(); + view.center[0] -= d.x / view.zoom; + view.center[1] -= d.y / view.zoom; + } else if primary { + let mods = ui.input(|i| i.modifiers); + if response.drag_started() + && let Some(p) = response.interact_pointer_pos() + { + let d = xf.to_doc(p); + tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: 1.0 }, mods); + } + if response.dragged() + && let Some(p) = response.interact_pointer_pos() + { + let d = xf.to_doc(p); + tool_event(app, ToolEvent::Move { x: d[0], y: d[1], pressure: 1.0 }, mods); + } + if response.drag_stopped() { + let p = response.interact_pointer_pos().map(|p| xf.to_doc(p)).or_else(|| app.drag.as_ref().and_then(|d| d.points.last().map(|q| [q[0], q[1]]))); + if let Some(d) = p { + tool_event(app, ToolEvent::Up { x: d[0], y: d[1] }, mods); + } + } + if response.clicked() + && let Some(p) = response.interact_pointer_pos() + { + let d = xf.to_doc(p); + match tool { + Tool::Zoom => { + let nz = zoom_step(view.zoom, if mods.alt { -1 } else { 1 }); + zoom_about(&mut view, &xf, p, nz); + } + _ => { + tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: 1.0 }, mods); + tool_event(app, ToolEvent::Up { x: d[0], y: d[1] }, mods); + } + } + } + draw_drag_preview(app, &painter, &xf); + // Brush cursor. + if matches!(app.ui.tool, Tool::Brush | Tool::Eraser) + && let Some(p) = response.hover_pos() + { + let r = app.session.tools.brush.size / 2.0 * view.zoom; + painter.circle_stroke(p, r.max(1.0), Stroke::new(1.0, Color32::from_white_alpha(200))); + painter.circle_stroke(p, r.max(1.0) + 1.0, Stroke::new(1.0, Color32::from_black_alpha(120))); + } + } + if primary { + app.ui.views[idx] = view.clone(); + } + view +} + +fn zoom_about(view: &mut View, xf: &ViewXform, p: Pos2, new_zoom: f32) { + let before = xf.to_doc(p); + view.zoom = new_zoom; + let d = (p - xf.rect.center()) / new_zoom; + view.center = [before[0] as f32 - d.x, before[1] as f32 - d.y]; +} + +/// Draw boundary segments as marching ants: white base, black dashes phased along x + y. +fn marching_ants_segments(painter: &egui::Painter, xf: &ViewXform, segs: &[crate::outline::Segment], time: f64) { + let dash = 4.0f32; + let phase = ((time * 10.0) % (dash as f64 * 2.0)) as f32; + let white = Stroke::new(1.0, Color32::WHITE); + let black = Stroke::new(1.0, Color32::BLACK); + let clip = painter.clip_rect(); + for (a, b) in segs { + let pa = xf.to_screen(a[0] as f32, a[1] as f32); + let pb = xf.to_screen(b[0] as f32, b[1] as f32); + if !clip.intersects(Rect::from_two_pos(pa, pb).expand(1.0)) { + continue; + } + let pa = pos2(pa.x.round() + 0.5, pa.y.round() + 0.5); + let pb = pos2(pb.x.round() + 0.5, pb.y.round() + 0.5); + painter.line_segment([pa, pb], white); + let len = pa.distance(pb); + if len < 0.5 { + continue; + } + let dir = (pb - pa) / len; + // Phase by screen position so dashes line up across joined segments. + let start = (pa.x + pa.y + phase).rem_euclid(dash * 2.0); + let mut t = -start; + while t < len { + let s0 = t.max(0.0); + let s1 = (t + dash).min(len); + if s1 > s0 { + painter.line_segment([pa + dir * s0, pa + dir * s1], black); + } + t += dash * 2.0; + } + } +} + +#[allow(dead_code)] +fn marching_ants(painter: &egui::Painter, r: Rect, time: f64) { + let dash = 4.0; + let offset = ((time * 8.0) % (dash as f64 * 2.0)) as f32; + painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Middle); + let pts = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()]; + for w in pts.windows(2) { + let (a, b) = (w[0], w[1]); + let len = a.distance(b); + let dir = (b - a) / len.max(1e-3); + let mut t = -offset; + while t < len { + let s = t.max(0.0); + let e = (t + dash).min(len); + if e > s { + painter.line_segment([a + dir * s, a + dir * e], Stroke::new(1.0, Color32::BLACK)); + } + t += dash * 2.0; + } + } +} + +fn draw_drag_preview(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { + let Some(d) = &app.drag else { return }; + let last = d.points.last().map(|p| [p[0], p[1]]).unwrap_or(d.start); + match d.tool { + Tool::Brush | Tool::Eraser => { + let c = if d.tool == Tool::Eraser { [1.0, 1.0, 1.0, 0.6] } else { app.session.tools.foreground }; + let color = Color32::from_rgba_unmultiplied((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8, (c[3] * 255.0) as u8); + let pts: Vec = d.points.iter().map(|p| xf.to_screen(p[0] as f32, p[1] as f32)).collect(); + let w = (app.session.tools.brush.size * xf.zoom).max(1.0); + if pts.len() == 1 { + painter.circle_filled(pts[0], w / 2.0, color); + } else { + painter.add(egui::Shape::line(pts, Stroke::new(w, color))); + } + } + Tool::RectMarquee | Tool::EllipseMarquee => { + let r = Rect::from_two_pos(xf.to_screen(d.start[0] as f32, d.start[1] as f32), xf.to_screen(last[0] as f32, last[1] as f32)); + if d.tool == Tool::EllipseMarquee { + painter.add(egui::Shape::ellipse_stroke(r.center(), r.size() / 2.0, Stroke::new(1.0, Color32::WHITE))); + } else { + painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Middle); + } + } + Tool::Move => { + let off = vec2(((last[0] - d.start[0]) as f32) * xf.zoom, ((last[1] - d.start[1]) as f32) * xf.zoom); + painter.arrow(xf.to_screen(d.start[0] as f32, d.start[1] as f32), off, Stroke::new(2.0, crate::theme::Tokens::get(painter.ctx()).accent)); + } + _ => {} + } +} + +/// Tool state machine. Shared by mouse input and automation. +pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { + let tool = app.ui.tool; + match ev { + ToolEvent::Down { x, y, pressure } => { + if tool == Tool::Eyedropper { + if let Ok(v) = app.run("document.pixel", json!({"x": x.floor(), "y": y.floor()})) { + let c: Vec = serde_json::from_value(v).unwrap_or_default(); + if c.len() == 4 && c[3] > 0.0 { + let key = if mods.alt { "background" } else { "foreground" }; + let _ = app.run("tools.setColors", json!({ key: [c[0], c[1], c[2], 1.0] })); + } + } + return; + } + app.drag = Some(Drag { tool, start: [x, y], points: vec![[x, y, pressure as f64]], modifiers: mods }); + } + ToolEvent::Move { x, y, pressure } => { + if let Some(d) = &mut app.drag + && d.points.last().is_none_or(|p| (p[0] - x).abs() + (p[1] - y).abs() > 0.25) + { + d.points.push([x, y, pressure as f64]); + } + } + ToolEvent::Up { x, y } => { + let Some(mut d) = app.drag.take() else { return }; + if d.points.last().is_none_or(|p| p[0] != x || p[1] != y) { + d.points.push([x, y, d.points.last().map_or(1.0, |p| p[2])]); + } + finish_gesture(app, d); + } + } +} + +fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { + let end = d.points.last().copied().unwrap_or([d.start[0], d.start[1], 1.0]); + match d.tool { + Tool::Brush | Tool::Eraser => { + let pts: Vec<[f64; 3]> = d.points.clone(); + let _ = app.run("paint.stroke", json!({ "points": pts, "erase": d.tool == Tool::Eraser, "smoothing": 0.3 })); + } + Tool::RectMarquee | Tool::EllipseMarquee => { + let (x0, y0) = (d.start[0].min(end[0]).floor(), d.start[1].min(end[1]).floor()); + let (x1, y1) = (d.start[0].max(end[0]).ceil(), d.start[1].max(end[1]).ceil()); + if x1 - x0 < 2.0 || y1 - y0 < 2.0 { + if app.session.is_enabled("select.deselect") { + let _ = app.run("select.deselect", json!({})); + } + return; + } + let bar = ["replace", "add", "subtract", "intersect"][app.ui.selection_mode.min(3) as usize]; + let mode = if !d.modifiers.shift && !d.modifiers.alt { + bar + } else if d.modifiers.shift && d.modifiers.alt { + "intersect" + } else if d.modifiers.shift { + "add" + } else if d.modifiers.alt { + "subtract" + } else { + "replace" + }; + let _ = app.run("select.rect", json!({"x": x0, "y": y0, "width": x1 - x0, "height": y1 - y0, "mode": mode, "ellipse": d.tool == Tool::EllipseMarquee})); + } + Tool::Move => { + let (dx, dy) = ((end[0] - d.start[0]).round(), (end[1] - d.start[1]).round()); + if dx != 0.0 || dy != 0.0 { + let _ = app.run("layer.translate", json!({"dx": dx, "dy": dy})); + } + } + _ => {} + } +} + +/// Extra OS windows showing documents (multi-window / multi-monitor). +pub fn extra_windows(app: &mut PhotocraftApp, ctx: &egui::Context) { + let wins = app.ui.windows.clone(); + for w in wins.into_iter().filter(|w| w.open) { + let Some(st) = app.session.documents().get(w.document) else { continue }; + let title = format!("{} — window {}", st.doc.name, w.id); + let vid = egui::ViewportId::from_hash_of(("docwin", w.id)); + let builder = egui::ViewportBuilder::default().with_title(title).with_inner_size([800.0, 600.0]); + let mut view = w.view.clone(); + let mut close = false; + ctx.show_viewport_immediate(vid, builder, |ui, _class| { + if ui.ctx().input(|i| i.viewport().close_requested()) { + close = true; + } + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(crate::theme::Tokens::get(ui.ctx()).canvas)).show(ui, |ui| { + let rect = ui.available_rect_before_wrap(); + view = canvas_view(app, ui, w.document, rect, view.clone(), false); + }); + }); + if let Some(win) = app.ui.windows.iter_mut().find(|x| x.id == w.id) { + win.view = view; + if close { + win.open = false; + } + } + } + app.ui.windows.retain(|w| w.open); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn view_transform_roundtrip() { + let xf = ViewXform { rect: Rect::from_min_size(pos2(100.0, 50.0), vec2(800.0, 600.0)), zoom: 2.5, center: [320.0, 240.0] }; + let s = xf.to_screen(10.0, 20.0); + let d = xf.to_doc(s); + assert!((d[0] - 10.0).abs() < 1e-3 && (d[1] - 20.0).abs() < 1e-3); + assert_eq!(xf.to_screen(320.0, 240.0), xf.rect.center()); + } + + #[test] + fn zoom_steps_monotone() { + assert_eq!(zoom_step(1.0, 1), 2.0); + assert_eq!(zoom_step(1.0, -1), 0.6667); + assert_eq!(zoom_step(0.4, 1), 0.5); + assert_eq!(zoom_step(32.0, 1), 32.0); + } + + #[test] + fn downsample_averages_premultiplied() { + let mut b = photocraft_compose::Buffer::transparent(DRect::new(0, 0, 2, 2)); + b.px[0] = [1.0, 0.0, 0.0, 1.0]; + let d = downsample(&b, 2); + assert_eq!(d.px.len(), 1); + let p = d.px[0]; + assert!((p[0] - 1.0).abs() < 1e-6 && (p[3] - 0.25).abs() < 1e-6, "{p:?}"); + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs new file mode 100644 index 00000000..16e7b8f5 --- /dev/null +++ b/crates/ui-egui/src/control.rs @@ -0,0 +1,279 @@ +//! Programmatic control of the running app, for agents, tests and (soon) MCP. +//! +//! Transport-agnostic: a transport thread (TCP in the desktop app, a channel in tests) sends +//! [`ControlRequest`]s; the UI thread handles them between frames and replies with JSON. +//! +//! Methods: +//! - `engine.execute {command, params}`: run any engine or UI command by id +//! - `engine.commands`: list commands with enablement +//! - `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, menu tree, window size) +//! - `ui.set {tool?, panels?, zoom?, center?, dark?}`: change UI state +//! - `ui.menu.invoke {id}` / `ui.menu.list`: activate a menu item by id; list the menu tree +//! - `ui.dialog.open {kind, fields?}` / `ui.dialog.set {dialog, field, value}` / `ui.dialog.confirm {dialog}` / `ui.dialog.cancel {dialog}` +//! - `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows +//! - `ui.pointer {events: [{kind: down|move|up, x, y, pressure?}], modifiers?}`: drive the active tool in document coordinates +//! - `ui.resize {width, height}`: resize the main window +//! - `ui.screenshot {path?, focus?}`: capture the main window (PNG). Raises the window first (default) +//! because occluded macOS windows stop rendering +//! - `ui.focus`: bring the main window to the front +//! - `app.open {path}` / `app.save {path}`: file I/O through the configured services +//! - `app.quit` + +use std::sync::mpsc::Sender; + +use serde_json::{Value, json}; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::{DialogKind, Tool, UiState}; + +pub type ControlResponse = Value; + +pub struct ControlRequest { + pub method: String, + pub params: Value, + pub reply: Sender, +} + +impl ControlRequest { + pub fn new(method: impl Into, params: Value) -> (Self, std::sync::mpsc::Receiver) { + let (tx, rx) = std::sync::mpsc::channel(); + (Self { method: method.into(), params, reply: tx }, rx) + } +} + +pub enum Outcome { + Done(Value), + Screenshot { token: u64, path: Option }, +} + +fn ok(v: Value) -> Outcome { + Outcome::Done(json!({"ok": true, "result": v})) +} +fn err(e: impl std::fmt::Display) -> Outcome { + Outcome::Done(json!({"ok": false, "error": e.to_string()})) +} +fn wrap(r: Result) -> Outcome { + match r { + Ok(v) => ok(v), + Err(e) => err(e), + } +} + +pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { + let p = &req.params; + let s = |k: &str| p.get(k).and_then(Value::as_str); + let u = |k: &str| p.get(k).and_then(Value::as_u64); + match req.method.as_str() { + "engine.execute" | "ui.menu.invoke" => { + let Some(id) = s("command").or(s("id")) else { return err("missing `command`") }; + let params = p.get("params").cloned().unwrap_or(json!({})); + wrap(crate::menus::invoke(app, ctx, id, params)) + } + "engine.commands" => wrap(app.run("command.list", json!({}))), + "ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_items(app)).unwrap_or_default()), + "ui.inspect" => ok(inspect(app, ctx)), + "ui.set" => { + if let Some(t) = s("tool") { + match Tool::from_name(t) { + Some(t) => app.ui.tool = t, + None => return err(format!("unknown tool `{t}`")), + } + } + if let Some(panels) = p.get("panels") { + let mut cur = serde_json::to_value(&app.ui.panels).unwrap_or_default(); + if let (Some(c), Some(n)) = (cur.as_object_mut(), panels.as_object()) { + for (k, v) in n { + c.insert(k.clone(), v.clone()); + } + } + match serde_json::from_value(cur) { + Ok(v) => app.ui.panels = v, + Err(e) => return err(e), + } + } + if let Some(i) = app.session.active_index() { + if let Some(z) = p.get("zoom").and_then(Value::as_f64) { + app.ui.views[i].zoom = (z as f32).clamp(0.01, 64.0); + app.ui.views[i].fit_pending = false; + } + if let Some(c) = p.get("center").and_then(Value::as_array) + && c.len() == 2 + { + app.ui.views[i].center = [c[0].as_f64().unwrap_or(0.0) as f32, c[1].as_f64().unwrap_or(0.0) as f32]; + app.ui.views[i].fit_pending = false; + } + if p.get("fit").and_then(Value::as_bool) == Some(true) { + app.ui.views[i].fit_pending = true; + } + } + if let Some(name) = s("theme") { + match crate::theme::ThemeKind::from_name(name) { + Some(k) => app.set_theme(ctx, k), + None => return err(format!("unknown theme `{name}` (studio, studioLight, classic)")), + } + } + if let Some(size) = p.get("brushSize").and_then(Value::as_f64) { + app.session.tools.brush.size = size as f32; + } + ok(Value::Null) + } + "ui.dialog.open" => { + let kind = match s("kind").unwrap_or("") { + "newDocument" | "NewDocument" => DialogKind::NewDocument, + "about" | "About" => DialogKind::About, + "layerStyle" | "LayerStyle" => { + return match crate::layer_style::open(app, s("effect")) { + Some(id) => ok(json!({"dialog": id})), + None => err("no active layer"), + }; + } + "command" | "Command" => { + let Some(cmd) = s("command") else { return err("command dialogs need `command`") }; + let label = photocraft_engine::commands::find(cmd).map(|c| c.label).unwrap_or(cmd); + return ok(json!({"dialog": crate::dialogs::open_command_dialog(app, cmd, label)})); + } + other => return err(format!("unknown dialog kind `{other}`")), + }; + let mut fields = if kind == DialogKind::NewDocument { UiState::new_document_fields() } else { Default::default() }; + if let Some(f) = p.get("fields").and_then(Value::as_object) { + fields.extend(f.clone()); + } + ok(json!({"dialog": app.ui.open_dialog(kind, fields)})) + } + "ui.dialog.set" => { + let (Some(id), Some(field)) = (u("dialog"), s("field")) else { return err("need `dialog` and `field`") }; + let value = p.get("value").cloned().unwrap_or(Value::Null); + match app.ui.dialog_mut(id) { + Some(d) => { + d.fields.insert(field.to_string(), value); + ok(Value::Null) + } + None => err(format!("no dialog {id}")), + } + } + "ui.dialog.confirm" => match u("dialog") { + Some(id) => wrap(crate::dialogs::confirm(app, id)), + None => err("missing `dialog`"), + }, + "ui.dialog.cancel" => match u("dialog").and_then(|id| app.ui.close_dialog(id)) { + Some(_) => ok(Value::Null), + None => err("no such dialog"), + }, + "ui.window.open" => { + if let Some(d) = u("document") { + app.session.set_active(d as usize); + } + wrap(crate::menus::invoke(app, ctx, "window.newWindowForDocument", json!({}))) + } + "ui.window.close" => { + let Some(id) = u("window") else { return err("missing `window`") }; + let before = app.ui.windows.len(); + app.ui.windows.retain(|w| w.id != id); + if app.ui.windows.len() < before { ok(Value::Null) } else { err(format!("no window {id}")) } + } + "ui.pointer" => { + let Some(events) = p.get("events").and_then(Value::as_array) else { return err("missing `events`") }; + let mods = egui::Modifiers { + shift: p.get("shift").and_then(Value::as_bool).unwrap_or(false), + alt: p.get("alt").and_then(Value::as_bool).unwrap_or(false), + ..Default::default() + }; + if let Some(t) = s("tool") { + if let Some(t) = Tool::from_name(t) { + app.ui.tool = t; + } + } + for e in events { + let x = e.get("x").and_then(Value::as_f64).unwrap_or(0.0); + let y = e.get("y").and_then(Value::as_f64).unwrap_or(0.0); + let pr = e.get("pressure").and_then(Value::as_f64).unwrap_or(1.0) as f32; + let ev = match e.get("kind").and_then(Value::as_str).unwrap_or("move") { + "down" => ToolEvent::Down { x, y, pressure: pr }, + "up" => ToolEvent::Up { x, y }, + _ => ToolEvent::Move { x, y, pressure: pr }, + }; + tool_event(app, ev, mods); + } + ok(json!({"status": app.ui.status})) + } + "ui.resize" => { + let (w, h) = (p.get("width").and_then(Value::as_f64).unwrap_or(1280.0), p.get("height").and_then(Value::as_f64).unwrap_or(800.0)); + ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w as f32, h as f32))); + ok(Value::Null) + } + "ui.focus" => { + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + ctx.request_repaint(); + ok(Value::Null) + } + "ui.screenshot" => { + // Occluded windows don't render on macOS, so the screenshot would never arrive: raise first. + if p.get("focus").and_then(Value::as_bool).unwrap_or(true) { + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + } + let token = app.ui.alloc_id(); + ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(token))); + ctx.request_repaint(); + Outcome::Screenshot { token, path: s("path").map(str::to_string) } + } + "app.open" => match s("path") { + Some(path) => wrap(crate::menus::invoke(app, ctx, "file.open", json!({"path": path}))), + None => err("missing `path`"), + }, + "app.save" => wrap(app.save_as(s("path").map(str::to_string)).map(|p| json!({"path": p}))), + "app.quit" => { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + ok(Value::Null) + } + other => err(format!("unknown method `{other}`")), + } +} + +/// Snapshot of everything on screen, addressable by id. +pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value { + let screen = ctx.content_rect(); + let dialogs: Vec = app + .ui + .dialogs + .iter() + .map(|d| json!({"id": d.id, "kind": d.kind, "title": crate::dialogs::title(d), "fields": d.fields})) + .collect(); + json!({ + "window": {"width": screen.width(), "height": screen.height(), "pixelsPerPoint": ctx.pixels_per_point()}, + "tool": app.ui.tool, + "panels": app.ui.panels, + "views": app.ui.views, + "dialogs": dialogs, + "windows": app.ui.windows, + "theme": app.ui.theme, + "status": app.ui.status, + "frame": app.frame, + "session": photocraft_engine::inspect::session(&app.session), + "document": app.session.active().map(photocraft_engine::inspect::document), + "perf": {"fps": app.fps, "timings": app.perf}, + "brush": {"size": app.session.tools.brush.size, "hardness": app.session.tools.brush.hardness, "opacity": app.session.tools.brush.opacity}, + }) +} + +pub fn save_screenshot(app: &mut PhotocraftApp, image: &egui::ColorImage, path: Option<&str>) -> Value { + let [w, h] = image.size; + let rgba: Vec = image.pixels.iter().flat_map(|c| c.to_array()).collect(); + let Some(path) = path else { + return json!({"ok": true, "result": {"width": w, "height": h}}); + }; + let png = match app.services.encode_png.as_ref() { + Some(enc) => enc(w as u32, h as u32, &rgba), + None => Err("no PNG encoder configured".into()), + }; + let r = png.and_then(|bytes| match app.services.write.as_mut() { + Some(wr) => wr(path, &bytes), + None => Err("no writer configured".into()), + }); + match r { + Ok(()) => json!({"ok": true, "result": {"path": path, "width": w, "height": h}}), + Err(e) => json!({"ok": false, "error": e}), + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs new file mode 100644 index 00000000..bc9d2ae5 --- /dev/null +++ b/crates/ui-egui/src/dialogs.rs @@ -0,0 +1,174 @@ +//! Dialogs rendered from `UiState::dialogs`. Field values live in the dialog data, so automation can +//! set them (`ui.dialog.set`) and confirm (`ui.dialog.confirm`) exactly like a user. + +use serde_json::{Value, json}; + +use crate::PhotocraftApp; +use crate::state::{Dialog, DialogKind}; + +pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { + let dialogs = app.ui.dialogs.clone(); + for d in dialogs { + let mut fields = d.fields.clone(); + let mut outcome: Option = None; // Some(true)=OK, Some(false)=Cancel + let title = title(&d); + let modal = egui::Modal::new(egui::Id::new(("dialog", d.id))).show(ctx, |ui| { + ui.set_min_width(380.0); + ui.set_max_width(if d.kind == DialogKind::LayerStyle { 600.0 } else { 440.0 }); + ui.label(egui::RichText::new(&title).font(crate::theme::semibold(15.0))); + ui.add_space(4.0); + crate::widgets::hairline(ui); + ui.add_space(8.0); + match d.kind { + DialogKind::NewDocument => new_document(ui, &mut fields), + DialogKind::About => { + ui.label("Photocraft — an open-source, native image editor written in Rust."); + ui.label(format!("Version {}", env!("CARGO_PKG_VERSION"))); + ui.weak("egui · wgpu · photocraft-engine"); + } + DialogKind::Command => command_fields(ui, &mut fields), + DialogKind::LayerStyle => crate::layer_style::body(ui, &mut fields), + DialogKind::Error => { + ui.label(fields.get("message").and_then(Value::as_str).unwrap_or("Error")); + } + } + ui.add_space(8.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.spacing_mut().item_spacing.x = 10.0; + if matches!(d.kind, DialogKind::About | DialogKind::Error) { + if crate::widgets::primary_button(ui, "OK", 84.0).clicked() { + outcome = Some(false); + } + } else { + let ok_label = if d.kind == DialogKind::NewDocument { "Create" } else { "OK" }; + if crate::widgets::primary_button(ui, ok_label, 84.0).clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { + outcome = Some(true); + } + if crate::widgets::secondary_button(ui, "Cancel", 84.0).clicked() { + outcome = Some(false); + } + } + }); + }); + if modal.should_close() && outcome.is_none() { + outcome = Some(false); + } + if let Some(dm) = app.ui.dialog_mut(d.id) { + dm.fields = fields; + } + match outcome { + Some(true) => { + let _ = confirm(app, d.id); + } + Some(false) => { + app.ui.close_dialog(d.id); + } + None => {} + } + } +} + +pub fn title(d: &Dialog) -> String { + match d.kind { + DialogKind::NewDocument => "New Document".into(), + DialogKind::About => "About Photocraft".into(), + DialogKind::LayerStyle => "Layer Style".into(), + DialogKind::Command => d.fields.get("__label").and_then(Value::as_str).unwrap_or("Command").trim_end_matches('…').to_string(), + DialogKind::Error => "Error".into(), + } +} + +/// Confirm a dialog: run its action and close it. Used by the OK button and by automation. +pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result { + let d = app.ui.close_dialog(id).ok_or_else(|| format!("no dialog {id}"))?; + match d.kind { + DialogKind::NewDocument => { + let r = app.run("file.new", Value::Object(d.fields)); + if let Some(i) = app.session.active_index() { + app.ui.views[i].fit_pending = true; + } + r + } + DialogKind::Command => { + let mut fields = d.fields; + let cmd = fields.remove("__command").and_then(|v| v.as_str().map(str::to_string)).ok_or("dialog has no command")?; + fields.remove("__label"); + fields.remove("__kind"); + app.run(&cmd, Value::Object(fields)) + } + DialogKind::LayerStyle => crate::layer_style::confirm(app, &d.fields), + DialogKind::About | DialogKind::Error => Ok(Value::Null), + } +} + +fn new_document(ui: &mut egui::Ui, f: &mut serde_json::Map) { + egui::Grid::new("newdoc").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| { + ui.label("Name"); + let mut name = f.get("name").and_then(Value::as_str).unwrap_or("Untitled-1").to_string(); + if ui.text_edit_singleline(&mut name).changed() { + f.insert("name".into(), json!(name)); + } + ui.end_row(); + for (key, label) in [("width", "Width"), ("height", "Height")] { + ui.label(label); + let mut v = f.get(key).and_then(Value::as_u64).unwrap_or(1024) as u32; + if ui.add(egui::DragValue::new(&mut v).range(1..=300_000).suffix(" px")).changed() { + f.insert(key.into(), json!(v)); + } + ui.end_row(); + } + ui.label("Color Mode"); + let mut mode = f.get("mode").and_then(Value::as_str).unwrap_or("rgb").to_string(); + crate::widgets::dropdown(ui, "nd-mode", &mut mode, &[("rgb".to_string(), "RGB Color"), ("gray".to_string(), "Grayscale"), ("cmyk".to_string(), "CMYK Color"), ("lab".to_string(), "Lab Color")], 150.0); + f.insert("mode".into(), json!(mode)); + ui.end_row(); + ui.label("Bit Depth"); + let mut depth = f.get("depth").and_then(Value::as_u64).unwrap_or(8); + crate::widgets::dropdown(ui, "nd-depth", &mut depth, &[(8u64, "8 bit"), (16, "16 bit"), (32, "32 bit")], 150.0); + f.insert("depth".into(), json!(depth)); + ui.end_row(); + ui.label("Background"); + let mut bg = f.get("background").and_then(Value::as_str).unwrap_or("white").to_string(); + crate::widgets::dropdown(ui, "nd-bg", &mut bg, &[("white".to_string(), "White"), ("black".to_string(), "Black"), ("transparent".to_string(), "Transparent")], 150.0); + f.insert("background".into(), json!(bg)); + ui.end_row(); + }); + ui.horizontal(|ui| { + ui.weak("Presets:"); + for (label, w, h) in [("HD", 1920, 1080), ("4K", 3840, 2160), ("Square", 2048, 2048), ("A4 300ppi", 2480, 3508)] { + if ui.small_button(label).clicked() { + f.insert("width".into(), json!(w)); + f.insert("height".into(), json!(h)); + } + } + }); +} + +fn command_fields(ui: &mut egui::Ui, f: &mut serde_json::Map) { + let kind = f.get("__kind").and_then(Value::as_str).unwrap_or_default().to_string(); + for &(key, label, min, max, default) in crate::panels::adjustment_sliders(&kind) { + let mut v = f.get(key).and_then(Value::as_f64).unwrap_or(default as f64) as f32; + if ui.add(egui::Slider::new(&mut v, min..=max).text(label)).changed() { + f.insert(key.into(), json!(v)); + } + } + if kind == "hueSaturation" { + let mut c = f.get("colorize").and_then(Value::as_bool).unwrap_or(false); + if ui.checkbox(&mut c, "Colorize").changed() { + f.insert("colorize".into(), json!(c)); + } + } +} + +/// Open a parameter dialog for a destructive `image.adjustments.*` command. +pub fn open_command_dialog(app: &mut PhotocraftApp, command: &str, label: &str) -> u64 { + let kind = command.rsplit('.').next().unwrap_or_default(); + let mut fields = serde_json::Map::new(); + fields.insert("__command".into(), json!(command)); + fields.insert("__label".into(), json!(label)); + fields.insert("__kind".into(), json!(kind)); + for &(key, _, _, _, default) in crate::panels::adjustment_sliders(kind) { + fields.insert(key.into(), json!(default)); + } + app.ui.open_dialog(DialogKind::Command, fields) +} diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs new file mode 100644 index 00000000..6f09e685 --- /dev/null +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -0,0 +1,700 @@ +//! GPU document canvas: an `egui_wgpu` paint callback that draws one document view with a custom +//! WGSL shader. +//! +//! The document's composite lives on the GPU as one or more mipmapped `Rgba8Unorm` textures +//! (premultiplied alpha, sRGB-encoded values, matching how the CPU path and egui blend). Uploads +//! are either full (new document, size change, re-render) or a damage rect (brush strokes); after +//! an upload only the affected region of each mip level is regenerated by a render-pass blit chain. +//! Documents larger than the device's maximum texture size are split into tiles. +//! +//! Per frame the shader draws, in *device pixels*: +//! - a soft analytic drop shadow around the document on the pasteboard (erf-blurred box), +//! - a screen-space transparency checkerboard (8 pt squares, grays 255/204) under the document, +//! - the document itself, sampled trilinearly when zoomed out, bilinearly between 1× and 2×, and +//! with an anti-aliased "sharp nearest" filter at integer scales and ≥ 2×, +//! - a one-device-pixel pixel grid at zoom ≥ 8. +//! +//! GPU resources live in the renderer's `callback_resources` type map; the per-frame callback only +//! carries plain view parameters, so it is `Send + Sync` on every target (including wasm). +//! +//! The document pixels still come from the CPU compositor (`photocraft-compose`) for now; the wgpu +//! compositor (M5) will render straight into these tiles. + +use std::collections::HashMap; +use std::num::NonZeroU64; + +use eframe::egui_wgpu::{self, CallbackResources, CallbackTrait, RenderState, ScreenDescriptor}; +use eframe::wgpu; +use eframe::wgpu::util::DeviceExt as _; + +/// Default tile side; documents up to this size (and the device limit) use a single texture. +pub const DEFAULT_TILE: u32 = 8192; +const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; +const VIEW_UNIFORM_SIZE: u64 = 64; +const TILE_UNIFORM_SIZE: u64 = 16; + +/// Parameters for drawing one document view. Positions are in egui points. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ViewParams { + /// Document key used with [`GpuCanvas::upload_full`]. + pub doc: u64, + /// Document size in pixels. + pub doc_size: [u32; 2], + /// Screen points per document pixel. + pub zoom: f32, + /// Document point shown at the centre of the canvas rect. + pub center: [f32; 2], + /// Draw the drop shadow on the pasteboard. + pub shadow: bool, + /// Draw the pixel grid at high zoom. + pub pixel_grid: bool, + /// Distinguishes views painted in the same frame (main canvas, extra windows). + pub view_key: u64, +} + +/// Uploads documents to the GPU and paints them. Cheap to clone. +#[derive(Clone)] +pub struct GpuCanvas { + rs: RenderState, + tile: u32, +} + +impl GpuCanvas { + /// Create pipelines and register resources with the egui renderer. + pub fn new(rs: &RenderState) -> Self { + let max = rs.device.limits().max_texture_dimension_2d; + let env_tile = std::env::var("PHOTOCRAFT_GPU_TILE").ok().and_then(|v| v.parse::().ok()); + let tile = env_tile.unwrap_or(DEFAULT_TILE).clamp(64, max); + let res = Resources::new(&rs.device, rs.target_format); + rs.renderer.write().callback_resources.insert(res); + log::info!("gpu canvas: target {:?}, max texture {max}, tile {tile}", rs.target_format); + Self { rs: rs.clone(), tile } + } + + /// Whether document `doc` has been uploaded with this size. + pub fn has(&self, doc: u64, size: [u32; 2]) -> bool { + let r = self.rs.renderer.read(); + r.callback_resources.get::().and_then(|res| res.docs.get(&doc)).is_some_and(|d| d.size == size) + } + + /// Replace the whole document image. `rgba` is premultiplied RGBA8, `size[0] * size[1] * 4` bytes. + pub fn upload_full(&self, doc: u64, size: [u32; 2], rgba: &[u8]) { + assert_eq!(rgba.len(), size[0] as usize * size[1] as usize * 4, "upload_full: buffer size mismatch"); + if size[0] == 0 || size[1] == 0 { + return; + } + let (device, queue) = (&self.rs.device, &self.rs.queue); + let mut renderer = self.rs.renderer.write(); + let Some(res) = renderer.callback_resources.get_mut::() else { return }; + if res.docs.get(&doc).is_none_or(|d| d.size != size) { + let tex = DocTextures::new(device, res, size, self.tile); + res.docs.insert(doc, tex); + } + let d = &res.docs[&doc]; + d.write(queue, [0, 0], size, size[0], rgba); + d.regenerate_mips(device, queue, res, [0, 0, size[0], size[1]]); + } + + /// Replace a rectangle `origin..origin+size` of an uploaded document with premultiplied RGBA8 + /// pixels (`size[0] * size[1] * 4` bytes). Returns false if the document isn't uploaded. + pub fn upload_rect(&self, doc: u64, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) -> bool { + assert_eq!(rgba.len(), size[0] as usize * size[1] as usize * 4, "upload_rect: buffer size mismatch"); + let (device, queue) = (&self.rs.device, &self.rs.queue); + let mut renderer = self.rs.renderer.write(); + let Some(res) = renderer.callback_resources.get_mut::() else { return false }; + let Some(d) = res.docs.get(&doc) else { return false }; + if origin[0] + size[0] > d.size[0] || origin[1] + size[1] > d.size[1] { + return false; + } + if size[0] == 0 || size[1] == 0 { + return true; + } + d.write(queue, origin, size, size[0], rgba); + d.regenerate_mips(device, queue, res, [origin[0], origin[1], origin[0] + size[0], origin[1] + size[1]]); + true + } + + /// Upload a CPU composite covering the whole document. + pub fn upload_buffer_full(&self, doc: u64, buf: &photocraft_compose::Buffer) { + let size = [buf.rect.width(), buf.rect.height()]; + self.upload_full(doc, size, &premultiply_rgba8(&buf.px)); + } + + /// Upload a CPU composite of a sub-rectangle (in document pixels, `buf.rect`). + pub fn upload_buffer_rect(&self, doc: u64, buf: &photocraft_compose::Buffer) -> bool { + let r = buf.rect; + if r.x0 < 0 || r.y0 < 0 { + return false; + } + self.upload_rect(doc, [r.x0 as u32, r.y0 as u32], [r.width(), r.height()], &premultiply_rgba8(&buf.px)) + } + + /// Free textures of documents not in `live`. + pub fn retain(&self, live: &[u64]) { + let mut renderer = self.rs.renderer.write(); + if let Some(res) = renderer.callback_resources.get_mut::() + && res.docs.keys().any(|k| !live.contains(k)) + { + res.docs.retain(|k, _| live.contains(k)); + } + } + + /// Add the paint callback drawing `params` into `rect` (clipped by the painter's clip rect). + pub fn paint(painter: &egui::Painter, rect: egui::Rect, params: ViewParams) { + painter.add(egui_wgpu::Callback::new_paint_callback(rect, CanvasCallback { rect, params })); + } +} + +/// Milliseconds since first call (monotonic). Always 0 on wasm, where `std::time` is unavailable. +pub fn now_ms() -> f64 { + #[cfg(not(target_arch = "wasm32"))] + { + static START: std::sync::OnceLock = std::sync::OnceLock::new(); + START.get_or_init(std::time::Instant::now).elapsed().as_secs_f64() * 1000.0 + } + #[cfg(target_arch = "wasm32")] + 0.0 +} + +/// UI-thread timings (CPU side), exponentially smoothed. +#[derive(Clone, Copy, Debug, Default, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Perf { + /// Average CPU time building the UI per frame (ms). + pub ui_ms: f64, + /// Worst UI frame of the last ~second (ms). + pub ui_max_ms: f64, + /// Last canvas refresh: CPU composite (ms). + pub composite_ms: f64, + /// Last canvas refresh: conversion + upload (ms). + pub upload_ms: f64, + /// Last canvas refresh kind: "full", "rect" or "none". + pub last_refresh: &'static str, + /// Pixels in the last refresh. + pub last_refresh_px: u64, + /// Whether the GPU canvas path is active. + pub gpu: bool, + #[serde(skip)] + max_decay: f64, +} + +impl Perf { + pub fn record(&mut self, kind: &'static str, px: u64, composite_ms: f64, upload_ms: f64) { + self.last_refresh = kind; + self.last_refresh_px = px; + self.composite_ms = composite_ms; + self.upload_ms = upload_ms; + } + pub fn frame(&mut self, ms: f64) { + self.ui_ms = if self.ui_ms == 0.0 { ms } else { self.ui_ms * 0.9 + ms * 0.1 }; + self.max_decay += 1.0; + if ms > self.ui_max_ms || self.max_decay > 60.0 { + self.ui_max_ms = ms; + self.max_decay = 0.0; + } + } +} + +/// Straight-alpha f32 RGBA (sRGB-encoded) → premultiplied RGBA8, split across threads on native. +pub fn premultiply_rgba8(px: &[[f32; 4]]) -> Vec { + fn convert(src: &[[f32; 4]], dst: &mut [u8]) { + for (o, p) in dst.chunks_exact_mut(4).zip(src) { + let a = p[3].clamp(0.0, 1.0); + o[0] = (p[0].clamp(0.0, 1.0) * a * 255.0 + 0.5) as u8; + o[1] = (p[1].clamp(0.0, 1.0) * a * 255.0 + 0.5) as u8; + o[2] = (p[2].clamp(0.0, 1.0) * a * 255.0 + 0.5) as u8; + o[3] = (a * 255.0 + 0.5) as u8; + } + } + let mut out = vec![0u8; px.len() * 4]; + #[cfg(not(target_arch = "wasm32"))] + if px.len() > 1 << 18 { + let threads = std::thread::available_parallelism().map_or(4, |n| n.get()).min(16); + let chunk = px.len().div_ceil(threads); + std::thread::scope(|s| { + for (src, dst) in px.chunks(chunk).zip(out.chunks_mut(chunk * 4)) { + s.spawn(move || convert(src, dst)); + } + }); + return out; + } + convert(px, &mut out); + out +} + +// --------------------------------------------------------------------------------------------- +// GPU resources + +struct Resources { + view_bgl: wgpu::BindGroupLayout, + tile_bgl: wgpu::BindGroupLayout, + shadow_pipeline: wgpu::RenderPipeline, + tile_pipeline: wgpu::RenderPipeline, + sampler: wgpu::Sampler, + mip_bgl: wgpu::BindGroupLayout, + mip_pipeline: wgpu::RenderPipeline, + mip_sampler: wgpu::Sampler, + docs: HashMap, + views: HashMap, + out_linear: bool, +} + +struct ViewGpu { + buffer: wgpu::Buffer, + bind_group: wgpu::BindGroup, + frame_data: [f32; 16], +} + +struct DocTextures { + size: [u32; 2], + tiles: Vec, +} + +struct Tile { + /// Document-pixel rect: x, y, w, h. + rect: [u32; 4], + texture: wgpu::Texture, + levels: Vec, + bind_group: wgpu::BindGroup, + _uniform: wgpu::Buffer, +} + +fn uniform_entry(binding: u32, size: u64, vis: wgpu::ShaderStages) -> wgpu::BindGroupLayoutEntry { + wgpu::BindGroupLayoutEntry { + binding, + visibility: vis, + ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: NonZeroU64::new(size) }, + count: None, + } +} +fn texture_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { + wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { multisampled: false, sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D2 }, + count: None, + } +} +fn sampler_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { + wgpu::BindGroupLayoutEntry { binding, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None } +} + +fn f32_bytes(v: &[f32]) -> Vec { + v.iter().flat_map(|f| f.to_ne_bytes()).collect() +} + +fn pipeline(device: &wgpu::Device, label: &str, layout: &wgpu::PipelineLayout, module: &wgpu::ShaderModule, (vs, fs): (&str, &str), format: wgpu::TextureFormat, blend: Option) -> wgpu::RenderPipeline { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(layout), + vertex: wgpu::VertexState { module, entry_point: Some(vs), buffers: &[], compilation_options: Default::default() }, + primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, + depth_stencil: None, + multisample: wgpu::MultisampleState { count: 1, mask: !0, alpha_to_coverage_enabled: false }, + fragment: Some(wgpu::FragmentState { module, entry_point: Some(fs), targets: &[Some(wgpu::ColorTargetState { format, blend, write_mask: wgpu::ColorWrites::ALL })], compilation_options: Default::default() }), + multiview_mask: None, + cache: None, + }) +} + +impl Resources { + fn new(device: &wgpu::Device, target: wgpu::TextureFormat) -> Self { + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("pc_canvas"), source: wgpu::ShaderSource::Wgsl(CANVAS_WGSL.into()) }); + let vis = wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT; + let view_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("pc_canvas_view"), entries: &[uniform_entry(0, VIEW_UNIFORM_SIZE, vis), sampler_entry(1)] }); + let tile_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("pc_canvas_tile"), entries: &[texture_entry(0), uniform_entry(1, TILE_UNIFORM_SIZE, vis)] }); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("pc_canvas"), bind_group_layouts: &[Some(&view_bgl), Some(&tile_bgl)], immediate_size: 0 }); + let shadow_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("pc_canvas_shadow"), bind_group_layouts: &[Some(&view_bgl)], immediate_size: 0 }); + // Premultiplied "over", with egui's alpha rule (keeps a transparent window's alpha sane). + let blend = wgpu::BlendState { + color: wgpu::BlendComponent { src_factor: wgpu::BlendFactor::One, dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, operation: wgpu::BlendOperation::Add }, + alpha: wgpu::BlendComponent { src_factor: wgpu::BlendFactor::OneMinusDstAlpha, dst_factor: wgpu::BlendFactor::One, operation: wgpu::BlendOperation::Add }, + }; + let shadow_pipeline = pipeline(device, "pc_canvas_shadow", &shadow_layout, &module, ("vs_shadow", "fs_shadow"), target, Some(blend)); + let tile_pipeline = pipeline(device, "pc_canvas_tile", &layout, &module, ("vs_tile", "fs_tile"), target, Some(blend)); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("pc_canvas_trilinear"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::MipmapFilterMode::Linear, + ..Default::default() + }); + + let mip_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("pc_mip"), source: wgpu::ShaderSource::Wgsl(MIP_WGSL.into()) }); + let mip_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("pc_mip"), entries: &[texture_entry(0), sampler_entry(1)] }); + let mip_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("pc_mip"), bind_group_layouts: &[Some(&mip_bgl)], immediate_size: 0 }); + let mip_pipeline = pipeline(device, "pc_mip", &mip_layout, &mip_module, ("vs", "fs"), FORMAT, None); + let mip_sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("pc_mip"), mag_filter: wgpu::FilterMode::Linear, min_filter: wgpu::FilterMode::Linear, ..Default::default() }); + Self { view_bgl, tile_bgl, shadow_pipeline, tile_pipeline, sampler, mip_bgl, mip_pipeline, mip_sampler, docs: HashMap::new(), views: HashMap::new(), out_linear: target.is_srgb() } + } +} + +fn mip_count(w: u32, h: u32) -> u32 { + 32 - w.max(h).max(1).leading_zeros() +} + +impl DocTextures { + fn new(device: &wgpu::Device, res: &Resources, size: [u32; 2], tile: u32) -> Self { + let mut tiles = Vec::new(); + for ty in (0..size[1]).step_by(tile as usize) { + for tx in (0..size[0]).step_by(tile as usize) { + let (w, h) = (tile.min(size[0] - tx), tile.min(size[1] - ty)); + let levels_n = mip_count(w, h); + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("pc_canvas_tile"), + size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + mip_level_count: levels_n, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FORMAT, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let levels = (0..levels_n).map(|l| texture.create_view(&wgpu::TextureViewDescriptor { base_mip_level: l, mip_level_count: Some(1), ..Default::default() })).collect(); + let full = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("pc_canvas_tile"), contents: &f32_bytes(&[tx as f32, ty as f32, w as f32, h as f32]), usage: wgpu::BufferUsages::UNIFORM }); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("pc_canvas_tile"), + layout: &res.tile_bgl, + entries: &[wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&full) }, wgpu::BindGroupEntry { binding: 1, resource: uniform.as_entire_binding() }], + }); + tiles.push(Tile { rect: [tx, ty, w, h], texture, levels, bind_group, _uniform: uniform }); + } + } + Self { size, tiles } + } + + /// Write `size` pixels at document `origin` from `rgba` (row stride `stride` pixels) into every + /// overlapping tile's level 0. + fn write(&self, queue: &wgpu::Queue, origin: [u32; 2], size: [u32; 2], stride: u32, rgba: &[u8]) { + let (x0, y0, x1, y1) = (origin[0], origin[1], origin[0] + size[0], origin[1] + size[1]); + for t in &self.tiles { + let [tx, ty, tw, th] = t.rect; + let (ix0, iy0, ix1, iy1) = (x0.max(tx), y0.max(ty), x1.min(tx + tw), y1.min(ty + th)); + if ix0 >= ix1 || iy0 >= iy1 { + continue; + } + let offset = ((iy0 - y0) as u64 * stride as u64 + (ix0 - x0) as u64) * 4; + queue.write_texture( + wgpu::TexelCopyTextureInfo { texture: &t.texture, mip_level: 0, origin: wgpu::Origin3d { x: ix0 - tx, y: iy0 - ty, z: 0 }, aspect: wgpu::TextureAspect::All }, + rgba, + wgpu::TexelCopyBufferLayout { offset, bytes_per_row: Some(stride * 4), rows_per_image: Some(iy1 - iy0) }, + wgpu::Extent3d { width: ix1 - ix0, height: iy1 - iy0, depth_or_array_layers: 1 }, + ); + } + } + + /// Rebuild the mip chain inside document rect `r` (x0, y0, x1, y1) with 2×2 box filtering. + fn regenerate_mips(&self, device: &wgpu::Device, queue: &wgpu::Queue, res: &Resources, r: [u32; 4]) { + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_mips") }); + for t in &self.tiles { + let [tx, ty, tw, th] = t.rect; + let (mut x0, mut y0, mut x1, mut y1) = (r[0].max(tx), r[1].max(ty), r[2].min(tx + tw), r[3].min(ty + th)); + if x0 >= x1 || y0 >= y1 { + continue; + } + (x0, y0, x1, y1) = (x0 - tx, y0 - ty, x1 - tx, y1 - ty); + for level in 1..t.levels.len() { + let (lw, lh) = ((tw >> level).max(1), (th >> level).max(1)); + // Damage at this level, padded by one texel for odd-size rounding. + (x0, y0) = ((x0 / 2).saturating_sub(1), (y0 / 2).saturating_sub(1)); + (x1, y1) = ((x1.div_ceil(2) + 1).min(lw), (y1.div_ceil(2) + 1).min(lh)); + let bg = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("pc_mip"), + layout: &res.mip_bgl, + entries: &[wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&t.levels[level - 1]) }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&res.mip_sampler) }], + }); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("pc_mip"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &t.levels[level], resolve_target: None, depth_slice: None, ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store } })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&res.mip_pipeline); + pass.set_bind_group(0, &bg, &[]); + pass.set_scissor_rect(x0, y0, x1 - x0, y1 - y0); + pass.draw(0..3, 0..1); + } + } + queue.submit([encoder.finish()]); + } +} + +// --------------------------------------------------------------------------------------------- +// Per-frame callback + +struct CanvasCallback { + rect: egui::Rect, + params: ViewParams, +} + +/// Sampling mode: 0 trilinear, 1 bilinear, 2 anti-aliased nearest. +fn filter_mode(scale: f32) -> (f32, f32) { + if scale < 0.999 { + (0.0, (-scale.log2()).max(0.0)) + } else if (scale - scale.round()).abs() < 1e-3 || scale >= 2.0 { + (2.0, 0.0) + } else { + (1.0, 0.0) + } +} + +impl CanvasCallback { + /// Screen-pixel origin of document (0, 0) (snapped to whole pixels) and device px per doc px. + fn placement(&self, ppp: f32) -> ([f32; 2], f32) { + let p = &self.params; + let scale = p.zoom * ppp; + let c = self.rect.center(); + let ox = (c.x * ppp - p.center[0] * scale).round(); + let oy = (c.y * ppp - p.center[1] * scale).round(); + ([ox, oy], scale) + } + + fn uniforms(&self, screen: [u32; 2], ppp: f32, out_linear: bool) -> [f32; 16] { + let p = &self.params; + let (origin, scale) = self.placement(ppp); + let (mode, lod) = filter_mode(scale); + let grid = if p.pixel_grid && p.zoom >= 8.0 { 0.16 } else { 0.0 }; + [ + screen[0] as f32, screen[1] as f32, scale, ppp, + origin[0], origin[1], p.doc_size[0] as f32, p.doc_size[1] as f32, + mode, lod, grid, (8.0 * ppp).round().max(1.0), + (self.rect.min.x * ppp).round(), (self.rect.min.y * ppp).round(), 0.0, if out_linear { 1.0 } else { 0.0 }, + ] + } +} + +impl CallbackTrait for CanvasCallback { + fn prepare(&self, device: &wgpu::Device, queue: &wgpu::Queue, screen: &ScreenDescriptor, _encoder: &mut wgpu::CommandEncoder, resources: &mut CallbackResources) -> Vec { + let Some(res) = resources.get_mut::() else { return Vec::new() }; + let data = self.uniforms(screen.size_in_pixels, screen.pixels_per_point, res.out_linear); + let (bgl, sampler) = (&res.view_bgl, &res.sampler); + let v = res.views.entry(self.params.view_key).or_insert_with(|| { + let buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("pc_canvas_view"), size: VIEW_UNIFORM_SIZE, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("pc_canvas_view"), + layout: bgl, + entries: &[wgpu::BindGroupEntry { binding: 0, resource: buffer.as_entire_binding() }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(sampler) }], + }); + ViewGpu { buffer, bind_group, frame_data: [f32::NAN; 16] } + }); + if v.frame_data != data { + queue.write_buffer(&v.buffer, 0, &f32_bytes(&data)); + v.frame_data = data; + } + Vec::new() + } + + fn paint(&self, info: egui::PaintCallbackInfo, pass: &mut wgpu::RenderPass<'static>, resources: &CallbackResources) { + let Some(res) = resources.get::() else { return }; + let Some(view) = res.views.get(&self.params.view_key) else { return }; + let [sw, sh] = info.screen_size_px; + let clip = info.clip_rect_in_pixels(); + if clip.width_px <= 0 || clip.height_px <= 0 || sw == 0 || sh == 0 { + return; + } + // Work in whole-screen pixel coordinates; the scissor does the clipping. + pass.set_viewport(0.0, 0.0, sw as f32, sh as f32, 0.0, 1.0); + pass.set_scissor_rect(clip.left_px.max(0) as u32, clip.top_px.max(0) as u32, clip.width_px as u32, clip.height_px as u32); + pass.set_bind_group(0, &view.bind_group, &[]); + if self.params.shadow { + pass.set_pipeline(&res.shadow_pipeline); + pass.draw(0..6, 0..1); + } + let Some(doc) = res.docs.get(&self.params.doc) else { return }; + if doc.size != self.params.doc_size { + return; + } + let (origin, scale) = self.placement(info.pixels_per_point); + let (cx0, cy0) = (clip.left_px as f32, clip.top_px as f32); + let (cx1, cy1) = (cx0 + clip.width_px as f32, cy0 + clip.height_px as f32); + pass.set_pipeline(&res.tile_pipeline); + for t in &doc.tiles { + let [tx, ty, tw, th] = t.rect.map(|v| v as f32); + let (x0, y0) = (origin[0] + tx * scale, origin[1] + ty * scale); + let (x1, y1) = (x0 + tw * scale, y0 + th * scale); + if x1 < cx0 || y1 < cy0 || x0 > cx1 || y0 > cy1 { + continue; + } + pass.set_bind_group(1, &t.bind_group, &[]); + pass.draw(0..6, 0..1); + } + } +} + +const CANVAS_WGSL: &str = r#" +struct View { + a: vec4, // screen_w, screen_h, scale (device px per doc px), pixels_per_point + b: vec4, // doc origin x, y (device px), doc w, h (doc px) + c: vec4, // filter mode, lod, grid alpha, checker square (device px) + d: vec4, // checker anchor x, y (device px), unused, output linear (sRGB target) +}; +struct Tile { r: vec4 }; // x, y, w, h in doc px + +@group(0) @binding(0) var view: View; +@group(0) @binding(1) var samp: sampler; +@group(1) @binding(0) var tex: texture_2d; +@group(1) @binding(1) var tile: Tile; + +struct VOut { @builtin(position) pos: vec4 }; + +fn corner(i: u32) -> vec2 { + var c = array, 6>(vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(0.0, 1.0), vec2(0.0, 1.0), vec2(1.0, 0.0), vec2(1.0, 1.0)); + return c[i]; +} + +fn px_to_clip(p: vec2) -> vec4 { + let n = p / view.a.xy * 2.0 - 1.0; + return vec4(n.x, -n.y, 0.0, 1.0); +} + +fn doc_min() -> vec2 { return view.b.xy; } +fn doc_max() -> vec2 { return view.b.xy + view.b.zw * view.a.z; } + +fn srgb_to_linear(c: vec3) -> vec3 { + let lo = c / 12.92; + let hi = pow((c + 0.055) / 1.055, vec3(2.4)); + return select(hi, lo, c <= vec3(0.04045)); +} + +// --- drop shadow --------------------------------------------------------------------------- + +fn erf2(x: vec2) -> vec2 { + let s = sign(x); + let a = abs(x); + var t = 1.0 + (0.278393 + (0.230389 + 0.078108 * (a * a)) * a) * a; + t = t * t; + return s - s / (t * t); +} + +// Coverage of the box [lo, hi] blurred by a Gaussian of std-dev `sigma`, at point p. +fn box_blur(p: vec2, lo: vec2, hi: vec2, sigma: f32) -> f32 { + let k = 0.70710678 / sigma; + let q = (erf2((p - lo) * k) - erf2((p - hi) * k)) * 0.5; + return q.x * q.y; +} + +@vertex +fn vs_shadow(@builtin(vertex_index) vi: u32) -> VOut { + let pad = 64.0 * view.a.w; + let p = mix(doc_min() - vec2(pad), doc_max() + vec2(pad), corner(vi)); + return VOut(px_to_clip(p)); +} + +@fragment +fn fs_shadow(in: VOut) -> @location(0) vec4 { + let p = in.pos.xy; + let lo = doc_min(); + let hi = doc_max(); + if (all(p >= lo) && all(p < hi)) { + discard; + } + let u = view.a.w; + let ambient = box_blur(p - vec2(0.0, 7.0 * u), lo, hi, 16.0 * u) * 0.40; + let contact = box_blur(p - vec2(0.0, 0.75 * u), lo, hi, 1.25 * u) * 0.30; + let a = 1.0 - (1.0 - ambient) * (1.0 - contact); + return vec4(0.0, 0.0, 0.0, a); +} + +// --- document tiles ------------------------------------------------------------------------ + +@vertex +fn vs_tile(@builtin(vertex_index) vi: u32) -> VOut { + let p = view.b.xy + (tile.r.xy + tile.r.zw * corner(vi)) * view.a.z; + return VOut(px_to_clip(p)); +} + +fn checker(p: vec2) -> vec3 { + let c = floor((p - view.d.xy) / view.c.w); + let odd = fract((c.x + c.y) * 0.5) > 0.25; + return select(vec3(1.0), vec3(0.8), odd); +} + +@fragment +fn fs_tile(in: VOut) -> @location(0) vec4 { + let p = in.pos.xy; + let scale = view.a.z; + let d = (p - view.b.xy) / scale; // document pixel coordinates + let size = tile.r.zw; + let t = d - tile.r.xy; // texel coordinates within this tile + let mode = view.c.x; + var col: vec4; + if (mode < 0.5) { + // Zoomed out: trilinear through the mip chain. + col = textureSampleLevel(tex, samp, t / size, view.c.y); + } else if (mode < 1.5) { + col = textureSampleLevel(tex, samp, t / size, 0.0); + } else { + // Crisp texels with a one-device-pixel anti-aliased edge ("sharp bilinear"). + let tt = t - 0.5; + let i = floor(tt); + let f = clamp((tt - i - 0.5) * scale + 0.5, vec2(0.0), vec2(1.0)); + col = textureSampleLevel(tex, samp, (i + 0.5 + f) / size, 0.0); + } + var rgb = col.rgb + checker(p) * (1.0 - col.a); + let grid = view.c.z; + if (grid > 0.0) { + let e = fract(d) * scale; + if (e.x < 1.0 || e.y < 1.0) { + let l = dot(rgb, vec3(0.299, 0.587, 0.114)); + rgb = mix(rgb, select(vec3(1.0), vec3(0.0), l > 0.55), grid); + } + } + if (view.d.w > 0.5) { + rgb = srgb_to_linear(rgb); + } + return vec4(rgb, 1.0); +} +"#; + +const MIP_WGSL: &str = r#" +@group(0) @binding(0) var src: texture_2d; +@group(0) @binding(1) var samp: sampler; + +struct V { @builtin(position) pos: vec4, @location(0) uv: vec2 }; + +@vertex +fn vs(@builtin(vertex_index) vi: u32) -> V { + let uv = vec2(f32((vi << 1u) & 2u), f32(vi & 2u)); + return V(vec4(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0), uv); +} + +@fragment +fn fs(in: V) -> @location(0) vec4 { + // A bilinear tap at the destination texel centre averages the 2×2 source block. + return textureSampleLevel(src, samp, in.uv, 0.0); +} +"#; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn filter_modes() { + assert_eq!(filter_mode(0.25), (0.0, 2.0)); + assert_eq!(filter_mode(1.0).0, 2.0); + assert_eq!(filter_mode(1.5).0, 1.0); + assert_eq!(filter_mode(3.3).0, 2.0); + } + + #[test] + fn mips_and_premultiply() { + assert_eq!(mip_count(1, 1), 1); + assert_eq!(mip_count(4096, 100), 13); + assert_eq!(mip_count(5, 3), 3); + assert_eq!(premultiply_rgba8(&[[1.0, 0.5, 0.0, 0.5]]), vec![128, 64, 0, 128]); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn shaders_validate() { + use wgpu::naga; + for (name, src) in [("canvas", CANVAS_WGSL), ("mip", MIP_WGSL)] { + let module = naga::front::wgsl::parse_str(src).unwrap_or_else(|e| panic!("{name}: {}", e.emit_to_string(src))); + naga::valid::Validator::new(naga::valid::ValidationFlags::all(), naga::valid::Capabilities::empty()).validate(&module).unwrap_or_else(|e| panic!("{name}: {e:?}")); + } + } +} diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs new file mode 100644 index 00000000..9bc6d140 --- /dev/null +++ b/crates/ui-egui/src/icon_data.rs @@ -0,0 +1,75 @@ +//! Lucide icons (ISC licence, see assets/icons/LICENSE-lucide.txt), embedded and tinted at runtime. +//! Photon Studio uses the same icon family; it is also what gives Photocraft its clean line-art look. + +pub static ICONS: &[(&str, &[u8])] = &[ + ("app-window", include_bytes!("../../../assets/icons/app-window.svg")), + ("arrow-left-right", include_bytes!("../../../assets/icons/arrow-left-right.svg")), + ("bandage", include_bytes!("../../../assets/icons/bandage.svg")), + ("blend", include_bytes!("../../../assets/icons/blend.svg")), + ("brush-cleaning", include_bytes!("../../../assets/icons/brush-cleaning.svg")), + ("brush", include_bytes!("../../../assets/icons/brush.svg")), + ("check", include_bytes!("../../../assets/icons/check.svg")), + ("chevron-down", include_bytes!("../../../assets/icons/chevron-down.svg")), + ("chevron-right", include_bytes!("../../../assets/icons/chevron-right.svg")), + ("chevrons-left", include_bytes!("../../../assets/icons/chevrons-left.svg")), + ("chevrons-right", include_bytes!("../../../assets/icons/chevrons-right.svg")), + ("circle-dashed", include_bytes!("../../../assets/icons/circle-dashed.svg")), + ("circle-dot", include_bytes!("../../../assets/icons/circle-dot.svg")), + ("clock", include_bytes!("../../../assets/icons/clock.svg")), + ("compass", include_bytes!("../../../assets/icons/compass.svg")), + ("contrast", include_bytes!("../../../assets/icons/contrast.svg")), + ("copy", include_bytes!("../../../assets/icons/copy.svg")), + ("crop", include_bytes!("../../../assets/icons/crop.svg")), + ("droplet", include_bytes!("../../../assets/icons/droplet.svg")), + ("ellipsis", include_bytes!("../../../assets/icons/ellipsis.svg")), + ("eraser", include_bytes!("../../../assets/icons/eraser.svg")), + ("eye-off", include_bytes!("../../../assets/icons/eye-off.svg")), + ("eye", include_bytes!("../../../assets/icons/eye.svg")), + ("file-plus", include_bytes!("../../../assets/icons/file-plus.svg")), + ("folder-open", include_bytes!("../../../assets/icons/folder-open.svg")), + ("folder-plus", include_bytes!("../../../assets/icons/folder-plus.svg")), + ("folder", include_bytes!("../../../assets/icons/folder.svg")), + ("gauge", include_bytes!("../../../assets/icons/gauge.svg")), + ("grid-3x3", include_bytes!("../../../assets/icons/grid-3x3.svg")), + ("hand", include_bytes!("../../../assets/icons/hand.svg")), + ("image", include_bytes!("../../../assets/icons/image.svg")), + ("info", include_bytes!("../../../assets/icons/info.svg")), + ("lasso", include_bytes!("../../../assets/icons/lasso.svg")), + ("layers", include_bytes!("../../../assets/icons/layers.svg")), + ("link", include_bytes!("../../../assets/icons/link.svg")), + ("lock-open", include_bytes!("../../../assets/icons/lock-open.svg")), + ("lock", include_bytes!("../../../assets/icons/lock.svg")), + ("maximize-2", include_bytes!("../../../assets/icons/maximize-2.svg")), + ("message-square", include_bytes!("../../../assets/icons/message-square.svg")), + ("minus", include_bytes!("../../../assets/icons/minus.svg")), + ("moon", include_bytes!("../../../assets/icons/moon.svg")), + ("mouse-pointer-2", include_bytes!("../../../assets/icons/mouse-pointer-2.svg")), + ("move", include_bytes!("../../../assets/icons/move.svg")), + ("navigation", include_bytes!("../../../assets/icons/navigation.svg")), + ("paint-bucket", include_bytes!("../../../assets/icons/paint-bucket.svg")), + ("palette", include_bytes!("../../../assets/icons/palette.svg")), + ("panel-right", include_bytes!("../../../assets/icons/panel-right.svg")), + ("panels-top-left", include_bytes!("../../../assets/icons/panels-top-left.svg")), + ("pen-tool", include_bytes!("../../../assets/icons/pen-tool.svg")), + ("pipette", include_bytes!("../../../assets/icons/pipette.svg")), + ("plus", include_bytes!("../../../assets/icons/plus.svg")), + ("redo-2", include_bytes!("../../../assets/icons/redo-2.svg")), + ("scan", include_bytes!("../../../assets/icons/scan.svg")), + ("scissors", include_bytes!("../../../assets/icons/scissors.svg")), + ("search", include_bytes!("../../../assets/icons/search.svg")), + ("settings", include_bytes!("../../../assets/icons/settings.svg")), + ("sliders-horizontal", include_bytes!("../../../assets/icons/sliders-horizontal.svg")), + ("sparkles", include_bytes!("../../../assets/icons/sparkles.svg")), + ("square-dashed", include_bytes!("../../../assets/icons/square-dashed.svg")), + ("square", include_bytes!("../../../assets/icons/square.svg")), + ("squares-subtract", include_bytes!("../../../assets/icons/squares-subtract.svg")), + ("stamp", include_bytes!("../../../assets/icons/stamp.svg")), + ("sun", include_bytes!("../../../assets/icons/sun.svg")), + ("swatch-book", include_bytes!("../../../assets/icons/swatch-book.svg")), + ("trash", include_bytes!("../../../assets/icons/trash.svg")), + ("type", include_bytes!("../../../assets/icons/type.svg")), + ("undo-2", include_bytes!("../../../assets/icons/undo-2.svg")), + ("wand-sparkles", include_bytes!("../../../assets/icons/wand-sparkles.svg")), + ("x", include_bytes!("../../../assets/icons/x.svg")), + ("zoom-in", include_bytes!("../../../assets/icons/zoom-in.svg")), +]; diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs new file mode 100644 index 00000000..f373c4c4 --- /dev/null +++ b/crates/ui-egui/src/icons.rs @@ -0,0 +1,109 @@ +//! Icon rendering. SVGs are embedded (see `icon_data.rs`), recoloured to white, rasterized by the +//! egui_extras SVG loader at the exact on-screen pixel size (crisp at any DPI), and tinted per use. +//! +//! (History: the first shell used Unicode glyphs; half of them rendered as tofu boxes because the +//! bundled fonts lacked them. Vector icons fix that for good.) + +use std::collections::HashMap; +use std::sync::{Arc, OnceLock}; + +use egui::{Color32, Rect, Response, Sense, Vec2}; + +use crate::icon_data::ICONS; +use crate::state::Tool; +use crate::theme::Tokens; + +fn white_icons() -> &'static HashMap<&'static str, Arc<[u8]>> { + static MAP: OnceLock>> = OnceLock::new(); + MAP.get_or_init(|| { + ICONS + .iter() + .map(|(name, bytes)| { + let svg = String::from_utf8_lossy(bytes).replace("currentColor", "#ffffff").replace("stroke-width=\"2\"", "stroke-width=\"1.75\""); + (*name, Arc::from(svg.into_bytes().into_boxed_slice())) + }) + .collect() + }) +} + +pub fn exists(name: &str) -> bool { + white_icons().contains_key(name) +} + +/// An egui image for an icon, tinted. +pub fn image(name: &str, size: f32, tint: Color32) -> egui::Image<'static> { + let bytes = white_icons().get(name).or_else(|| white_icons().get("square")).cloned().expect("square icon present"); + egui::Image::from_bytes(format!("bytes://icons/{name}.svg"), egui::load::Bytes::Shared(bytes)).fit_to_exact_size(Vec2::splat(size)).tint(tint) +} + +/// Paint an icon centred in `rect`. +pub fn paint(ui: &egui::Ui, rect: Rect, name: &str, size: f32, tint: Color32) { + let r = Rect::from_center_size(rect.center(), Vec2::splat(size)); + image(name, size, tint).paint_at(ui, r); +} + +pub fn tool_icon(t: Tool) -> &'static str { + match t { + Tool::Move => "move", + Tool::RectMarquee => "square-dashed", + Tool::EllipseMarquee => "circle-dashed", + Tool::Brush => "brush", + Tool::Eraser => "eraser", + Tool::Eyedropper => "pipette", + Tool::Hand => "hand", + Tool::Zoom => "zoom-in", + } +} + +/// Square icon button: transparent until hovered; `selected` gets the accent treatment. +pub fn button(ui: &mut egui::Ui, name: &str, box_size: f32, selected: bool, tooltip: &str) -> Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(box_size), Sense::click()); + let hovered = resp.hovered(); + if selected { + ui.painter().rect_filled(rect, t.radius_sm, t.accent_soft); + ui.painter().rect_stroke(rect, t.radius_sm, egui::Stroke::new(1.0, t.accent_border), egui::StrokeKind::Inside); + } else if hovered { + ui.painter().rect_filled(rect, t.radius_sm, t.hover); + } + let tint = if selected { t.accent_text } else if hovered { t.text } else { t.icon }; + paint(ui, rect, name, (box_size * 0.52).round(), tint); + if tooltip.is_empty() { resp } else { resp.on_hover_text(tooltip) } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_tool_has_an_icon() { + for t in Tool::ALL { + assert!(exists(tool_icon(t)), "{t:?}"); + } + } + + #[test] + fn icons_are_recoloured() { + let m = white_icons(); + assert!(m.len() >= 60); + for (name, b) in m.iter() { + let s = std::str::from_utf8(b).unwrap(); + assert!(!s.contains("currentColor"), "{name}"); + } + } +} + +/// Rail toggle: "on" gets a quiet filled background and full-strength icon (no accent). +pub fn rail_button(ui: &mut egui::Ui, name: &str, box_size: f32, on: bool, tooltip: &str) -> Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(box_size), Sense::click()); + if on { + ui.painter().rect_filled(rect, t.radius_sm, t.card); + ui.painter().rect_stroke(rect, t.radius_sm, egui::Stroke::new(1.0, t.card_border), egui::StrokeKind::Inside); + } else if resp.hovered() { + ui.painter().rect_filled(rect, t.radius_sm, t.hover); + } + let tint = if on || resp.hovered() { t.text } else { t.text_faint }; + paint(ui, rect, name, (box_size * 0.52).round(), tint); + resp.on_hover_text(tooltip) +} diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs new file mode 100644 index 00000000..133fbbe7 --- /dev/null +++ b/crates/ui-egui/src/layer_style.rs @@ -0,0 +1,337 @@ +//! Photoshop-style Layer Style dialog: effect list with checkboxes on the left, parameters on the right. +//! State lives in the dialog's `fields` (JSON), so automation can drive it like any other dialog. + +use egui::{Color32, RichText, Sense, Stroke, StrokeKind, vec2}; +use photocraft_doc::{Effect, Layer}; +use photocraft_doc::effects::{FxPaint, StrokePosition}; +use serde_json::{Map, Value, json}; + +use crate::theme::Tokens; +use crate::{PhotocraftApp, widgets}; + +#[derive(Clone, Copy)] +enum P { + Slider(f32, f32, &'static str), + Color, + Blend, + Choice(&'static [(&'static str, &'static str)]), + Check, +} + +/// Effect kinds in Photoshop's list order: (command kind, label, params). +pub const KINDS: &[(&str, &str)] = &[ + ("bevelEmboss", "Bevel & Emboss"), + ("stroke", "Stroke"), + ("innerShadow", "Inner Shadow"), + ("innerGlow", "Inner Glow"), + ("satin", "Satin"), + ("colorOverlay", "Color Overlay"), + ("gradientOverlay", "Gradient Overlay"), + ("outerGlow", "Outer Glow"), + ("dropShadow", "Drop Shadow"), +]; + +fn spec(kind: &str) -> &'static [(&'static str, &'static str, P)] { + const POS: &[(&str, &str)] = &[("outside", "Outside"), ("inside", "Inside"), ("center", "Center")]; + const GSTYLE: &[(&str, &str)] = &[("linear", "Linear"), ("radial", "Radial"), ("angle", "Angle"), ("reflected", "Reflected"), ("diamond", "Diamond")]; + const BSTYLE: &[(&str, &str)] = &[("inner", "Inner Bevel"), ("outer", "Outer Bevel"), ("emboss", "Emboss"), ("pillow", "Pillow Emboss")]; + const DIR: &[(&str, &str)] = &[("up", "Up"), ("down", "Down")]; + const SRC: &[(&str, &str)] = &[("edge", "Edge"), ("center", "Center")]; + match kind { + "dropShadow" => &[("blend", "Blend Mode", P::Blend), ("color", "Color", P::Color), ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), ("angle", "Angle", P::Slider(-180.0, 180.0, "°")), ("distance", "Distance", P::Slider(0.0, 300.0, "px")), ("spread", "Spread", P::Slider(0.0, 100.0, "%")), ("size", "Size", P::Slider(0.0, 250.0, "px")), ("knocksOut", "Layer Knocks Out Drop Shadow", P::Check)], + "innerShadow" => &[("blend", "Blend Mode", P::Blend), ("color", "Color", P::Color), ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), ("angle", "Angle", P::Slider(-180.0, 180.0, "°")), ("distance", "Distance", P::Slider(0.0, 300.0, "px")), ("choke", "Choke", P::Slider(0.0, 100.0, "%")), ("size", "Size", P::Slider(0.0, 250.0, "px"))], + "outerGlow" => &[("blend", "Blend Mode", P::Blend), ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), ("color", "Color", P::Color), ("spread", "Spread", P::Slider(0.0, 100.0, "%")), ("size", "Size", P::Slider(0.0, 250.0, "px")), ("range", "Range", P::Slider(1.0, 100.0, "%"))], + "innerGlow" => &[("blend", "Blend Mode", P::Blend), ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), ("color", "Color", P::Color), ("source", "Source", P::Choice(SRC)), ("choke", "Choke", P::Slider(0.0, 100.0, "%")), ("size", "Size", P::Slider(0.0, 250.0, "px"))], + "stroke" => &[("size", "Size", P::Slider(1.0, 250.0, "px")), ("position", "Position", P::Choice(POS)), ("blend", "Blend Mode", P::Blend), ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), ("color", "Color", P::Color)], + "colorOverlay" => &[("blend", "Blend Mode", P::Blend), ("color", "Color", P::Color), ("opacity", "Opacity", P::Slider(0.0, 100.0, "%"))], + "gradientOverlay" => &[("blend", "Blend Mode", P::Blend), ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), ("from", "From", P::Color), ("to", "To", P::Color), ("reverse", "Reverse", P::Check), ("style", "Style", P::Choice(GSTYLE)), ("angle", "Angle", P::Slider(-180.0, 180.0, "°")), ("scale", "Scale", P::Slider(10.0, 150.0, "%"))], + "bevelEmboss" => &[("style", "Style", P::Choice(BSTYLE)), ("depth", "Depth", P::Slider(1.0, 1000.0, "%")), ("direction", "Direction", P::Choice(DIR)), ("size", "Size", P::Slider(0.0, 250.0, "px")), ("soften", "Soften", P::Slider(0.0, 16.0, "px")), ("angle", "Angle", P::Slider(-180.0, 180.0, "°")), ("altitude", "Altitude", P::Slider(0.0, 90.0, "°"))], + "satin" => &[("blend", "Blend Mode", P::Blend), ("color", "Color", P::Color), ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), ("angle", "Angle", P::Slider(-180.0, 180.0, "°")), ("distance", "Distance", P::Slider(0.0, 250.0, "px")), ("size", "Size", P::Slider(0.0, 250.0, "px")), ("invert", "Invert", P::Check)], + _ => &[], + } +} + +fn defaults(kind: &str) -> Value { + match kind { + "dropShadow" => json!({"blend": "Multiply", "color": "#000000", "opacity": 75, "angle": 120, "distance": 5, "spread": 0, "size": 5, "knocksOut": true}), + "innerShadow" => json!({"blend": "Multiply", "color": "#000000", "opacity": 75, "angle": 120, "distance": 5, "choke": 0, "size": 5}), + "outerGlow" => json!({"blend": "Screen", "opacity": 75, "color": "#ffffbe", "spread": 0, "size": 5, "range": 50}), + "innerGlow" => json!({"blend": "Screen", "opacity": 75, "color": "#ffffbe", "source": "edge", "choke": 0, "size": 5}), + "stroke" => json!({"size": 3, "position": "outside", "blend": "Normal", "opacity": 100, "color": "#000000"}), + "colorOverlay" => json!({"blend": "Normal", "color": "#ff0000", "opacity": 100}), + "gradientOverlay" => json!({"blend": "Normal", "opacity": 100, "from": "#000000", "to": "#ffffff", "reverse": false, "style": "linear", "angle": 90, "scale": 100}), + "bevelEmboss" => json!({"style": "inner", "depth": 100, "direction": "up", "size": 5, "soften": 0, "angle": 120, "altitude": 30}), + "satin" => json!({"blend": "Multiply", "color": "#000000", "opacity": 50, "angle": 19, "distance": 11, "size": 14, "invert": true}), + _ => json!({}), + } +} + +fn hex(c: &photocraft_doc::Color) -> String { + let [r, g, b, _] = c.to_rgba8(); + format!("#{r:02x}{g:02x}{b:02x}") +} + +fn kind_of(e: &Effect) -> &'static str { + match e { + Effect::DropShadow(_) => "dropShadow", + Effect::InnerShadow(_) => "innerShadow", + Effect::OuterGlow(_) => "outerGlow", + Effect::InnerGlow(_) => "innerGlow", + Effect::Stroke(_) => "stroke", + Effect::ColorOverlay { .. } => "colorOverlay", + Effect::GradientOverlay { .. } => "gradientOverlay", + Effect::PatternOverlay { .. } => "patternOverlay", + Effect::Satin(_) => "satin", + Effect::BevelEmboss(_) => "bevelEmboss", + } +} + +/// Current values of an existing effect, in the command's parameter units. +fn values_of(e: &Effect) -> Value { + let mut v = defaults(kind_of(e)); + let set = |v: &mut Value, k: &str, x: Value| { + v[k] = x; + }; + match e { + Effect::DropShadow(s) | Effect::InnerShadow(s) => { + set(&mut v, "blend", json!(s.common.blend.label())); + set(&mut v, "opacity", json!((s.common.opacity * 100.0).round())); + set(&mut v, "color", json!(hex(&s.color))); + set(&mut v, "angle", json!(s.angle)); + set(&mut v, "distance", json!(s.distance)); + set(&mut v, if matches!(e, Effect::DropShadow(_)) { "spread" } else { "choke" }, json!(s.spread)); + set(&mut v, "size", json!(s.size)); + } + Effect::OuterGlow(g) | Effect::InnerGlow(g) => { + set(&mut v, "blend", json!(g.common.blend.label())); + set(&mut v, "opacity", json!((g.common.opacity * 100.0).round())); + if let FxPaint::Color(c) = &g.paint { + set(&mut v, "color", json!(hex(c))); + } + set(&mut v, if matches!(e, Effect::OuterGlow(_)) { "spread" } else { "choke" }, json!(g.spread)); + set(&mut v, "size", json!(g.size)); + } + Effect::Stroke(s) => { + set(&mut v, "blend", json!(s.common.blend.label())); + set(&mut v, "opacity", json!((s.common.opacity * 100.0).round())); + set(&mut v, "size", json!(s.size)); + set(&mut v, "position", json!(match s.position { StrokePosition::Inside => "inside", StrokePosition::Center => "center", _ => "outside" })); + if let FxPaint::Color(c) = &s.paint { + set(&mut v, "color", json!(hex(c))); + } + } + Effect::ColorOverlay { common, color } => { + set(&mut v, "blend", json!(common.blend.label())); + set(&mut v, "opacity", json!((common.opacity * 100.0).round())); + set(&mut v, "color", json!(hex(color))); + } + Effect::GradientOverlay { common, .. } => { + set(&mut v, "blend", json!(common.blend.label())); + set(&mut v, "opacity", json!((common.opacity * 100.0).round())); + } + Effect::Satin(s) => { + set(&mut v, "blend", json!(s.common.blend.label())); + set(&mut v, "opacity", json!((s.common.opacity * 100.0).round())); + set(&mut v, "color", json!(hex(&s.color))); + set(&mut v, "angle", json!(s.angle)); + set(&mut v, "distance", json!(s.distance)); + set(&mut v, "size", json!(s.size)); + set(&mut v, "invert", json!(s.invert)); + } + Effect::BevelEmboss(b) => { + set(&mut v, "depth", json!(b.depth)); + set(&mut v, "size", json!(b.size)); + set(&mut v, "soften", json!(b.soften)); + set(&mut v, "angle", json!(b.angle)); + set(&mut v, "altitude", json!(b.altitude)); + set(&mut v, "direction", json!(if b.up { "up" } else { "down" })); + } + Effect::PatternOverlay { .. } => {} + } + v +} + +/// Dialog fields for a layer: selected kind, and per kind {enabled, params}. +pub fn initial_fields(layer: &Layer, select: Option<&str>) -> Map { + let mut f = Map::new(); + f.insert("layer".into(), json!(layer.id.0)); + let mut first = None; + for &(kind, _) in KINDS { + let existing = layer.effects.items.iter().find(|e| kind_of(e) == kind); + if existing.is_some() && first.is_none() { + first = Some(kind); + } + f.insert(format!("on:{kind}"), json!(existing.is_some_and(|e| e.enabled()))); + f.insert(format!("p:{kind}"), existing.map(values_of).unwrap_or_else(|| defaults(kind))); + } + let sel = select.or(first).unwrap_or("dropShadow"); + f.insert("selected".into(), json!(sel)); + if select.is_some() { + f.insert(format!("on:{sel}"), json!(true)); + } + f +} + +pub fn open(app: &mut PhotocraftApp, select: Option<&str>) -> Option { + let st = app.session.active()?; + let layer = st.doc.layer(st.active_layer?)?.clone(); + Some(app.ui.open_dialog(crate::state::DialogKind::LayerStyle, initial_fields(&layer, select))) +} + +/// Apply the dialog: replace the layer's effects with the enabled ones. +pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { + let layer = f.get("layer").cloned().unwrap_or(Value::Null); + let _ = app.run("layer.layerStyle.clear", json!({"layer": layer})); + for &(kind, _) in KINDS { + if f.get(&format!("on:{kind}")).and_then(Value::as_bool) == Some(true) { + let mut p = f.get(&format!("p:{kind}")).cloned().unwrap_or_else(|| json!({})); + p["layer"] = layer.clone(); + app.run(&format!("layer.layerStyle.{kind}"), p)?; + } + } + Ok(Value::Null) +} + +/// Dialog body (left list, right parameters). +pub fn body(ui: &mut egui::Ui, f: &mut Map) { + let t = Tokens::get(ui.ctx()); + let selected = f.get("selected").and_then(Value::as_str).unwrap_or("dropShadow").to_string(); + ui.horizontal_top(|ui| { + // Left: effect list. + ui.vertical(|ui| { + ui.set_width(190.0); + ui.label(RichText::new("Styles").color(t.text_faint)); + ui.label(RichText::new("Blending Options").color(t.text_dim)); + ui.add_space(4.0); + for &(kind, label) in KINDS { + let (rect, resp) = ui.allocate_exact_size(vec2(190.0, 26.0), Sense::click()); + let is_sel = kind == selected; + if is_sel { + ui.painter().rect_filled(rect, t.radius_sm, t.row_selected.gamma_multiply(if t.pro { 1.0 } else { 0.0 }).max_alpha(t.hover)); + } else if resp.hovered() { + ui.painter().rect_filled(rect, t.radius_sm, t.hover.gamma_multiply(0.5)); + } + let key = format!("on:{kind}"); + let mut on = f.get(&key).and_then(Value::as_bool).unwrap_or(false); + let cb = egui::Rect::from_min_size(rect.min + vec2(6.0, 6.0), vec2(14.0, 14.0)); + let cresp = ui.interact(cb, ui.id().with(("fxcb", kind)), Sense::click()); + if on { + ui.painter().rect_filled(cb, 2.0, t.accent); + ui.painter().line_segment([cb.left_center() + vec2(3.0, 0.5), cb.center_bottom() + vec2(-1.0, -3.5)], Stroke::new(1.8, Color32::WHITE)); + ui.painter().line_segment([cb.center_bottom() + vec2(-1.0, -3.5), cb.right_top() + vec2(-3.0, 3.5)], Stroke::new(1.8, Color32::WHITE)); + } else { + ui.painter().rect_stroke(cb, 2.0, Stroke::new(1.5, t.text_faint), StrokeKind::Inside); + } + ui.painter().text(rect.left_center() + vec2(28.0, 0.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.5), if on || is_sel { t.text } else { t.text_dim }); + if cresp.clicked() { + on = !on; + f.insert(key, json!(on)); + f.insert("selected".into(), json!(kind)); + } else if resp.clicked() { + f.insert("selected".into(), json!(kind)); + f.insert(key, json!(true)); + } + } + }); + widgets::vline(ui, 330.0); + // Right: parameters of the selected effect. + ui.vertical(|ui| { + ui.set_width(330.0); + let label = KINDS.iter().find(|k| k.0 == selected).map(|k| k.1).unwrap_or(""); + ui.label(RichText::new(label).font(crate::theme::semibold(14.0)).color(t.text)); + ui.add_space(6.0); + let pkey = format!("p:{selected}"); + let mut p = f.get(&pkey).cloned().unwrap_or_else(|| defaults(&selected)); + for &(key, label, kind) in spec(&selected) { + match kind { + P::Slider(min, max, unit) => { + let mut v = p.get(key).and_then(Value::as_f64).unwrap_or(min as f64) as f32; + if widgets::slider_row(ui, label, &mut v, min..=max, unit, None).changed() { + p[key] = json!(v.round()); + } + } + P::Color => { + ui.horizontal(|ui| { + ui.label(RichText::new(label).color(t.text_dim)); + let hexs = p.get(key).and_then(Value::as_str).unwrap_or("#000000").to_string(); + let mut c = parse_hex(&hexs); + if ui.color_edit_button_srgba(&mut c).changed() { + p[key] = json!(format!("#{:02x}{:02x}{:02x}", c.r(), c.g(), c.b())); + } + }); + } + P::Blend => { + ui.horizontal(|ui| { + ui.label(RichText::new(label).color(t.text_dim)); + let mut cur = p.get(key).and_then(Value::as_str).unwrap_or("Normal").to_string(); + let opts: Vec<(String, &str)> = photocraft_color::BlendMode::LAYER_MODES.iter().map(|m| (m.label().to_string(), m.label())).collect(); + if widgets::dropdown(ui, &format!("fx-blend-{selected}"), &mut cur, &opts, 150.0) { + p[key] = json!(cur); + } + }); + } + P::Choice(options) => { + ui.horizontal(|ui| { + ui.label(RichText::new(label).color(t.text_dim)); + let mut cur = p.get(key).and_then(Value::as_str).unwrap_or(options[0].0).to_string(); + let opts: Vec<(String, &str)> = options.iter().map(|(v, l)| (v.to_string(), *l)).collect(); + if widgets::dropdown(ui, &format!("fx-{selected}-{key}"), &mut cur, &opts, 150.0) { + p[key] = json!(cur); + } + }); + } + P::Check => { + let mut b = p.get(key).and_then(Value::as_bool).unwrap_or(false); + if widgets::checkbox(ui, &mut b, label).changed() { + p[key] = json!(b); + } + } + } + ui.add_space(2.0); + } + f.insert(pkey, p); + }); + }); +} + +fn parse_hex(s: &str) -> Color32 { + let s = s.trim_start_matches('#'); + let b = |i: usize| u8::from_str_radix(s.get(i..i + 2).unwrap_or("00"), 16).unwrap_or(0); + Color32::from_rgb(b(0), b(2), b(4)) +} + +trait MaxAlpha { + fn max_alpha(self, other: Color32) -> Color32; +} + +impl MaxAlpha for Color32 { + /// Fall back to `other` when this colour is fully transparent (non-Pro themes have no row colour). + fn max_alpha(self, other: Color32) -> Color32 { + if self.a() == 0 { other } else { self } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_kind_has_spec_and_defaults() { + for &(kind, _) in KINDS { + assert!(!spec(kind).is_empty(), "{kind}"); + let d = defaults(kind); + for &(key, _, _) in spec(kind) { + assert!(d.get(key).is_some(), "{kind}.{key} has no default"); + } + } + } + + #[test] + fn initial_fields_select_requested_kind() { + let l = Layer::raster("x", photocraft_doc::PixelFormat::RGBA8); + let f = initial_fields(&l, Some("stroke")); + assert_eq!(f["selected"], "stroke"); + assert_eq!(f["on:stroke"], true); + assert_eq!(f["on:dropShadow"], false); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs new file mode 100644 index 00000000..652ebfed --- /dev/null +++ b/crates/ui-egui/src/lib.rs @@ -0,0 +1,431 @@ +//! Photocraft's first UI shell, built on egui/eframe. +//! +//! This crate is deliberately thin. Every action goes through +//! [`photocraft_engine::Session::execute`], and all UI state lives in [`state::UiState`] (plain +//! data). The [`control`] module exposes both to automation, so agents can drive and inspect every +//! part of the interface. +#![forbid(unsafe_code)] + +pub mod canvas; +pub mod control; +pub mod dialogs; +pub mod gpu_canvas; +pub mod icons; +pub mod layer_style; +pub mod menus; +pub mod outline; +pub mod palette; +pub mod proxy; +pub mod panels; +pub mod shortcuts; +pub mod state; +pub mod theme; +pub mod widgets; +mod icon_data; + +use std::collections::HashMap; +use std::sync::mpsc::{Receiver, Sender}; + +use photocraft_doc::{DocId, Document}; +use photocraft_engine::Session; +use serde_json::Value; + +pub use control::{ControlRequest, ControlResponse}; +pub use state::{Tool, UiState}; + +/// Platform services injected by the app binary (file dialogs, codecs), keeping this crate free of +/// I/O dependencies. +#[derive(Default)] +pub struct Services { + /// Decode a file's bytes into a document (PSD, PNG, JPEG, …). + pub import: Option Result>>, + /// Encode a document for a file name (format chosen by extension). + pub export: Option Result, String>>>, + /// Show an "open file" dialog; returns (name, bytes). + pub pick_open: Option Option<(String, Vec)>>>, + /// Show a "save file" dialog; returns a path/name to write. + pub pick_save: Option Option>>, + /// Write bytes to a path (native) or trigger a download (web). + pub write: Option Result<(), String>>>, + /// Encode an RGBA8 image as PNG (used for screenshots and `ui.render`). + pub encode_png: Option Result, String>>>, + /// Files delivered asynchronously (web file pickers, drag-and-drop): drained every frame. + pub inbox: Option)>>>>, +} + +pub struct PhotocraftApp { + pub session: Session, + pub ui: UiState, + pub services: Services, + canvases: HashMap, + checker: Option, + drag: Option, + control_rx: Option>, + pending_screenshots: Vec<(u64, Option, Sender)>, + /// Live (uncommitted) adjustment edit shown on canvas while a slider is dragged. + pub live_adjust: Option<(photocraft_doc::LayerId, Value)>, + /// Frames rendered (for tests and the status bar). + pub frame: u64, + /// Apply theme on first frame. + styled: bool, + /// Whether the window uses an integrated (transparent) macOS title bar. + pub integrated_titlebar: bool, + fonts_ready: bool, + /// Screen rect of the main canvas last frame (for overlays and the navigator). + pub last_canvas_rect: egui::Rect, + pub fps: f32, + last_frame_time: f64, + thumbs: HashMap<(photocraft_doc::LayerId, bool), (u64, egui::TextureHandle)>, + /// Content bounds cached per (key, revision): scanning a 36 MP layer every frame cost ~77 ms. + bounds_cache: HashMap, + /// Downsampled proxy of the active document for live previews: (doc, revision, k, proxy). + pub(crate) proxy: Option<(DocId, u64, u32, std::sync::Arc)>, + /// Key of the preview currently uploaded to the GPU (doc, params hash). + pub(crate) proxy_uploaded: Option<(DocId, u64)>, + /// Selection outline cache: (doc, revision, segments). + pub(crate) outline_cache: Option<(DocId, u64, std::sync::Arc>)>, + /// GPU canvas renderer, when running on the wgpu backend (see [`Self::set_wgpu`]). + gpu: Option, + /// Frame and canvas-upload timings (exposed via `ui.inspect`). + pub perf: gpu_canvas::Perf, + #[cfg(all(debug_assertions, not(target_arch = "wasm32")))] + live_tokens: theme::live::LiveTokens, +} + +impl PhotocraftApp { + pub fn new(session: Session, services: Services) -> Self { + Self { + session, + ui: UiState::default(), + services, + canvases: HashMap::new(), + checker: None, + drag: None, + control_rx: None, + pending_screenshots: Vec::new(), + live_adjust: None, + frame: 0, + styled: false, + integrated_titlebar: false, + fonts_ready: false, + last_canvas_rect: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)), + fps: 0.0, + last_frame_time: 0.0, + thumbs: HashMap::new(), + bounds_cache: HashMap::new(), + proxy: None, + proxy_uploaded: None, + outline_cache: None, + gpu: None, + perf: Default::default(), + #[cfg(all(debug_assertions, not(target_arch = "wasm32")))] + live_tokens: theme::live::LiveTokens::from_env(), + } + } + + /// Draw the document canvas on the GPU (custom WGSL shader) instead of via egui textures. + /// Call from the app creator with `cc.wgpu_render_state`; without it the CPU path is used. + pub fn set_wgpu(&mut self, rs: eframe::egui_wgpu::RenderState) { + self.gpu = Some(gpu_canvas::GpuCanvas::new(&rs)); + } + + /// Attach a control channel (requests arrive from a transport thread: TCP, stdin, tests). + pub fn with_control(mut self, rx: Receiver) -> Self { + self.control_rx = Some(rx); + self + } + + /// Run an engine command, reporting errors in the status bar. + pub fn run(&mut self, id: &str, params: Value) -> Result { + let r = self.session.execute(id, params).map_err(|e| e.to_string()); + match &r { + Ok(_) => { + self.sync_views(); + if self.ui.status_error { + self.ui.status.clear(); + self.ui.status_error = false; + } + } + Err(e) => { + self.ui.status = e.clone(); + self.ui.status_error = true; + } + } + r + } + + /// Keep one view per document. + pub fn sync_views(&mut self) { + let n = self.session.documents().len(); + self.ui.views.resize_with(n, Default::default); + self.ui.windows.retain(|w| w.document < n); + } + + pub fn open_bytes(&mut self, name: &str, bytes: &[u8]) -> Result<(), String> { + let import = self.services.import.as_ref().ok_or("no importer configured")?; + let doc = import(name, bytes)?; + self.session.add_document(doc, Some(name.to_string())); + self.sync_views(); + self.ui.status = format!("Opened {name}"); + Ok(()) + } + + pub fn open_dialog_file(&mut self) { + let picked = self.services.pick_open.as_mut().and_then(|f| f()); + if let Some((name, bytes)) = picked + && let Err(e) = self.open_bytes(&name, &bytes) + { + self.ui.status = format!("Couldn't open {name}: {e}"); + } + } + + pub fn save_as(&mut self, path: Option) -> Result { + let st = self.session.active().ok_or("no document")?; + let suggested = st.path.clone().unwrap_or_else(|| format!("{}.psd", st.doc.name)); + let path = match path { + Some(p) => p, + None => self.services.pick_save.as_mut().and_then(|f| f(&suggested)).ok_or("cancelled")?, + }; + let export = self.services.export.as_ref().ok_or("no exporter configured")?; + let bytes = export(&st.doc, &path)?; + let write = self.services.write.as_mut().ok_or("no writer configured")?; + write(&path, &bytes)?; + if let Some(st) = self.session.active_mut() { + st.path = Some(path.clone()); + st.saved_revision = st.revision; + } + self.ui.status = format!("Saved {path}"); + Ok(path) + } + + fn drain_control(&mut self, ctx: &egui::Context) { + let Some(rx) = self.control_rx.take() else { return }; + while let Ok(req) = rx.try_recv() { + let reply = req.reply.clone(); + match control::handle(self, ctx, &req) { + control::Outcome::Done(v) => { + let _ = reply.send(v); + } + control::Outcome::Screenshot { token, path } => self.pending_screenshots.push((token, path, reply)), + } + } + self.control_rx = Some(rx); + } + + fn collect_screenshots(&mut self, ctx: &egui::Context) { + if self.pending_screenshots.is_empty() { + return; + } + let events: Vec<_> = ctx.input(|i| { + i.raw + .events + .iter() + .filter_map(|e| match e { + egui::Event::Screenshot { user_data, image, .. } => { + let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::()).copied()?; + Some((token, image.clone())) + } + _ => None, + }) + .collect() + }); + for (token, image) in events { + if let Some(i) = self.pending_screenshots.iter().position(|(t, _, _)| *t == token) { + let (_, path, reply) = self.pending_screenshots.remove(i); + let r = control::save_screenshot(self, &image, path.as_deref()); + let _ = reply.send(r); + } + } + } +} + +impl eframe::App for PhotocraftApp { + fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + if !self.styled { + Self::setup_context(ctx, self.ui.theme); + self.styled = true; + } else { + self.fonts_ready = true; + } + self.frame += 1; + let now = ctx.input(|i| i.time); + let dt = (now - self.last_frame_time) as f32; + if dt > 0.0 { + self.fps = self.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1; + } + self.last_frame_time = now; + self.sync_views(); + #[cfg(all(debug_assertions, not(target_arch = "wasm32")))] + if self.live_tokens.poll(ctx, self.ui.theme) { + self.checker = None; + } + self.drain_control(ctx); + self.collect_screenshots(ctx); + shortcuts::handle(self, ctx); + let arrived: Vec<(String, Vec)> = self.services.inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default(); + for (name, bytes) in arrived { + if let Err(e) = self.open_bytes(&name, &bytes) { + self.ui.status = format!("Couldn't open {name}: {e}"); + self.ui.status_error = true; + } + } + // Files dropped onto the window open as documents. + let dropped = ctx.input(|i| i.raw.dropped_files.clone()); + for f in dropped { + let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); + match read_dropped(&*f) { + Ok(bytes) => { + if let Err(e) = self.open_bytes(&name, &bytes) { + self.ui.status = format!("Couldn't open {name}: {e}"); + } + } + Err(e) => self.ui.status = format!("Couldn't read {name}: {e}"), + } + } + if self.control_rx.is_some() || !self.pending_screenshots.is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(50)); + } + } + + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + let ctx = ui.ctx().clone(); + // Fonts registered via set_fonts only take effect next frame; named families would panic now. + if !self.fonts_ready { + ctx.request_repaint(); + return; + } + let t0 = gpu_canvas::now_ms(); + panels::title_bar(self, ui); + if self.ui.panels.options_bar { + panels::options_bar(self, ui); + } + if self.ui.panels.status_bar { + panels::status_bar(self, ui); + } + if self.ui.panels.toolbar { + panels::toolbar(self, ui); + } + panels::right_dock(self, ui); + let t = theme::Tokens::get(&ctx); + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.canvas)).show(ui, |ui| { + canvas::document_area(self, ui); + }); + panels::properties_window(self, &ctx); + palette::show(self, &ctx); + dialogs::show(self, &ctx); + canvas::extra_windows(self, &ctx); + self.perf.frame(gpu_canvas::now_ms() - t0); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn read_dropped(f: &(dyn egui::DroppedFile + Send + Sync)) -> Result, String> { + f.bytes() +} + +/// On the web, dropped-file bytes arrive asynchronously; the web shell handles drops itself. +#[cfg(target_arch = "wasm32")] +fn read_dropped(_f: &dyn egui::DroppedFile) -> Result, String> { + Err("drag-and-drop on the web is handled by the page; use File → Open".into()) +} + +impl PhotocraftApp { + pub fn set_theme(&mut self, ctx: &egui::Context, kind: theme::ThemeKind) { + self.ui.theme = kind; + theme::apply(ctx, kind); + self.checker = None; + } + + /// Cached 64px thumbnail of a pixel-ish layer, laid out in document space. + pub fn layer_thumb(&mut self, ctx: &egui::Context, doc: &Document, layer: &photocraft_doc::Layer) -> egui::TextureId { + let rev = self.session.active().map_or(0, |d| d.revision); + let key = (layer.id, false); + if let Some((r, tex)) = self.thumbs.get(&key) + && *r == rev + { + return tex.id(); + } + let img = thumb_image(doc, 64, |x, y| layer.surface().map_or([0.0; 4], |s| s.rgba(x, y))); + self.store_thumb(ctx, key, rev, img) + } + + pub fn mask_thumb(&mut self, ctx: &egui::Context, doc: &Document, id: photocraft_doc::LayerId, mask: &photocraft_doc::LayerMask) -> egui::TextureId { + let rev = self.session.active().map_or(0, |d| d.revision); + let key = (id, true); + if let Some((r, tex)) = self.thumbs.get(&key) + && *r == rev + { + return tex.id(); + } + let img = thumb_image(doc, 64, |x, y| { + let v = mask.surface.pixel(x, y)[0]; + [v, v, v, 1.0] + }); + self.store_thumb(ctx, key, rev, img) + } + + fn store_thumb(&mut self, ctx: &egui::Context, key: (photocraft_doc::LayerId, bool), rev: u64, img: egui::ColorImage) -> egui::TextureId { + match self.thumbs.get_mut(&key) { + Some((r, tex)) => { + tex.set(img, egui::TextureOptions::LINEAR); + *r = rev; + tex.id() + } + None => { + let tex = ctx.load_texture(format!("thumb-{}-{}", key.0.0, key.1), img, egui::TextureOptions::LINEAR); + let id = tex.id(); + self.thumbs.insert(key, (rev, tex)); + id + } + } + } +} + +/// Square thumbnail of the canvas area, letterboxed, sampling `f(x, y)` in document space. +fn thumb_image(doc: &Document, side: usize, f: impl Fn(i32, i32) -> [f32; 4]) -> egui::ColorImage { + let (w, h) = (doc.size.width.max(1) as f32, doc.size.height.max(1) as f32); + let scale = w.max(h) / side as f32; + let (ox, oy) = ((side as f32 - w / scale) / 2.0, (side as f32 - h / scale) / 2.0); + let mut px = vec![egui::Color32::TRANSPARENT; side * side]; + for ty in 0..side { + for tx in 0..side { + let dx = (tx as f32 - ox + 0.5) * scale; + let dy = (ty as f32 - oy + 0.5) * scale; + if dx < 0.0 || dy < 0.0 || dx >= w || dy >= h { + continue; + } + let c = f(dx as i32, dy as i32); + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + px[ty * side + tx] = egui::Color32::from_rgba_unmultiplied(q(c[0]), q(c[1]), q(c[2]), q(c[3])); + } + } + egui::ColorImage::new([side, side], px) +} + +impl PhotocraftApp { + /// Install fonts, image loaders and the theme. Call from the app creator when possible so the + /// very first frame renders; otherwise `logic` does it and the first frame is skipped. + pub fn setup_context(ctx: &egui::Context, kind: theme::ThemeKind) { + theme::install_fonts(ctx); + egui_extras::install_image_loaders(ctx); + theme::apply(ctx, kind); + } +} + +impl PhotocraftApp { + /// Cached `Surface::content_bounds` keyed by an id and the active document revision. + pub fn cached_bounds(&mut self, key: u64, surface: &photocraft_raster::Surface) -> photocraft_geom::Rect { + let rev = self.session.active().map_or(0, |d| d.revision); + if let Some((r, b)) = self.bounds_cache.get(&key) + && *r == rev + { + return *b; + } + let b = surface.content_bounds(); + if self.bounds_cache.len() > 256 { + self.bounds_cache.clear(); + } + self.bounds_cache.insert(key, (rev, b)); + b + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs new file mode 100644 index 00000000..934ce36b --- /dev/null +++ b/crates/ui-egui/src/menus.rs @@ -0,0 +1,280 @@ +//! Menu bar generated from the engine command registry plus UI-level commands. + +use serde_json::{Value, json}; + +use crate::PhotocraftApp; +use crate::state::{DialogKind, UiState}; + +/// Top-level menus in Photoshop order. +pub const TOP_MENUS: [&str; 9] = ["File", "Edit", "Image", "Layer", "Select", "Filter", "View", "Window", "Help"]; + +/// UI-level commands (handled by the shell rather than the engine): id, label, menu, shortcut. +pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ + ("file.open", "Open…", &["File"], Some("Cmd+O")), + ("file.save", "Save", &["File"], Some("Cmd+S")), + ("file.saveAs", "Save As…", &["File"], Some("Cmd+Shift+S")), + ("view.zoomIn", "Zoom In", &["View"], Some("Cmd+=")), + ("view.zoomOut", "Zoom Out", &["View"], Some("Cmd+-")), + ("view.fitOnScreen", "Fit on Screen", &["View"], Some("Cmd+0")), + ("view.actualPixels", "100%", &["View"], Some("Cmd+1")), + ("window.newWindowForDocument", "New Window for Document", &["Window", "Arrange"], None), + ("window.toggle.layers", "Layers", &["Window"], Some("F7")), + ("window.toggle.history", "History", &["Window"], None), + ("window.toggle.properties", "Properties", &["Window"], None), + ("window.toggle.color", "Color", &["Window"], Some("F6")), + ("window.toggle.navigator", "Navigator", &["Window"], None), + ("window.toggle.toolbar", "Tools", &["Window"], None), + ("window.toggle.options", "Options", &["Window"], None), + ("window.theme.toggle", "Next Theme", &["Window"], None), + ("window.theme.pro", "Pro Theme", &["Window", "Theme"], None), + ("window.theme.studio", "Studio Theme", &["Window", "Theme"], None), + ("window.theme.studioLight", "Studio Light Theme", &["Window", "Theme"], None), + ("window.theme.classic", "Classic Theme", &["Window", "Theme"], None), + ("edit.search", "Search…", &["Edit"], Some("Cmd+K")), + ("help.about", "About Photocraft", &["Help"], None), +]; + +/// Run a command id from any source (menu, shortcut, palette, automation). +pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result { + match id { + "file.new" if params.as_object().is_none_or(|o| o.is_empty()) => { + let d = app.ui.open_dialog(DialogKind::NewDocument, UiState::new_document_fields()); + Ok(json!({"dialog": d})) + } + "file.open" => { + if let Some(path) = params.get("path").and_then(Value::as_str) { + open_path(app, path) + } else { + app.open_dialog_file(); + Ok(Value::Null) + } + } + "file.save" => { + let path = params.get("path").and_then(Value::as_str).map(str::to_string).or_else(|| app.session.active().and_then(|d| d.path.clone()).filter(|p| p.ends_with(".psd") || p.ends_with(".psb"))); + app.save_as(path).map(|p| json!({"path": p})) + } + "file.saveAs" => app.save_as(params.get("path").and_then(Value::as_str).map(str::to_string)).map(|p| json!({"path": p})), + "view.zoomIn" | "view.zoomOut" | "view.fitOnScreen" | "view.actualPixels" => { + let i = app.session.active_index().ok_or("no document")?; + let v = &mut app.ui.views[i]; + match id { + "view.zoomIn" => v.zoom = crate::canvas::zoom_step(v.zoom, 1), + "view.zoomOut" => v.zoom = crate::canvas::zoom_step(v.zoom, -1), + "view.fitOnScreen" => v.fit_pending = true, + _ => v.zoom = 1.0, + } + Ok(Value::Null) + } + "window.newWindowForDocument" => { + let doc = app.session.active_index().ok_or("no document")?; + let wid = app.ui.alloc_id(); + let view = app.ui.views[doc].clone(); + app.ui.windows.push(crate::state::DocWindow { id: wid, document: doc, view, open: true }); + Ok(json!({"window": wid})) + } + "window.theme.toggle" => { + let next = app.ui.theme.next(); + app.set_theme(ctx, next); + Ok(Value::Null) + } + "window.theme.pro" | "window.theme.studio" | "window.theme.studioLight" | "window.theme.classic" => { + let k = crate::theme::ThemeKind::from_name(&id["window.theme.".len()..]).unwrap_or_default(); + app.set_theme(ctx, k); + Ok(Value::Null) + } + "edit.search" => { + app.ui.palette_open = !app.ui.palette_open; + Ok(Value::Null) + } + "help.about" => Ok(json!({"dialog": app.ui.open_dialog(DialogKind::About, Default::default())})), + a if a.starts_with("image.adjustments.") + && params.as_object().is_none_or(|o| o.is_empty()) + && !crate::panels::adjustment_sliders(a.rsplit('.').next().unwrap_or("")).is_empty() => + { + let label = photocraft_engine::commands::find(a).map(|c| c.label).unwrap_or(a); + Ok(json!({"dialog": crate::dialogs::open_command_dialog(app, a, label)})) + } + t if t.starts_with("window.toggle.") => { + let p = &mut app.ui.panels; + let slot = match &t["window.toggle.".len()..] { + "layers" => &mut p.layers, + "history" => &mut p.history, + "properties" => &mut p.properties, + "color" => &mut p.color, + "navigator" => &mut p.navigator, + "toolbar" => &mut p.toolbar, + "options" => &mut p.options_bar, + _ => return Err(format!("unknown panel in {t}")), + }; + *slot = !*slot; + Ok(Value::Null) + } + _ => app.run(id, params), + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn open_path(app: &mut PhotocraftApp, path: &str) -> Result { + let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.to_string()); + app.open_bytes(&name, &bytes)?; + if let Some(st) = app.session.active_mut() { + st.path = Some(path.to_string()); + } + Ok(Value::Null) +} + +#[cfg(target_arch = "wasm32")] +fn open_path(_app: &mut PhotocraftApp, path: &str) -> Result { + Err(format!("cannot open paths on the web: {path}")) +} + +pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool { + match id { + "file.open" | "help.about" | "edit.search" => true, + i if i.starts_with("window.theme.") => true, + "file.save" | "file.saveAs" => app.session.active().is_some() && app.services.export.is_some(), + i if i.starts_with("window.toggle.") => true, + i if i.starts_with("view.") || i == "window.newWindowForDocument" => app.session.active().is_some(), + i => app.session.is_enabled(i), + } +} + +/// Is a UI-level panel toggle currently on (for checkmarks)? +fn checked(app: &PhotocraftApp, id: &str) -> Option { + let p = &app.ui.panels; + Some(match id { + "window.toggle.layers" => p.layers, + "window.toggle.history" => p.history, + "window.toggle.properties" => p.properties, + "window.toggle.color" => p.color, + "window.toggle.navigator" => p.navigator, + "window.toggle.toolbar" => p.toolbar, + "window.toggle.options" => p.options_bar, + _ => return None, + }) +} + +/// Menu tree entry for rendering and for `ui.inspect`. +#[derive(Clone, Debug, serde::Serialize)] +pub struct MenuItem { + pub id: String, + pub label: String, + pub path: Vec, + pub shortcut: Option, + pub enabled: bool, + pub checked: Option, +} + +pub fn menu_items(app: &PhotocraftApp) -> Vec { + let mut items: Vec = Vec::new(); + for &(id, label, path, sc) in UI_COMMANDS { + items.push(MenuItem { id: id.into(), label: label.into(), path: path.iter().map(|s| s.to_string()).collect(), shortcut: sc.map(Into::into), enabled: is_enabled(app, id), checked: checked(app, id) }); + } + for c in photocraft_engine::command_specs().iter().filter(|c| !c.menu.is_empty()) { + items.push(MenuItem { id: c.id.into(), label: c.label.into(), path: c.menu.iter().map(|s| s.to_string()).collect(), shortcut: c.shortcut.map(Into::into), enabled: is_enabled(app, c.id), checked: None }); + } + // Stable, Photoshop-like order: File menu items in a sensible sequence. + let order = |id: &str| -> i32 { + match id { + "file.new" => 0, + "file.open" => 1, + "file.close" => 2, + "file.save" => 3, + "file.saveAs" => 4, + _ => 10, + } + }; + items.sort_by_key(|i| (TOP_MENUS.iter().position(|m| Some(m) == i.path.first().map(|s| s.as_str()).as_ref()).unwrap_or(99), order(&i.id))); + items +} + +pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let items = menu_items(app); + let mut clicked: Option = None; + let t = crate::theme::Tokens::get(ui.ctx()); + ui.scope(|ui| { + ui.spacing_mut().button_padding = egui::vec2(8.0, 3.0); + ui.spacing_mut().item_spacing.x = 0.0; + let v = &mut ui.style_mut().visuals; + v.widgets.inactive.weak_bg_fill = egui::Color32::TRANSPARENT; + v.widgets.inactive.bg_stroke = egui::Stroke::NONE; + v.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, t.text_dim); + v.widgets.hovered.bg_stroke = egui::Stroke::NONE; + egui::MenuBar::new().ui(ui, |ui| { + for top in TOP_MENUS { + let mine: Vec<&MenuItem> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(top)).collect(); + ui.menu_button(egui::RichText::new(top).color(t.text_dim), |ui| { + ui.set_min_width(220.0); + if mine.is_empty() { + ui.weak("(coming soon)"); + } + render_level(ui, &mine, 1, &mut clicked); + }); + } + }); + }); + if let Some(id) = clicked { + let ctx = ui.ctx().clone(); + let _ = invoke(app, &ctx, &id, json!({})); + } +} + +fn render_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option) { + let t = crate::theme::Tokens::get(ui.ctx()); + if t.pro { + // Spectrum/macOS menus: blue highlight row with white text. + let v = &mut ui.style_mut().visuals; + v.widgets.hovered.weak_bg_fill = t.accent; + v.widgets.hovered.bg_fill = t.accent; + v.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, egui::Color32::WHITE); + v.widgets.hovered.corner_radius = egui::CornerRadius::same(3); + ui.spacing_mut().button_padding = egui::vec2(10.0, 4.0); + } + let mut subs: Vec = Vec::new(); + for it in items { + if it.path.len() > depth { + let s = it.path[depth].clone(); + if !subs.contains(&s) { + subs.push(s); + } + } + } + for it in items.iter().filter(|i| i.path.len() == depth) { + let mut text = it.label.clone(); + if let Some(c) = it.checked { + text = format!("{} {}", if c { "✔" } else { " " }, text); + } + let mut b = egui::Button::new(text); + if let Some(sc) = &it.shortcut { + b = b.shortcut_text(crate::shortcuts::pretty(sc)); + } + if ui.add_enabled(it.enabled, b).clicked() { + *clicked = Some(it.id.clone()); + ui.close(); + } + } + if !subs.is_empty() && items.iter().any(|i| i.path.len() == depth) { + ui.separator(); + } + for s in subs { + let child: Vec<&MenuItem> = items.iter().copied().filter(|i| i.path.len() > depth && i.path[depth] == s).collect(); + ui.menu_button(&s, |ui| render_level(ui, &child, depth + 1, clicked)); + } +} + +/// Workspace presets (Window → Workspace): which panels are visible. +pub fn apply_workspace(app: &mut PhotocraftApp) { + let p = &mut app.ui.panels; + let (nav, color, layers, history, props) = match app.ui.workspace.as_str() { + "Photography" => (true, false, true, true, true), + "Painting" => (false, true, true, false, false), + "Graphic and Web" => (false, true, true, false, true), + _ => (false, true, true, false, true), + }; + p.navigator = nav; + p.color = color; + p.layers = layers; + p.history = history; + p.properties = props; +} diff --git a/crates/ui-egui/src/outline.rs b/crates/ui-egui/src/outline.rs new file mode 100644 index 00000000..691d9408 --- /dev/null +++ b/crates/ui-egui/src/outline.rs @@ -0,0 +1,85 @@ +//! Selection outlines: boundary segments of a coverage mask (threshold 0.5), merged into long runs, +//! computed once per document revision and drawn as animated marching ants. + +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +/// A boundary segment in document pixel coordinates (axis-aligned). +pub type Segment = ([i32; 2], [i32; 2]); + +/// Extract the boundary of `mask` (> 0.5 = selected) as merged horizontal and vertical segments. +pub fn outline(mask: &Surface, bounds: Rect) -> Vec { + if bounds.is_empty() { + return Vec::new(); + } + // Pad by one so edges at the bounds are detected. + let r = bounds.inflate(1); + let (w, h) = (r.width() as usize, r.height() as usize); + let data = mask.read_region(r); + let n = mask.channels(); + let inside = |x: usize, y: usize| data[(y * w + x) * n] > 0.5; + let mut segs = Vec::new(); + // Horizontal edges between rows y-1 and y, merged along x. + for y in 1..h { + let mut run: Option = None; + for x in 0..=w { + let edge = x < w && inside(x, y) != inside(x, y - 1); + match (edge, run) { + (true, None) => run = Some(x), + (false, Some(x0)) => { + segs.push(([r.x0 + x0 as i32, r.y0 + y as i32], [r.x0 + x as i32, r.y0 + y as i32])); + run = None; + } + _ => {} + } + } + } + // Vertical edges between columns x-1 and x, merged along y. + for x in 1..w { + let mut run: Option = None; + for y in 0..=h { + let edge = y < h && inside(x, y) != inside(x - 1, y); + match (edge, run) { + (true, None) => run = Some(y), + (false, Some(y0)) => { + segs.push(([r.x0 + x as i32, r.y0 + y0 as i32], [r.x0 + x as i32, r.y0 + y as i32])); + run = None; + } + _ => {} + } + } + } + segs +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_color::PixelFormat; + + #[test] + fn rectangle_has_four_edges() { + let mut m = Surface::new(PixelFormat::GRAY8); + m.fill_rect(Rect::new(2, 3, 10, 7), &[1.0]); + let s = outline(&m, m.content_bounds()); + assert_eq!(s.len(), 4, "{s:?}"); + assert!(s.contains(&([2, 3], [10, 3]))); + assert!(s.contains(&([2, 7], [10, 7]))); + assert!(s.contains(&([2, 3], [2, 7]))); + assert!(s.contains(&([10, 3], [10, 7]))); + } + + #[test] + fn hole_adds_inner_edges() { + let mut m = Surface::new(PixelFormat::GRAY8); + m.fill_rect(Rect::new(0, 0, 10, 10), &[1.0]); + m.fill_rect(Rect::new(4, 4, 6, 6), &[0.0]); + assert_eq!(outline(&m, m.content_bounds()).len(), 8); + } + + #[test] + fn empty_mask_has_no_outline() { + let m = Surface::new(PixelFormat::GRAY8); + assert!(outline(&m, m.content_bounds()).is_empty()); + } +} diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs new file mode 100644 index 00000000..447d29ea --- /dev/null +++ b/crates/ui-egui/src/palette.rs @@ -0,0 +1,156 @@ +//! ⌘K command palette: fuzzy search over every menu command and tool. + +use egui::{Align2, Color32, CornerRadius, RichText, Sense, Stroke, vec2}; + +use crate::theme::{self, Tokens}; +use crate::{PhotocraftApp, icons}; + +/// Case-insensitive subsequence match score (higher is better); None = no match. +pub fn fuzzy_score(query: &str, text: &str) -> Option { + if query.is_empty() { + return Some(0); + } + let t: Vec = text.to_lowercase().chars().collect(); + let mut score = 0; + let mut ti = 0; + let mut last = None; + for qc in query.to_lowercase().chars() { + let mut found = false; + while ti < t.len() { + if t[ti] == qc { + score += if last == Some(ti.wrapping_sub(1)) { 8 } else { 1 }; + if ti == 0 || t[ti - 1] == ' ' { + score += 5; + } + last = Some(ti); + ti += 1; + found = true; + break; + } + ti += 1; + } + if !found { + return None; + } + } + Some(score - (t.len() as i32 / 8)) +} + +pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { + if !app.ui.palette_open { + return; + } + let t = Tokens::get(ctx); + let screen = ctx.content_rect(); + // Scrim. + egui::Area::new(egui::Id::new("palette-scrim")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { + let r = ui.allocate_exact_size(screen.size(), Sense::click()).1; + ui.painter().rect_filled(screen, 0.0, Color32::from_black_alpha(110)); + if r.clicked() { + app.ui.palette_open = false; + } + }); + let width = 560.0; + let mut run: Option = None; + egui::Area::new(egui::Id::new("palette")).order(egui::Order::Tooltip).pivot(Align2::CENTER_TOP).fixed_pos(egui::pos2(screen.center().x, screen.top() + 110.0)).show(ctx, |ui| { + egui::Frame::NONE + .fill(t.card) + .stroke(Stroke::new(1.0, t.card_border)) + .corner_radius(CornerRadius::same(t.radius_lg as u8)) + .shadow(egui::Shadow { offset: [0, 18], blur: 48, spread: 0, color: t.shadow }) + .inner_margin(egui::Margin::same(10)) + .show(ui, |ui| { + ui.set_width(width); + let qid = egui::Id::new("palette-query"); + let mut q: String = ui.data_mut(|d| d.get_temp(qid).unwrap_or_default()); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover()); + icons::paint(ui, r, "search", 16.0, t.text_dim); + let te = egui::TextEdit::singleline(&mut q).hint_text("Search commands, tools and panels…").frame(egui::Frame::NONE).font(egui::FontId::proportional(15.0)).desired_width(width - 40.0); + let resp = ui.add(te); + resp.request_focus(); + }); + ui.add_space(6.0); + crate::widgets::hairline(ui); + ui.add_space(6.0); + let mut hits: Vec<(i32, String, String, Option, bool)> = crate::menus::menu_items(app) + .into_iter() + .filter_map(|m| { + let path = m.path.join(" › "); + let s = fuzzy_score(&q, &format!("{} {}", m.label, path))?; + Some((s, m.id, format!("{}", m.label.trim_end_matches('…')), Some(path).filter(|_| true).map(|p| p + &m.shortcut.map(|s| format!(" {}", crate::shortcuts::pretty(&s))).unwrap_or_default()), m.enabled)) + }) + .collect(); + for tool in crate::state::Tool::ALL { + if let Some(s) = fuzzy_score(&q, tool.label()) { + hits.push((s + 2, format!("tool:{tool:?}"), tool.label().into(), Some(format!("Tool {}", tool.key())), true)); + } + } + hits.sort_by(|a, b| b.0.cmp(&a.0).then(a.2.cmp(&b.2))); + hits.truncate(12); + let sel_id = egui::Id::new("palette-sel"); + let mut sel: usize = ui.data_mut(|d| d.get_temp(sel_id).unwrap_or(0)); + let (down, up, enter, esc) = ui.input(|i| (i.key_pressed(egui::Key::ArrowDown), i.key_pressed(egui::Key::ArrowUp), i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape))); + if down { + sel = (sel + 1).min(hits.len().saturating_sub(1)); + } + if up { + sel = sel.saturating_sub(1); + } + sel = sel.min(hits.len().saturating_sub(1)); + if hits.is_empty() { + ui.label(RichText::new("No matching commands").color(t.text_faint)); + } + for (i, (_, id, label, detail, enabled)) in hits.iter().enumerate() { + let (rect, resp) = ui.allocate_exact_size(vec2(width, 32.0), Sense::click()); + if i == sel || resp.hovered() { + ui.painter().rect_filled(rect, t.radius_sm, if i == sel { t.accent_soft } else { t.hover }); + } + let color = if *enabled { t.text } else { t.text_faint }; + ui.painter().text(rect.left_center() + vec2(12.0, 0.0), Align2::LEFT_CENTER, label, theme::medium(13.0), color); + if let Some(d) = detail { + ui.painter().text(rect.right_center() - vec2(12.0, 0.0), Align2::RIGHT_CENTER, d, egui::FontId::proportional(11.5), t.text_faint); + } + if resp.clicked() && *enabled { + run = Some(id.clone()); + } + } + if enter && let Some(h) = hits.get(sel).filter(|h| h.4) { + run = Some(h.1.clone()); + } + if esc { + app.ui.palette_open = false; + } + ui.add_space(4.0); + ui.label(RichText::new("↑↓ navigate ↵ run esc close").small().color(t.text_faint)); + ui.data_mut(|d| { + d.insert_temp(qid, q); + d.insert_temp(sel_id, sel); + }); + }); + }); + if let Some(id) = run { + app.ui.palette_open = false; + ctx.data_mut(|d| d.insert_temp(egui::Id::new("palette-query"), String::new())); + if let Some(tool) = id.strip_prefix("tool:") { + if let Some(t) = crate::state::Tool::from_name(tool) { + app.ui.tool = t; + } + } else if let Err(e) = crate::menus::invoke(app, ctx, &id, serde_json::json!({})) { + app.ui.status = e; + } + } +} + +#[cfg(test)] +mod tests { + use super::fuzzy_score; + + #[test] + fn fuzzy_ranks_prefix_and_contiguous_higher() { + assert!(fuzzy_score("hue", "Hue/Saturation…").unwrap() > fuzzy_score("hue", "Channel Mixer Hue").unwrap_or(-99)); + assert!(fuzzy_score("gblur", "Gaussian Blur").is_some()); + assert!(fuzzy_score("xyz", "Gaussian Blur").is_none()); + assert_eq!(fuzzy_score("", "anything"), Some(0)); + } +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs new file mode 100644 index 00000000..991f731a --- /dev/null +++ b/crates/ui-egui/src/panels.rs @@ -0,0 +1,1429 @@ +//! Chrome around the canvas: title bar, options bar, toolbar, status bar, dock cards, Properties. + +use egui::{Align2, Color32, CornerRadius, Rect, RichText, Sense, Stroke, StrokeKind, Vec2, pos2, vec2}; +use photocraft_color::BlendMode; +use photocraft_doc::{Layer, LayerContent, LayerId}; +use serde_json::{Value, json}; + +use crate::state::Tool; +use crate::theme::{self, Tokens}; +use crate::{PhotocraftApp, icons, widgets}; + +// ----------------------------------------------------------------------------- toolbar + +/// Tool groups separated by hairlines, Photoshop order. +const TOOL_GROUPS: &[&[Tool]] = &[&[Tool::Move], &[Tool::RectMarquee, Tool::EllipseMarquee], &[Tool::Eyedropper], &[Tool::Brush, Tool::Eraser], &[Tool::Hand, Tool::Zoom]]; + +pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let (w, bx, m) = if t.pro { (40.0, 30.0, 5i8) } else { (50.0, 36.0, 7i8) }; + egui::Panel::left("toolbar").resizable(false).exact_size(w).frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(m, 8))).show(ui, |ui| { + if t.pro { + let r = ui.max_rect(); + ui.painter().line_segment([r.right_top() + vec2(m as f32, -8.0), r.right_bottom() + vec2(m as f32, 8.0)], Stroke::new(1.0, t.separator)); + // collapse chevrons like Photoshop's toolbar header + let (cr, _) = ui.allocate_exact_size(vec2(bx, 14.0), Sense::hover()); + icons::paint(ui, cr, "chevrons-right", 11.0, t.text_faint); + ui.add_space(4.0); + } + // Subtle violet wash at the bottom of the toolbar. + let full = ui.max_rect(); + if !t.bevel && t.dark() { + let mut mesh = egui::Mesh::default(); + let r = Rect::from_min_max(pos2(full.left() - 7.0, full.bottom() - 260.0), pos2(full.right() + 7.0, full.bottom() + 8.0)); + let top = Color32::TRANSPARENT; + let bottom = Color32::from_rgba_unmultiplied(90, 70, 190, 34); + mesh.colored_vertex(r.left_top(), top); + mesh.colored_vertex(r.right_top(), top); + mesh.colored_vertex(r.right_bottom(), bottom); + mesh.colored_vertex(r.left_bottom(), bottom); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + ui.painter().add(mesh); + } + ui.spacing_mut().item_spacing = vec2(0.0, 3.0); + for (gi, group) in TOOL_GROUPS.iter().enumerate() { + if gi > 0 { + ui.add_space(4.0); + let (r, _) = ui.allocate_exact_size(vec2(bx, 1.0), Sense::hover()); + ui.painter().line_segment([r.left_center() + vec2(8.0, 0.0), r.right_center() - vec2(8.0, 0.0)], Stroke::new(1.0, t.separator)); + ui.add_space(4.0); + } + for &tool in *group { + let sel = app.ui.tool == tool; + let resp = icons::button(ui, icons::tool_icon(tool), bx, sel, &format!("{} ({})", tool.label(), tool.key())); + if t.pro && group.len() > 1 { + // Photoshop's little corner triangle marks a tool group. + let r = resp.rect; + let tri = vec![r.right_bottom() + vec2(-2.0, -2.0), r.right_bottom() + vec2(-6.0, -2.0), r.right_bottom() + vec2(-2.0, -6.0)]; + ui.painter().add(egui::Shape::convex_polygon(tri, t.text_faint, Stroke::NONE)); + } + if resp.clicked() { + app.ui.tool = tool; + } + } + } + ui.add_space(14.0); + color_chips(app, ui); + if t.pro { + ui.add_space(10.0); + let _ = icons::button(ui, "square-dashed", bx, false, "Edit in Quick Mask Mode (Q)"); + let _ = icons::button(ui, "app-window", bx, false, "Change Screen Mode (F)"); + let _ = icons::button(ui, "ellipsis", bx, false, "Edit Toolbar…"); + } + }); +} + +fn c32(c: [f32; 4]) -> Color32 { + Color32::from_rgba_unmultiplied((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8, (c[3] * 255.0) as u8) +} + +fn color_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(vec2(36.0, 38.0), Sense::hover()); + let bg = Rect::from_min_size(rect.min + vec2(13.0, 13.0), vec2(21.0, 21.0)); + let fg = Rect::from_min_size(rect.min + vec2(2.0, 2.0), vec2(21.0, 21.0)); + let p = ui.painter(); + p.rect_filled(bg, 5.0, c32(app.session.tools.background)); + p.rect_stroke(bg, 5.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); + p.rect_filled(fg, 5.0, c32(app.session.tools.foreground)); + p.rect_stroke(fg, 5.0, Stroke::new(1.5, t.chrome), StrokeKind::Outside); + p.rect_stroke(fg, 5.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + let fg_resp = ui.interact(fg, ui.id().with("fgchip"), Sense::click()); + if fg_resp.on_hover_text("Foreground colour").clicked() { + app.ui.panels.color = true; + app.ui.dock_tabs.color = 1; + } + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + if icons::button(ui, "arrow-left-right", 18.0, false, "Swap colours (X)").clicked() { + let _ = app.run("tools.swapColors", json!({})); + } + if icons::button(ui, "contrast", 18.0, false, "Default colours (D)").clicked() { + let _ = app.run("tools.defaultColors", json!({})); + } + }); +} + +// ----------------------------------------------------------------------------- title bar + +pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let left = if cfg!(target_os = "macos") && app.integrated_titlebar { 78 } else { 10 }; + egui::Panel::top("title_bar").exact_size(if t.pro { 32.0 } else { 38.0 }).frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left, right: 10, top: 0, bottom: 0 })).show(ui, |ui| { + let full = ui.max_rect(); + let drag = ui.interact(full, ui.id().with("titledrag"), Sense::click_and_drag()); + if drag.drag_started() { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + if drag.double_clicked() { + let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false)); + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); + } + let title = app.session.active().map(|d| format!("{}{}", d.doc.name, if d.is_dirty() { " •" } else { "" })).unwrap_or_else(|| "Photocraft".into()); + ui.painter().text(full.center(), Align2::CENTER_CENTER, title, theme::medium(13.0), t.text_dim); + ui.horizontal_centered(|ui| { + crate::menus::menu_bar(app, ui); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.spacing_mut().item_spacing.x = 6.0; + let mut ws = app.ui.workspace.clone(); + let opts = [("Essentials".to_string(), "Essentials"), ("Photography".to_string(), "Photography"), ("Painting".to_string(), "Painting"), ("Graphic and Web".to_string(), "Graphic and Web")]; + if widgets::dropdown(ui, "workspace", &mut ws, &opts, 130.0) { + app.ui.workspace = ws; + crate::menus::apply_workspace(app); + } + if icons::button(ui, "search", 28.0, app.ui.palette_open, "Search commands (⌘K)").clicked() { + app.ui.palette_open = !app.ui.palette_open; + } + let theme_icon = if t.dark() { "sun" } else { "moon" }; + if icons::button(ui, theme_icon, 28.0, false, "Switch theme").clicked() { + let next = app.ui.theme.next(); + app.set_theme(ui.ctx(), next); + } + }); + }); + }); +} + +// ----------------------------------------------------------------------------- options bar + +pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + egui::Panel::top("options_bar").exact_size(if t.pro { 36.0 } else { 42.0 }).frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(10, 0))).show(ui, |ui| { + let r = ui.max_rect(); + ui.painter().line_segment([r.left_top(), r.right_top()], Stroke::new(1.0, t.separator)); + ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.separator)); + ui.horizontal_centered(|ui| { + let _ = icons::button(ui, icons::tool_icon(app.ui.tool), if t.pro { 26.0 } else { 28.0 }, !t.pro, app.ui.tool.label()); + if t.pro { + let (r, _) = ui.allocate_exact_size(vec2(10.0, 20.0), Sense::hover()); + icons::paint(ui, r, "chevron-down", 10.0, t.text_faint); + } + widgets::vline(ui, 22.0); + let b = &mut app.session.tools.brush; + match app.ui.tool { + Tool::Brush | Tool::Eraser if t.pro => { + brush_preset_chip(ui, b.size, b.hardness); + widgets::vline(ui, 22.0); + opt_label(ui, "Mode"); + let mut mode = BlendMode::Normal; + let opts: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (*m, m.label())).collect(); + widgets::dropdown(ui, "brush-mode", &mut mode, &opts, 96.0); + opt_label(ui, "Opacity"); + let mut o = b.opacity * 100.0; + if widgets::value_field(ui, &mut o, 0.0..=100.0, "%", 62.0).changed() { + b.opacity = o / 100.0; + } + if icons::button(ui, "circle-dot", 24.0, b.pressure_opacity, "Always use pressure for opacity").clicked() { + b.pressure_opacity = !b.pressure_opacity; + } + opt_label(ui, "Flow"); + let mut f = b.flow * 100.0; + if widgets::value_field(ui, &mut f, 1.0..=100.0, "%", 62.0).changed() { + b.flow = f / 100.0; + } + let _ = icons::button(ui, "sparkles", 24.0, false, "Enable airbrush-style build-up effects"); + opt_label(ui, "Smoothing"); + let mut sm = 10.0f32; + widgets::value_field(ui, &mut sm, 0.0..=100.0, "%", 58.0); + let _ = icons::button(ui, "settings", 24.0, false, "Set additional smoothing options"); + widgets::vline(ui, 22.0); + if icons::button(ui, "circle-dot", 24.0, b.pressure_size, "Always use pressure for size").clicked() { + b.pressure_size = !b.pressure_size; + } + let _ = icons::button(ui, "arrow-left-right", 24.0, false, "Set painting symmetry options"); + } + Tool::Brush | Tool::Eraser => { + opt_label(ui, "Size"); + widgets::value_field(ui, &mut b.size, 1.0..=2500.0, "px", 76.0); + widgets::vline(ui, 22.0); + opt_label(ui, "Hardness"); + let mut h = b.hardness * 100.0; + if widgets::value_field(ui, &mut h, 0.0..=100.0, "%", 66.0).changed() { + b.hardness = h / 100.0; + } + opt_label(ui, "Opacity"); + let mut o = b.opacity * 100.0; + if widgets::value_field(ui, &mut o, 0.0..=100.0, "%", 66.0).changed() { + b.opacity = o / 100.0; + } + opt_label(ui, "Flow"); + let mut f = b.flow * 100.0; + if widgets::value_field(ui, &mut f, 1.0..=100.0, "%", 66.0).changed() { + b.flow = f / 100.0; + } + widgets::vline(ui, 22.0); + widgets::toggle(ui, &mut b.pressure_size, "Pressure for Size"); + widgets::toggle(ui, &mut b.pressure_opacity, "Pressure for Opacity"); + } + Tool::RectMarquee | Tool::EllipseMarquee if t.pro => { + ui.spacing_mut().item_spacing.x = 2.0; + for (i, (icon, tip)) in [("square", "New selection"), ("plus", "Add to selection (⇧)"), ("minus", "Subtract from selection (⌥)"), ("squares-subtract", "Intersect with selection (⇧⌥)")].iter().enumerate() { + if icons::button(ui, icon, 24.0, app.ui.selection_mode == i as u8, tip).clicked() { + app.ui.selection_mode = i as u8; + } + } + ui.spacing_mut().item_spacing.x = 8.0; + widgets::vline(ui, 22.0); + opt_label(ui, "Feather"); + let mut feather = 0.0f32; + widgets::value_field(ui, &mut feather, 0.0..=1000.0, "px", 62.0); + let mut aa = true; + widgets::checkbox(ui, &mut aa, "Anti-alias"); + widgets::vline(ui, 22.0); + opt_label(ui, "Style"); + let mut style = 0u8; + widgets::dropdown(ui, "marquee-style", &mut style, &[(0u8, "Normal"), (1, "Fixed Ratio"), (2, "Fixed Size")], 96.0); + widgets::vline(ui, 22.0); + let _ = widgets::secondary_button(ui, "Select and Mask…", 0.0); + } + Tool::Move if t.pro => { + let mut auto = false; + widgets::checkbox(ui, &mut auto, "Auto-Select:"); + let mut target = 0u8; + widgets::dropdown(ui, "move-target", &mut target, &[(0u8, "Layer"), (1, "Group")], 76.0); + widgets::vline(ui, 22.0); + let mut tc = false; + widgets::checkbox(ui, &mut tc, "Show Transform Controls"); + widgets::vline(ui, 22.0); + ui.spacing_mut().item_spacing.x = 0.0; + for (icon, tip) in [("panels-top-left", "Align left edges"), ("app-window", "Align horizontal centers"), ("panel-right", "Align right edges")] { + let _ = icons::button(ui, icon, 24.0, false, tip); + } + ui.add_space(6.0); + let _ = icons::button(ui, "ellipsis", 24.0, false, "Align and distribute"); + } + Tool::RectMarquee | Tool::EllipseMarquee => hint(ui, "Drag to select · ⇧ add · ⌥ subtract · ⇧⌥ intersect · click to deselect"), + Tool::Move => hint(ui, "Drag to move the active layer"), + Tool::Eyedropper => hint(ui, "Click to sample the foreground colour · ⌥-click for background"), + Tool::Zoom => { + hint(ui, "Click to zoom in · ⌥-click to zoom out"); + if widgets::secondary_button(ui, "Fit Screen", 0.0).clicked() + && let Some(i) = app.session.active_index() + { + app.ui.views[i].fit_pending = true; + } + if widgets::secondary_button(ui, "100%", 0.0).clicked() + && let Some(i) = app.session.active_index() + { + app.ui.views[i].zoom = 1.0; + } + } + Tool::Hand => hint(ui, "Drag to pan · hold Space with any tool"), + } + }); + }); +} + +fn label(ui: &mut egui::Ui, s: &str) { + let t = Tokens::get(ui.ctx()); + ui.label(RichText::new(s).color(t.text_dim)); +} + +/// Options-bar label: Photoshop writes "Size:" with a colon. +fn opt_label(ui: &mut egui::Ui, s: &str) { + let t = Tokens::get(ui.ctx()); + let text = if t.pro && !s.ends_with(':') { format!("{s}:") } else { s.to_string() }; + ui.label(RichText::new(text).color(t.text_dim)); +} + +fn hint(ui: &mut egui::Ui, s: &str) { + let t = Tokens::get(ui.ctx()); + ui.label(RichText::new(s).color(t.text_faint)); +} + +// ----------------------------------------------------------------------------- status bar + +pub fn status_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + egui::Panel::bottom("status_bar").exact_size(if t.pro { 24.0 } else { 30.0 }).frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(10, 0))).show(ui, |ui| { + let r = ui.max_rect(); + ui.painter().line_segment([r.left_top(), r.right_top()], Stroke::new(1.0, t.separator)); + ui.horizontal_centered(|ui| { + if let (Some(st), Some(i)) = (app.session.active(), app.session.active_index()) { + let (w, h, mode, bits, layers) = (st.doc.size.width, st.doc.size.height, crate::canvas::mode_label(&st.doc), st.doc.depth.bits(), st.doc.layer_count()); + let mut pct = app.ui.views[i].zoom * 100.0; + if widgets::value_field(ui, &mut pct, 1.0..=3200.0, "%", 78.0).changed() { + app.ui.views[i].zoom = pct / 100.0; + app.ui.views[i].fit_pending = false; + } + widgets::vline(ui, 16.0); + label(ui, &format!("{mode} Color · {bits} bit")); + widgets::vline(ui, 16.0); + label(ui, &format!("{w} × {h} px")); + widgets::vline(ui, 16.0); + label(ui, &format!("{layers} layers")); + } else { + label(ui, "No document"); + } + if !app.ui.status.is_empty() { + widgets::vline(ui, 16.0); + let is_err = app.ui.status_error || app.ui.status.starts_with("Couldn"); + ui.label(RichText::new(&app.ui.status).color(if is_err { t.warning } else { t.text_faint })); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if app.session.active().is_some() + && !t.pro + && widgets::secondary_button(ui, "Fit", 0.0).clicked() + && let Some(i) = app.session.active_index() + { + app.ui.views[i].fit_pending = true; + } + }); + }); + }); +} + +// ----------------------------------------------------------------------------- dock + +pub fn right_dock(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let p = app.ui.panels.clone(); + if t.pro { + dock_panels(app, ui, &p, &t); + } + // Narrow icon rail (always visible) that toggles panels. + let (rw, rb) = if t.pro { (36.0, 28.0) } else { (44.0, 32.0) }; + egui::Panel::right("rail").resizable(false).exact_size(rw).frame(egui::Frame::NONE.fill(if t.pro { t.chrome } else { t.chrome }).inner_margin(egui::Margin::symmetric(4, 8))).show(ui, |ui| { + let r = ui.max_rect(); + ui.painter().line_segment([r.left_top() - vec2(6.0, 8.0), r.left_bottom() + vec2(-6.0, 8.0)], Stroke::new(1.0, t.separator)); + ui.spacing_mut().item_spacing.y = 4.0; + let entries: [(&str, &str, bool); 5] = [ + ("sliders-horizontal", "Properties", p.properties), + ("navigation", "Navigator", p.navigator), + ("palette", "Color & Swatches", p.color), + ("layers", "Layers", p.layers), + ("clock", "History", p.history), + ]; + for (icon, name, on) in entries { + if icons::rail_button(ui, icon, rb, on, name).clicked() { + let panels = &mut app.ui.panels; + match name { + "Properties" => panels.properties = !panels.properties, + "Navigator" => panels.navigator = !panels.navigator, + "Color & Swatches" => panels.color = !panels.color, + "Layers" => panels.layers = !panels.layers, + _ => panels.history = !panels.history, + } + } + } + }); + if !t.pro { + dock_panels(app, ui, &p, &t); + } +} + +fn dock_panels(app: &mut PhotocraftApp, ui: &mut egui::Ui, p: &crate::state::Panels, t: &Tokens) { + let p = p.clone(); + if !(p.layers || p.history || p.color || p.navigator) { + return; + } + let margin = if t.pro { 2 } else { 8 }; + egui::Panel::right("dock").resizable(true).default_size(if t.pro { 290.0 } else { 300.0 }).size_range(250.0..=520.0).frame(egui::Frame::NONE.fill(t.dock).inner_margin(egui::Margin::same(margin))).show(ui, |ui| { + let draw = |app: &mut PhotocraftApp, ui: &mut egui::Ui| { + ui.spacing_mut().item_spacing.y = if t.pro { 0.0 } else { 6.0 }; + // Photoshop Essentials order: Color, Properties, (Navigator, History), Layers last and filling. + if p.color { + let mut sel = app.ui.dock_tabs.color; + let tabs: &[&str] = if t.pro { &["Color", "Swatches"] } else { &["Swatches", "Color"] }; + let pro = t.pro; + widgets::card(ui, "color", tabs, &mut sel, |ui, tab| match (pro, tab) { + (true, 0) => color_field(app, ui), + (true, _) => swatches(app, ui), + (false, 0) => swatches(app, ui), + (false, _) => color_picker(app, ui), + }); + app.ui.dock_tabs.color = sel; + } + if t.pro && p.properties { + let mut sel = app.ui.dock_tabs.properties; + widgets::card(ui, "properties", &["Properties", "Adjustments"], &mut sel, |ui, tab| if tab == 0 { properties_body(app, ui) } else { adjustments_grid(app, ui) }); + app.ui.dock_tabs.properties = sel; + } + if p.navigator { + let mut sel = 0; + widgets::card(ui, "navigator", &["Navigator", "Histogram"], &mut sel, |ui, tab| if tab == 0 { navigator(app, ui) } else { empty(ui, "Histogram arrives with the GPU pipeline (M5)") }); + } + if p.history { + let mut sel = 0; + widgets::card(ui, "history", &["History"], &mut sel, |ui, _| history(app, ui)); + } + if p.layers { + let mut sel = app.ui.dock_tabs.layers; + widgets::card(ui, "layers", &["Layers", "Channels", "Paths"], &mut sel, |ui, tab| match tab { + 0 => layers(app, ui), + 1 => channels(app, ui), + _ => empty(ui, "No paths"), + }); + app.ui.dock_tabs.layers = sel; + } + }; + if t.pro { + // Fixed panels on top, Layers takes the rest (it scrolls internally). + draw(app, ui); + } else { + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| draw(app, ui)); + } + }); +} + +fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let Some(idx) = app.session.active_index() else { + empty(ui, "No document"); + return; + }; + let ctx = ui.ctx().clone(); + let Some((tex, _)) = crate::canvas::ensure_texture(app, &ctx, idx) else { return }; + let size = app.session.documents()[idx].doc.size; + let avail = ui.available_width(); + let box_h = 150.0; + let (frame, resp) = ui.allocate_exact_size(vec2(avail, box_h), Sense::click_and_drag()); + ui.painter().rect_filled(frame, t.radius_sm, t.canvas); + let aspect = size.width as f32 / size.height.max(1) as f32; + let (w, h) = if aspect > avail / box_h { (avail - 16.0, (avail - 16.0) / aspect) } else { ((box_h - 16.0) * aspect, box_h - 16.0) }; + let rect = Rect::from_center_size(frame.center(), vec2(w, h)); + widgets::checker(ui.painter(), rect, 6.0); + ui.painter().image(tex, rect, Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE); + let s = w / size.width as f32; + if (resp.clicked() || resp.dragged()) + && let Some(pp) = resp.interact_pointer_pos() + { + let d = (pp - rect.min) / s; + app.ui.views[idx].center = [d.x.clamp(0.0, size.width as f32), d.y.clamp(0.0, size.height as f32)]; + app.ui.views[idx].fit_pending = false; + } + // Visible-area rectangle. + let v = app.ui.views[idx].clone(); + let canvas = app.last_canvas_rect; + let vw = canvas.width() / v.zoom * s; + let vh = canvas.height() / v.zoom * s; + let c = pos2(rect.min.x + v.center[0] * s, rect.min.y + v.center[1] * s); + let vr = Rect::from_center_size(c, vec2(vw, vh)).intersect(frame.shrink(1.0)); + ui.painter().rect_stroke(vr, 2.0, Stroke::new(1.5, Color32::from_rgb(255, 84, 84)), StrokeKind::Middle); + ui.add_space(4.0); + ui.horizontal(|ui| { + label(ui, "Zoom"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + for (lbl, z) in [("200%", 2.0), ("100%", 1.0)] { + if widgets::pill_tab(ui, lbl, (v.zoom - z).abs() < 1e-3).clicked() { + app.ui.views[idx].zoom = z; + app.ui.views[idx].fit_pending = false; + } + } + if widgets::pill_tab(ui, "Fit", false).clicked() { + app.ui.views[idx].fit_pending = true; + } + }); + }); + let mut lz = v.zoom.max(0.01).log2(); + if widgets::slider(ui, &mut lz, -6.64..=5.0, None).changed() { + app.ui.views[idx].zoom = 2f32.powf(lz); + app.ui.views[idx].fit_pending = false; + } +} + +fn empty(ui: &mut egui::Ui, s: &str) { + let t = Tokens::get(ui.ctx()); + ui.add_space(6.0); + ui.label(RichText::new(s).color(t.text_faint)); + ui.add_space(6.0); +} + +const SWATCHES: [[u8; 3]; 40] = [ + [0, 0, 0], [26, 26, 26], [51, 51, 51], [77, 77, 77], [102, 102, 102], [128, 128, 128], [153, 153, 153], [179, 179, 179], [204, 204, 204], [255, 255, 255], + [236, 128, 128], [244, 176, 132], [250, 224, 128], [214, 240, 128], [150, 232, 150], [128, 232, 200], [128, 220, 240], [128, 176, 244], [168, 144, 244], [232, 144, 232], + [230, 40, 40], [245, 120, 30], [250, 210, 30], [160, 220, 40], [40, 200, 80], [30, 200, 170], [30, 170, 230], [40, 100, 230], [120, 70, 220], [210, 50, 180], + [120, 20, 20], [130, 60, 10], [130, 110, 10], [80, 120, 20], [20, 100, 40], [10, 100, 90], [10, 80, 120], [20, 50, 120], [60, 30, 110], [110, 20, 90], +]; + +fn swatches(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let cols = 10; + let gap = 4.0; + let w = ui.available_width(); + let cell = ((w - gap * (cols as f32 - 1.0)) / cols as f32).floor(); + let rows = SWATCHES.len().div_ceil(cols); + let (area, _) = ui.allocate_exact_size(vec2(w, rows as f32 * (cell + gap)), Sense::hover()); + for (i, s) in SWATCHES.iter().enumerate() { + let (cx, cy) = ((i % cols) as f32, (i / cols) as f32); + let r = Rect::from_min_size(area.min + vec2(cx * (cell + gap), cy * (cell + gap)), Vec2::splat(cell)); + let resp = ui.interact(r, ui.id().with(("sw", i)), Sense::click()); + ui.painter().rect_filled(r, 4.0, Color32::from_rgb(s[0], s[1], s[2])); + if resp.hovered() { + ui.painter().rect_stroke(r, 4.0, Stroke::new(1.5, t.text), StrokeKind::Outside); + } + let c = [s[0] as f32 / 255.0, s[1] as f32 / 255.0, s[2] as f32 / 255.0, 1.0]; + if resp.clicked() { + app.session.tools.foreground = c; + } + if resp.secondary_clicked() { + app.session.tools.background = c; + } + } + ui.add_space(2.0); + ui.label(RichText::new("Click sets foreground · right-click sets background").small().color(t.text_faint)); +} + +fn color_picker(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let fg = app.session.tools.foreground; + let hsva0 = srgb_hsva(fg); + let mut h = hsva0.h * 360.0; + let mut s = hsva0.s * 100.0; + let mut v = hsva0.v * 100.0; + let hue = widgets::hue_stops(); + widgets::slider_row(ui, "Hue", &mut h, 0.0..=360.0, "°", Some(&hue)); + let sat_stops = [egui::ecolor::Hsva::new(hsva0.h, 0.0, hsva0.v.max(0.2), 1.0), egui::ecolor::Hsva::new(hsva0.h, 1.0, hsva0.v.max(0.2), 1.0)].map(Color32::from); + widgets::slider_row(ui, "Saturation", &mut s, 0.0..=100.0, "%", Some(&sat_stops)); + let val_stops = [Color32::BLACK, Color32::from(egui::ecolor::Hsva::new(hsva0.h, hsva0.s, 1.0, 1.0))]; + widgets::slider_row(ui, "Brightness", &mut v, 0.0..=100.0, "%", Some(&val_stops)); + let hsva = egui::ecolor::Hsva::new(h / 360.0, s / 100.0, v / 100.0, 1.0); + if hsva != hsva0 { + app.session.tools.foreground = hsva_srgb(hsva); + } + let [r, g, b, _] = hsva.to_srgba_unmultiplied(); + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + let (sw, _) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::hover()); + ui.painter().rect_filled(sw, 6.0, Color32::from_rgb(r, g, b)); + ui.label(RichText::new(format!("#{r:02X}{g:02X}{b:02X}")).font(theme::mono(12.5)).color(t.text)); + ui.label(RichText::new(format!("RGB {r} {g} {b}")).font(theme::mono(11.5)).color(t.text_faint)); + }); +} + +// ----------------------------------------------------------------------------- layers + +fn blend_options(groups: bool) -> Vec<(BlendMode, &'static str)> { + std::iter::once(BlendMode::PassThrough).filter(|_| groups).chain(BlendMode::LAYER_MODES).map(|m| (m, m.label())).collect() +} + +fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let Some(st) = app.session.active() else { + empty(ui, "No document"); + return; + }; + let doc = st.doc.clone(); + let active = st.active_layer; + let active_layer = active.and_then(|id| doc.layer(id)).cloned(); + let mut actions: Vec<(String, Value)> = Vec::new(); + + let t = Tokens::get(ui.ctx()); + if t.pro { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + let (r, _) = ui.allocate_exact_size(vec2(64.0, 22.0), Sense::hover()); + ui.painter().rect_filled(r, t.radius_sm, t.field); + ui.painter().rect_stroke(r, t.radius_sm, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + icons::paint(ui, Rect::from_center_size(r.left_center() + vec2(11.0, 0.0), vec2(14.0, 14.0)), "search", 11.0, t.text_faint); + ui.painter().text(r.left_center() + vec2(22.0, 0.0), Align2::LEFT_CENTER, "Kind", egui::FontId::proportional(11.5), t.text_dim); + ui.add_space(4.0); + for (icon, tip) in [("image", "Pixel layers"), ("contrast", "Adjustment layers"), ("type", "Type layers"), ("square", "Shape layers"), ("app-window", "Smart objects")] { + let _ = icons::button(ui, icon, 22.0, false, tip); + } + }); + ui.add_space(4.0); + } + if let Some(l) = &active_layer { + ui.horizontal(|ui| { + let mut m = l.blend; + let w = ui.available_width() - 150.0; + if widgets::dropdown(ui, "blend", &mut m, &blend_options(l.is_group()), w.max(100.0)) { + actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "blend": m.label()}))); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut o = l.opacity * 100.0; + if widgets::value_field(ui, &mut o, 0.0..=100.0, "%", 66.0).changed() { + actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "opacity": o / 100.0}))); + } + label(ui, if t.pro { "Opacity:" } else { "Opacity" }); + }); + }); + ui.horizontal(|ui| { + label(ui, if t.pro { "Lock:" } else { "Lock" }); + if t.pro { + ui.spacing_mut().item_spacing.x = 0.0; + for (icon, tip) in [("grid-3x3", "Lock transparent pixels"), ("brush", "Lock image pixels"), ("move", "Lock position"), ("scan", "Prevent auto-nesting"), ("lock", "Lock all")] { + let on = icon == "lock" && (l.locks.all || l.locks.transparency || l.locks.position); + let _ = icons::button(ui, icon, 20.0, on, tip); + } + } + if !t.pro { + let locked = l.locks.transparency || l.locks.position || l.locks.all; + let _ = icons::button(ui, if locked { "lock" } else { "lock-open" }, 22.0, locked, "Lock layer"); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut f = l.fill_opacity * 100.0; + if widgets::value_field(ui, &mut f, 0.0..=100.0, "%", 66.0).changed() { + actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "fill": f / 100.0}))); + } + label(ui, if t.pro { "Fill:" } else { "Fill" }); + }); + }); + ui.add_space(2.0); + } + + let rows = doc.walk(); + let ctx = ui.ctx().clone(); + let footer = 38.0; + let fill = t.pro && ui.available_height() > footer + 60.0; + let rows_h = if fill { ui.available_height() - footer } else { f32::INFINITY }; + egui::ScrollArea::vertical().id_salt("layer-rows").max_height(rows_h).min_scrolled_height(if fill { rows_h } else { 0.0 }).auto_shrink([false, !fill]).show(ui, |ui| { + for (_, depth, l) in rows.iter().rev() { + layer_row(app, &ctx, ui, &doc, l, *depth, active == Some(l.id), &mut actions); + if !l.effects.items.is_empty() { + effect_rows(app, ui, l, *depth); + } + } + }); + // End any layer drag after every row has had a chance to accept the drop. + if ctx.input(|i| i.pointer.any_released()) { + ctx.data_mut(|d| d.remove::(egui::Id::new("layer-drag"))); + } + ui.add_space(4.0); + widgets::hairline(ui); + ui.add_space(2.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if icons::button(ui, "trash", 26.0, false, "Delete layer").clicked() { + actions.push(("layer.delete".into(), json!({}))); + } + if icons::button(ui, "plus", 26.0, false, "New layer (⇧⌘N)").clicked() { + actions.push(("layer.new.layer".into(), json!({}))); + } + if icons::button(ui, "folder-plus", 26.0, false, "New group").clicked() { + actions.push(("layer.new.group".into(), json!({}))); + } + let adj = icons::button(ui, "contrast", 26.0, false, "New fill or adjustment layer"); + egui::Popup::menu(&adj).show(|ui| { + ui.set_min_width(190.0); + for c in photocraft_engine::command_specs().iter().filter(|c| c.id.starts_with("layer.newAdjustmentLayer.")) { + if ui.button(c.label.trim_end_matches('…')).clicked() { + actions.push((c.id.into(), json!({}))); + ui.close(); + } + } + ui.separator(); + if ui.button("Solid Color…").clicked() { + actions.push(("layer.newFillLayer.solidColor".into(), json!({}))); + ui.close(); + } + if ui.button("Gradient…").clicked() { + actions.push(("layer.newFillLayer.gradient".into(), json!({}))); + ui.close(); + } + }); + if icons::button(ui, "square-dashed", 26.0, false, "Add layer mask").clicked() { + actions.push(("layer.layerMask.revealAll".into(), json!({}))); + } + if icons::button(ui, "copy", 26.0, false, "Duplicate layer (⌘J)").clicked() { + actions.push(("layer.duplicate".into(), json!({}))); + } + let fx = icons::button(ui, "sparkles", 26.0, false, "Add a layer style"); + egui::Popup::menu(&fx).show(|ui| { + ui.set_min_width(180.0); + if ui.button("Blending Options…").clicked() { + crate::layer_style::open(app, None); + ui.close(); + } + ui.separator(); + for &(kind, label) in crate::layer_style::KINDS { + if ui.button(format!("{label}…")).clicked() { + crate::layer_style::open(app, Some(kind)); + ui.close(); + } + } + }); + if icons::button(ui, "link", 26.0, false, "Create clipping mask (⌥⌘G)").clicked() { + actions.push(("layer.createClippingMask".into(), json!({}))); + } + }); + }); + for (id, p) in actions { + let _ = app.run(&id, p); + } +} + +#[allow(clippy::too_many_arguments)] +fn layer_row(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &mut egui::Ui, doc: &photocraft_doc::Document, l: &Layer, depth: usize, selected: bool, actions: &mut Vec<(String, Value)>) { + let t = Tokens::get(ctx); + let row_h = if t.pro { 36.0 } else { 46.0 }; + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click_and_drag()); + layer_drag_and_drop(ctx, ui, l, rect, &resp, actions); + let painter = ui.painter_at(rect.expand(1.0)); + if t.pro { + if selected { + painter.rect_filled(rect, 0.0, t.row_selected); + } else if resp.hovered() { + painter.rect_filled(rect, 0.0, t.hover.gamma_multiply(0.5)); + } + // eye column divider + row divider, as in Photoshop + painter.line_segment([pos2(rect.left() + 30.0, rect.top()), pos2(rect.left() + 30.0, rect.bottom())], Stroke::new(1.0, t.separator)); + painter.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(1.0, t.separator)); + } else if selected { + painter.rect_filled(rect, t.radius, t.hover); + painter.rect_stroke(rect, t.radius, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + } else if resp.hovered() { + painter.rect_filled(rect, t.radius, t.hover.gamma_multiply(0.5)); + } + let mut x = rect.left() + 6.0; + let eye = Rect::from_min_size(pos2(x, rect.center().y - 11.0), vec2(22.0, 22.0)); + let eye_resp = ui.interact(eye, ui.id().with(("eye", l.id.0)), Sense::click()); + icons::paint(ui, eye, if l.visible { "eye" } else { "eye-off" }, 15.0, if l.visible { t.icon } else { t.text_faint }); + if eye_resp.clicked() { + actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "visible": !l.visible}))); + } + x += 28.0 + depth as f32 * 14.0; + if l.clipped { + painter.text(pos2(x, rect.center().y), Align2::LEFT_CENTER, "↳", egui::FontId::proportional(13.0), t.text_faint); + x += 12.0; + } + let ts = if t.pro { 28.0 } else { 34.0 }; + let thumb = Rect::from_min_size(pos2(x, rect.center().y - ts / 2.0), vec2(ts, ts)); + draw_layer_thumb(app, ctx, ui, doc, l, thumb, selected); + x += ts + 6.0; + if let Some(m) = &l.mask { + let mr = Rect::from_min_size(pos2(x, rect.center().y - 17.0), vec2(34.0, 34.0)); + let tex = app.mask_thumb(ctx, doc, l.id, m); + painter.image(tex, mr, Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE); + painter.rect_stroke(mr, 4.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + x += 40.0; + } + let name_color = if l.visible { t.text } else { t.text_faint }; + let font = if selected && !t.pro { theme::medium(13.0) } else { egui::FontId::proportional(if t.pro { 12.0 } else { 13.0 }) }; + // Photoshop sets the locked Background layer's name in italics. + let italic = l.name == "Background" && l.locks.transparency; + let mut job = egui::text::LayoutJob::default(); + job.append(&l.name, 0.0, egui::TextFormat { font_id: font, color: name_color, italics: italic, ..Default::default() }); + let galley = painter.layout_job(job); + let is_pixel = matches!(l.content, LayerContent::Raster(_)); + let text_pos = pos2(x, rect.center().y - galley.size().y / 2.0 - if is_pixel { 0.0 } else { 7.0 }); + painter.galley(text_pos, galley, name_color); + if !is_pixel { + let sub = match &l.content { + LayerContent::Adjustment(a) => a.label().to_string(), + LayerContent::Group(g) => format!("Group · {} layers", g.children.len()), + other => other.kind_name().to_string(), + }; + painter.text(pos2(x, rect.center().y + 8.0), Align2::LEFT_CENTER, sub, egui::FontId::proportional(11.0), t.text_faint); + } + if !l.effects.items.is_empty() { + painter.text(pos2(rect.right() - 34.0, rect.center().y), Align2::RIGHT_CENTER, "fx", theme::semibold(11.0), t.text_dim); + } + if l.blend != BlendMode::Normal && l.blend != BlendMode::PassThrough { + painter.text(pos2(rect.right() - 30.0, rect.center().y), Align2::RIGHT_CENTER, l.blend.label(), egui::FontId::proportional(10.5), t.text_faint); + } + if l.locks.transparency || l.locks.position || l.locks.all { + icons::paint(ui, Rect::from_center_size(pos2(rect.right() - 14.0, rect.center().y), vec2(14.0, 14.0)), "lock", 12.0, t.text_faint); + } + if resp.clicked() && !eye_resp.clicked() { + actions.push(("layer.select".into(), json!({"layer": l.id.0}))); + } + // Double-click the name to rename in place (Photoshop ergonomics). + let rename_id = egui::Id::new(("rename", l.id.0)); + if resp.double_clicked() { + ctx.data_mut(|d| d.insert_temp(rename_id, l.name.clone())); + } + if let Some(mut text) = ctx.data(|d| d.get_temp::(rename_id)) { + let edit_rect = Rect::from_min_max(pos2(x - 3.0, rect.center().y - 11.0), pos2(rect.right() - 36.0, rect.center().y + 11.0)); + let te = ui.put(edit_rect, egui::TextEdit::singleline(&mut text).font(egui::FontId::proportional(12.5))); + te.request_focus(); + let (enter, esc) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape))); + if esc { + ctx.data_mut(|d| d.remove::(rename_id)); + } else if enter || te.lost_focus() { + ctx.data_mut(|d| d.remove::(rename_id)); + if !text.trim().is_empty() && text != l.name { + actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "name": text.trim()}))); + } + } else { + ctx.data_mut(|d| d.insert_temp(rename_id, text)); + } + } + // Right-click context menu. + resp.context_menu(|ui| { + ui.set_min_width(200.0); + let id = l.id.0; + let mut item = |ui: &mut egui::Ui, label: &str, cmd: &str| { + if ui.button(label).clicked() { + actions.push(("layer.select".into(), json!({"layer": id}))); + actions.push((cmd.into(), json!({"layer": id}))); + ui.close(); + } + }; + item(ui, "Duplicate Layer", "layer.duplicate"); + item(ui, "Delete Layer", "layer.delete"); + ui.separator(); + if l.clipped { + item(ui, "Release Clipping Mask", "layer.releaseClippingMask"); + } else { + item(ui, "Create Clipping Mask", "layer.createClippingMask"); + } + if l.mask.is_some() { + item(ui, "Delete Layer Mask", "layer.layerMask.delete"); + } else { + item(ui, "Add Layer Mask", "layer.layerMask.revealAll"); + } + ui.separator(); + item(ui, "Group Layers", "layer.groupLayers"); + item(ui, "Merge Down", "layer.mergeDown"); + if ui.button("Flatten Image").clicked() { + actions.push(("layer.flattenImage".into(), json!({}))); + ui.close(); + } + ui.separator(); + if ui.button("Rename Layer…").clicked() { + ui.ctx().data_mut(|d| d.insert_temp(rename_id, l.name.clone())); + ui.close(); + } + }); +} + +fn draw_layer_thumb(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &egui::Ui, doc: &photocraft_doc::Document, l: &Layer, rect: Rect, selected: bool) { + let t = Tokens::get(ctx); + let p = ui.painter(); + match &l.content { + LayerContent::Adjustment(_) | LayerContent::Group(_) => { + p.rect_filled(rect, 6.0, t.field); + let icon = if l.is_group() { "folder" } else { "contrast" }; + icons::paint(ui, rect, icon, 18.0, t.icon); + } + LayerContent::Fill(f) => { + let c = match f { + photocraft_doc::Fill::Solid(c) => c.to_rgba8(), + photocraft_doc::Fill::Gradient { stops, .. } => stops.first().map(|s| s.1.to_rgba8()).unwrap_or([0; 4]), + _ => [128, 128, 128, 255], + }; + p.rect_filled(rect, 6.0, Color32::from_rgba_unmultiplied(c[0], c[1], c[2], c[3])); + } + _ => { + widgets::checker(p, rect, 5.0); + let tex = app.layer_thumb(ctx, doc, l); + p.image(tex, rect, Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE); + } + } + let stroke = if t.pro { Stroke::new(1.0, Color32::from_gray(20)) } else if selected { Stroke::new(1.5, t.text) } else { Stroke::new(1.0, t.field_border) }; + p.rect_stroke(rect, CornerRadius::same(if t.pro { 0 } else { 5 }), stroke, StrokeKind::Outside); +} + +fn channels(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let Some(st) = app.session.active() else { + empty(ui, "No document"); + return; + }; + let names: Vec<&str> = match st.doc.mode { + photocraft_doc::ColorMode::Cmyk => vec!["CMYK", "Cyan", "Magenta", "Yellow", "Black"], + photocraft_doc::ColorMode::Grayscale => vec!["Gray"], + photocraft_doc::ColorMode::Lab => vec!["Lab", "Lightness", "a", "b"], + _ => vec!["RGB", "Red", "Green", "Blue"], + }; + let t = Tokens::get(ui.ctx()); + for (i, n) in names.iter().enumerate() { + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover()); + icons::paint(ui, r, "eye", 15.0, t.icon); + ui.label(RichText::new(*n).color(t.text)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(RichText::new(format!("⌘{}", i + 2)).font(theme::mono(11.0)).color(t.text_faint)); + }); + }); + } +} + +fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.active() else { + empty(ui, "No document"); + return; + }; + let entries = st.history.entries(); + let current = entries.len() - 1; + let redo: Vec = { + let mut v = Vec::new(); + let mut h = st.history.clone(); + let mut d = st.doc.clone(); + while let Some(label) = h.redo_label().map(str::to_string) { + v.push(label); + match h.redo(d.clone()) { + Some(n) => d = n, + None => break, + } + } + v + }; + let mut target: Option = None; + let all = entries.iter().map(|e| (e.clone(), false)).chain(redo.iter().map(|e| (e.clone(), true))); + for (i, (e, is_redo)) in all.enumerate() { + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click()); + if i == current { + ui.painter().rect_filled(rect, t.radius_sm, t.accent_soft); + } else if resp.hovered() { + ui.painter().rect_filled(rect, t.radius_sm, t.hover); + } + let color = if is_redo { t.text_faint } else { t.text }; + ui.painter().text(rect.left_center() + vec2(10.0, 0.0), Align2::LEFT_CENTER, &e, egui::FontId::proportional(12.5), color); + if resp.clicked() { + target = Some(i as isize - current as isize); + } + } + if let Some(delta) = target { + let (cmd, n) = if delta < 0 { ("edit.undo", -delta) } else { ("edit.redo", delta) }; + for _ in 0..n { + if app.run(cmd, json!({})).is_err() { + break; + } + } + } +} + +// ----------------------------------------------------------------------------- properties + +/// Slider spec for adjustment properties: (param key, label, min, max, default). +pub fn adjustment_sliders(kind: &str) -> &'static [(&'static str, &'static str, f32, f32, f32)] { + match kind { + "hueSaturation" => &[("hue", "Hue", -180.0, 180.0, 0.0), ("saturation", "Saturation", -100.0, 100.0, 0.0), ("lightness", "Lightness", -100.0, 100.0, 0.0)], + "brightnessContrast" => &[("brightness", "Brightness", -150.0, 150.0, 0.0), ("contrast", "Contrast", -50.0, 100.0, 0.0)], + "exposure" => &[("exposure", "Exposure", -5.0, 5.0, 0.0), ("offset", "Offset", -0.5, 0.5, 0.0), ("gamma", "Gamma Correction", 0.1, 3.0, 1.0)], + "vibrance" => &[("vibrance", "Vibrance", -100.0, 100.0, 0.0), ("saturation", "Saturation", -100.0, 100.0, 0.0)], + "levels" => &[("inBlack", "Input Black", 0.0, 253.0, 0.0), ("gamma", "Midtones", 0.1, 9.99, 1.0), ("inWhite", "Input White", 2.0, 255.0, 255.0)], + "threshold" => &[("level", "Threshold Level", 1.0, 255.0, 128.0)], + "posterize" => &[("levels", "Levels", 2.0, 255.0, 4.0)], + "photoFilter" => &[("density", "Density", 0.0, 100.0, 25.0)], + _ => &[], + } +} + +pub fn adjustment_values(a: &photocraft_doc::Adjustment) -> Value { + use photocraft_doc::Adjustment as A; + match a { + A::HueSaturation { hue, saturation, lightness, colorize } => json!({"hue": hue, "saturation": saturation, "lightness": lightness, "colorize": colorize}), + A::BrightnessContrast { brightness, contrast, .. } => json!({"brightness": brightness, "contrast": contrast}), + A::Exposure { exposure, offset, gamma } => json!({"exposure": exposure, "offset": offset, "gamma": gamma}), + A::Vibrance { vibrance, saturation } => json!({"vibrance": vibrance, "saturation": saturation}), + A::Levels { master, .. } => json!({"inBlack": master.in_black * 255.0, "inWhite": master.in_white * 255.0, "gamma": master.gamma}), + A::Threshold { level } => json!({"level": level * 255.0}), + A::Posterize { levels } => json!({"levels": levels}), + A::PhotoFilter { density, .. } => json!({"density": density * 100.0}), + _ => json!({}), + } +} + +/// Floating Properties card anchored to the canvas' top-right corner. +pub fn properties_window(app: &mut PhotocraftApp, ctx: &egui::Context) { + // Pro (Photoshop) docks Properties; Studio (Photon) floats it over the canvas. + if !app.ui.panels.properties || Tokens::get(ctx).pro { + return; + } + let Some(st) = app.session.active() else { return }; + let Some(id) = st.active_layer else { return }; + let Some(layer) = st.doc.layer(id).cloned() else { return }; + // Like Photon: the floating card appears for adjustment and fill layers (their controls live here). + if !matches!(layer.content, LayerContent::Adjustment(_) | LayerContent::Fill(_)) { + return; + } + let t = Tokens::get(ctx); + let canvas = app.last_canvas_rect; + let width = 320.0; + let pos = pos2(canvas.right() - width - 12.0, canvas.top() + 44.0); + let frame = egui::Frame::NONE + .fill(t.card) + .stroke(Stroke::new(1.0, t.card_border)) + .corner_radius(CornerRadius::same(t.radius_lg as u8)) + .shadow(egui::Shadow { offset: [0, 12], blur: 36, spread: 0, color: t.shadow }) + .inner_margin(egui::Margin::same(14)); + egui::Area::new(egui::Id::new("properties-card")).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| { + frame.show(ui, |ui| { + ui.set_width(width - 28.0); + ui.horizontal(|ui| { + ui.label(RichText::new("Properties").font(theme::semibold(13.5)).color(t.text)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if icons::button(ui, "minus", 22.0, false, "Hide Properties").clicked() { + app.ui.panels.properties = false; + } + }); + }); + ui.add_space(6.0); + let icon = match &layer.content { + LayerContent::Adjustment(_) => "sliders-horizontal", + LayerContent::Group(_) => "folder", + LayerContent::Fill(_) => "paint-bucket", + _ => "image", + }; + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::hover()); + ui.painter().rect_filled(r, t.radius_sm, t.field); + icons::paint(ui, r, icon, 17.0, t.icon); + ui.vertical(|ui| { + ui.label(RichText::new(&layer.name).font(theme::medium(13.0)).color(t.text)); + let kind = match &layer.content { + LayerContent::Adjustment(a) => format!("{} Properties", a.label()), + other => format!("{} Layer", other.kind_name()), + }; + ui.label(RichText::new(kind).small().color(t.text_faint)); + }); + }); + ui.add_space(8.0); + widgets::hairline(ui); + ui.add_space(8.0); + if let LayerContent::Adjustment(adj) = &layer.content { + adjustment_controls(app, ui, id, adj); + } else { + layer_controls(app, ui, &layer); + } + }); + }); +} + +fn adjustment_controls(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj: &photocraft_doc::Adjustment) { + let t = Tokens::get(ui.ctx()); + let kind = photocraft_engine::commands::adjustment_kind(adj); + let committed = adjustment_values(adj); + let mut values = match &app.live_adjust { + Some((l, v)) if *l == id => v.clone(), + _ => committed, + }; + let mut commit = false; + let mut live = false; + let hue = widgets::hue_stops(); + for &(key, label, min, max, default) in adjustment_sliders(kind) { + let mut v = values.get(key).and_then(Value::as_f64).unwrap_or(default as f64) as f32; + let gradient = if key == "hue" { Some(&hue[..]) } else { None }; + let unit = match key { + "hue" => "°", + "saturation" | "lightness" | "vibrance" | "density" => "%", + _ => "", + }; + let r = widgets::slider_row(ui, label, &mut v, min..=max, unit, gradient); + if r.changed() { + values[key] = json!(v); + live = true; + } + if r.drag_stopped() || (r.changed() && !r.dragged()) { + commit = true; + } + } + if kind == "hueSaturation" { + let mut c = values.get("colorize").and_then(Value::as_bool).unwrap_or(false); + if widgets::toggle(ui, &mut c, "Colorize").changed() { + values["colorize"] = json!(c); + commit = true; + } + ui.add_space(4.0); + } + if adjustment_sliders(kind).is_empty() && kind != "invert" { + ui.label(RichText::new("Detailed controls for this adjustment arrive in milestone M7.").color(t.text_faint)); + } + if live { + app.live_adjust = Some((id, values.clone())); + } + if commit { + let mut p = values; + p["layer"] = json!(id.0); + let _ = app.run("layer.setAdjustment", p); + app.live_adjust = None; + } + ui.add_space(6.0); + let layer = app.session.active().and_then(|s| s.doc.layer(id).cloned()); + if let Some(l) = layer { + ui.horizontal(|ui| { + let mut vis = l.visible; + if widgets::toggle(ui, &mut vis, "Adjustment visible").changed() { + let _ = app.run("layer.setProps", json!({"layer": id.0, "visible": vis})); + } + let mut clip = l.clipped; + if widgets::toggle(ui, &mut clip, "Clip to layer below").changed() { + let _ = app.run("layer.setProps", json!({"layer": id.0, "clipped": clip})); + } + }); + } + ui.add_space(6.0); + if widgets::secondary_button(ui, "Reset to defaults", ui.available_width()).clicked() { + let _ = app.run("layer.setAdjustment", json!({"layer": id.0})); + app.live_adjust = None; + } +} + +fn layer_controls(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { + let t = Tokens::get(ui.ctx()); + if let Some(s) = layer.surface() { + let b = app.cached_bounds(layer.id.0, s); + egui::Grid::new("props-grid").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| { + for (k, v) in [("X", b.x0.to_string()), ("Y", b.y0.to_string()), ("W", b.width().to_string()), ("H", b.height().to_string())] { + ui.label(RichText::new(k).color(t.text_dim)); + ui.label(RichText::new(format!("{v} px")).font(theme::mono(12.0)).color(t.text)); + ui.end_row(); + } + }); + ui.add_space(6.0); + } + let mut o = layer.opacity * 100.0; + let r = widgets::slider_row(ui, "Opacity", &mut o, 0.0..=100.0, "%", None); + if r.drag_stopped() || (r.changed() && !r.dragged()) { + let _ = app.run("layer.setProps", json!({"layer": layer.id.0, "opacity": o / 100.0})); + } + let mut f = layer.fill_opacity * 100.0; + let r = widgets::slider_row(ui, "Fill", &mut f, 0.0..=100.0, "%", None); + if r.drag_stopped() || (r.changed() && !r.dragged()) { + let _ = app.run("layer.setProps", json!({"layer": layer.id.0, "fill": f / 100.0})); + } +} + +/// Docked Properties body (Pro theme). +fn properties_body(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.active() else { + empty(ui, "No properties"); + return; + }; + let Some(id) = st.active_layer else { + empty(ui, "No layer selected"); + return; + }; + let Some(layer) = st.doc.layer(id).cloned() else { return }; + ui.horizontal(|ui| { + let icon = match &layer.content { + LayerContent::Adjustment(_) => "sliders-horizontal", + LayerContent::Group(_) => "folder", + LayerContent::Fill(_) => "paint-bucket", + _ => "image", + }; + let (r, _) = ui.allocate_exact_size(vec2(20.0, 20.0), Sense::hover()); + icons::paint(ui, r, icon, 15.0, t.icon); + let kind = match &layer.content { + LayerContent::Adjustment(a) => a.label().to_string(), + other => format!("{} Layer", other.kind_name()), + }; + ui.label(RichText::new(kind).color(t.text)); + }); + ui.add_space(4.0); + widgets::hairline(ui); + ui.add_space(6.0); + if let LayerContent::Adjustment(adj) = &layer.content { + adjustment_controls(app, ui, id, adj); + } else { + layer_controls(app, ui, &layer); + } +} + +/// Photoshop's Adjustments panel: a grid of one-click adjustment layers. +fn adjustments_grid(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + ui.label(RichText::new("Add an adjustment").color(t.text_dim)); + ui.add_space(4.0); + let items: [(&str, &str, &str); 14] = [ + ("brightnessContrast", "sun", "Brightness/Contrast"), + ("levels", "gauge", "Levels"), + ("curves", "pen-tool", "Curves"), + ("exposure", "scan", "Exposure"), + ("vibrance", "sparkles", "Vibrance"), + ("hueSaturation", "droplet", "Hue/Saturation"), + ("colorBalance", "blend", "Color Balance"), + ("blackWhite", "contrast", "Black & White"), + ("photoFilter", "circle-dot", "Photo Filter"), + ("channelMixer", "sliders-horizontal", "Channel Mixer"), + ("invert", "squares-subtract", "Invert"), + ("posterize", "layers", "Posterize"), + ("threshold", "square", "Threshold"), + ("gradientMap", "palette", "Gradient Map"), + ]; + let mut run = None; + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(2.0, 2.0); + for (kind, icon, tip) in items { + if icons::button(ui, icon, 30.0, false, tip).clicked() { + run = Some(format!("layer.newAdjustmentLayer.{kind}")); + } + } + }); + if let Some(id) = run { + let _ = app.run(&id, json!({})); + app.ui.dock_tabs.properties = 0; + } +} + +/// Photoshop Color panel: saturation/brightness field + hue strip, drawn as shaded meshes. +fn color_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let fg = app.session.tools.foreground; + let key = egui::Id::new("color-field-hue"); + let mut hsva = srgb_hsva(fg); + // Keep hue stable for greys (where RGB->HSV hue is undefined). + let remembered: f32 = ui.data(|d| d.get_temp(key)).unwrap_or(hsva.h); + if hsva.s < 0.01 || hsva.v < 0.01 { + hsva.h = remembered; + } + let w = ui.available_width(); + let strip_w = 14.0; + let h = 120.0; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 8.0; + // Colour chips (fg/bg) at left, Photoshop style. + let (chips, _) = ui.allocate_exact_size(vec2(34.0, h), Sense::hover()); + let bgr = Rect::from_min_size(chips.min + vec2(10.0, 10.0), vec2(22.0, 22.0)); + let fgr = Rect::from_min_size(chips.min, vec2(22.0, 22.0)); + ui.painter().rect_filled(bgr, 2.0, c32(app.session.tools.background)); + ui.painter().rect_stroke(bgr, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); + ui.painter().rect_filled(fgr, 2.0, c32(fg)); + ui.painter().rect_stroke(fgr, 2.0, Stroke::new(1.0, Color32::from_gray(210)), StrokeKind::Outside); + // SV field. + let field_w = w - 34.0 - strip_w - 16.0; + let (field, fresp) = ui.allocate_exact_size(vec2(field_w, h), Sense::click_and_drag()); + let mut mesh = egui::Mesh::default(); + let n = 16; + for j in 0..=n { + for i in 0..=n { + let (sx, vy) = (i as f32 / n as f32, j as f32 / n as f32); + let c = Color32::from(egui::ecolor::Hsva::new(hsva.h, sx, 1.0 - vy, 1.0)); + mesh.colored_vertex(pos2(field.left() + sx * field.width(), field.top() + vy * field.height()), c); + } + } + for j in 0..n { + for i in 0..n { + let a = (j * (n + 1) + i) as u32; + let b = a + 1; + let c = a + (n + 1) as u32; + let d = c + 1; + mesh.add_triangle(a, b, d); + mesh.add_triangle(a, d, c); + } + } + ui.painter().add(mesh); + ui.painter().rect_stroke(field, 0.0, Stroke::new(1.0, t.separator), StrokeKind::Outside); + if (fresp.dragged() || fresp.clicked()) + && let Some(p) = fresp.interact_pointer_pos() + { + hsva.s = ((p.x - field.left()) / field.width()).clamp(0.0, 1.0); + hsva.v = 1.0 - ((p.y - field.top()) / field.height()).clamp(0.0, 1.0); + } + let knob = pos2(field.left() + hsva.s * field.width(), field.top() + (1.0 - hsva.v) * field.height()); + ui.painter().circle_stroke(knob, 5.0, Stroke::new(1.5, Color32::WHITE)); + ui.painter().circle_stroke(knob, 6.5, Stroke::new(1.0, Color32::from_black_alpha(160))); + // Hue strip. + let (strip, sresp) = ui.allocate_exact_size(vec2(strip_w, h), Sense::click_and_drag()); + let mut m2 = egui::Mesh::default(); + let steps = 24; + for k in 0..=steps { + let f = k as f32 / steps as f32; + let c = Color32::from(egui::ecolor::Hsva::new(1.0 - f, 1.0, 1.0, 1.0)); + m2.colored_vertex(pos2(strip.left(), strip.top() + f * strip.height()), c); + m2.colored_vertex(pos2(strip.right(), strip.top() + f * strip.height()), c); + } + for k in 0..steps { + let a = (k * 2) as u32; + m2.add_triangle(a, a + 1, a + 3); + m2.add_triangle(a, a + 3, a + 2); + } + ui.painter().add(m2); + if (sresp.dragged() || sresp.clicked()) + && let Some(p) = sresp.interact_pointer_pos() + { + hsva.h = 1.0 - ((p.y - strip.top()) / strip.height()).clamp(0.0, 0.9999); + } + let y = strip.top() + (1.0 - hsva.h) * strip.height(); + let tri = vec![pos2(strip.right() + 1.0, y), pos2(strip.right() + 6.0, y - 4.0), pos2(strip.right() + 6.0, y + 4.0)]; + ui.painter().add(egui::Shape::convex_polygon(tri, t.text, Stroke::NONE)); + if fresp.dragged() || fresp.clicked() || sresp.dragged() || sresp.clicked() { + app.session.tools.foreground = hsva_srgb(hsva); + ui.data_mut(|d| d.insert_temp(key, hsva.h)); + } + }); + let [r, g, b, _] = hsva.to_srgba_unmultiplied(); + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.label(RichText::new(format!("#{r:02X}{g:02X}{b:02X}")).font(theme::mono(12.0)).color(t.text)); + ui.label(RichText::new(format!("R {r} G {g} B {b}")).font(theme::mono(11.0)).color(t.text_faint)); + }); +} + +/// Tool colours are sRGB-encoded floats; egui's Hsva works on sRGB bytes via these helpers +/// (its `from_rgba_unmultiplied` expects *linear* RGB, which gave wrong readouts). +fn srgb_hsva(c: [f32; 4]) -> egui::ecolor::Hsva { + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + egui::ecolor::Hsva::from_srgb([q(c[0]), q(c[1]), q(c[2])]) +} + +fn hsva_srgb(h: egui::ecolor::Hsva) -> [f32; 4] { + let [r, g, b] = h.to_srgb(); + [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0] +} + +#[cfg(test)] +mod color_tests { + use super::*; + + #[test] + fn srgb_hsva_roundtrip() { + for c in [[0.847, 0.271, 0.180, 1.0], [0.0, 0.0, 0.0, 1.0], [1.0, 1.0, 1.0, 1.0], [0.2, 0.6, 0.4, 1.0]] { + let back = hsva_srgb(srgb_hsva(c)); + for i in 0..3 { + assert!((back[i] - c[i]).abs() <= 1.0 / 255.0, "{c:?} -> {back:?}"); + } + } + } +} + +/// Photoshop's brush preset picker chip: a soft/hard round tip preview with the size underneath. +fn brush_preset_chip(ui: &mut egui::Ui, size: f32, hardness: f32) { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(44.0, 30.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, t.radius_sm, t.hover); + } + let c = pos2(r.left() + 14.0, r.top() + 11.0); + // soft edge: concentric circles fading out + let rad = 7.0; + let inner = rad * hardness.clamp(0.05, 1.0); + let steps = 6; + for k in (0..=steps).rev() { + let f = k as f32 / steps as f32; + let rr = inner + (rad - inner) * f; + let a = if hardness >= 0.99 { 255 } else { (255.0 * (1.0 - f).powf(1.5)) as u8 }; + ui.painter().circle_filled(c, rr, Color32::from_white_alpha(a)); + } + ui.painter().text(pos2(c.x, r.bottom() - 5.0), Align2::CENTER_CENTER, format!("{}", size.round() as i64), egui::FontId::proportional(9.5), t.text_dim); + icons::paint(ui, Rect::from_center_size(pos2(r.right() - 9.0, c.y), vec2(10.0, 10.0)), "chevron-down", 9.0, t.text_faint); + resp.on_hover_text("Brush preset picker (size and hardness in the options bar and with [ ])"); +} + +/// Drag a layer row to reorder: drop on the upper/lower half to place above/below, or on the middle +/// of a group to move into it. One `layer.moveTo` command (one undo step). +fn layer_drag_and_drop(ctx: &egui::Context, ui: &egui::Ui, l: &Layer, rect: Rect, resp: &egui::Response, actions: &mut Vec<(String, Value)>) { + let t = Tokens::get(ctx); + let key = egui::Id::new("layer-drag"); + if resp.drag_started() { + ctx.data_mut(|d| d.insert_temp(key, l.id.0)); + } + let Some(dragged) = ctx.data(|d| d.get_temp::(key)) else { return }; + let pointer = ctx.input(|i| i.pointer.interact_pos()); + let released = ctx.input(|i| i.pointer.any_released()); + if dragged == l.id.0 { + // Ghost label following the pointer. + if let Some(p) = pointer { + let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("layer-drag-ghost")); + let painter = ctx.layer_painter(layer); + let g = painter.layout_no_wrap(l.name.clone(), egui::FontId::proportional(12.0), t.text); + let r = Rect::from_min_size(p + vec2(12.0, -10.0), g.size() + vec2(16.0, 8.0)); + painter.rect_filled(r, t.radius_sm, t.card.gamma_multiply(0.95)); + painter.rect_stroke(r, t.radius_sm, Stroke::new(1.0, t.accent), StrokeKind::Inside); + painter.galley(r.min + vec2(8.0, 4.0), g, t.text); + } + return; + } + let Some(p) = pointer else { return }; + if !rect.contains(p) { + return; + } + let f = (p.y - rect.top()) / rect.height(); + let position = if l.is_group() && (0.3..0.7).contains(&f) { + "into" + } else if f < 0.5 { + "above" + } else { + "below" + }; + let painter = ui.painter(); + match position { + "into" => { + painter.rect_stroke(rect.shrink(1.0), t.radius_sm, Stroke::new(2.0, t.accent), StrokeKind::Inside); + } + "above" => { + painter.line_segment([rect.left_top(), rect.right_top()], Stroke::new(2.0, t.accent)); + } + _ => { + painter.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(2.0, t.accent)); + } + } + if released { + actions.push(("layer.moveTo".into(), json!({"layer": dragged, "target": l.id.0, "position": position}))); + } +} + +/// Photoshop shows a layer's effects as indented sub-rows ("Effects", then each effect). +fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usize) { + let t = Tokens::get(ui.ctx()); + let indent = 30.0 + depth as f32 * 14.0 + 34.0; + let mut rows: Vec<(String, bool, Option<&'static str>)> = vec![("Effects".into(), l.effects.enabled, None)]; + for e in &l.effects.items { + let kind = crate::layer_style::KINDS.iter().find(|k| k.1 == e.label()).map(|k| k.0); + rows.push((e.label().to_string(), e.enabled(), kind)); + } + for (i, (name, on, kind)) in rows.into_iter().enumerate() { + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(rect, 0.0, t.hover.gamma_multiply(0.35)); + } + if t.pro { + ui.painter().line_segment([pos2(rect.left() + 30.0, rect.top()), pos2(rect.left() + 30.0, rect.bottom())], Stroke::new(1.0, t.separator)); + } + let eye = Rect::from_min_size(pos2(rect.left() + 6.0, rect.center().y - 9.0), vec2(18.0, 18.0)); + icons::paint(ui, eye, if on { "eye" } else { "eye-off" }, 12.0, if on { t.icon } else { t.text_faint }); + let x = rect.left() + indent + if i == 0 { 0.0 } else { 16.0 }; + if i == 0 { + icons::paint(ui, Rect::from_center_size(pos2(x - 12.0, rect.center().y), vec2(14.0, 14.0)), "sparkles", 11.0, t.text_dim); + } + ui.painter().text(pos2(x, rect.center().y), Align2::LEFT_CENTER, name, egui::FontId::proportional(11.5), if on { t.text_dim } else { t.text_faint }); + if resp.double_clicked() { + crate::layer_style::open(app, kind); + } + } +} diff --git a/crates/ui-egui/src/proxy.rs b/crates/ui-egui/src/proxy.rs new file mode 100644 index 00000000..7351a410 --- /dev/null +++ b/crates/ui-egui/src/proxy.rs @@ -0,0 +1,112 @@ +//! Downsampled proxy documents for fast interactive previews (e.g. dragging an adjustment slider on +//! a 36 MP image). Built once per document revision; only adjustment parameters change while the +//! slider moves, so the proxy's pixels stay valid and each preview frame composites ~1/k² pixels. + +use photocraft_doc::{Document, Layer, LayerContent, Size}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +/// Target pixel count for previews (fast enough to composite every frame on CPU). +pub const PREVIEW_PIXELS: u64 = 2_500_000; + +/// Downscale factor for a document, or 1 when it's already small. +pub fn factor(doc: &Document) -> u32 { + let px = doc.size.area(); + if px <= PREVIEW_PIXELS * 2 { + return 1; + } + ((px as f64 / PREVIEW_PIXELS as f64).sqrt().ceil() as u32).max(2) +} + +/// Nearest-neighbour downsample of a surface by integer factor `k` (document coordinates / k). +pub fn downsample(s: &Surface, k: u32) -> Surface { + let mut out = Surface::with_default(s.format(), &s.default_pixel()); + let b = s.content_bounds(); + if b.is_empty() || k <= 1 { + return if k <= 1 { s.clone() } else { out }; + } + let k = k as i32; + let bpp = s.format().bytes_per_pixel(); + let (x0, x1) = (b.x0.div_euclid(k), (b.x1 + k - 1).div_euclid(k)); + let w = (x1 - x0).max(0) as usize; + let mut row = vec![0u8; w * bpp]; + for oy in b.y0.div_euclid(k)..(b.y1 + k - 1).div_euclid(k) { + let src = s.to_interleaved(Rect::new(x0 * k, oy * k, x1 * k, oy * k + 1)); + for i in 0..w { + row[i * bpp..(i + 1) * bpp].copy_from_slice(&src[i * k as usize * bpp..(i * k as usize + 1) * bpp]); + } + out.write_interleaved(Rect::new(x0, oy, x1, oy + 1), &row); + } + out +} + +fn shrink_layer(l: &mut Layer, k: u32) { + if let Some(m) = &mut l.mask { + m.surface = downsample(&m.surface, k); + } + if let Some(fc) = &mut l.fill_cache { + fc.surface = downsample(&fc.surface, k); + } + match &mut l.content { + LayerContent::Raster(s) => *s = downsample(s, k), + LayerContent::Group(g) => { + for c in &mut g.children { + shrink_layer(c, k); + } + } + LayerContent::Text(t) => t.cache = t.cache.as_ref().map(|s| downsample(s, k)), + LayerContent::Shape(sh) => sh.cache = sh.cache.as_ref().map(|s| downsample(s, k)), + LayerContent::Smart(so) => so.cache = so.cache.as_ref().map(|s| downsample(s, k)), + LayerContent::Adjustment(_) | LayerContent::Fill(_) => {} + } +} + +/// A copy of `doc` scaled down by `k` (same layer ids, so adjustments can be swapped in). +pub fn proxy_document(doc: &Document, k: u32) -> Document { + let mut p = doc.clone(); + if k <= 1 { + return p; + } + p.size = Size::new(doc.size.width.div_ceil(k), doc.size.height.div_ceil(k)); + for l in &mut p.layers { + shrink_layer(l, k); + } + p.selection = None; + p +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; + + #[test] + fn factor_scales_with_size() { + let small = Document::new("s", Size::new(1000, 1000), ColorMode::Rgb, SampleType::U8); + assert_eq!(factor(&small), 1); + let big = Document::new("b", Size::new(6016, 6016), ColorMode::Rgb, SampleType::U8); + let k = factor(&big); + assert!((3..=5).contains(&k), "{k}"); + assert!(big.size.area() / (k as u64 * k as u64) <= PREVIEW_PIXELS); + } + + #[test] + fn downsample_picks_every_kth_pixel() { + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 0.0, 0.0, 1.0]); + s.write_pixel(4, 4, &[0.0, 0.0, 1.0, 1.0]); + let d = downsample(&s, 4); + assert_eq!(d.pixel(0, 0), vec![1.0, 0.0, 0.0, 1.0]); + assert_eq!(d.pixel(1, 1), vec![0.0, 0.0, 1.0, 1.0]); + assert_eq!(d.pixel(2, 2)[3], 0.0); + } + + #[test] + fn proxy_keeps_structure_and_ids() { + let doc = Document::with_background("d", Size::new(4000, 3000), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let p = proxy_document(&doc, 4); + assert_eq!((p.size.width, p.size.height), (1000, 750)); + assert_eq!(p.layers[0].id, doc.layers[0].id); + assert_eq!(p.layers[0].surface().unwrap().pixel(999, 749), vec![1.0; 4]); + } +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs new file mode 100644 index 00000000..4cee696d --- /dev/null +++ b/crates/ui-egui/src/shortcuts.rs @@ -0,0 +1,142 @@ +//! Keyboard shortcuts: Photoshop defaults from the command registry, plus single-key tools. + +use egui::{Key, KeyboardShortcut, Modifiers}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::Tool; + +/// Parse `Cmd+Shift+N` style strings. `Cmd` maps to ⌘ on macOS and Ctrl elsewhere. +pub fn parse(s: &str) -> Option { + let mut mods = Modifiers::NONE; + let mut key = None; + for part in s.split('+') { + match part { + "Cmd" => mods = mods | Modifiers::COMMAND, + "Shift" => mods = mods | Modifiers::SHIFT, + "Alt" => mods = mods | Modifiers::ALT, + "Ctrl" => mods = mods | Modifiers::CTRL, + "" => key = Some(Key::Plus), + k => key = Key::from_name(k).or(match k { + "=" => Some(Key::Equals), + "-" => Some(Key::Minus), + "[" => Some(Key::OpenBracket), + "]" => Some(Key::CloseBracket), + _ => None, + }), + } + } + Some(KeyboardShortcut::new(mods, key?)) +} + +/// Human-readable form for menus. +pub fn pretty(s: &str) -> String { + let mac = cfg!(target_os = "macos"); + s.split('+') + .map(|p| match (p, mac) { + ("Cmd", true) => "⌘".to_string(), + ("Cmd", false) => "Ctrl".to_string(), + ("Shift", true) => "⇧".to_string(), + ("Alt", true) => "⌥".to_string(), + (other, _) => other.to_string(), + }) + .collect::>() + .join(if mac { "" } else { "+" }) +} + +pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { + if ctx.egui_wants_keyboard_input() || !app.ui.dialogs.is_empty() { + return; + } + // Registry + UI command shortcuts, most-modifiers first so ⇧⌘Z wins over ⌘Z. + let mut all: Vec<(String, KeyboardShortcut)> = crate::menus::UI_COMMANDS + .iter() + .filter_map(|(id, _, _, sc)| Some((id.to_string(), parse((*sc)?)?))) + .chain(photocraft_engine::command_specs().iter().filter_map(|c| Some((c.id.to_string(), parse(c.shortcut?)?)))) + .filter(|(_, sc)| sc.modifiers != Modifiers::NONE || !matches!(sc.logical_key, Key::X | Key::D)) + .collect(); + all.sort_by_key(|(_, sc)| std::cmp::Reverse(sc.modifiers.shift as u8 + sc.modifiers.alt as u8 + sc.modifiers.command as u8)); + for (id, sc) in all { + if ctx.input_mut(|i| i.consume_shortcut(&sc)) { + if crate::menus::is_enabled(app, &id) { + let r = if id.starts_with("image.adjustments.") && !crate::panels::adjustment_sliders(id.rsplit('.').next().unwrap_or("")).is_empty() { + let label = photocraft_engine::commands::find(&id).map(|c| c.label).unwrap_or(""); + crate::dialogs::open_command_dialog(app, &id, label); + Ok(serde_json::Value::Null) + } else { + crate::menus::invoke(app, ctx, &id, json!({})) + }; + if let Err(e) = r { + app.ui.status = e; + } + } + return; + } + } + // Single-key tools and colours (no modifiers). + let pressed = |k: Key| ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)); + for t in Tool::ALL { + let k = Key::from_name(&t.key().to_string()); + if let Some(k) = k + && pressed(k) + { + // M toggles between marquee shapes like Photoshop's Shift+M cycling. + app.ui.tool = if t.key() == 'M' && app.ui.tool == Tool::RectMarquee { Tool::EllipseMarquee } else if t.key() == 'M' { Tool::RectMarquee } else { t }; + return; + } + } + if pressed(Key::X) { + let _ = app.run("tools.swapColors", json!({})); + } + if pressed(Key::D) { + let _ = app.run("tools.defaultColors", json!({})); + } + let b = &mut app.session.tools.brush; + if pressed(Key::OpenBracket) { + b.size = (b.size / 1.25).max(1.0).round(); + } + if pressed(Key::CloseBracket) { + b.size = (b.size * 1.25).min(2500.0).round().max(b.size + 1.0); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_registry_shortcuts() { + let sc = parse("Cmd+Shift+N").unwrap(); + assert_eq!(sc.logical_key, Key::N); + assert!(sc.modifiers.command && sc.modifiers.shift); + assert_eq!(parse("Cmd+]").unwrap().logical_key, Key::CloseBracket); + assert_eq!(parse("Cmd+=").unwrap().logical_key, Key::Equals); + assert_eq!(parse("F7").unwrap().logical_key, Key::F7); + assert!(parse("Cmd+Nonsense").is_none()); + } + + #[test] + fn every_registered_shortcut_parses() { + for c in photocraft_engine::command_specs() { + if let Some(sc) = c.shortcut { + assert!(parse(sc).is_some(), "{}: {sc}", c.id); + } + } + for (id, _, _, sc) in crate::menus::UI_COMMANDS { + if let Some(sc) = sc { + assert!(parse(sc).is_some(), "{id}: {sc}"); + } + } + } + + #[test] + fn no_duplicate_shortcuts() { + let mut seen = std::collections::HashMap::new(); + let all = photocraft_engine::command_specs().iter().filter_map(|c| c.shortcut.map(|s| (c.id, s))).chain(crate::menus::UI_COMMANDS.iter().filter_map(|(id, _, _, s)| s.map(|s| (*id, s)))); + for (id, s) in all { + if let Some(prev) = seen.insert(s, id) { + panic!("shortcut {s} bound to both {prev} and {id}"); + } + } + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs new file mode 100644 index 00000000..668b693f --- /dev/null +++ b/crates/ui-egui/src/state.rs @@ -0,0 +1,242 @@ +//! UI state as plain data, so the control channel (and later MCP) can read and drive every +//! aspect of the interface: tools, zoom, panels, dialogs and windows. + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Tool { + Move, + RectMarquee, + EllipseMarquee, + Brush, + Eraser, + Eyedropper, + Hand, + Zoom, +} + +impl Tool { + pub const ALL: [Tool; 8] = [Tool::Move, Tool::RectMarquee, Tool::EllipseMarquee, Tool::Brush, Tool::Eraser, Tool::Eyedropper, Tool::Hand, Tool::Zoom]; + + pub fn label(self) -> &'static str { + match self { + Tool::Move => "Move Tool", + Tool::RectMarquee => "Rectangular Marquee Tool", + Tool::EllipseMarquee => "Elliptical Marquee Tool", + Tool::Brush => "Brush Tool", + Tool::Eraser => "Eraser Tool", + Tool::Eyedropper => "Eyedropper Tool", + Tool::Hand => "Hand Tool", + Tool::Zoom => "Zoom Tool", + } + } + /// Photoshop default single-key shortcut. + pub fn key(self) -> char { + match self { + Tool::Move => 'V', + Tool::RectMarquee | Tool::EllipseMarquee => 'M', + Tool::Brush => 'B', + Tool::Eraser => 'E', + Tool::Eyedropper => 'I', + Tool::Hand => 'H', + Tool::Zoom => 'Z', + } + } + /// Glyph drawn in the toolbar (vector icons come later). + pub fn glyph(self) -> &'static str { + match self { + Tool::Move => "✥", + Tool::RectMarquee => "⬚", + Tool::EllipseMarquee => "◌", + Tool::Brush => "🖌", + Tool::Eraser => "⌫", + Tool::Eyedropper => "💧", + Tool::Hand => "✋", + Tool::Zoom => "🔍", + } + } + pub fn from_name(s: &str) -> Option { + let n = s.to_ascii_lowercase().replace([' ', '_', '-'], ""); + Tool::ALL.into_iter().find(|t| { + let a = format!("{t:?}").to_ascii_lowercase(); + n == a || n == a.replace("marquee", "") || n == t.label().to_ascii_lowercase().replace(' ', "") + }) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Panels { + pub layers: bool, + pub history: bool, + pub properties: bool, + pub color: bool, + pub navigator: bool, + pub toolbar: bool, + pub options_bar: bool, + pub status_bar: bool, +} + +impl Default for Panels { + fn default() -> Self { + Self { layers: true, history: false, properties: true, color: true, navigator: false, toolbar: true, options_bar: true, status_bar: true } + } +} + +/// A modal or modeless dialog, identified by `id`, with editable fields. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Dialog { + pub id: u64, + pub kind: DialogKind, + /// Field values by name. Dialog widgets read and write these; so can automation. + pub fields: serde_json::Map, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum DialogKind { + NewDocument, + About, + /// Parameter dialog for a command (generated from its params). + Command, + Error, + /// Photoshop Layer Style dialog (see `layer_style.rs`). + LayerStyle, +} + +/// Per-document view (camera) state. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct View { + /// Screen pixels per document pixel. + pub zoom: f32, + /// Document-space point shown at the canvas centre. + pub center: [f32; 2], + /// Recompute fit-to-screen on next frame. + pub fit_pending: bool, +} + +impl Default for View { + fn default() -> Self { + Self { zoom: 1.0, center: [0.0, 0.0], fit_pending: true } + } +} + +/// An extra OS window showing a document ("Window → Arrange → New Window for Document"). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct DocWindow { + pub id: u64, + pub document: usize, + pub view: View, + pub open: bool, +} + +/// Selected tab per dock card. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct DockTabs { + pub properties: usize, + pub color: usize, + pub layers: usize, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct UiState { + pub tool: Tool, + pub panels: Panels, + /// Views per open document (index-aligned with the session's documents). + pub views: Vec, + pub dialogs: Vec, + pub windows: Vec, + pub theme: crate::theme::ThemeKind, + pub workspace: String, + pub palette_open: bool, + pub dock_tabs: DockTabs, + /// Marquee options-bar mode: 0 new, 1 add, 2 subtract, 3 intersect (modifier keys override). + #[serde(default)] + pub selection_mode: u8, + pub next_id: u64, + /// Last status message (errors from commands, hints). + pub status: String, + /// The status message is an error (shown in the warning colour). + #[serde(default)] + pub status_error: bool, +} + +impl Default for UiState { + fn default() -> Self { + Self { + tool: Tool::Brush, + panels: Panels::default(), + views: Vec::new(), + dialogs: Vec::new(), + windows: Vec::new(), + theme: crate::theme::ThemeKind::Pro, + workspace: "Essentials".into(), + palette_open: false, + dock_tabs: DockTabs::default(), + selection_mode: 0, + next_id: 1, + status: String::new(), + status_error: false, + } + } +} + +impl UiState { + pub fn alloc_id(&mut self) -> u64 { + let id = self.next_id; + self.next_id += 1; + id + } + + pub fn open_dialog(&mut self, kind: DialogKind, fields: serde_json::Map) -> u64 { + let id = self.alloc_id(); + self.dialogs.push(Dialog { id, kind, fields }); + id + } + + pub fn dialog_mut(&mut self, id: u64) -> Option<&mut Dialog> { + self.dialogs.iter_mut().find(|d| d.id == id) + } + + pub fn close_dialog(&mut self, id: u64) -> Option { + let i = self.dialogs.iter().position(|d| d.id == id)?; + Some(self.dialogs.remove(i)) + } + + pub fn new_document_fields() -> serde_json::Map { + let v = serde_json::json!({"name": "Untitled-1", "width": 1920, "height": 1080, "mode": "rgb", "depth": 8, "background": "white"}); + v.as_object().cloned().unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tool_names_parse() { + assert_eq!(Tool::from_name("brush"), Some(Tool::Brush)); + assert_eq!(Tool::from_name("Rect"), Some(Tool::RectMarquee)); + assert_eq!(Tool::from_name("RectMarquee"), Some(Tool::RectMarquee)); + assert_eq!(Tool::from_name("Eraser Tool"), Some(Tool::Eraser)); + assert_eq!(Tool::from_name("nope"), None); + } + + #[test] + fn dialogs_open_and_close() { + let mut s = UiState::default(); + let a = s.open_dialog(DialogKind::About, Default::default()); + let b = s.open_dialog(DialogKind::NewDocument, UiState::new_document_fields()); + assert_ne!(a, b); + assert_eq!(s.dialog_mut(b).unwrap().fields["width"], 1920); + assert!(s.close_dialog(a).is_some()); + assert_eq!(s.dialogs.len(), 1); + } + + #[test] + fn ui_state_serializes() { + let s = UiState::default(); + let v = serde_json::to_value(&s).unwrap(); + assert_eq!(v["tool"], "Brush"); + let back: UiState = serde_json::from_value(v).unwrap(); + assert_eq!(back, s); + } +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs new file mode 100644 index 00000000..d7eadb5c --- /dev/null +++ b/crates/ui-egui/src/theme.rs @@ -0,0 +1,477 @@ +//! Design system: themes, colour tokens, radii, typography. +//! +//! - **Studio** (default): near-black surfaces, rounded cards, Inter + JetBrains Mono, soft violet +//! accent. Modelled on the look of modern pro editors (Photon Studio, Photoshop 2025). +//! - **Studio Light**: the same system on light surfaces. +//! - **Classic**: a deliberately Windows-2000-era look (grey bevels, square corners, navy selection) +//! for people who prefer it. +//! +//! Widgets read [`Tokens::get`] instead of hard-coding colours, so every theme applies everywhere. + +use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Stroke, TextStyle, Visuals}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ThemeKind { + /// Photoshop-style Spectrum dark: flat charcoal panels, tab strips, blue accents (default). + #[default] + Pro, + Studio, + StudioLight, + Classic, +} + +impl ThemeKind { + pub const ALL: [ThemeKind; 4] = [ThemeKind::Pro, ThemeKind::Studio, ThemeKind::StudioLight, ThemeKind::Classic]; + pub fn label(self) -> &'static str { + match self { + ThemeKind::Pro => "Pro (Dark)", + ThemeKind::Studio => "Studio (Dark)", + ThemeKind::StudioLight => "Studio (Light)", + ThemeKind::Classic => "Classic", + } + } + pub fn next(self) -> Self { + let i = Self::ALL.iter().position(|k| *k == self).unwrap_or(0); + Self::ALL[(i + 1) % Self::ALL.len()] + } + pub fn from_name(s: &str) -> Option { + match s.to_ascii_lowercase().replace([' ', '_', '-', '(', ')'], "").as_str() { + "pro" | "prodark" | "photoshop" | "dark" => Some(ThemeKind::Pro), + "studio" | "studiodark" => Some(ThemeKind::Studio), + "studiolight" | "light" => Some(ThemeKind::StudioLight), + "classic" | "win2000" | "retro" => Some(ThemeKind::Classic), + _ => None, + } + } +} + +/// Colour and shape tokens. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Tokens { + pub kind: ThemeKind, + /// Window chrome (title bar, toolbars). + pub chrome: Color32, + /// Canvas surround. + pub canvas: Color32, + /// Dot colour of the canvas grid pattern. + pub canvas_dot: Color32, + /// Dock background (behind cards). + pub dock: Color32, + /// Cards / panels. + pub card: Color32, + pub card_border: Color32, + /// Inputs, fields, dropdowns. + pub field: Color32, + pub field_border: Color32, + pub hover: Color32, + pub pressed: Color32, + pub text: Color32, + pub text_dim: Color32, + pub text_faint: Color32, + pub icon: Color32, + pub accent: Color32, + pub accent_soft: Color32, + pub accent_border: Color32, + pub accent_text: Color32, + pub separator: Color32, + pub shadow: Color32, + pub primary_bg: Color32, + pub primary_text: Color32, + pub danger: Color32, + pub warning: Color32, + pub radius_sm: f32, + pub radius: f32, + pub radius_lg: f32, + /// Classic theme draws 3D bevels instead of flat fills. + pub bevel: bool, + /// Pro (Photoshop-grammar) layout: tab strips, flat panels, checkboxes, pill buttons. + pub pro: bool, + /// Panel tab-strip background (Pro). + pub tab_strip: Color32, + /// Selected list row (layers, history). + pub row_selected: Color32, +} + +impl Tokens { + pub fn for_kind(kind: ThemeKind) -> Self { + match kind { + ThemeKind::Pro => Tokens { + kind, + chrome: Color32::from_rgb(50, 50, 50), + canvas: Color32::from_rgb(40, 40, 40), + canvas_dot: Color32::from_rgb(40, 40, 40), + dock: Color32::from_rgb(30, 30, 30), + card: Color32::from_rgb(50, 50, 50), + card_border: Color32::from_rgb(30, 30, 30), + field: Color32::from_rgb(36, 36, 36), + field_border: Color32::from_rgb(74, 74, 74), + hover: Color32::from_rgb(66, 66, 66), + pressed: Color32::from_rgb(78, 78, 78), + text: Color32::from_rgb(222, 222, 222), + text_dim: Color32::from_rgb(178, 178, 178), + text_faint: Color32::from_rgb(128, 128, 128), + icon: Color32::from_rgb(200, 200, 200), + accent: Color32::from_rgb(55, 142, 240), + accent_soft: Color32::from_rgb(78, 78, 78), + accent_border: Color32::from_rgb(55, 142, 240), + accent_text: Color32::WHITE, + separator: Color32::from_rgb(30, 30, 30), + shadow: Color32::from_black_alpha(150), + primary_bg: Color32::from_rgb(55, 142, 240), + primary_text: Color32::WHITE, + danger: Color32::from_rgb(236, 91, 98), + warning: Color32::from_rgb(232, 176, 70), + radius_sm: 3.0, + radius: 4.0, + radius_lg: 6.0, + bevel: false, + pro: true, + tab_strip: Color32::from_rgb(38, 38, 38), + row_selected: Color32::from_rgb(82, 82, 82), + }, + ThemeKind::Studio => Tokens { + kind, + chrome: Color32::from_rgb(20, 20, 21), + canvas: Color32::from_rgb(14, 14, 15), + canvas_dot: Color32::from_rgb(46, 46, 50), + dock: Color32::from_rgb(17, 17, 18), + card: Color32::from_rgb(26, 26, 28), + card_border: Color32::from_rgb(40, 40, 44), + field: Color32::from_rgb(35, 35, 38), + field_border: Color32::from_rgb(52, 52, 57), + hover: Color32::from_rgb(44, 44, 48), + pressed: Color32::from_rgb(56, 56, 62), + text: Color32::from_rgb(236, 236, 240), + text_dim: Color32::from_rgb(150, 150, 158), + text_faint: Color32::from_rgb(96, 96, 104), + icon: Color32::from_rgb(196, 196, 204), + accent: Color32::from_rgb(139, 124, 246), + accent_soft: Color32::from_rgba_unmultiplied(139, 124, 246, 46), + accent_border: Color32::from_rgba_unmultiplied(160, 148, 255, 110), + accent_text: Color32::from_rgb(214, 208, 255), + separator: Color32::from_rgb(38, 38, 42), + shadow: Color32::from_black_alpha(140), + primary_bg: Color32::from_rgb(246, 246, 248), + primary_text: Color32::from_rgb(12, 12, 14), + danger: Color32::from_rgb(240, 96, 96), + warning: Color32::from_rgb(240, 190, 90), + radius_sm: 6.0, + radius: 8.0, + radius_lg: 12.0, + bevel: false, + pro: false, + tab_strip: Color32::TRANSPARENT, + row_selected: Color32::TRANSPARENT, + }, + ThemeKind::StudioLight => Tokens { + kind, + chrome: Color32::from_rgb(246, 246, 248), + canvas: Color32::from_rgb(226, 226, 230), + canvas_dot: Color32::from_rgb(200, 200, 206), + dock: Color32::from_rgb(240, 240, 243), + card: Color32::from_rgb(252, 252, 253), + card_border: Color32::from_rgb(222, 222, 228), + field: Color32::from_rgb(242, 242, 245), + field_border: Color32::from_rgb(214, 214, 220), + hover: Color32::from_rgb(232, 232, 237), + pressed: Color32::from_rgb(220, 220, 226), + text: Color32::from_rgb(24, 24, 28), + text_dim: Color32::from_rgb(96, 96, 106), + text_faint: Color32::from_rgb(150, 150, 160), + icon: Color32::from_rgb(60, 60, 68), + accent: Color32::from_rgb(108, 92, 231), + accent_soft: Color32::from_rgba_unmultiplied(108, 92, 231, 36), + accent_border: Color32::from_rgba_unmultiplied(108, 92, 231, 120), + accent_text: Color32::from_rgb(80, 64, 200), + separator: Color32::from_rgb(226, 226, 232), + shadow: Color32::from_black_alpha(50), + primary_bg: Color32::from_rgb(20, 20, 24), + primary_text: Color32::from_rgb(250, 250, 252), + danger: Color32::from_rgb(210, 60, 60), + warning: Color32::from_rgb(190, 130, 20), + radius_sm: 6.0, + radius: 8.0, + radius_lg: 12.0, + bevel: false, + pro: false, + tab_strip: Color32::TRANSPARENT, + row_selected: Color32::TRANSPARENT, + }, + ThemeKind::Classic => Tokens { + kind, + chrome: Color32::from_rgb(212, 208, 200), + canvas: Color32::from_rgb(128, 128, 128), + canvas_dot: Color32::from_rgb(128, 128, 128), + dock: Color32::from_rgb(212, 208, 200), + card: Color32::from_rgb(212, 208, 200), + card_border: Color32::from_rgb(128, 128, 128), + field: Color32::WHITE, + field_border: Color32::from_rgb(128, 128, 128), + hover: Color32::from_rgb(226, 222, 214), + pressed: Color32::from_rgb(190, 186, 178), + text: Color32::BLACK, + text_dim: Color32::from_rgb(64, 64, 64), + text_faint: Color32::from_rgb(128, 128, 128), + icon: Color32::BLACK, + accent: Color32::from_rgb(10, 36, 106), + accent_soft: Color32::from_rgb(10, 36, 106), + accent_border: Color32::from_rgb(10, 36, 106), + accent_text: Color32::WHITE, + separator: Color32::from_rgb(128, 128, 128), + shadow: Color32::from_black_alpha(0), + primary_bg: Color32::from_rgb(212, 208, 200), + primary_text: Color32::BLACK, + danger: Color32::from_rgb(160, 0, 0), + warning: Color32::from_rgb(128, 96, 0), + radius_sm: 0.0, + radius: 0.0, + radius_lg: 0.0, + bevel: true, + pro: false, + tab_strip: Color32::from_rgb(212, 208, 200), + row_selected: Color32::from_rgb(10, 36, 106), + }, + } + } + + /// Tokens for the active theme (stored in egui's context data by [`apply`]). + pub fn get(ctx: &egui::Context) -> Tokens { + ctx.data(|d| d.get_temp::(egui::Id::new("photocraft-theme"))).unwrap_or_else(|| Tokens::for_kind(ThemeKind::Studio)) + } + + pub fn dark(&self) -> bool { + matches!(self.kind, ThemeKind::Studio | ThemeKind::Pro) + } +} + +/// Register Inter (UI) and JetBrains Mono (numbers) plus named weights. +pub fn install_fonts(ctx: &egui::Context) { + let mut fonts = FontDefinitions::default(); + let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| { + fonts.font_data.insert(name.to_owned(), Arc::new(FontData::from_static(bytes))); + }; + add(&mut fonts, "Inter", include_bytes!("../../../assets/fonts/Inter-Regular.ttf")); + add(&mut fonts, "Inter-Medium", include_bytes!("../../../assets/fonts/Inter-Medium.ttf")); + add(&mut fonts, "Inter-SemiBold", include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf")); + add(&mut fonts, "JetBrainsMono", include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf")); + fonts.families.entry(FontFamily::Proportional).or_default().insert(0, "Inter".to_owned()); + fonts.families.entry(FontFamily::Monospace).or_default().insert(0, "JetBrainsMono".to_owned()); + // Named weights fall back to the default stack for missing glyphs. + let fallback: Vec = fonts.families[&FontFamily::Proportional].clone(); + for (fam, primary) in [("medium", "Inter-Medium"), ("semibold", "Inter-SemiBold")] { + let mut stack = vec![primary.to_owned()]; + stack.extend(fallback.iter().cloned()); + fonts.families.insert(FontFamily::Name(fam.into()), stack); + } + ctx.set_fonts(fonts); +} + +pub fn medium(size: f32) -> FontId { + FontId::new(size, FontFamily::Name("medium".into())) +} +pub fn semibold(size: f32) -> FontId { + FontId::new(size, FontFamily::Name("semibold".into())) +} +pub fn mono(size: f32) -> FontId { + FontId::monospace(size) +} + +/// Apply a theme to egui's global style and publish its tokens. +pub fn apply(ctx: &egui::Context, kind: ThemeKind) { + let t = Tokens::for_kind(kind); + ctx.data_mut(|d| d.insert_temp(egui::Id::new("photocraft-theme"), t)); + let mut v = if t.dark() { Visuals::dark() } else { Visuals::light() }; + v.panel_fill = t.chrome; + v.window_fill = t.card; + v.window_stroke = Stroke::new(1.0, t.card_border); + v.extreme_bg_color = t.field; + v.faint_bg_color = t.card; + v.code_bg_color = t.field; + v.override_text_color = Some(t.text); + v.hyperlink_color = t.accent; + v.warn_fg_color = t.warning; + v.error_fg_color = t.danger; + v.window_corner_radius = CornerRadius::same(t.radius_lg as u8); + v.menu_corner_radius = CornerRadius::same(t.radius as u8); + v.window_shadow = egui::Shadow { offset: [0, 10], blur: 32, spread: 0, color: t.shadow }; + v.popup_shadow = egui::Shadow { offset: [0, 6], blur: 20, spread: 0, color: t.shadow }; + v.selection.bg_fill = if t.bevel || t.pro { t.accent } else { t.accent_soft }; + v.selection.stroke = Stroke::new(1.0, t.accent_text); + v.slider_trailing_fill = true; + v.handle_shape = egui::style::HandleShape::Circle; + v.striped = false; + let r = CornerRadius::same(t.radius_sm as u8); + let w = &mut v.widgets; + w.noninteractive.bg_fill = t.card; + w.noninteractive.weak_bg_fill = t.card; + w.noninteractive.bg_stroke = Stroke::new(1.0, t.separator); + w.noninteractive.fg_stroke = Stroke::new(1.0, t.text_dim); + w.noninteractive.corner_radius = r; + for (wv, bg, stroke) in [(&mut w.inactive, t.field, t.field_border), (&mut w.hovered, t.hover, t.field_border), (&mut w.active, t.pressed, t.accent_border), (&mut w.open, t.hover, t.field_border)] { + wv.bg_fill = bg; + wv.weak_bg_fill = bg; + wv.bg_stroke = if t.bevel { Stroke::new(1.0, Color32::from_gray(64)) } else { Stroke::new(1.0, stroke) }; + wv.fg_stroke = Stroke::new(1.0, t.text); + wv.corner_radius = r; + wv.expansion = 0.0; + } + ctx.set_visuals(v); + ctx.global_style_mut(|s| { + s.text_styles = [ + (TextStyle::Small, FontId::proportional(10.5)), + (TextStyle::Body, FontId::proportional(if t.pro { 12.0 } else { 12.5 })), + (TextStyle::Button, FontId::proportional(if t.pro { 12.0 } else { 12.5 })), + (TextStyle::Heading, semibold(15.0)), + (TextStyle::Monospace, FontId::monospace(12.0)), + ] + .into(); + s.spacing.item_spacing = egui::vec2(8.0, 6.0); + s.spacing.button_padding = egui::vec2(10.0, 4.0); + s.spacing.interact_size = egui::vec2(24.0, 24.0); + s.spacing.slider_width = 150.0; + s.spacing.combo_width = 120.0; + s.spacing.menu_margin = egui::Margin::same(6); + s.spacing.window_margin = egui::Margin::same(16); + s.spacing.icon_width = 14.0; + s.visuals.indent_has_left_vline = false; + s.interaction.tooltip_delay = 0.35; + // Thin overlay scrollbars that appear on hover (Photoshop/macOS style). + s.spacing.scroll = if t.bevel { egui::style::ScrollStyle::solid() } else { egui::style::ScrollStyle::thin() }; + s.spacing.tooltip_width = 280.0; + }); +} + +pub fn canvas_bg(t: &Tokens) -> Color32 { + t.canvas +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn theme_names_parse() { + assert_eq!(ThemeKind::from_name("Classic"), Some(ThemeKind::Classic)); + assert_eq!(ThemeKind::from_name("studio (light)"), Some(ThemeKind::StudioLight)); + assert_eq!(ThemeKind::from_name("dark"), Some(ThemeKind::Pro)); + assert_eq!(ThemeKind::from_name("studio"), Some(ThemeKind::Studio)); + assert_eq!(ThemeKind::from_name("neon"), None); + } + + #[test] + fn studio_text_contrast_is_high() { + let t = Tokens::for_kind(ThemeKind::Studio); + let p = Tokens::for_kind(ThemeKind::Pro); + assert!(p.pro && !t.pro); + let lum = |c: Color32| 0.2126 * c.r() as f32 + 0.7152 * c.g() as f32 + 0.0722 * c.b() as f32; + assert!(lum(t.text) - lum(t.card) > 180.0); + assert!(lum(t.text_dim) - lum(t.card) > 90.0); + } +} + +/// Development feature: live design-token overrides. +/// +/// Set `PHOTOCRAFT_THEME_FILE=/path/tokens.json` in a debug build; the file is polled and applied +/// on change, so colours, radii and sizes can be tuned without recompiling. Keys are `Tokens` field +/// names; values are `"#rrggbb"`, `"#rrggbbaa"` or numbers. Compiled out of release builds. +#[cfg(all(debug_assertions, not(target_arch = "wasm32")))] +pub mod live { + use super::Tokens; + use egui::Color32; + use std::time::SystemTime; + + #[derive(Default)] + pub struct LiveTokens { + path: Option, + stamp: Option, + last_check: f64, + } + + impl LiveTokens { + pub fn from_env() -> Self { + Self { path: std::env::var_os("PHOTOCRAFT_THEME_FILE").map(Into::into), ..Default::default() } + } + + /// Re-apply overrides if the file changed. Returns true when tokens were updated. + pub fn poll(&mut self, ctx: &egui::Context, kind: super::ThemeKind) -> bool { + let Some(path) = &self.path else { return false }; + let now = ctx.input(|i| i.time); + if now - self.last_check < 0.4 { + ctx.request_repaint_after(std::time::Duration::from_millis(400)); + return false; + } + self.last_check = now; + ctx.request_repaint_after(std::time::Duration::from_millis(400)); + let Ok(meta) = std::fs::metadata(path) else { return false }; + let stamp = meta.modified().ok(); + if stamp == self.stamp { + return false; + } + self.stamp = stamp; + let Ok(text) = std::fs::read_to_string(path) else { return false }; + match serde_json::from_str::(&text) { + Ok(v) => { + super::apply(ctx, kind); + let mut t = Tokens::get(ctx); + let unknown = apply_overrides(&mut t, &v); + ctx.data_mut(|d| d.insert_temp(egui::Id::new("photocraft-theme"), t)); + if !unknown.is_empty() { + log::warn!("unknown token keys: {unknown:?}"); + } + true + } + Err(e) => { + log::warn!("theme file: {e}"); + false + } + } + } + } + + fn color(v: &serde_json::Value) -> Option { + let s = v.as_str()?.trim_start_matches('#'); + let b = |i: usize| u8::from_str_radix(s.get(i..i + 2)?, 16).ok(); + match s.len() { + 6 => Some(Color32::from_rgb(b(0)?, b(2)?, b(4)?)), + 8 => Some(Color32::from_rgba_unmultiplied(b(0)?, b(2)?, b(4)?, b(6)?)), + _ => None, + } + } + + /// Apply JSON overrides onto tokens; returns unknown keys. + pub fn apply_overrides(t: &mut Tokens, v: &serde_json::Value) -> Vec { + let mut unknown = Vec::new(); + let Some(obj) = v.as_object() else { return unknown }; + for (k, val) in obj { + macro_rules! c { + ($($f:ident),*) => { + match k.as_str() { + $(stringify!($f) => { if let Some(c) = color(val) { t.$f = c; } })* + "radius_sm" => { if let Some(x) = val.as_f64() { t.radius_sm = x as f32; } } + "radius" => { if let Some(x) = val.as_f64() { t.radius = x as f32; } } + "radius_lg" => { if let Some(x) = val.as_f64() { t.radius_lg = x as f32; } } + _ => unknown.push(k.clone()), + } + }; + } + c!(chrome, canvas, canvas_dot, dock, card, card_border, field, field_border, hover, pressed, text, text_dim, text_faint, icon, accent, accent_soft, accent_border, accent_text, separator, shadow, primary_bg, primary_text, danger, warning, tab_strip, row_selected); + } + unknown + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn overrides_apply_and_report_unknown() { + let mut t = Tokens::for_kind(super::super::ThemeKind::Pro); + let unknown = apply_overrides(&mut t, &serde_json::json!({"card": "#102030", "radius": 9, "nope": 1})); + assert_eq!(t.card, Color32::from_rgb(16, 32, 48)); + assert_eq!(t.radius, 9.0); + assert_eq!(unknown, vec!["nope".to_string()]); + } + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs new file mode 100644 index 00000000..7cebb4ce --- /dev/null +++ b/crates/ui-egui/src/widgets.rs @@ -0,0 +1,420 @@ +//! Custom widgets for the Photocraft look: cards with pill tabs, thin sliders with round knobs, +//! monospace value fields with dimmed units, toggle switches, primary/secondary buttons. + +use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; + +use crate::theme::{self, Tokens}; + +/// Draw a bevelled box (Classic theme) or a flat rounded box. +pub fn surface(ui: &Ui, rect: Rect, fill: Color32, raised: bool) { + let t = Tokens::get(ui.ctx()); + let p = ui.painter(); + p.rect_filled(rect, t.radius_sm, fill); + if t.bevel { + let (hi, lo) = if raised { (Color32::WHITE, Color32::from_gray(64)) } else { (Color32::from_gray(64), Color32::WHITE) }; + p.line_segment([rect.left_bottom(), rect.left_top()], Stroke::new(1.0, hi)); + p.line_segment([rect.left_top(), rect.right_top()], Stroke::new(1.0, hi)); + p.line_segment([rect.right_top(), rect.right_bottom()], Stroke::new(1.0, lo)); + p.line_segment([rect.right_bottom(), rect.left_bottom()], Stroke::new(1.0, lo)); + } +} + +/// A dock card: rounded container with a header of pill tabs and optional trailing actions. +/// Returns the index of the selected tab. +pub fn card(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, body: impl FnOnce(&mut Ui, usize)) { + let t = Tokens::get(ui.ctx()); + if t.pro { + return pro_panel(ui, id, tabs, selected, body); + } + let frame = egui::Frame::NONE + .fill(t.card) + .stroke(Stroke::new(1.0, t.card_border)) + .corner_radius(CornerRadius::same(t.radius as u8)) + .inner_margin(egui::Margin { left: 8, right: 8, top: 6, bottom: 10 }); + frame.show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + for (i, name) in tabs.iter().enumerate() { + if pill_tab(ui, name, *selected == i).clicked() { + *selected = i; + } + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.spacing_mut().item_spacing.x = 0.0; + let _ = crate::icons::button(ui, "ellipsis", 22.0, false, &format!("{} options", tabs.get(*selected).copied().unwrap_or(id))); + }); + }); + ui.add_space(6.0); + body(ui, *selected); + }); + ui.add_space(6.0); +} + +/// Photoshop-grammar panel group: dark tab strip with flat tabs, flat body, hamburger menu. +fn pro_panel(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, body: impl FnOnce(&mut Ui, usize)) { + let t = Tokens::get(ui.ctx()); + let width = ui.available_width(); + // Tab strip. + let (strip, _) = ui.allocate_exact_size(vec2(width, 26.0), Sense::hover()); + ui.painter().rect_filled(strip, CornerRadius { nw: 3, ne: 3, sw: 0, se: 0 }, t.tab_strip); + let mut x = strip.left(); + for (i, name) in tabs.iter().enumerate() { + let galley = ui.painter().layout_no_wrap((*name).to_owned(), egui::FontId::proportional(11.5), t.text); + let r = Rect::from_min_size(pos2(x, strip.top()), vec2(galley.size().x + 22.0, strip.height())); + let resp = ui.interact(r, ui.id().with((id, "tab", i)), Sense::click()); + let active = *selected == i; + if active { + ui.painter().rect_filled(r, CornerRadius { nw: if i == 0 { 3 } else { 0 }, ne: 0, sw: 0, se: 0 }, t.card); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.4)); + } + let color = if active { t.text } else if resp.hovered() { t.text_dim } else { t.text_faint }; + ui.painter().galley_with_override_text_color(r.center() - galley.size() / 2.0, galley, color); + if resp.clicked() { + *selected = i; + } + x = r.right(); + } + // Panel menu (hamburger). + let menu = Rect::from_center_size(pos2(strip.right() - 14.0, strip.center().y), vec2(20.0, 18.0)); + let mresp = ui.interact(menu, ui.id().with((id, "menu")), Sense::click()); + let c = if mresp.hovered() { t.text } else { t.text_faint }; + for k in 0..3 { + let y = menu.center().y - 3.5 + k as f32 * 3.5; + ui.painter().line_segment([pos2(menu.center().x - 5.0, y), pos2(menu.center().x + 5.0, y)], Stroke::new(1.0, c)); + } + // Body. + egui::Frame::NONE.fill(t.card).corner_radius(CornerRadius { nw: 0, ne: 0, sw: 3, se: 3 }).inner_margin(egui::Margin { left: 8, right: 8, top: 8, bottom: 8 }).show(ui, |ui| { + ui.set_width(width - 16.0); + body(ui, *selected); + }); + ui.add_space(2.0); +} + +pub fn pill_tab(ui: &mut Ui, label: &str, selected: bool) -> Response { + let t = Tokens::get(ui.ctx()); + let font = theme::medium(12.5); + let galley = ui.painter().layout_no_wrap(label.to_owned(), font, t.text); + let size = vec2(galley.size().x + 20.0, 24.0); + let (rect, resp) = ui.allocate_exact_size(size, Sense::click()); + if selected { + surface(ui, rect, t.hover, true); + if !t.bevel { + ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + } + } else if resp.hovered() { + ui.painter().rect_filled(rect, t.radius_sm, t.hover.gamma_multiply(0.6)); + } + let color = if selected { t.text } else { t.text_dim }; + ui.painter().galley_with_override_text_color(rect.center() - galley.size() / 2.0, galley, color); + resp +} + +/// Monospace numeric field with a dimmed unit suffix, Photon/Photoshop style. Drag to scrub. +pub fn value_field(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive, suffix: &str, width: f32) -> Response { + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(vec2(width, 24.0), Sense::hover()); + surface(ui, rect, t.field, false); + if !t.bevel { + ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + } + let suffix_w = if suffix.is_empty() { 0.0 } else { 16.0 }; + let field = Rect::from_min_max(rect.min + vec2(4.0, 2.0), rect.max - vec2(4.0 + suffix_w, 2.0)); + // new_child (not scope_builder): a scope would move the parent cursor back to the child rect. + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(field)); + let resp = { + let ui = &mut child; + { + ui.style_mut().visuals.widgets.inactive.bg_fill = Color32::TRANSPARENT; + ui.style_mut().visuals.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT; + ui.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE; + ui.style_mut().visuals.widgets.hovered.bg_stroke = Stroke::NONE; + ui.style_mut().visuals.widgets.hovered.weak_bg_fill = Color32::TRANSPARENT; + ui.style_mut().override_font_id = Some(theme::mono(12.0)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_sized(field.size(), egui::DragValue::new(value).range(range).speed(0.5).custom_formatter(|v, _| fmt_num(v))) + }) + .inner + } + }; + if !suffix.is_empty() { + ui.painter().text(pos2(rect.right() - 6.0, rect.center().y), Align2::RIGHT_CENTER, suffix, theme::mono(11.0), t.text_faint); + } + resp +} + +/// Thin-track slider with a round knob. `gradient` paints the track (e.g. hue spectrum). +pub fn slider(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive, gradient: Option<&[Color32]>) -> Response { + let t = Tokens::get(ui.ctx()); + let width = ui.available_width().max(60.0); + let (rect, mut resp) = ui.allocate_exact_size(vec2(width, 18.0), Sense::click_and_drag()); + let (lo, hi) = (*range.start(), *range.end()); + let track = Rect::from_center_size(rect.center(), vec2(rect.width() - 14.0, if gradient.is_some() { 5.0 } else { 3.0 })); + if let Some(p) = resp.interact_pointer_pos() + && (resp.dragged() || resp.clicked()) + { + let f = ((p.x - track.left()) / track.width()).clamp(0.0, 1.0); + let nv = lo + f * (hi - lo); + if (nv - *value).abs() > f32::EPSILON { + *value = nv; + resp.mark_changed(); + } + } + let f = ((*value - lo) / (hi - lo)).clamp(0.0, 1.0); + let knob_x = track.left() + f * track.width(); + let painter = ui.painter(); + match gradient { + Some(colors) if colors.len() >= 2 => { + let n = colors.len() - 1; + for (i, w) in colors.windows(2).enumerate() { + let x0 = track.left() + track.width() * i as f32 / n as f32; + let x1 = track.left() + track.width() * (i + 1) as f32 / n as f32; + let mut mesh = egui::Mesh::default(); + let r = Rect::from_min_max(pos2(x0, track.top()), pos2(x1, track.bottom())); + mesh.colored_vertex(r.left_top(), w[0]); + mesh.colored_vertex(r.right_top(), w[1]); + mesh.colored_vertex(r.right_bottom(), w[1]); + mesh.colored_vertex(r.left_bottom(), w[0]); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + painter.add(mesh); + } + } + _ => { + painter.rect_filled(track, 2.0, t.field_border); + let filled = Rect::from_min_max(track.min, pos2(knob_x, track.max.y)); + painter.rect_filled(filled, 2.0, if t.bevel { t.accent } else { t.text_dim }); + } + } + let knob = pos2(knob_x, rect.center().y); + if t.bevel { + let kr = Rect::from_center_size(knob, vec2(10.0, 16.0)); + surface(ui, kr, t.card, true); + } else { + let kr = if t.pro { 6.0 } else { 7.0 }; + painter.circle_filled(knob + vec2(0.0, 1.0), kr + 0.5, Color32::from_black_alpha(90)); + painter.circle_filled(knob, kr, if t.pro { Color32::from_gray(236) } else { Color32::WHITE }); + if t.pro { + painter.circle_stroke(knob, kr, Stroke::new(1.0, Color32::from_gray(40))); + } + if resp.hovered() || resp.dragged() { + painter.circle_stroke(knob, 9.5, Stroke::new(2.0, t.accent_soft)); + } + } + resp +} + +/// Labelled slider row: `Label ........ [value field]` above a full-width thin slider. +pub fn slider_row(ui: &mut Ui, label: &str, value: &mut f32, range: std::ops::RangeInclusive, suffix: &str, gradient: Option<&[Color32]>) -> Response { + let t = Tokens::get(ui.ctx()); + let mut changed_resp = None; + ui.horizontal(|ui| { + ui.label(egui::RichText::new(label).color(t.text_dim)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + changed_resp = Some(value_field(ui, value, range.clone(), suffix, 74.0)); + }); + }); + let s = slider(ui, value, range, gradient); + let mut r = s.clone(); + if let Some(v) = changed_resp + && v.changed() + { + r.mark_changed(); + } + ui.add_space(4.0); + r +} + +/// iOS/Photon-style toggle switch. +pub fn toggle(ui: &mut Ui, on: &mut bool, label: &str) -> Response { + let t = Tokens::get(ui.ctx()); + if t.pro { + return checkbox(ui, on, label); + } + let mut resp = ui + .horizontal(|ui| { + let (rect, resp) = ui.allocate_exact_size(vec2(30.0, 17.0), Sense::click()); + let how_on = ui.ctx().animate_bool(resp.id, *on); + let bg = if *on { t.accent } else { t.field_border }; + if t.bevel { + surface(ui, rect, if *on { t.accent } else { t.field }, false); + } else { + ui.painter().rect_filled(rect, 8.5, bg); + } + let x = egui::lerp((rect.left() + 8.5)..=(rect.right() - 8.5), how_on); + ui.painter().circle_filled(pos2(x, rect.center().y), 6.5, Color32::WHITE); + ui.label(egui::RichText::new(label).color(if *on { t.text } else { t.text_dim })); + resp + }) + .inner; + if resp.clicked() { + *on = !*on; + resp.mark_changed(); + } + resp +} + +/// Big white primary button (Photon "OK" style). +pub fn primary_button(ui: &mut Ui, label: &str, min_width: f32) -> Response { + let t = Tokens::get(ui.ctx()); + button_impl(ui, label, min_width, t.primary_bg, t.primary_text, true) +} + +pub fn secondary_button(ui: &mut Ui, label: &str, min_width: f32) -> Response { + let t = Tokens::get(ui.ctx()); + button_impl(ui, label, min_width, t.field, t.text, false) +} + +fn button_impl(ui: &mut Ui, label: &str, min_width: f32, bg: Color32, fg: Color32, primary: bool) -> Response { + let t = Tokens::get(ui.ctx()); + let galley = ui.painter().layout_no_wrap(label.to_owned(), theme::medium(13.0), fg); + let h = if t.pro { 28.0 } else { 30.0 }; + let size = vec2((galley.size().x + 28.0).max(min_width), h); + let (rect, resp) = ui.allocate_exact_size(size, Sense::click()); + if t.pro { + // Spectrum buttons: fully rounded; primary = filled accent, secondary = outline. + let down = resp.is_pointer_button_down_on(); + let r = h / 2.0; + if primary { + let fill = if down { bg.gamma_multiply(0.8) } else if resp.hovered() { bg.gamma_multiply(0.9) } else { bg }; + ui.painter().rect_filled(rect, r, fill); + } else { + if resp.hovered() || down { + ui.painter().rect_filled(rect, r, t.hover); + } + ui.painter().rect_stroke(rect, r, Stroke::new(1.5, if resp.hovered() { t.text } else { t.text_dim }), StrokeKind::Inside); + } + ui.painter().galley(rect.center() - galley.size() / 2.0, galley, fg); + return resp; + } + let fill = if resp.is_pointer_button_down_on() { + bg.gamma_multiply(0.85) + } else if resp.hovered() { + if primary { bg.gamma_multiply(0.93) } else { t.hover } + } else { + bg + }; + surface(ui, rect, fill, !resp.is_pointer_button_down_on()); + if !t.bevel && !primary { + ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + } + ui.painter().galley(rect.center() - galley.size() / 2.0, galley, fg); + resp +} + +/// Small caps section label. +pub fn section_label(ui: &mut Ui, text: &str) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(text).font(theme::medium(11.5)).color(t.text_faint)); +} + +/// Hairline separator. +pub fn hairline(ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 1.0), Sense::hover()); + ui.painter().line_segment([r.left_center(), r.right_center()], Stroke::new(1.0, t.separator)); +} + +/// Vertical hairline for horizontal layouts. +pub fn vline(ui: &mut Ui, height: f32) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(9.0, height), Sense::hover()); + ui.painter().line_segment([r.center_top(), r.center_bottom()], Stroke::new(1.0, t.separator)); +} + +/// Hue spectrum stops for colour sliders. +pub fn hue_stops() -> Vec { + (0..=12) + .map(|i| { + let h = i as f32 / 12.0; + let rgb = egui::ecolor::Hsva::new(h, 0.85, 1.0, 1.0).to_srgb(); + Color32::from_rgb(rgb[0], rgb[1], rgb[2]) + }) + .collect() +} + +/// A compact labelled dropdown with Photon styling. +pub fn dropdown(ui: &mut Ui, id: &str, current: &mut T, options: &[(T, &str)], width: f32) -> bool { + let label = options.iter().find(|(v, _)| v == current).map(|(_, l)| *l).unwrap_or("—"); + let mut changed = false; + egui::ComboBox::from_id_salt(id).selected_text(label).width(width).height(420.0).icon(chevron_icon).show_ui(ui, |ui| { + for (v, l) in options { + if ui.selectable_label(v == current, *l).clicked() { + *current = v.clone(); + changed = true; + } + } + }); + changed +} + +/// Paint a small checkerboard (transparency) in `rect`. +pub fn checker(painter: &egui::Painter, rect: Rect, cell: f32) { + painter.rect_filled(rect, 0.0, Color32::from_gray(250)); + let nx = (rect.width() / cell).ceil() as i32; + let ny = (rect.height() / cell).ceil() as i32; + for j in 0..ny { + for i in 0..nx { + if (i + j) % 2 == 1 { + let r = Rect::from_min_size(Pos2::new(rect.left() + i as f32 * cell, rect.top() + j as f32 * cell), Vec2::splat(cell)).intersect(rect); + painter.rect_filled(r, 0.0, Color32::from_gray(214)); + } + } + } +} + +/// Spectrum-style checkbox (blue when checked). +pub fn checkbox(ui: &mut Ui, on: &mut bool, label: &str) -> Response { + let t = Tokens::get(ui.ctx()); + let mut resp = ui + .horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + let (rect, resp) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::click()); + let p = ui.painter(); + if *on { + p.rect_filled(rect, 2.0, t.accent); + let a = rect.left_center() + vec2(3.0, 0.5); + let b = rect.center_bottom() + vec2(-1.0, -3.5); + let c = rect.right_top() + vec2(-3.0, 3.5); + p.line_segment([a, b], Stroke::new(1.8, Color32::WHITE)); + p.line_segment([b, c], Stroke::new(1.8, Color32::WHITE)); + } else { + p.rect_filled(rect, 2.0, t.field); + p.rect_stroke(rect, 2.0, Stroke::new(1.5, if resp.hovered() { t.text_dim } else { t.text_faint }), StrokeKind::Inside); + } + let l = ui.add(egui::Label::new(egui::RichText::new(label).color(t.text_dim)).sense(Sense::click())); + resp.union(l) + }) + .inner; + if resp.clicked() { + *on = !*on; + resp.mark_changed(); + } + resp +} + +/// Small chevron for dropdowns (replaces egui's filled triangle). +pub fn chevron_icon(ui: &Ui, rect: Rect, visuals: &egui::style::WidgetVisuals, _open: bool) { + let c = rect.center(); + let s = 3.2; + let stroke = Stroke::new(1.3, visuals.fg_stroke.color.gamma_multiply(0.8)); + ui.painter().line_segment([c + vec2(-s, -s * 0.5), c + vec2(0.0, s * 0.5)], stroke); + ui.painter().line_segment([c + vec2(0.0, s * 0.5), c + vec2(s, -s * 0.5)], stroke); +} + +/// Photoshop-style numbers: "100", "12.5" (never "100.0"). +pub fn fmt_num(v: f64) -> String { + let r = (v * 10.0).round() / 10.0; + if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r:.1}") } +} + +#[cfg(test)] +mod tests { + #[test] + fn numbers_drop_trailing_zero() { + assert_eq!(super::fmt_num(100.0), "100"); + assert_eq!(super::fmt_num(12.46), "12.5"); + assert_eq!(super::fmt_num(-3.0), "-3"); + } +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..a40f7443 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,534 @@ +# Photocraft Architecture + +Status: draft v1 (2026-09-30). This plan does not assume a final UI toolkit. The candidates are discussed in [`rust-framework-options.md`](rust-framework-options.md). + +This plan sets out: +- the Cargo workspace, +- the rules that keep the engine independent of the UI, +- the interfaces a UI has to implement, +- how the PSD, compositing, history and automation parts fit together. + +--- + +## 1. Goals and principles + +**Goals** +- A native, portable (macOS / Windows / Linux), fully open-source layered image editor, roughly matching Photon Studio's and Photoshop's feature surface over time. +- **The UI can be swapped** without touching the core: egui today, maybe Slint, Qt or something else later. +- **The core compiles to `wasm32`,** which keeps a browser demo possible. +- PSD read and write lives in its **own crate that can be published separately.** + +**Principles** + +1. **Engine-first, headless-first.** Every feature is reachable without a GUI: from tests, the CLI and MCP. The GUI is one client of the engine. +2. **Everything is a command.** Each user-visible action has a stable `CommandId` (such as `filter.blur.gaussian`) with typed, serializable parameters. The menu, command palette, shortcuts, recorded actions/macros, CLI, MCP and plugins all dispatch the same commands. Photon does this, and it is the single best architectural idea in that app. Its 720-item menu tree ([`photon-studio/06-menu-snapshot.md`](photon-studio/06-menu-snapshot.md)) is effectively our feature backlog. +3. **Data describes the UI; toolkits draw it.** Tools produce *overlay primitives* (lines, handles, marching-ants paths) as data. Filter and adjustment dialogs are generated from *parameter schemas*. A new toolkit implements one renderer for each, not about 150 bespoke dialogs. +4. **One algorithm, one parameter struct.** Every algorithm has a CPU reference implementation (Rust + rayon, deterministic). Some also get a GPU implementation (WGSL). Both read the same `#[repr(C)]` `bytemuck::Pod` parameter struct. Photon keeps TypeScript, WASM, WGSL and C++ copies in sync by hand, and we will not repeat that. +5. **Immutable snapshots, copy-on-write tiles.** Pixel data is stored in `Arc`-shared 256² tiles. Taking a document snapshot is O(layers), which makes undo, background jobs, autosave and UI reads cheap and lock-free. +6. **Determinism.** The same input gives the same output across CPU and GPU (within tolerance), across thread counts, and between preview and export. Noise is hashed from document coordinates. +7. **Pure Rust by default.** C/C++ dependencies are allowed only behind Cargo features, in I/O-edge crates (for example HEIF, or an optional LibRaw). +8. **Clean-room with respect to Photon Studio.** Photon is proprietary freeware. We studied its *behaviour and architecture*, and we must **not copy its source** (Rust, WGSL, C++ or JS), even though parts of it ship as readable source. Specs come from public format docs (Adobe PSD spec, ISO/ICC), academic papers (PatchMatch, Poisson blending, ARAP) and observed behaviour. Contributors should not paste from the extracted bundle. + +### 1.1 Avoiding GIMP's hole + +The criticism of GIMP is historically true, and much of it still applies in 2026. + +**Bit depth.** +- GIMP's original core hard-coded 8-bit-per-channel tiles. +- High bit depth needed a whole new engine, GEGL. GEGL began around 2000, was introduced as optional in 2.6 (2008), and the port only finished in **GIMP 2.10 (2018)**, "the result of six years of work" on that port alone. +- Only then did GIMP get 16/32-bit integer and float processing. + +**Non-destructive editing.** +- Photoshop has had adjustment layers since about 1994. +- GIMP got non-destructive *filters* in **3.0 (March 2025)**, about seven years after 2.10 because of the GTK3 port. +- It got non-destructive *layer types* (link layers, vector layers) in **3.2 (March 2026)**. + +**CMYK.** +- GIMP 3.x is still "a 100% RGB editor" internally. +- CMYK exists only as export (JPEG/TIFF/PSD) and soft-proofing. + +**PSD.** +- Import is still lossy. Photoshop adjustment layers and most layer styles are not carried over faithfully, and text often needs re-typing. +- 3.2 converts legacy drop and inner shadows into GIMP filters, and added PSB export. + +**Root cause:** fundamental properties were **baked into the core as assumptions instead of being data.** Pixel depth, colour model, and "a layer is a pixel buffer" were all hard-coded. Every later feature that touched them needed a rewrite of everything built on top. + +Sources: [GIMP 2.10 release notes](https://gimp.org/release-notes/gimp-2.10.html), [GEGL on Wikipedia](https://en.wikipedia.org/wiki/GEGL), [GIMP 3.2 release notes](https://www.gimp.org/release-notes/gimp-3.2.html), [LWN on GIMP 3.0 colour](https://lwn.net/Articles/998793/). + +**Rules we adopt from day one** (cheap now, and each would force a rewrite later): + +| Property | Rule | Where | +|---|---|---| +| **Bit depth** | Every surface carries a runtime `PixelFormat { depth: U8\|U16\|F16\|F32, model, alpha }`. Algorithms are written generically over a `Sample` trait and monomorphized per depth, or run on f32 working buffers and convert at tile edges. No `u8` pixel type appears in any public engine API. CI runs the golden suite at 8, 16 and 32f. | `raster`, `color`, `algo` | +| **Colour model** | `ColorMode` (RGB, Gray, CMYK, Lab, Indexed, Bitmap, plus multichannel/spot) is in `doc` from the start. Channel count is dynamic (1–N plus alpha plus spot). Only RGB/Gray *rendering* ships first, but the storage, PSD round-trip and conversion paths never assume 3 or 4 channels. | `doc`, `raster`, `psd` | +| **Colour management** | Every document has an ICC profile. The compositor takes a working-space descriptor. The display transform is a separate final stage. Blending space (gamma vs linear) is a per-document option. | `color`, `compose` | +| **Non-destructive by construction** | `LayerContent` is an enum that includes Adjustment, Fill, Text, Shape and Smart from v1, with smart filters as a stack on any layer. The compositor evaluates a *graph*, not a list of flattened buffers. Destructive ops are just "apply and bake". | `doc`, `compose` | +| **File formats** | Formats plug in through `trait ImageDecoder/Encoder` plus a registry with capability flags (depths, modes, layers, alpha, metadata, ICC). Adding a format is one crate feature. It must never need a change in core types. Lossless PSD round-trip preserves unknown blocks. | `codecs`, `io`, `psd` | +| **Size** | Tiled, sparse storage behind a `TileStore` trait, with 64-bit coordinates for PSB-scale canvases (300k × 300k). Nothing assumes a document fits in one allocation or one GPU texture. | `raster` | +| **UI toolkit** | The core never depends on the toolkit (§3–§4). GIMP spent years porting GTK2 to GTK3, and our UI crate can be replaced without touching the engine. | layering rule 2 | + +The test is simple: **adding 64-bit float, a new colour model, or a new file format should touch a leaf crate, not the foundation.** We check this in phase 2 by adding a (hidden) CMYK and a 32f code path early, even before they are exposed in the UI. + +--- + +## 2. Workspace layout + +```text +photocraft/ +├─ Cargo.toml # [workspace], shared deps, lints, profiles +├─ crates/ +│ │ ── foundation (no_std-friendly where practical, wasm-safe) ── +│ ├─ geom/ photocraft-geom points, rects, affine/perspective, tile coords, bezier (kurbo) +│ ├─ color/ photocraft-color pixel formats, color spaces, ICC (moxcms), blend-mode math (scalar reference) +│ ├─ raster/ photocraft-raster tiled COW surfaces, masks, mip pyramids, damage regions, pixel iterators +│ │ ── document ── +│ ├─ doc/ photocraft-doc document model: layer tree, masks, effects, channels, paths, guides, metadata (pure data + serde) +│ ├─ ops/ photocraft-ops reversible operations on a doc + transactions + history/undo +│ │ ── algorithms (CPU reference impls, rayon, wasm-safe) ── +│ ├─ algo/ photocraft-algo adjustments, filters, selection, inpaint/heal, warp/transform, resampling +│ ├─ paint/ photocraft-paint brush engine: dabs, dynamics, stroke smoothing, brush presets (.abr import later) +│ ├─ text/ photocraft-text font DB trait, shaping + layout (parley/skrifa/harfrust), text-layer rasterization +│ ├─ vector/ photocraft-vector shapes, paths, strokes → coverage (vello_cpu / kurbo) +│ │ ── rendering ── +│ ├─ compose/ photocraft-compose layer tree → DrawOp plan; CPU compositor (reference + export + wasm fallback) +│ ├─ gpu/ photocraft-gpu wgpu backend for the DrawOp plan + GPU kernels (WGSL), residency cache, memory budget +│ ├─ viewport/ photocraft-viewport camera (zoom/pan/rotate), visible tiles/mips, renders a doc into a wgpu texture; toolkit-agnostic +│ │ ── I/O ── +│ ├─ psd/ photocraft-psd PSD/PSB read + write; its OWN format-level model; depends on nothing in this workspace +│ ├─ adobe-assets/ photocraft-adobe-assets .abr .asl .aco/.ase .grd .pat .csh .atn .cube/.3dl, ACR .xmp presets (standalone, like psd) +│ ├─ codecs/ photocraft-codecs png/jpeg/tiff/webp/gif/bmp/avif/jxl (+heif via feature) decode/encode +│ ├─ raw/ photocraft-raw camera RAW decode (rawler) + develop pipeline + lens profiles +│ ├─ format/ photocraft-format native document format (.pcraft bundle): manifest + content-addressed tiles +│ ├─ io/ photocraft-io import/export orchestration; doc ⇄ PSD mapping; PDF/SVG import (features) +│ │ ── intelligence ── +│ ├─ ml/ photocraft-ml model registry + InferenceBackend trait (ort native / burn|candle web); SAM2, matting, depth, sky +│ │ ── the façade every frontend talks to ── +│ ├─ tools/ photocraft-tools tool state machines (move, marquee, lasso, brush, clone, gradient, crop, transform, pen, text…) +│ ├─ engine/ photocraft-engine Session: open docs, command registry + dispatch, jobs, events, view-models, preferences +│ │ ── platform + frontends ── +│ ├─ platform/ photocraft-platform traits: file dialogs, clipboard, fonts, tablet input, menus, storage; native + web impls +│ ├─ ui-egui/ photocraft-ui-egui the (first) GUI shell: panels, dialogs, canvas widget, theme +│ ├─ automation/ photocraft-automation MCP server (rmcp) + JSON-RPC over the command registry +│ └─ testkit/ photocraft-testkit golden images, perceptual diff, fixtures, PSD corpus helpers +├─ apps/ +│ ├─ photocraft/ desktop binary (winit + wgpu + ui-egui + platform-native) +│ ├─ photocraft-cli/ headless batch CLI (open → commands → export), also hosts `mcp` subcommand +│ └─ photocraft-web/ wasm32 binary (ui-egui + platform-web), demo +├─ shaders/ (or inside gpu/) WGSL, with generated struct headers +├─ assets/ icons, bundled fonts, default brushes/swatches/presets +├─ fuzz/ cargo-fuzz targets (psd, codecs, format) +├─ xtask/ build tasks: layer-check, shader-gen, corpus fetch, release +└─ plan/ this directory +``` + +**Crate granularity:** start with the crates above. Split `algo` into `-adjust`, `-filters`, `-select`, `-inpaint` and `-warp` once any module passes about 10k lines, or once compile times hurt. Its internal module boundaries should already follow those lines. + +**Naming:** directories are short and packages are prefixed `photocraft-`. `photocraft-psd` gets a neutral, publishable name (such as `psd-rw`) if we release it to crates.io. + +--- + +## 3. Dependency layering (enforced) + +```text + L7 apps/* (binaries: wire everything together) + L6 ui-egui · automation · platform + L5 engine + L4 tools · viewport · io · ml + L3 compose · gpu · format · raw · codecs + L2 ops · algo · paint · text · vector + L1 doc + L0 geom · color · raster psd, adobe-assets (standalone, no workspace deps) +``` + +**Rules** (checked by `cargo xtask layers` in CI, which parses `cargo metadata`): + +1. A crate may depend only on crates in **lower** layers. No cycles and no sideways dependencies, except where listed. +2. **Nothing below L6 may depend on any UI toolkit, winit, or a `platform` implementation.** Platform services reach the engine through traits defined in `engine` (or in `platform`'s trait-only core), and are injected at startup. +3. **`photocraft-psd` depends on no workspace crate.** The doc ⇄ PSD mapping lives in `io`. This keeps the PSD crate publishable and reusable by other projects. +4. **wasm gate:** every crate in L0–L5 (except feature-gated native backends) must build for `wasm32-unknown-unknown`. CI runs `cargo build -p photocraft-engine --target wasm32-unknown-unknown --no-default-features --features web`. +5. `gpu` is optional for `engine`. Engine features are `gpu` (default on) and `cpu-only`, and `cpu-only` builds are what the headless CLI and CI tests use. +6. **C dependencies** (libheif, optional LibRaw, pdfium) only behind features, only in `codecs`, `raw` or `io`, and never on by default for the web target. + +--- + +## 4. The UI-swap seam (the most important interface) + +A frontend needs exactly five things from the core. Keeping these small and data-oriented is what makes the UI replaceable. + +### 4.1 `Session`: commands in, events out + +```rust +// photocraft-engine +pub struct Session { /* docs, registry, job pool, prefs, services */ } + +impl Session { + pub fn new(services: Services) -> Self; // platform traits injected + pub fn dispatch(&self, doc: Option, cmd: CommandInvocation) -> Result; + pub fn registry(&self) -> &CommandRegistry; // metadata for menus/palette/shortcuts + pub fn snapshot(&self, doc: DocId) -> Arc; // lock-free read (arc-swap) + pub fn events(&self) -> EventReceiver; // DocChanged{damage}, JobProgress, Toast, … + pub fn view_models(&self, doc: DocId) -> ViewModels; // layers, history, channels, tool options… +} + +pub struct CommandInvocation { pub id: CommandId, pub params: serde_json::Value /* validated vs schema */ } +``` + +### 4.2 `CommandRegistry`: the menu, palette and automation catalogue + +Each command registers the following: +- `id` (`"filter.blur.gaussian"`), `label`, `menu_path` (`["Filter","Blur"]`), and a `default_shortcut`. +- `params: schemars::Schema`, plus optional `ui_hints` (slider ranges, units, log scale, grouping). +- `enabled(&DocSnapshot, &ToolState) -> bool` and `checked(...) -> Option`. +- `preview: PreviewMode` (None / Live / Proxy). This lets dialogs show on-canvas preview generically. +- `kind`: Instant / Dialog / Interactive (hands control to a tool, e.g. Free Transform). + +**Consumers:** +- The GUI builds the menu bar, the ⌘K palette and the shortcut editor from it. +- `automation` exposes the registry as MCP tools. +- `photocraft-cli` exposes `photocraft run --cmd filter.blur.gaussian --param radius=4 in.psd out.png`. +- Action recording is simply `Vec`. + +### 4.3 Tools: pointer events in, ops and overlays out + +```rust +// photocraft-tools +pub struct PointerEvent { pub pos_doc: Point, pub pressure: f32, pub tilt: Vector, pub twist: f32, + pub time_us: u64, pub buttons: Buttons, pub mods: Modifiers, pub kind: PointerKind /* mouse|pen|eraser|touch */ } + +pub trait Tool { + fn id(&self) -> ToolId; + fn options_schema(&self) -> Schema; // options bar generated from schema + fn on_pointer(&mut self, ev: &PointerEvent, cx: &mut ToolCx) -> ToolResponse; // ToolCx gives snapshot + op sink + fn on_key(&mut self, key: Key, cx: &mut ToolCx) -> ToolResponse; + fn overlay(&self, view: &ViewTransform) -> Vec; // drawn by any UI + fn cursor(&self) -> CursorSpec; +} + +pub enum OverlayPrim { Path{path: BezPath, style: StrokeStyle, space: Space}, MarchingAnts{path: BezPath}, + Handle{pos: Point, kind: HandleKind}, BrushOutline{center: Point, radius: f32, ..}, + Text{pos: Point, text: String}, Guide{..}, Grid{..} } +``` + +Coalesced high-rate pen samples come from `platform` and are handed to tools as a batch, so the brush sees every sample even when the UI renders at 60–120 Hz. + +### 4.4 Viewport: document to pixels + +```rust +// photocraft-viewport +pub struct Viewport { pub camera: Camera /* zoom, pan, rotation, dpr */, /* tile/mip cache */ } +impl Viewport { + pub fn render_gpu(&mut self, snap: &DocSnapshot, gpu: &GpuContext, target: &wgpu::TextureView, damage: &Region); + pub fn render_cpu(&mut self, snap: &DocSnapshot, target: &mut RgbaImageMut, damage: &Region); // wasm fallback, tests + pub fn doc_to_screen(&self) -> Affine; pub fn screen_to_doc(&self) -> Affine; +} +``` + +- Any toolkit that can host a `wgpu::TextureView` (egui paint callbacks, Slint's wgpu texture import, Bevy, a raw winit surface) embeds the canvas the same way. +- A toolkit without wgpu can fall back to `render_cpu`. + +### 4.5 View-models and schemas + +- Panels (Layers, History, Channels, Paths, Properties, Navigator, Histogram, Swatches) read plain structs produced from a snapshot, such as `LayersModel { rows: Vec }> }`. Thumbnails are `ThumbHandle`s that the UI resolves to textures through an engine thumbnail cache. +- Dialogs are generated from command `params` schemas. A toolkit may register *custom* dialog widgets for a few complex commands: Curves, Levels, Camera Raw, Liquify, Layer Style. + +**Litmus test for the seam:** `apps/photocraft-cli` and the automation server must be able to do *everything* the GUI does, except pointer painting, which they do by replaying `PointerEvent` streams through tools. + +--- + +## 5. Document model (`photocraft-doc`) + +```rust +pub struct Document { + pub id: DocId, pub size: Size, pub resolution_dpi: f32, + pub mode: ColorMode /* Rgb | Gray | Cmyk | Lab | Bitmap | Indexed */, pub depth: Depth /* U8 | U16 | F32 */, + pub profile: IccProfileRef, + pub root: LayerGroup, // tree + pub channels: Vec, // saved selections/spot + pub paths: Vec, pub guides: Guides, pub slices: .., pub metadata: Metadata /* XMP, EXIF */, + pub selection: Option, // current selection (a raster mask + optional vector outline) +} + +pub struct Layer { pub id: LayerId, pub name: String, pub visible: bool, pub locks: Locks, + pub blend: BlendMode, pub opacity: f32, pub fill_opacity: f32, pub clipped: bool, + pub mask: Option, pub vector_mask: Option, + pub effects: Effects, pub blend_if: BlendIf, pub content: LayerContent } + +pub enum LayerContent { + Raster(TiledSurface), + Group(LayerGroup /* children, pass-through or isolated */), + Adjustment(Adjustment /* Curves, Levels, HueSat, … as params */), + Fill(Fill /* solid | gradient | pattern */), + Text(TextLayer /* runs, paragraph styles, warp; cached raster */), + Shape(ShapeLayer /* vector paths + fill/stroke */), + Smart(SmartObject /* embedded or linked source + transform + smart filters */), +} +``` + +- **Pixels.** `raster::TiledSurface` is a sparse map from `TileCoord` to `Arc>`, with 256×256 tiles. Missing tiles are transparent, or take a fill value. Mutation calls `Arc::make_mut`, so it is copy-on-write. +- **Tile generation counters** feed the GPU residency cache and the thumbnail cache. +- **Mip pyramids** are derived lazily per layer, for zoomed-out views. +- **Big documents.** The tile store is behind a `TileStore` trait: in-memory now, disk-spilling ("scratch disk") later, with LRU eviction under a global memory budget. +- **The first milestone supports RGB and Gray, at 8/16/32f.** CMYK and Lab are modelled in the types from day one (so PSD round-trips don't lose them) but are rendered by converting to RGB until later phases. + +--- + +## 6. Operations and history (`photocraft-ops`) + +- **Every mutation is an `Op`**, such as `SetLayerProps`, `PaintTiles{layer, tiles_before, tiles_after}`, `AddLayer`, `MoveLayer` or `ApplyFilter`. +- **A command produces a `Transaction` of ops** with a label ("Gaussian Blur"). +- **History stores transactions.** Because tiles are `Arc`-shared, "before" states cost only the tiles that actually changed. Undo means swapping `Arc`s, so no pixel copies are made. +- **Brush strokes** accumulate into one transaction per stroke. Tiles are published incrementally with damage regions, so the viewport updates live. +- **Snapshots for readers.** After each committed op the engine publishes a new `Arc` through `arc-swap`. The UI, background jobs (filters, AI inference, export) and autosave read snapshots without locks. +- **Background jobs** compute from snapshot N and commit as a transaction. If the doc changed meanwhile, the job rebases, meaning it re-targets the same layer id if it still exists, or reports a conflict. +- **History features:** a history panel, a memory cap with oldest-first eviction, and named snapshots. The history brush comes later, reading tiles from a past state. + +--- + +## 7. Rendering + +### 7.1 Planner (`compose`) + +`plan(&DocSnapshot, region) -> Vec` flattens the layer tree into a linear instruction stream: +- `PushGroup{isolated, blend, opacity}` / `PopGroup` +- `BeginClipBase` / `Clipped` +- `DrawTiles{layer, blend, opacity, mask}` +- `Adjust{program}` +- `Effect{kind, params}` +- `Fill{...}` + +Both backends consume the same plan. This is the only place that encodes Photoshop layer semantics: pass-through groups, clipping groups, fill vs opacity, blend-if and knockout. + +### 7.2 Backends + +- **CPU (`compose`).** The reference implementation, done per tile with rayon. It is used for export, tests, the CLI, and the web fallback when WebGPU is unavailable. +- **GPU (`gpu`).** wgpu compute and render pipelines that run the same plan. They use: + - `rgba16float`/`rgba32float` accumulators, + - specialized pipelines per blend family and adjustment (WGSL `override` constants, not one giant `switch`), + - a residency cache keyed by `(TileId, generation)`, + - cached group and effect results that are invalidated by generation, + - one global `Budget` for GPU memory. +- **Parity tests.** For every blend mode, adjustment and effect, the CPU and GPU results must match within a tolerance of ≤1/255 for 8-bit output. This runs in CI on a software adapter (wgpu with lavapipe or WARP). + +### 7.3 Color + +- Blending happens in document space by default, which is Photoshop-compatible. A per-document "linear light blending" option is also available. +- **Display transform:** compose the ICC transforms with moxcms, then bake them into a 1D + 3D LUT that the viewport's final pass applies. +- **HDR/EDR output** (an `rgba16float` surface with an extended-range colorspace) is a later-phase feature. The interfaces already carry `f32` pixels. + +### 7.4 Oracle + +**PSD files contain Photoshop's own merged composite.** Running our compositor on a PSD's layers and diffing against its embedded composite gives a free, large conformance test suite. `testkit` automates this over a PSD corpus. + +--- + +## 8. `photocraft-psd`: standalone PSD/PSB crate + +**Scope:** faithful, format-level read and write of PSD (v1) and PSB (v2, 64-bit lengths). It has no dependency on our document model. + +```text +psd/src/ + header.rs color mode, depth, size + color_mode_data.rs + resources/ image resources (1005 resolution, 1036 thumbnail, 1039 ICC, 1060 XMP, 1058 EXIF, slices, guides, …) + layers/ layer records, channel image data (raw | RLE/PackBits | ZIP | ZIP+prediction), masks, blending ranges + tagged/ additional layer info blocks, one module per key: + luni lsct lyid lfx2/lrFX (effects) TySh (text) SoLd/PlLd/lnk2 (smart objects) vmsk/vsms/vogk (vector) + adjustment keys (curv levl hue2 blnc selc mixr grdm phfl brit thrs post nvrt expA vibA clrL blwh) + SoCo GdFl PtFl (fills), Patt, Txt2, … + descriptor.rs ActionDescriptor / OSType parser+writer (used by many tagged blocks) + engine_data.rs text "EngineData" (PostScript-like) parser+writer + image_data.rs merged composite + write.rs serializer (+ RLE/ZIP encoders) +``` + +- **Lossless round-trip.** Unknown or unsupported tagged blocks are kept as raw bytes and re-emitted unchanged. That way files keep features we don't model yet, like 3D or video. +- **Lazy decode.** The parser indexes channel data and decodes it only on request. This makes opening a 2 GB PSB fast and memory-bounded. +- **The merged composite on write is supplied by the caller** as an image buffer. `io` renders it with `compose`, so `psd` never needs a compositor. +- **Mapping lives in `io::psd`.** It converts `psd::File` into `doc::Document` and back, with fidelity levels: + 1. Flattened image. + 2. Raster layers, masks, blend/opacity/fill, visibility, names. + 3. Groups (including pass-through), clipping, blend-if, adjustment and fill layers. + 4. Layer effects (lfx2). + 5. Text layers: shaped with `text`; the original descriptors are kept for round-trip. + 6. Smart objects: embedded and linked. + 7. Vector masks and shape layers. +- **Testing:** + - Round-trip byte-stability for unmodified files. + - Open → save → open equality. + - Composite-oracle diffs (§7.4). + - `cargo-fuzz` on the parser. + - A corpus from MIT/BSD-licensed test sets (ag-psd, psd-tools), fetched by `xtask`, not committed. +- **Existing crates:** `psd` (read-only, limited) and the new `ag-psd` Rust port. We evaluate them in spike week 1, then either depend on one, fork it, or write our own. Given how central PSD is, writing our own behind a stable API is likely. + +--- + +## 9. Native file format (`photocraft-format`) + +- **Format:** a `.pcraft` file is a zip (store mode) or directory bundle. + - `manifest.json`: a versioned document tree with serde, including `format_version` and migrations. + - `tiles/.zst`: content-addressed tiles, zstd-compressed. + - `thumb.png` and `composite/` mips for quick look and previews. +- **Saving is incremental.** Tiles are content-addressed, so a save writes only new tiles plus the manifest, and garbage-collects unreferenced ones. +- **Autosave and crash recovery** reuse the same writer into a recovery directory, reading from a snapshot on a background thread. +- **PSD is the interchange format,** and `.pcraft` is the lossless native one. + +--- + +## 10. Algorithms (`algo`, `paint`, `text`, `vector`, `ml`) + +| Area | Contents (first pass) | GPU kernel? | +|---|---|---| +| Adjustments | levels, curves, brightness/contrast, exposure, vibrance, hue/sat, color balance, B&W, photo filter, channel mixer, gradient map, selective color, invert/posterize/threshold, 3D LUT | yes (LUT + small kernels) | +| Filters | gaussian/box/motion/radial/surface/lens blur, unsharp/smart sharpen, high pass, noise add/reduce/median/dust, distort family (twirl, wave, polar, spherize, displace), stylize, render (clouds, lens flare), pixelate | many | +| Selection | marquee/lasso geometry → mask, magic wand (flood fill tolerance), color range, quick select (graph cut / superpixels), feather, grow/shrink, refine edge | some | +| Inpaint/heal | spot heal, healing brush (Poisson), content-aware fill (PatchMatch + multiscale EM) | later | +| Warp | free transform (affine/perspective/warp mesh), liquify, puppet (ARAP), lens correction, resampling (Lanczos/bicubic) | yes | +| Paint (`paint`) | dab generation (hardness, roundness, angle, spacing, scatter), dynamics from pressure/tilt/velocity, smoothing (pulled string / lazy mouse), wet edges, airbrush, eraser, clone/heal sources, blend modes per stroke, `.abr` import | CPU first, GPU later | +| Text (`text`) | font DB trait (system fonts native, bundled fonts on web), parley layout, per-run styles, paragraph options, warp; renders to a raster cache | — | +| Vector (`vector`) | shapes, bezier paths, pen tool geometry, strokes (dash, align, caps), boolean ops → coverage via vello_cpu | — | +| ML (`ml`) | `InferenceBackend` trait; tasks: `segment_point/box` (SAM2), `matte_subject` (background removal), `depth`, `sky`; model registry with SHA-256 verification and on-demand download | backend-dependent | + +**Rules for every algorithm:** +- **Signature:** a pure function `fn(input: &SurfaceView, params: &P, region: Rect, cancel: &CancelToken) -> Surface`. +- **Params:** `P: Pod + JsonSchema + Serialize + Deserialize`. +- **Registration:** registered with the command registry by the engine, not by the algo crate itself, so `algo` stays UI- and engine-agnostic. +- **Tiles:** tile-aware, requesting an input halo of `radius` pixels. +- **Testing:** golden-image tests in `testkit`, plus benchmarks with criterion. + +--- + +## 11. Engine runtime model + +```text + UI thread ──dispatch()──► Engine thread (owns mutable docs, applies ops, publishes snapshots) + ▲ │ spawns + │ events (damage, progress)│ + │ ▼ + │ Job pool (rayon): filters, AI, export, autosave — read snapshots, return transactions + │ + Render (UI thread or dedicated): Viewport::render_gpu(snapshot) into toolkit texture +``` + +- **Pen input path for low latency:** platform coalesced samples → tool (on the engine thread) → dab rasterization into COW tiles → damage published → viewport re-composites only the damaged tiles. Target: the stroke appears within the next frame. +- **Cancellation:** every job takes a `CancelToken`, and the UI can cancel any job with progress. +- **Services injected into `Session`:** + - `FileSystem` (native fs / web File System Access / in-memory for tests) + - `Clipboard`, `FontSource`, `ModelStore` + - `Clock`, `Notifier` + +--- + +## 12. Automation and extensibility + +- **`automation`:** an MCP server built on `rmcp`. It exposes: + - `session.list`, `doc.open`, `doc.save`, `doc.export`, `doc.inspect` (layer tree as JSON), `doc.render_preview` (PNG). + - `command.list` and `command.run(id, params)`, both generated from the registry. + - Stdio for agent CLIs, and optionally loopback TCP with a token so it can attach to a running GUI. Photon does exactly this with Claude Code, Codex and Cursor, and it's worth copying the idea. +- **Actions:** recorded `Vec`, replayable in batch (File → Automate → Batch). +- **Scripting (later):** embed a scripting language over the same registry. Options are Rhai, or Lua via mlua (C). JS via QuickJS is possible if we want Photoshop-script familiarity. +- **Plugins (later):** a WASM component-model plugin API (wasmtime) for filters and panels. It is sandboxed and portable, and fits the command/schema model. Photoshop CEP/UXP/8BF compatibility (which Photon attempts) is explicitly out of scope for v1. + +--- + +## 13. Testing and quality + +| Layer | What | +|---|---| +| Unit | per crate; algorithms against small hand-computed fixtures | +| Golden images | `testkit::assert_image_eq(actual, "golden/name.png", tol)`; goldens regenerated by `xtask bless` | +| CPU/GPU parity | every GPU kernel vs its CPU reference, software adapter in CI | +| PSD | round-trip, composite oracle, fuzzing | +| Engine | command-level tests: open fixture → dispatch commands → assert snapshot/render; action replays | +| UI | headless egui tests (egui_kittest) for panels; kept thin because the UI is thin | +| Perf | criterion benches + budget checks in CI (composite 4k² 20 layers, brush dab throughput, open 100 MP) | +| Portability | CI matrix: macOS arm64, Windows x64, Linux x64, wasm32 build check | + +**Performance targets for v1:** +- Stroke-to-pixel within one frame at 120 Hz. +- Pan and zoom at display refresh on a 100 MP / 30-layer document. +- Open a 100 MP JPEG in under 1.5 s. +- 20k×20k 16-bit documents on a 16 GB machine. + +--- + +## 14. Cargo workspace sketch + +```toml +[workspace] +resolver = "3" +members = ["crates/*", "apps/*", "xtask"] + +[workspace.package] +edition = "2024" +license = "MIT OR Apache-2.0" # decision pending, see §16 +rust-version = "1.90" + +[workspace.dependencies] +# foundation +bytemuck = { version = "1", features = ["derive"] } +kurbo = "*" # pin at scaffold time +glam = "*" +smallvec = "1" +arc-swap = "1" +rayon = "1" +thiserror = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +schemars = "1" +blake3 = "1" +zstd = "*" # native; ruzstd for wasm decode +# color / codecs +moxcms = "*" +image = { version = "*", default-features = false } +jxl-oxide = "*" +# gpu +wgpu = "*" # one version shared by gpu, viewport, ui +# text / vector +parley = "*" +skrifa = "*" +vello_cpu = "*" +# ml +ort = { version = "*", optional = true } +# automation +rmcp = "*" + +[workspace.lints.rust] +unsafe_code = "deny" # allowed per-crate with justification (gpu, platform) + +[profile.release] +lto = "thin" +codegen-units = 1 + +[profile.dev.package."*"] +opt-level = 2 # image code is unusable at opt-level 0 +``` + +Versions get pinned when we scaffold, using the numbers in `rust-framework-options.md`. + +--- + +## 15. Phased roadmap + +Each phase ends with a demoable build and green CI on all targets. Phases 8–10 (colour/print pro, photographer/designer pro, frontier) and the per-feature phase assignments are in [`photoshop-parity.md`](photoshop-parity.md). + +| Phase | Scope | Done when | +|---|---|---| +| **0. Skeleton** (1–2 wk) | workspace, all crates stubbed, layering check, CI matrix incl. wasm, testkit, xtask | `cargo xtask ci` green on 3 OS + wasm | +| **1. Viewer** | raster doc, tiles, codecs (png/jpeg/tiff/webp), viewport pan/zoom/rotate on GPU + CPU fallback, `.pcraft` save/open, CLI `convert` | open a 100 MP JPEG, pan at refresh rate, save/reopen losslessly | +| **2. Layers** | layer tree, all 27 blend modes, opacity/fill, masks, groups, clipping; planner + CPU/GPU compositors + parity tests; history; **PSD read** fidelity 1–3 | composite-oracle suite ≥ 95% within tolerance on the corpus | +| **3. Paint and select** | platform tablet input, brush engine + presets, eraser, marquee/lasso/wand, move, free transform, crop | draw with pen pressure at <1 frame latency; selection-restricted painting | +| **4. Adjust and filter** | adjustment layers (first 10), first 20 filters with schema-generated dialogs + live preview; **PSD write**; actions recording | PSD open → edit → save opens correctly in Photoshop/Photopea | +| **5. Text, vector, styles** | text layers, shapes/pen/paths, layer effects (drop shadow, stroke, glows, overlays, bevel) | PSD text/shape/effects round-trip at fidelity 4–5 | +| **6. Smart features** | ML backend + SAM2 select subject/object, background removal, content-aware fill, healing, RAW develop | select subject + remove background on a portrait in <3 s | +| **7. Automation and web** | MCP server + CLI parity, batch; `photocraft-web` demo | an agent edits a PSD through MCP; the web demo opens a PSD, paints and filters | + +--- + +## 16. Open decisions + +1. **UI toolkit.** The leading option is egui on winit + wgpu. Bevy, Slint and Qt are the alternatives (see `rust-framework-options.md`). This plan works with any of them. +2. **License.** MIT/Apache-2.0 is maximally reusable. GPL-3.0 is the Krita/GIMP model and protects against proprietary forks. Note that some candidate deps are LGPL (rawler) or GPL-or-commercial (Slint), and MIT/Apache would constrain which of them we can use. +3. **Project name.** `photocraft` (the repo name) is assumed. +4. **PSD crate.** Build our own, or adopt or fork `ag-psd`/`psd`. Decide after the week-1 spike. +5. **Default working depth and blending.** 8-bit + gamma-space blending matches Photoshop. 16-bit/float + linear is the modern default. The proposal is to follow Photoshop by default and make the other a per-document option. +6. **Scope of Photoshop plugin compatibility.** Proposed: none in v1. diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 00000000..eea5488c --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,11 @@ +# Contributing + +- **Language:** Rust only. No JavaScript or TypeScript. On the web, `wasm-bindgen` generates a small loader; never hand-write JS. +- **Licence:** contributions are MIT OR Apache-2.0 (workspace default; see `plan/` for the pending licence decision). +- **Clean-room:** do not copy code, shaders, icons or other assets from proprietary software (Photon Studio, Photoshop). Match behaviour and look by observation and public specs. Only use assets with permissive licences, and record them next to the asset (e.g. `assets/fonts/OFL-*.txt`, `assets/icons/LICENSE-lucide.txt`). +- **Commands, not handlers:** new features are engine commands with tests, and the UI calls them. +- **Layering:** `cargo xtask layers` must pass. Register new crates in `xtask/src/layers.rs`. +- **Tests:** required for every change. Format code needs round-trip and malformed-input tests. +- **Style:** `cargo fmt`, and `cargo clippy -- -D warnings`. Match surrounding code. Comments explain *why*. +- **UI:** use `theme::Tokens` and `widgets::*`. Verify visually through the control channel before submitting, and attach before/after screenshots to PRs. +- **Commits:** small, focused, with a clear subject line. diff --git a/docs/control-protocol.md b/docs/control-protocol.md new file mode 100644 index 00000000..d119ec32 --- /dev/null +++ b/docs/control-protocol.md @@ -0,0 +1,50 @@ +# Control protocol + +The desktop app (`photocraft --control `) listens on `127.0.0.1:` (loopback only). Each request is one JSON line: + +```json +{"id": 1, "method": "ui.inspect", "params": {}} +``` + +Each reply is one JSON line with the same `id`: + +```json +{"id": 1, "ok": true, "result": { ... }} +{"id": 2, "ok": false, "error": "unknown tool `foo`"} +``` + +The transport is `apps/photocraft/src/control_server.rs`, and the handlers are in `crates/ui-egui/src/control.rs`. An MCP server will wrap this same protocol (milestone M11). + +## Methods + +- `engine.execute {command, params}`: run any engine or UI command by id +- `engine.commands`: list commands with enablement +- `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, menu tree, window size) +- `ui.set {tool?, panels?, zoom?, center?, dark?}`: change UI state +- `ui.menu.invoke {id}` / `ui.menu.list`: activate a menu item by id; list the menu tree +- `ui.dialog.open {kind, fields?}` / `ui.dialog.set {dialog, field, value}` / `ui.dialog.confirm {dialog}` / `ui.dialog.cancel {dialog}` +- `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows +- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?}], modifiers?}`: drive the active tool in document coordinates +- `ui.resize {width, height}`: resize the main window +- `ui.screenshot {path?, focus?}`: capture the main window (PNG). Raises the window first (default) + because occluded macOS windows stop rendering +- `ui.focus`: bring the main window to the front +- `app.open {path}` / `app.save {path}`: file I/O through the configured services +- `app.quit` + +## Engine commands + +`engine.execute` runs any command by id. `engine.commands` (or the engine command `command.list`) lists them all, with labels, menu paths, shortcuts, a parameter description, and whether each is currently enabled. Examples: + +| Command | Params | +|---|---| +| `file.new` | `{"width":1920,"height":1080,"mode":"rgb","depth":8,"background":"white"}` | +| `layer.new.layer` | `{"name":"Ink"}` | +| `layer.setProps` | `{"layer":id?,"name":…,"visible":…,"opacity":0..1,"blend":"Multiply"}` | +| `layer.newAdjustmentLayer.hueSaturation` | `{"hue":30,"saturation":10}` | +| `paint.stroke` | `{"points":[[x,y,pressure],…],"size":20,"color":"#ff0000"}` | +| `select.rect` | `{"x":0,"y":0,"width":100,"height":50,"mode":"add","ellipse":false}` | +| `document.inspect` | `{}`: layer tree, history, selection bounds | +| `document.pixel` | `{"x":10,"y":10}`: composite RGBA | + +UI-level commands (`file.open`, `file.save`, `view.zoomIn`, `window.theme.pro`, `edit.search`, …) are also accepted by `engine.execute` and `ui.menu.invoke`. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 00000000..d427f80c --- /dev/null +++ b/docs/development.md @@ -0,0 +1,92 @@ +# Development guide + +## Prerequisites + +- Rust stable (1.90+). Add the web target with `rustup target add wasm32-unknown-unknown`. +- macOS, Windows or Linux. Linux needs `libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev libgtk-3-dev`. + +## Build and run + +```sh +cargo run --release -p photocraft -- path/to/image.psd # desktop app +cargo run --release -p photocraft -- --control 7878 img.jpg # with the JSON control server +cargo test --workspace # everything +cargo xtask ci # fmt + clippy + tests + layers + wasm +cargo xtask stats # tests and lines per crate +``` + +Image code is slow at `opt-level 0`, so the workspace profile builds dependencies at `opt-level 2`. Use `--release` for anything interactive. + +## Environment variables + +| Variable | Effect | +|---|---| +| `PHOTOCRAFT_CONTROL_PORT` | Same as `--control ` | +| `PHOTOCRAFT_CPU_CANVAS=1` | Force the CPU canvas path instead of the wgpu shader canvas | +| `PHOTOCRAFT_GPU_TILE=2048` | Force GPU canvas tiling (tests tile seams) | +| `PHOTOCRAFT_THEME_FILE=tokens.json` | **Debug builds only:** live design-token overrides, re-read on change | + +### Live design tokens + +```json +{ "card": "#323232", "tab_strip": "#262626", "accent": "#378ef0", "radius": 4 } +``` + +Keys are the field names of `theme::Tokens` (`crates/ui-egui/src/theme.rs`). Edit and save the file while the app runs to see changes immediately, with no recompile. This is compiled out of release builds. + +## Driving the app programmatically + +Start the app with `--control 7878`. It then accepts JSON lines on `127.0.0.1:7878`: + +```sh +printf '%s\n' '{"id":1,"method":"engine.execute","params":{"command":"layer.newAdjustmentLayer.hueSaturation","params":{"hue":30}}}' \ + '{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}' | nc 127.0.0.1 7878 +``` + +See `docs/control-protocol.md` for every method. Tips: + +- **macOS does not render occluded windows.** `ui.screenshot` raises the window first (`focus: true` by default). Use `ui.focus` before other visual checks. +- **Capture only our own window.** For native captures (to see the real title bar and traffic lights), capture by window id (`screencapture -l `), never a screen region, which can grab other apps. +- **Pointer gestures:** `ui.pointer` takes events in *document* coordinates and runs them through the same tool state machine as the mouse. + +## Testing strategy + +- **Unit and property tests** in every crate. proptest is used for tile COW, regions and codecs. +- **Format crates:** + - synthetic generators (`psd::testgen`) + - byte-exact round trips + - malformed-input sweeps (truncate at every offset) + - fuzz targets (`crates/*/fuzz`) +- **Real-file corpus** in `corpus/` (gitignored). `corpus/psd` holds MIT-licensed test PSDs, listed with their sources in `corpus/psd/SOURCES.md`, and `cargo xtask corpus --download` fetches PngSuite. Tests skip silently when a corpus is absent. +- **Composite oracle:** a PSD's embedded merged image is compared with our compositor's output. The pass rate is tracked in the roadmap. +- **UI:** unit tests for widgets and state, plus screenshot checks through the control channel. + +## Performance notes + +- The canvas is presented by a custom WGSL shader (`ui-egui/src/gpu_canvas.rs`): mip-mapped/nearest sampling, procedural checkerboard, pixel grid, tiling. Brush strokes upload only their damage rect. +- **Compositing is still CPU** (`photocraft-compose`). Live adjustment previews on large documents use a downsampled proxy (`ui-egui/src/proxy.rs`). The GPU compositor is milestone M5. +- `ui.inspect` returns `perf` timings (UI ms per frame, composite ms, upload ms). +- Never scan full surfaces per frame. Cache per document revision (`PhotocraftApp::cached_bounds`). An uncached `content_bounds()` on a 36 MP layer once cost 77 ms per frame. + +## Web build + +`apps/photocraft-web` runs the same `PhotocraftApp` in the browser through eframe's web runner. The renderer is wgpu: WebGPU where the browser has it, WebGL2 otherwise. It is Rust only. The only JavaScript is the glue that wasm-bindgen generates. + +```sh +brew install trunk # or: cargo install trunk --locked +cd apps/photocraft-web +trunk build --release # writes ../../dist/web (index.html, .js glue, .wasm) +trunk serve --release # dev server on http://127.0.0.1:8765 +``` + +Any static file server works for `dist/web`, for example `python3 -m http.server 8765` run inside that directory. Trunk downloads the matching `wasm-bindgen` and `wasm-opt` itself. The release `.wasm` is about 12.7 MB, or 5.0 MB gzipped. Serve it with compression. + +URL flags: `?webgl` forces the WebGL2 backend, and `?cpu` forces the CPU canvas path. + +How the web shell (`apps/photocraft-web/src/web.rs`) differs from desktop: + +- **Open** uses `rfd::AsyncFileDialog`. The bytes arrive asynchronously in `Services::inbox`, which the app drains every frame. +- **Save / Save As / Export** trigger a browser download of the encoded bytes. The shell does this with a Blob, an object URL and a temporary ``, all created from Rust. There is no save dialog, so the suggested name becomes the download name. +- **Drag-and-drop:** `WebShell` takes the frame's `dropped_files` before the app sees them. It reads each file with `DroppedFile::bytes_async` and pushes the bytes into the inbox. +- **No control server:** browsers can't listen on TCP. To automate the web build, drive headless Chrome with `--remote-debugging-port`. `Page.setInterceptFileChooserDialog` plus `DOM.setFileInputFiles` covers Open, `Input.dispatchDragEvent` with `files` covers drops, and `Browser.setDownloadBehavior` captures downloads. +- Headless Chrome on macOS (`--headless=new --enable-unsafe-webgpu`) gets a real WebGPU adapter. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 00000000..c0ff9628 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,39 @@ +# Roadmap + +Status legend: ✅ done · 🟡 in progress · ⬜ not started. Updated 2026-09-30. + +| M | Status | Notes | +|---|---|---| +| M0 Skeleton | ✅ | workspace, xtask (layers/wasm/ci/stats/corpus), CI workflow | +| M1 Foundation | ✅ | geom, color (27 blend modes), raster (COW tiles, any depth), doc, ops | +| M2 PSD v1 | ✅ | photocraft-psd: 134/135 real files byte-exact round trip | +| M3 Viewer app | 🟡 | egui shell with Pro/Studio/Classic themes, codecs (13 formats), io, native app; web build pending | +| M4 Native format + engine | 🟡 | engine + command registry done; `.pcraft` bundle and CLI pending | +| M5 GPU compositor | 🟡 | GPU canvas presentation (WGSL) done; GPU compositing pending | +| M6 Paint + select | 🟡 | brush v1, eraser, marquee, move done; tablet input, lasso/wand, transform pending | +| M7 Adjust + filters | 🟡 | 14 adjustment layers; filters pending | +| M8 PSD v2 | 🟡 | adjustments, fills, raw-preserved text/smart/shape; composite oracle 82/135 | +| M9 Text, vector, styles | 🟡 | layer effects in progress | +| M10–M12 | ⬜ | smart features, automation/MCP, pro parity | + +## Milestone definitions + +Each milestone has a **definition of done (DoD)** and must leave `main` green on all Tier-1 platforms plus a wasm build. + +| M | Name | Scope (key items) | DoD / acceptance | +|---|---|---|---| +| **M0** | Skeleton | Workspace, all crate stubs, lints, `xtask` (layers check, ci), CI matrix, testkit | `cargo test --workspace` green; `cargo check --target wasm32-unknown-unknown -p photocraft-engine -p photocraft-ui-egui` green; layering check passes | +| **M1** | Foundation | geom, color (formats, blend math for all 27 modes, sRGB/linear), raster (sparse COW tiles, U8/U16/F32), doc (full type model incl. CMYK/Lab/adjust/smart), ops (history) | Property tests (proptest) on tile COW and history; blend-mode reference tests against published formulas | +| **M2** | PSD v1 | `photocraft-psd`: header, resources, layer records, channel data (raw/RLE/ZIP/ZIP+pred), masks, groups (lsct), unicode names, unknown-block passthrough, merged image, PSB; writer | Round-trip byte-stability tests; synthetic PSD generator tests; fuzz target; ≥150 unit tests | +| **M3** | Viewer app | codecs (png/jpeg/tiff/webp/gif/bmp/tga/pnm/qoi/exr/hdr, all read+write), CPU compositor, egui shell (menu from registry, canvas pan/zoom, layers panel, history), open/save, native + web build | Opens PNG/JPEG/PSD, shows layers, toggles visibility, undo/redo, saves PNG/PSD; web build loads a file from the browser | +| **M4** | Native format + engine | `.pcraft` bundle, command registry with schemas, jobs/cancellation, snapshots via arc-swap, CLI (`convert`, `run`, `inspect`) | CLI parity tests: every GUI command also runs headless | +| **M5** | GPU compositor | wgpu planner backend, tile residency, parity tests vs CPU (all blend modes), viewport on GPU, mips | GPU vs CPU ≤1/255; pan/zoom 60 fps on a 100 MP / 20-layer document | +| **M6** | Paint + select | platform input (pen: macOS/Windows/Linux/web), brush engine v1, eraser, marquee/lasso/wand, move, free transform, crop | Brush latency <1 frame; selection-restricted paint tests | +| **M7** | Adjust + filters | 16 adjustment layers, first 30 filters, schema-generated dialogs + live preview, actions record/replay | Golden tests at 8/16/32f; action replay determinism | +| **M8** | PSD v2 | adjustment layers, fill layers, lfx2 effects (descriptor parser), text (EngineData) preserve+render, smart objects, vector masks | Composite-oracle pass rate ≥90% on corpus | +| **M9** | Text, vector, styles | parley text layers, shapes/pen/paths, all 10 layer effects on GPU | Visual goldens; PSD text round-trip | +| **M10** | Smart features | `ml` (ort native / ort-web on the web), Select Subject/Object/Sky, Remove BG, Remove tool, content-aware fill, healing, AI denoise, RAW develop | Quality benchmarks on a public dataset; timing budgets | +| **M11** | Automation + formats | MCP server, batch, scripting, remaining formats (JP2, DICOM, DPX…), C2PA | An agent completes 10 scripted edit tasks via MCP | +| **M12** | Pro parity | CMYK/Lab UI, print, HDR display, photomerge/HDR merge, timeline, layer comps, artboards, symmetry, neural filters | `xtask parity` ≥ 90% of Photoshop menu checklist | + +**Today's target: M0 + M1 + M2 + an M3 vertical slice.** diff --git a/docs/ui-design.md b/docs/ui-design.md new file mode 100644 index 00000000..878db7f7 --- /dev/null +++ b/docs/ui-design.md @@ -0,0 +1,25 @@ +# UI design system + +## Themes + +| Theme | Intent | +|---|---| +| **Pro** (default) | Photoshop-style Spectrum dark: flat charcoal panels (#323232), dark tab strips, Spectrum blue accent (#378ef0), pill buttons, checkboxes, compact 12 px type | +| Studio | Photon-style: near-black, rounded cards, pill tabs, violet accent, toggles | +| Studio Light | Studio on light surfaces | +| Classic | Windows-2000 bevels, square corners, navy selection | + +Switch themes with the sun icon, Window → Theme, or `ui.set {"theme":"classic"}` over the control channel. + +## Rules + +- **Colours and radii come from tokens.** Read them with `Tokens::get(ctx)`; never hard-code a colour in a widget. +- **Shared widgets live in `widgets.rs`:** `card` (panel group; Pro renders a Photoshop tab strip), `value_field`, `slider`/`slider_row`, `toggle`/`checkbox`, `primary_button`/`secondary_button`, `dropdown` (with a chevron icon), `hairline`/`vline`. +- **Icons:** Lucide SVGs in `assets/icons` (ISC licence), embedded via `icon_data.rs`. Regenerate that file when you add icons. +- **Fonts:** Inter (UI) and JetBrains Mono (numbers), both OFL. Named families `medium` and `semibold` are available via `theme::medium()` and `theme::semibold()`. +- **Photoshop layout grammar (Pro):** + - Essentials dock order: Color | Swatches, then Properties | Adjustments, then Layers | Channels | Paths (Layers fills the remaining height). + - Options-bar labels end with a colon ("Size:"). + - Document tabs read "name @ 12.5% (RGB/8)". + - Toolbar tool groups carry a corner triangle. +- **Verify every visual change** with `ui.screenshot`, at several window sizes and in every theme. diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 00000000..6d935ae8 --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "xtask" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] +serde_json = { workspace = true } diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs new file mode 100644 index 00000000..1ca7c929 --- /dev/null +++ b/xtask/src/layers.rs @@ -0,0 +1,498 @@ +//! Dependency layering rules (plan/architecture.md §3). +//! +//! The rule engine works on a small, metadata-independent model so it can be +//! unit-tested; `from_metadata` builds that model from `cargo metadata`. + +use serde_json::Value; + +/// Where a workspace crate sits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Class { + /// Regular layered crate. + Layer(u8), + /// Layer 0, and additionally may depend on no workspace crate at all. + Standalone, + /// Test tooling: may depend on anything up to L5 (it sits at L6 for rule + /// purposes); other crates may use it only as a dev-dependency. + Testkit, + /// Binaries and build tooling: exempt from the rules. + Exempt, +} + +impl Class { + fn layer(self) -> Option { + match self { + Class::Layer(l) => Some(l), + Class::Standalone => Some(0), + Class::Testkit => Some(6), + Class::Exempt => None, + } + } +} + +/// The layering table. Names are package names without the `photocraft-` +/// prefix. +pub const TABLE: &[(&str, Class)] = &[ + ("geom", Class::Layer(0)), + ("color", Class::Layer(0)), + ("raster", Class::Layer(0)), + ("psd", Class::Standalone), + ("codecs", Class::Standalone), + ("adobe-assets", Class::Standalone), + ("doc", Class::Layer(1)), + ("ops", Class::Layer(2)), + ("paint", Class::Layer(2)), + ("algo", Class::Layer(2)), + ("text", Class::Layer(2)), + ("vector", Class::Layer(2)), + ("compose", Class::Layer(3)), + ("gpu", Class::Layer(3)), + ("format", Class::Layer(3)), + ("raw", Class::Layer(3)), + ("io", Class::Layer(4)), + ("tools", Class::Layer(4)), + ("viewport", Class::Layer(4)), + ("ml", Class::Layer(4)), + ("engine", Class::Layer(5)), + ("ui-egui", Class::Layer(6)), + ("automation", Class::Layer(6)), + ("platform", Class::Layer(6)), + ("testkit", Class::Testkit), + // L7 apps and tooling + ("photocraft", Class::Exempt), + ("cli", Class::Exempt), + ("web", Class::Exempt), + ("xtask", Class::Exempt), +]; + +/// Explicit orderings *within* a layer (earlier may be used by later). +/// The L0 foundation is a small chain: `raster` builds on `color` and +/// `geom`, which the §3 diagram draws on one line. +pub const INTRA_LAYER_ORDER: &[&[&str]] = &[&["geom", "color", "raster"]]; + +fn intra_layer_allowed(from: &str, to: &str) -> bool { + let (from, to) = (short_name(from), short_name(to)); + INTRA_LAYER_ORDER.iter().any(|chain| { + match ( + chain.iter().position(|n| *n == from), + chain.iter().position(|n| *n == to), + ) { + (Some(f), Some(t)) => t < f, + _ => false, + } + }) +} + +/// External crates that constitute a UI toolkit / windowing dependency. +/// Entries ending in `*` are prefixes. +pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy*"]; + +/// First layer allowed to use UI crates. +pub const UI_MIN_LAYER: u8 = 6; + +pub fn short_name(pkg: &str) -> &str { + pkg.strip_prefix("photocraft-").unwrap_or(pkg) +} + +pub fn classify(pkg: &str) -> Option { + let s = short_name(pkg); + TABLE.iter().find(|(n, _)| *n == s).map(|(_, c)| *c) +} + +fn is_ui_crate(name: &str) -> bool { + UI_CRATES.iter().any(|p| match p.strip_suffix('*') { + Some(prefix) => name.starts_with(prefix), + None => name == *p, + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DepKind { + Normal, + Dev, + Build, +} + +#[derive(Debug, Clone)] +pub struct Dep { + pub name: String, + pub kind: DepKind, + /// `true` if the dependency is a workspace member. + pub workspace: bool, +} + +#[derive(Debug, Clone)] +pub struct Crate { + pub name: String, + pub deps: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Violation { + Unregistered { + krate: String, + }, + Upward { + krate: String, + dep: String, + from: u8, + to: u8, + kind: DepKind, + }, + StandaloneHasWorkspaceDep { + krate: String, + dep: String, + }, + TestkitAsNormalDep { + krate: String, + }, + UiBelowL6 { + krate: String, + dep: String, + layer: u8, + }, +} + +impl std::fmt::Display for Violation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Violation::Unregistered { krate } => write!( + f, + "{krate}: unknown workspace crate; register it in xtask/src/layers.rs TABLE (see plan/architecture.md §3)" + ), + Violation::Upward { + krate, + dep, + from, + to, + kind, + } => write!( + f, + "{krate} (L{from}) -> {dep} (L{to}) [{kind:?}]: may only depend on strictly lower layers" + ), + Violation::StandaloneHasWorkspaceDep { krate, dep } => { + write!( + f, + "{krate}: standalone crate must not depend on workspace crate {dep}" + ) + } + Violation::TestkitAsNormalDep { krate } => { + write!( + f, + "{krate}: photocraft-testkit may only be a dev-dependency" + ) + } + Violation::UiBelowL6 { krate, dep, layer } => { + write!( + f, + "{krate} (L{layer}) depends on UI crate `{dep}`; UI toolkits are only allowed in L6+" + ) + } + } + } +} + +/// Check all rules. Returns violations sorted for stable output. +pub fn check(crates: &[Crate]) -> Vec { + let mut out = Vec::new(); + for c in crates { + let Some(class) = classify(&c.name) else { + out.push(Violation::Unregistered { + krate: c.name.clone(), + }); + continue; + }; + if class == Class::Exempt { + continue; + } + let layer = class.layer().unwrap_or(0); + for d in &c.deps { + // Self dev-dependencies (e.g. to enable features in tests) are fine. + if d.name == c.name { + continue; + } + if d.workspace { + if class == Class::Standalone { + out.push(Violation::StandaloneHasWorkspaceDep { + krate: c.name.clone(), + dep: d.name.clone(), + }); + continue; + } + match classify(&d.name) { + // Unregistered deps are reported on their own entry. + None => {} + Some(Class::Testkit) => { + if d.kind != DepKind::Dev { + out.push(Violation::TestkitAsNormalDep { + krate: c.name.clone(), + }); + } + } + Some(dc) => { + let to = dc.layer().unwrap_or(u8::MAX); + if to >= layer && !(to == layer && intra_layer_allowed(&c.name, &d.name)) { + out.push(Violation::Upward { + krate: c.name.clone(), + dep: d.name.clone(), + from: layer, + to: if to == u8::MAX { 7 } else { to }, + kind: d.kind, + }); + } + } + } + } else if layer < UI_MIN_LAYER && is_ui_crate(&d.name) { + out.push(Violation::UiBelowL6 { + krate: c.name.clone(), + dep: d.name.clone(), + layer, + }); + } + } + } + out.sort_by_key(|v| v.to_string()); + out.dedup(); + out +} + +/// Build the model from `cargo metadata --format-version 1 --no-deps`. +pub fn from_metadata(meta: &Value) -> Result, String> { + let pkgs = meta["packages"] + .as_array() + .ok_or("metadata: no packages array")?; + let members: Vec<&str> = pkgs.iter().filter_map(|p| p["name"].as_str()).collect(); + let mut out = Vec::new(); + for p in pkgs { + let name = p["name"].as_str().ok_or("package without name")?.to_owned(); + let mut deps = Vec::new(); + for d in p["dependencies"].as_array().into_iter().flatten() { + let dname = d["name"].as_str().unwrap_or_default().to_owned(); + let kind = match d["kind"].as_str() { + Some("dev") => DepKind::Dev, + Some("build") => DepKind::Build, + _ => DepKind::Normal, + }; + let workspace = members.contains(&dname.as_str()) || d["path"].is_string(); + deps.push(Dep { + name: dname, + kind, + workspace, + }); + } + out.push(Crate { name, deps }); + } + out.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(out) +} + +pub fn describe(class: Option) -> String { + match class { + Some(Class::Layer(l)) => format!("L{l}"), + Some(Class::Standalone) => "L0 standalone".into(), + Some(Class::Testkit) => "testkit".into(), + Some(Class::Exempt) => "exempt".into(), + None => "UNREGISTERED".into(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn c(name: &str, deps: &[(&str, DepKind, bool)]) -> Crate { + Crate { + name: name.into(), + deps: deps + .iter() + .map(|(n, k, w)| Dep { + name: (*n).into(), + kind: *k, + workspace: *w, + }) + .collect(), + } + } + use DepKind::*; + + #[test] + fn clean_downward_graph_passes() { + let g = [ + c("photocraft-geom", &[("kurbo", Normal, false)]), + c("photocraft-doc", &[("photocraft-geom", Normal, true)]), + c( + "photocraft-engine", + &[ + ("photocraft-doc", Normal, true), + ("photocraft-testkit", Dev, true), + ], + ), + c( + "photocraft-ui-egui", + &[("photocraft-engine", Normal, true), ("egui", Normal, false)], + ), + c("photocraft-cli", &[("photocraft-ui-egui", Normal, true)]), + ]; + assert!(check(&g).is_empty(), "{:?}", check(&g)); + } + + #[test] + fn upward_dependency_flagged() { + let v = check(&[c("photocraft-doc", &[("photocraft-engine", Normal, true)])]); + assert!(matches!(v[..], [Violation::Upward { from: 1, to: 5, .. }])); + } + + #[test] + fn sideways_dependency_flagged() { + let v = check(&[c("photocraft-ops", &[("photocraft-algo", Normal, true)])]); + assert!(matches!(v[..], [Violation::Upward { from: 2, to: 2, .. }])); + } + + #[test] + fn l0_foundation_chain_allowed_one_way() { + assert!( + check(&[c( + "photocraft-raster", + &[ + ("photocraft-color", Normal, true), + ("photocraft-geom", Normal, true) + ] + )]) + .is_empty() + ); + assert!(check(&[c("photocraft-color", &[("photocraft-geom", Normal, true)])]).is_empty()); + let v = check(&[c("photocraft-geom", &[("photocraft-raster", Normal, true)])]); + assert!(matches!(v[..], [Violation::Upward { from: 0, to: 0, .. }])); + } + + #[test] + fn self_dev_dependency_ignored() { + assert!(check(&[c("photocraft-psd", &[("photocraft-psd", Dev, true)])]).is_empty()); + } + + #[test] + fn upward_dev_dependency_flagged() { + let v = check(&[c("photocraft-geom", &[("photocraft-doc", Dev, true)])]); + assert!(matches!(v[..], [Violation::Upward { kind: Dev, .. }])); + } + + #[test] + fn standalone_crates_have_no_workspace_deps() { + for s in [ + "photocraft-psd", + "photocraft-codecs", + "photocraft-adobe-assets", + ] { + let v = check(&[c(s, &[("photocraft-geom", Normal, true)])]); + assert!( + matches!(v[..], [Violation::StandaloneHasWorkspaceDep { .. }]), + "{s}" + ); + assert!(check(&[c(s, &[("image", Normal, false)])]).is_empty()); + } + } + + #[test] + fn standalone_is_usable_from_higher_layers() { + assert!(check(&[c("photocraft-io", &[("photocraft-psd", Normal, true)])]).is_empty()); + let v = check(&[c("photocraft-geom", &[("photocraft-codecs", Normal, true)])]); + assert!(matches!(v[..], [Violation::Upward { from: 0, to: 0, .. }])); + } + + #[test] + fn ui_crates_below_l6_flagged() { + for dep in [ + "egui", + "eframe", + "winit", + "egui_kittest", + "rfd", + "bevy_ecs", + "bevy", + ] { + let v = check(&[c("photocraft-engine", &[(dep, Normal, false)])]); + assert!( + matches!(v[..], [Violation::UiBelowL6 { layer: 5, .. }]), + "{dep}" + ); + } + assert!( + check(&[c( + "photocraft-engine", + &[("egui_extras_not", Normal, false)] + )]) + .is_empty() + ); + assert!(check(&[c("photocraft-platform", &[("winit", Normal, false)])]).is_empty()); + } + + #[test] + fn unregistered_crate_is_error() { + let v = check(&[c("photocraft-mystery", &[])]); + assert!( + matches!(&v[..], [Violation::Unregistered { krate }] if krate == "photocraft-mystery") + ); + assert!(v[0].to_string().contains("register")); + } + + #[test] + fn testkit_only_as_dev_dependency() { + let v = check(&[c( + "photocraft-raster", + &[("photocraft-testkit", Normal, true)], + )]); + assert!(matches!(v[..], [Violation::TestkitAsNormalDep { .. }])); + assert!(check(&[c("photocraft-raster", &[("photocraft-testkit", Dev, true)])]).is_empty()); + // testkit itself may use anything up to L5 but not L6 crates. + assert!( + check(&[c( + "photocraft-testkit", + &[("photocraft-engine", Normal, true)] + )]) + .is_empty() + ); + assert!( + !check(&[c( + "photocraft-testkit", + &[("photocraft-ui-egui", Normal, true)] + )]) + .is_empty() + ); + } + + #[test] + fn apps_and_xtask_exempt() { + for app in ["photocraft", "photocraft-cli", "photocraft-web", "xtask"] { + assert!( + check(&[c( + app, + &[ + ("egui", Normal, false), + ("photocraft-ui-egui", Normal, true) + ] + )]) + .is_empty() + ); + } + } + + #[test] + fn metadata_parsing() { + let meta: Value = serde_json::from_str( + r#"{"packages":[ + {"name":"photocraft-doc","dependencies":[ + {"name":"photocraft-geom","kind":null,"path":"/x/crates/geom"}, + {"name":"serde","kind":null}, + {"name":"proptest","kind":"dev"}]}, + {"name":"photocraft-geom","dependencies":[]} + ]}"#, + ) + .unwrap(); + let g = from_metadata(&meta).unwrap(); + assert_eq!(g.len(), 2); + let doc = g.iter().find(|c| c.name == "photocraft-doc").unwrap(); + assert!(doc.deps[0].workspace && !doc.deps[1].workspace); + assert_eq!(doc.deps[2].kind, Dev); + assert!(check(&g).is_empty()); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 00000000..4d841fc2 --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,272 @@ +//! Workspace tooling: `cargo xtask `. +//! +//! Pure Rust (std + serde_json). External tools (`cargo`, `curl`, `tar`) are +//! invoked through `std::process::Command`. + +mod layers; +mod stats; + +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode}; + +const USAGE: &str = "\ +usage: cargo xtask + +commands: + layers enforce the crate dependency layering (plan/architecture.md §3) + wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates + ci fmt --check, clippy -D warnings, test, layers, wasm (stops at first failure) + corpus [--download] + show where test corpora live; --download fetches PngSuite into corpus/pngsuite + stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) +"; + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect(); + let result = match args.first().map(String::as_str) { + Some("layers") => cmd_layers(), + Some("wasm") => cmd_wasm(), + Some("ci") => cmd_ci(), + Some("corpus") => cmd_corpus(rest.contains(&"--download")), + Some("stats") => stats::run(&root(), rest.contains(&"--exact")), + Some("-h" | "--help" | "help") | None => { + print!("{USAGE}"); + Ok(()) + } + Some(other) => Err(format!("unknown command `{other}`\n\n{USAGE}")), + }; + match result { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("error: {e}"); + ExitCode::FAILURE + } + } +} + +/// Workspace root (parent of the xtask crate). +pub fn root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("xtask has a parent dir") + .to_path_buf() +} + +pub fn cargo() -> Command { + let mut c = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into())); + c.current_dir(root()); + c +} + +fn run(mut cmd: Command, what: &str) -> Result<(), String> { + eprintln!("$ {what}"); + let status = cmd + .status() + .map_err(|e| format!("{what}: failed to spawn: {e}"))?; + if status.success() { + Ok(()) + } else { + Err(format!("{what}: exited with {status}")) + } +} + +pub fn metadata() -> Result { + let out = cargo() + .args(["metadata", "--format-version", "1", "--no-deps"]) + .output() + .map_err(|e| format!("cargo metadata: {e}"))?; + if !out.status.success() { + return Err(format!( + "cargo metadata failed:\n{}", + String::from_utf8_lossy(&out.stderr) + )); + } + serde_json::from_slice(&out.stdout).map_err(|e| format!("cargo metadata: bad JSON: {e}")) +} + +fn cmd_layers() -> Result<(), String> { + let crates = layers::from_metadata(&metadata()?)?; + println!("Dependency layering (plan/architecture.md §3)\n"); + println!("{:<28} {:<14} workspace deps", "crate", "layer"); + for c in &crates { + let ws: Vec = c + .deps + .iter() + .filter(|d| d.workspace) + .map(|d| { + let k = match d.kind { + layers::DepKind::Normal => "", + layers::DepKind::Dev => " (dev)", + layers::DepKind::Build => " (build)", + }; + format!("{}{k}", layers::short_name(&d.name)) + }) + .collect(); + println!( + "{:<28} {:<14} {}", + c.name, + layers::describe(layers::classify(&c.name)), + ws.join(", ") + ); + } + let violations = layers::check(&crates); + println!(); + if violations.is_empty() { + println!("OK: {} crates, no layering violations.", crates.len()); + Ok(()) + } else { + println!("{} violation(s):", violations.len()); + for v in &violations { + println!(" - {v}"); + } + Err(format!("{} layering violation(s)", violations.len())) + } +} + +/// Workspace packages that must build for wasm32: all L0–L5 crates plus +/// the egui shell. +fn wasm_set() -> Result, String> { + let crates = layers::from_metadata(&metadata()?)?; + Ok(crates + .into_iter() + .filter(|c| match layers::classify(&c.name) { + Some(layers::Class::Layer(l)) => l <= 5 || layers::short_name(&c.name) == "ui-egui", + Some(layers::Class::Standalone) => true, + _ => false, + }) + .map(|c| c.name) + .collect()) +} + +fn cmd_wasm() -> Result<(), String> { + let set = wasm_set()?; + let mut results = Vec::new(); + for pkg in &set { + let mut c = cargo(); + c.args(["check", "--target", "wasm32-unknown-unknown", "-p", pkg]); + let ok = run( + c, + &format!("cargo check --target wasm32-unknown-unknown -p {pkg}"), + ) + .is_ok(); + results.push((pkg.clone(), ok)); + } + println!("\nwasm32-unknown-unknown check:"); + for (p, ok) in &results { + println!(" {:<6} {p}", if *ok { "ok" } else { "FAIL" }); + } + let failed = results.iter().filter(|r| !r.1).count(); + if failed == 0 { + Ok(()) + } else { + Err(format!("{failed} crate(s) failed the wasm check")) + } +} + +fn cmd_ci() -> Result<(), String> { + type Step = (&'static str, Box Result<(), String>>); + let steps: Vec = vec![ + ( + "fmt", + Box::new(|| { + let mut c = cargo(); + c.args(["fmt", "--all", "--", "--check"]); + run(c, "cargo fmt --all -- --check") + }), + ), + ( + "clippy", + Box::new(|| { + let mut c = cargo(); + c.args([ + "clippy", + "--workspace", + "--all-targets", + "--", + "-D", + "warnings", + ]); + run(c, "cargo clippy --workspace --all-targets -- -D warnings") + }), + ), + ( + "test", + Box::new(|| { + let mut c = cargo(); + c.args(["test", "--workspace"]); + run(c, "cargo test --workspace") + }), + ), + ("layers", Box::new(cmd_layers)), + ("wasm", Box::new(cmd_wasm)), + ]; + let mut done = Vec::new(); + for (name, f) in &steps { + eprintln!("\n=== ci: {name} ==="); + if let Err(e) = f() { + println!("\nCI summary:"); + for d in &done { + println!(" ok {d}"); + } + println!(" FAIL {name}: {e}"); + for (n, _) in steps.iter().skip(done.len() + 1) { + println!(" skip {n}"); + } + return Err(format!("ci failed at `{name}`")); + } + done.push(*name); + } + println!( + "\nCI summary: all {} steps passed ({})", + done.len(), + done.join(", ") + ); + Ok(()) +} + +const PNGSUITE_URL: &str = "http://www.schaik.com/pngsuite/PngSuite-2017jul19.tgz"; + +fn cmd_corpus(download: bool) -> Result<(), String> { + let corpus = root().join("corpus"); + println!( + "Test corpora live under {} (git-ignored, never committed). +Tests that use a corpus skip cleanly when it is absent. + + corpus/pngsuite/ PngSuite (public domain) — photocraft-codecs compares every file + against the `image` crate. Fetch: cargo xtask corpus --download + corpus/psd/ PSD samples from MIT/BSD projects (ag-psd, psd-tools test data). + Copy files in manually; licences must be MIT/BSD/CC0. + corpus/tiff/ libtiff pics (optional) + corpus/exr/ OpenEXR sample images (optional) + corpus/raw/ raw.pixls.us samples, CC0 (optional) +", + corpus.display() + ); + if !download { + return Ok(()); + } + let dest = corpus.join("pngsuite"); + std::fs::create_dir_all(&dest).map_err(|e| format!("create {}: {e}", dest.display()))?; + let tgz = corpus.join("PngSuite-2017jul19.tgz"); + let mut curl = Command::new("curl"); + curl.args(["-fsSL", "-o"]).arg(&tgz).arg(PNGSUITE_URL); + run(curl, &format!("curl {PNGSUITE_URL}"))?; + let mut tar = Command::new("tar"); + tar.arg("-xzf").arg(&tgz).arg("-C").arg(&dest); + run(tar, "tar -xzf PngSuite-2017jul19.tgz")?; + let _ = std::fs::remove_file(&tgz); + let n = std::fs::read_dir(&dest) + .map(|d| { + d.flatten() + .filter(|e| e.path().extension().is_some_and(|x| x == "png")) + .count() + }) + .unwrap_or(0); + println!("PngSuite: {n} PNG files in {}", dest.display()); + if n == 0 { + Err("no PNG files extracted".into()) + } else { + Ok(()) + } +} diff --git a/xtask/src/stats.rs b/xtask/src/stats.rs new file mode 100644 index 00000000..e9c87b95 --- /dev/null +++ b/xtask/src/stats.rs @@ -0,0 +1,149 @@ +//! `cargo xtask stats`: tests and lines of Rust per crate. + +use std::path::Path; + +#[derive(Default, Clone, Copy)] +struct Counts { + files: usize, + lines: usize, + tests: usize, + proptests: usize, +} + +fn walk(dir: &Path, c: &mut Counts) { + let Ok(rd) = std::fs::read_dir(dir) else { + return; + }; + for e in rd.flatten() { + let p = e.path(); + let name = e.file_name(); + let name = name.to_string_lossy(); + if p.is_dir() { + // Nested fuzz workspaces and build output are not part of the crate. + if name != "target" && name != "fuzz" && !name.starts_with('.') { + walk(&p, c); + } + } else if name.ends_with(".rs") + && let Ok(s) = std::fs::read_to_string(&p) + { + c.files += 1; + c.lines += s.lines().count(); + let (t, pt) = count_tests(&s); + c.tests += t; + c.proptests += pt; + } + } +} + +/// `#[test]` attributes and `proptest!` blocks (each block may hold many +/// property tests, and macro-generated tests are only counted once — use +/// `--exact` for harness-accurate numbers). +fn count_tests(src: &str) -> (usize, usize) { + let mut tests = 0; + let mut props = 0; + for line in src.lines() { + let t = line.trim_start(); + if t.starts_with("//") { + continue; + } + tests += t.matches("#[test]").count(); + if t.starts_with("proptest!") { + props += 1; + } + } + (tests, props) +} + +fn exact_tests(pkg: &str) -> Option { + let out = crate::cargo() + .args(["test", "-q", "-p", pkg, "--", "--list"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + Some( + String::from_utf8_lossy(&out.stdout) + .lines() + .filter(|l| l.ends_with(": test")) + .count(), + ) +} + +pub fn run(_root: &Path, exact: bool) -> Result<(), String> { + let meta = crate::metadata()?; + let mut rows = Vec::new(); + for p in meta["packages"].as_array().into_iter().flatten() { + let name = p["name"].as_str().unwrap_or("?").to_owned(); + let Some(dir) = p["manifest_path"] + .as_str() + .and_then(|m| Path::new(m).parent().map(Path::to_path_buf)) + else { + continue; + }; + let mut c = Counts::default(); + walk(&dir, &mut c); + let ex = if exact { exact_tests(&name) } else { None }; + rows.push((name, c, ex)); + } + rows.sort_by(|a, b| a.0.cmp(&b.0)); + + let exact_col = if exact { + format!(" {:>8}", "harness") + } else { + String::new() + }; + println!( + "{:<26} {:>6} {:>8} {:>7} {:>10}{exact_col}", + "crate", "files", "lines", "#[test]", "proptest!" + ); + println!("{}", "-".repeat(62 + if exact { 9 } else { 0 })); + let mut total = Counts::default(); + let mut total_exact = 0; + for (name, c, ex) in &rows { + let ex_s = match (exact, ex) { + (false, _) => String::new(), + (true, Some(n)) => { + total_exact += n; + format!(" {n:>8}") + } + (true, None) => format!(" {:>8}", "error"), + }; + println!( + "{name:<26} {:>6} {:>8} {:>7} {:>10}{ex_s}", + c.files, c.lines, c.tests, c.proptests + ); + total.files += c.files; + total.lines += c.lines; + total.tests += c.tests; + total.proptests += c.proptests; + } + println!("{}", "-".repeat(62 + if exact { 9 } else { 0 })); + let ex_s = if exact { + format!(" {total_exact:>8}") + } else { + String::new() + }; + println!( + "{:<26} {:>6} {:>8} {:>7} {:>10}{ex_s}", + "TOTAL", total.files, total.lines, total.tests, total.proptests + ); + if !exact { + println!( + "\n(#[test] counts source attributes; macro-generated tests count once. Use --exact for harness counts.)" + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::count_tests; + + #[test] + fn counts_attributes_and_proptest_blocks() { + let src = + "#[test]\nfn a() {}\n// #[test] commented\n #[test]\nfn b() {}\nproptest! {\n}\n"; + assert_eq!(count_tests(src), (2, 1)); + } +}