diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bec29abaf5..f2c7a2207c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,7 @@ jobs: # platform: the app shell, UI, text and fonts, GPU, pen input, build scripts or the lockfile. # macOS is tested only by release.yml. platforms: + timeout-minutes: 10 name: select platforms runs-on: ubuntu-latest outputs: @@ -65,7 +66,7 @@ jobs: # The browser build (a fat-LTO wasm build) runs only when something it compiles or # packages changed: the web app and its packaging, crate sources and manifests (the web # build compiles nearly every crate), the workspace manifests and this workflow. - if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/photocraft-web/|packaging/web/|packaging/env\.sh$|crates/[^/]+/(src/|Cargo\.toml$|build\.rs$)|Cargo\.(toml|lock)$|rust-toolchain(\.toml)?$|\.github/workflows/ci\.yml$)'; then + if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/photocraft-web(-boot)?/|packaging/web/|packaging/env\.sh$|crates/[^/]+/(src/|Cargo\.toml$|build\.rs$)|Cargo\.(toml|lock)$|rust-toolchain(\.toml)?$|\.github/workflows/ci\.yml$)'; then echo "web=true" >> "$GITHUB_OUTPUT" else echo "web=false" >> "$GITHUB_OUTPUT" @@ -77,6 +78,7 @@ jobs: fi test: + timeout-minutes: 100 name: test (${{ matrix.os }}) needs: platforms runs-on: ${{ matrix.os }} @@ -111,10 +113,24 @@ jobs: save-if: ${{ github.ref == 'refs/heads/main' }} cache-on-failure: true + # The cheap checks run first: a format, lint, layering or stale-scorecard failure is red + # after a few minutes instead of after the test passes (#6119). Clippy restores main's cache. - name: Format if: runner.os == 'Linux' run: cargo fmt --all -- --check + - name: Clippy + run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings + + - name: Dependency layering + run: cargo xtask layers + + # docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors, + # prefs audit). Static: it reads committed files only, no benchmarks run here. + - name: Scorecard up to date + if: runner.os == 'Linux' + run: cargo xtask scorecard --check + # `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate. - name: Test run: cargo test --workspace --locked --features heif @@ -145,19 +161,8 @@ jobs: CRAFT_FONTS_REQUIRED: "1" run: cargo test --workspace --locked --features heif - - name: Clippy - run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings - - - name: Dependency layering - run: cargo xtask layers - - # docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors, - # prefs audit). Static: it reads committed files only, no benchmarks run here. - - name: Scorecard up to date - if: runner.os == 'Linux' - run: cargo xtask scorecard --check - corpus: + timeout-minutes: 30 # Real-file corpus tests (cargo feature `corpus`), on every PR and on main. The corpora are # pinned in xtask/src/corpus_pins.rs and sha256-verified (xtask/*.sha256) after both a cache # restore and a download; a missing corpus fails the tests. The cache key changes only when a @@ -190,6 +195,7 @@ jobs: run: cargo xtask test-corpus wasm: + timeout-minutes: 30 name: wasm32 check runs-on: ubuntu-latest steps: @@ -206,6 +212,7 @@ jobs: run: cargo xtask wasm web: + timeout-minutes: 60 name: web build and wasm size gate # PRs run it only when web-relevant files change (see `platforms`); main always does. needs: platforms @@ -229,8 +236,8 @@ jobs: curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \ | tar -xz -C "$HOME/.cargo/bin" trunk trunk --version - # package.sh runs Trunk's release build, validates relative URLs, checks the 24 MiB - # wasm limit and exercises the same packaging step used by release.yml. + # package.sh runs Trunk's release builds, validates relative URLs, cuts the wasm into + # parts, checks the 24 MiB per-file limit and exercises the packaging step release.yml uses. - name: Build and verify browser release run: packaging/web/package.sh - name: Report wasm size @@ -241,15 +248,19 @@ jobs: echo echo "| File | Bytes | MiB | Limit |" echo "| --- | ---: | ---: | ---: |" + row() { echo "| $1 | $2 | $(awk -v n="$2" 'BEGIN { printf "%.2f", n / 1048576 }') | $3 |"; } + # The app's wasm as built; it ships gzipped in parts, so only the download grows with it. for wasm in dist/web/*.wasm; do [ -f "$wasm" ] || continue - bytes=$(wc -c < "$wasm" | tr -d ' ') - mib=$(awk -v n="$bytes" 'BEGIN { printf "%.2f", n / 1048576 }') - echo "| $(basename "$wasm") | $bytes | $mib | 24 MiB |" + row "$(basename "$wasm")" "$(wc -c < "$wasm" | tr -d ' ')" "none" done + # What a host sees: the largest file of the packaged site. + largest=$(find target/web-package -type f -printf '%s %f\n' | sort -n | tail -1) + row "largest file in the site: ${largest#* }" "${largest%% *}" "24 MiB" } >> "$GITHUB_STEP_SUMMARY" fuzz: + timeout-minutes: 60 name: fuzz (${{ matrix.target.crate }}/${{ matrix.target.name }}) # Placeholder: manual trigger only until a nightly schedule is enabled. if: github.event_name == 'workflow_dispatch' && inputs.fuzz diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 1d6c4c1f56..67e6b31894 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -27,6 +27,7 @@ concurrency: jobs: build: + timeout-minutes: 15 name: mdBook build runs-on: ubuntu-latest steps: diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 272715dc81..1d110e28f2 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -23,6 +23,7 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main verified-appimage-inputs: + timeout-minutes: 10 name: Verified AppImage inputs runs-on: ubuntu-latest steps: diff --git a/AGENTS.md b/AGENTS.md index 6adc5923db..97b5ccdbc0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ apps/ photocraft desktop app (eframe/wgpu), TCP control server photocraft-cli headless CLI (convert/info/run/batch/commands/mcp) photocraft-web the same app in the browser (trunk + wasm-bindgen) + photocraft-web-boot starts the packaged browser app from compressed parts of its wasm (no file over a host's size cap) xtask/ cargo xtask layers | wasm | ci | stats | corpus | test-corpus | parity | perf | scorecard ``` diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 2b56055a26..dc2ad10e6c 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -115,6 +115,6 @@ next to the files: | `corpus/exr/` (5 files, 2.3 MB) | The deep OpenEXR test images `11`, `42`, `64`, `multivariate` and `objectid.deep.exr` | Contributors to the OpenEXR Project | [openexr `src/test/bin/test_images`](https://github.com/AcademySoftwareFoundation/openexr/tree/main/src/test/bin/test_images) | BSD-3-Clause | | `corpus/affinity/` (60 files) | Public Affinity documents and PNG references: `vector-art/` (9 documents), `afdesignload/` (9), `jac21/` (2), `asset-store-template/` (1), and `affinity-samples/` (18 CC0 documents plus exported references) | samuel-etver; Nick Beeuwsaert; Jeremy Cantu; DAIKI (eviltwo); satoshoe-dev | [vector-art `simple/`](https://github.com/samuel-etver/vector-art/tree/255f8add3c8f0740196e22bd59502b811b532f0b/simple), [AFDesignLoad `testDesigns/`](https://github.com/NickBeeuwsaert/AFDesignLoad/tree/a18dd50a7079fb861a28835eeefa0d015f03f4a1/testDesigns), [Jac21/Branding `Logos/JC/DesignerFiles/Affinity`](https://github.com/Jac21/Branding/tree/57ae3f45bf4637f6927c0b07de25b922c55f944f/Logos/JC/DesignerFiles/Affinity), [AssetStoreTemplate `AssetStoreTemplate/`](https://github.com/eviltwo/AssetStoreTemplate/tree/f671981ee89d15526285b3cc87b4393d3defe644/AssetStoreTemplate), [affinity-samples](https://github.com/satoshoe-dev/affinity-samples/tree/96aca6bd86bbe7a677f1cf0630bd832d23943be8) | CC0-1.0 (vector-art and affinity-samples); MIT, Copyright (c) 2015 Nick Beeuwsaert; MIT, Copyright (c) 2018 Jeremy Cantu; MIT, Copyright (c) 2024 DAIKI | | `corpus/pngsuite/` | PngSuite | Willem van Schaik | | Public domain | -| `corpus/pixls/` (7 files, 226 MB) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked | +| `corpus/pixls/` (11 files) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF and Z6 paired 12/14-bit lossy and packed NEFs, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF), [DSC_0750.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0750.NEF), [DSC_0751.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0751.NEF), [DSC_0753.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0753.NEF), [DSC_0754.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0754.NEF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked | Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0. diff --git a/Cargo.lock b/Cargo.lock index 54085cf447..138ca3d814 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5073,6 +5073,17 @@ dependencies = [ "web-sys", ] +[[package]] +name = "photocraft-web-boot" +version = "0.9.0" +dependencies = [ + "flate2", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "pico-args" version = "0.5.0" @@ -6102,9 +6113,9 @@ dependencies = [ [[package]] name = "ruzstd" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888" +checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5" dependencies = [ "twox-hash", ] diff --git a/apps/photocraft-web-boot/Cargo.toml b/apps/photocraft-web-boot/Cargo.toml new file mode 100644 index 0000000000..dd12341af2 --- /dev/null +++ b/apps/photocraft-web-boot/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "photocraft-web-boot" +description = "Starts Photocraft in the browser from compressed parts of its WebAssembly" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[lints] +workspace = true + +[[bin]] +name = "photocraft-web-boot" +path = "src/main.rs" + +[dependencies] +# Inflates the parts in browsers without `DecompressionStream`. +flate2 = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "WritableStream", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] } diff --git a/apps/photocraft-web-boot/Trunk.toml b/apps/photocraft-web-boot/Trunk.toml new file mode 100644 index 0000000000..56192a9921 --- /dev/null +++ b/apps/photocraft-web-boot/Trunk.toml @@ -0,0 +1,6 @@ +# Built by packaging/web/package.sh: trunk build --release (from apps/photocraft-web-boot) +[build] +target = "index.html" +dist = "../../dist/web-boot" +public_url = "./" +filehash = true diff --git a/apps/photocraft-web-boot/index.html b/apps/photocraft-web-boot/index.html new file mode 100644 index 0000000000..8ed9a6961e --- /dev/null +++ b/apps/photocraft-web-boot/index.html @@ -0,0 +1,30 @@ + + + + + + PhotoCraft + + + + + +
+

Loading PhotoCraft…

+

This is taking longer than usual. If PhotoCraft doesn't start, its files may have failed to + download or come from a different version of this page. Reload to try again; the browser + console lists what failed.

+
+ + diff --git a/apps/photocraft-web-boot/src/main.rs b/apps/photocraft-web-boot/src/main.rs new file mode 100644 index 0000000000..56c2bf1517 --- /dev/null +++ b/apps/photocraft-web-boot/src/main.rs @@ -0,0 +1,356 @@ +//! Starts Photocraft in the browser from compressed parts of its WebAssembly. +//! +//! Static hosts cap the size of one file (Cloudflare Pages: 25 MiB), and the app's wasm is at +//! that size. `packaging/web/package.sh` gzips it and cuts it into parts listed in +//! `app/parts.txt`. This small module is what the packaged page loads: it fetches the parts in +//! order, joins and inflates them as they arrive, and hands the stream to the app's own generated +//! loader, so the browser still compiles while downloading. No file on the host is larger than +//! one part, however large the app grows. +//! +//! The browser inflates the parts (`DecompressionStream`: Safari 16.4, Firefox 113, Chrome 80). +//! In one that can't, this module inflates them itself, a little slower. +//! +//! A download that fails is tried again, the later tries past the browser's cache (a bad copy +//! there would otherwise fail forever). When every try fails the page says so and offers a reload. +//! +//! `trunk build` in `apps/photocraft-web` still gives the app as one `.wasm`, for development and +//! for hosts without a per-file cap; only the packaged site starts through this module. + +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +/// First line of `parts.txt`. Some hosts answer a missing file with the home page and HTTP 200; +/// without this an HTML page would be read as a list of parts. +#[cfg(any(target_arch = "wasm32", test))] +const MARKER: &str = "photocraft-parts 1"; + +/// The part names in a `parts.txt`, in order, or `None` when the text isn't one. A name is a +/// file next to `parts.txt`: anything that could point elsewhere is refused. +#[cfg(any(target_arch = "wasm32", test))] +fn parts(list: &str) -> Option> { + let mut lines = list.lines().map(str::trim).filter(|l| !l.is_empty()); + if lines.next() != Some(MARKER) { + return None; + } + let parts: Vec = lines.map(str::to_owned).collect(); + let plain = |p: &String| !p.starts_with('.') && !p.contains(['/', '\\', '?', '#', ':', '%']); + (!parts.is_empty() && parts.iter().all(plain)).then_some(parts) +} + +/// Gzip inflated a chunk at a time, for browsers that can't do it themselves. +#[cfg(any(target_arch = "wasm32", test))] +struct Inflater(flate2::write::GzDecoder>); + +#[cfg(any(target_arch = "wasm32", test))] +impl Inflater { + fn new() -> Self { + Inflater(flate2::write::GzDecoder::new(Vec::new())) + } + + /// What the next `chunk` of the gzip stream inflates to (often nothing: the decoder holds on + /// to input until it has a whole block). + fn push(&mut self, chunk: &[u8]) -> std::io::Result> { + std::io::Write::write_all(&mut self.0, chunk)?; + Ok(std::mem::take(self.0.get_mut())) + } + + /// The rest, once the stream has ended. An error when it ended early or its checksum is + /// wrong, the same as the browser's inflater gives. + fn finish(&mut self) -> std::io::Result> { + self.0.try_finish()?; + Ok(std::mem::take(self.0.get_mut())) + } +} + +#[cfg(target_arch = "wasm32")] +mod boot { + use std::cell::RefCell; + use std::rc::Rc; + + use wasm_bindgen::prelude::*; + use wasm_bindgen_futures::{JsFuture, spawn_local}; + use web_sys::{ + Headers, ReadableStream, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions, TransformStream, + TransformStreamDefaultController, Transformer, WritableStream, + }; + + use super::Inflater; + + /// Where packaging puts the app, next to this module's own files. + const PARTS: &str = "./app/parts.txt"; + /// Seconds to wait before each further try; one more than this many tries are made. + const RETRY_AFTER: [u32; 2] = [1, 3]; + + // The app's `wasm-bindgen` loader. Its default export instantiates the module it is given and + // runs the app's `main`. The path is fixed at compile time, so packaging gives the file this + // name (Trunk's own name for it carries a content hash). + #[wasm_bindgen(raw_module = "./app/photocraft-web.js")] + extern "C" { + #[wasm_bindgen(js_name = default, catch)] + async fn start_app(module: JsValue) -> Result; + } + + // The browser's own inflater. `web-sys` has it only behind its unstable-API flag, which would + // have to be set for the whole build, so the three members used are declared here. + #[wasm_bindgen] + extern "C" { + type DecompressionStream; + #[wasm_bindgen(constructor, catch)] + fn new(format: &str) -> Result; + #[wasm_bindgen(method, getter)] + fn readable(this: &DecompressionStream) -> ReadableStream; + #[wasm_bindgen(method, getter)] + fn writable(this: &DecompressionStream) -> WritableStream; + } + + fn window() -> Result { + web_sys::window().ok_or_else(|| JsValue::from_str("no window")) + } + + /// `fresh` skips the browser's cache. + async fn fetch(url: &str, fresh: bool) -> Result { + let init = RequestInit::new(); + if fresh { + init.set_cache(RequestCache::Reload); + } + let response: Response = JsFuture::from(window()?.fetch_with_str_and_init(url, &init)).await?.dyn_into()?; + if !response.ok() { + return Err(JsValue::from_str(&format!("{url}: HTTP {}", response.status()))); + } + if response.headers().get("Content-Type")?.is_some_and(|t| t.starts_with("text/html")) { + return Err(JsValue::from_str(&format!("{url}: the server sent a web page, not the file"))); + } + Ok(response) + } + + /// Copies each part into `sink` in order, then closes it. + async fn feed(parts: Vec, sink: WritableStream, fresh: bool) -> Result<(), JsValue> { + let keep_open = StreamPipeOptions::new(); + keep_open.set_prevent_close(true); + for part in &parts { + let body = fetch(&format!("./app/{part}"), fresh).await?.body().ok_or_else(|| JsValue::from_str("part without a body"))?; + JsFuture::from(body.pipe_to_with_options(&sink, &keep_open)).await?; + } + JsFuture::from(sink.close()).await?; + Ok(()) + } + + /// Passes inflated bytes on to the stream's reader. + fn emit(inflated: std::io::Result>, out: &TransformStreamDefaultController) -> Result<(), JsValue> { + let inflated = inflated.map_err(|e| JsValue::from_str(&format!("the download is damaged ({e})")))?; + if inflated.is_empty() { Ok(()) } else { out.enqueue_with_chunk(&js_sys::Uint8Array::from(inflated.as_slice())) } + } + + /// A gzip inflater with the shape of `DecompressionStream`, running in this module. + fn own_inflater() -> Result { + let inflater = Rc::new(RefCell::new(Inflater::new())); + let last = inflater.clone(); + let busy = || JsValue::from_str("the inflater is in use"); + let transform = Closure:: Result<(), JsValue>>::new( + move |chunk: JsValue, out: TransformStreamDefaultController| { + let chunk = js_sys::Uint8Array::new(&chunk).to_vec(); + emit(inflater.try_borrow_mut().map_err(|_| busy())?.push(&chunk), &out) + }, + ); + let flush = Closure:: Result<(), JsValue>>::new(move |out: TransformStreamDefaultController| { + emit(last.try_borrow_mut().map_err(|_| busy())?.finish(), &out) + }); + let transformer = Transformer::new(); + transformer.set_transform(&transform.into_js_value().unchecked_into()); + transformer.set_flush(&flush.into_js_value().unchecked_into()); + TransformStream::new_with_transformer(&transformer) + } + + /// `compressed` inflated: by the browser where it can, otherwise here. + fn inflated(compressed: &ReadableStream) -> Result { + let pair = if js_sys::Reflect::has(&js_sys::global(), &JsValue::from_str("DecompressionStream"))? { + let inflater = DecompressionStream::new("gzip")?; + ReadableWritablePair::new(&inflater.readable(), &inflater.writable()) + } else { + let inflater = own_inflater()?; + ReadableWritablePair::new(&inflater.readable(), &inflater.writable()) + }; + Ok(compressed.pipe_through(&pair)) + } + + async fn run(fresh: bool) -> Result<(), JsValue> { + let list = JsFuture::from(fetch(PARTS, fresh).await?.text()?).await?.as_string().unwrap_or_default(); + let parts = super::parts(&list).ok_or_else(|| JsValue::from_str("app/parts.txt is not a parts list"))?; + let joined = TransformStream::new()?; + let wasm = inflated(&joined.readable())?; + // Why the download stopped: the app's loader only reports that its stream broke. + let cause = Rc::new(RefCell::new(None)); + let failed = cause.clone(); + let sink = joined.writable(); + spawn_local(async move { + if let Err(e) = feed(parts, sink.clone(), fresh).await { + let _ = sink.abort_with_reason(&e); + failed.replace(Some(e)); + } + }); + let headers = Headers::new()?; + headers.set("Content-Type", "application/wasm")?; + let init = ResponseInit::new(); + init.set_headers(&headers); + let response = Response::new_with_opt_readable_stream_and_init(Some(&wasm), &init)?; + let arg = js_sys::Object::new(); + js_sys::Reflect::set(&arg, &JsValue::from_str("module_or_path"), &response)?; + match start_app(arg.into()).await { + Ok(_) => Ok(()), + Err(e) => Err(cause.take().unwrap_or(e)), + } + } + + async fn sleep(seconds: u32) { + let wait = js_sys::Promise::new(&mut |done, _| { + let ms = i32::try_from(seconds.saturating_mul(1000)).unwrap_or(i32::MAX); + if window().and_then(|w| w.set_timeout_with_callback_and_timeout_and_arguments_0(&done, ms)).is_err() { + let _ = done.call0(&JsValue::NULL); + } + }); + let _ = JsFuture::from(wait).await; + } + + /// Replaces the page's "Loading" text. `reload` adds a link that reloads the page. + fn say(text: &str, reload: bool) { + let Some(document) = web_sys::window().and_then(|w| w.document()) else { return }; + let Some(loading) = document.get_element_by_id("photocraft_loading") else { return }; + loading.set_text_content(None); + let Ok(line) = document.create_element("p") else { return }; + line.set_text_content(Some(text)); + if reload && let Ok(link) = document.create_element("a") { + link.set_text_content(Some("Reload")); + let _ = link.set_attribute("href", ""); + let _ = line.append_child(&link); + } + let _ = loading.append_child(&line); + } + + pub fn start() { + spawn_local(async { + let mut attempt = 0; + loop { + let error = match run(attempt > 0).await { + Ok(()) => return, + Err(e) => e, + }; + web_sys::console::error_2(&JsValue::from_str("photocraft-web-boot: could not start PhotoCraft:"), &error); + let Some(&wait) = RETRY_AFTER.get(attempt) else { + say("PhotoCraft couldn't be downloaded. Check your connection, then try again: ", true); + return; + }; + say("Loading PhotoCraft\u{2026} (the download failed, trying again)", false); + sleep(wait).await; + attempt += 1; + } + }); + } +} + +#[cfg(target_arch = "wasm32")] +fn main() { + boot::start(); +} + +#[cfg(not(target_arch = "wasm32"))] +fn main() { + eprintln!("photocraft-web-boot only runs in the browser: packaging/web/package.sh builds it with `trunk build --release` in apps/photocraft-web-boot"); +} + +#[cfg(test)] +mod tests { + use super::{Inflater, MARKER, parts}; + + fn gzip(bytes: &[u8]) -> Vec { + let mut z = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut z, bytes).expect("gzip"); + z.finish().expect("gzip") + } + + /// Bytes that don't compress to nothing, so the gzip stream spans many chunks. + fn sample() -> Vec { + let mut x = 0x2545_f491_4f6c_dd1d_u64; + (0..200_000) + .map(|i| { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + if i % 3 == 0 { x as u8 } else { (i / 7) as u8 } + }) + .collect() + } + + /// The parts arrive in whatever pieces the network gives; the inflater must not care where + /// they are cut. + #[test] + fn the_inflater_gives_back_the_bytes_however_the_stream_is_cut() { + let (plain, packed) = (sample(), gzip(&sample())); + for cut in [1, 7, 4096, 65_536, packed.len()] { + let mut inflater = Inflater::new(); + let mut out = Vec::new(); + for chunk in packed.chunks(cut) { + out.extend(inflater.push(chunk).expect("push")); + } + out.extend(inflater.finish().expect("finish")); + assert_eq!(out, plain, "chunks of {cut}"); + } + } + + /// A download that stops early, or whose bytes changed on the way, must fail (the boot module + /// then tries again), never start a damaged app. + #[test] + fn the_inflater_refuses_a_short_or_damaged_stream() { + let packed = gzip(&sample()); + let run = |bytes: &[u8]| -> std::io::Result> { + let mut inflater = Inflater::new(); + let mut out = inflater.push(bytes)?; + out.extend(inflater.finish()?); + Ok(out) + }; + assert!(run(&packed).is_ok()); + assert!(run(&packed[..packed.len() / 2]).is_err(), "cut short"); + assert!(run(&packed[..packed.len() - 4]).is_err(), "trailer cut"); + let mut damaged = packed.clone(); + let middle = damaged.len() / 2; + damaged[middle] ^= 0x55; + assert!(run(&damaged).is_err(), "a changed byte"); + assert!(run(b"not gzip").is_err(), "a web page"); + assert!(run(b"").is_err(), "nothing at all"); + } + + #[test] + fn a_parts_list_gives_its_names_in_order() { + let list = format!("{MARKER}\nphotocraft-web-0123abcd.part-aa\r\n\n photocraft-web-0123abcd.part-ab \n"); + assert_eq!(parts(&list), Some(vec!["photocraft-web-0123abcd.part-aa".to_owned(), "photocraft-web-0123abcd.part-ab".to_owned()])); + } + + /// A host that answers a missing file with its home page must not have that page read as a + /// list of parts. + #[test] + fn a_web_page_is_not_a_parts_list() { + assert_eq!(parts(include_str!("../index.html")), None); + assert_eq!(parts(""), None); + assert_eq!(parts("photocraft-web.part-aa\n"), None, "no marker"); + assert_eq!(parts(&format!("{MARKER}\n")), None, "no parts"); + assert_eq!(parts("photocraft-parts 2\na.part-aa\n"), None, "a later layout this build doesn't know"); + } + + /// Part names are joined onto `./app/`, so one that could leave that folder, or name another + /// origin, refuses the whole list. + #[test] + fn a_part_name_cannot_point_outside_the_app_folder() { + for bad in ["../index.html", "a/b.part-aa", "a\\b", "https://example.com/x", "//example.com/x", "x?y", "x#y", ".hidden", "%2e%2e"] { + assert_eq!(parts(&format!("{MARKER}\ngood.part-aa\n{bad}\n")), None, "{bad}"); + } + } + + /// The packaged site shows this crate's page, the development build the app's own. They are + /// the same page apart from which program the `` line builds. + #[test] + fn the_page_is_the_apps_page() { + let trunk = |html: &str| html.lines().filter(|l| !l.contains("data-trunk")).collect::>().join("\n"); + let (boot, app) = (include_str!("../index.html"), include_str!("../../photocraft-web/index.html")); + assert_eq!(trunk(boot), trunk(app)); + assert!(boot.contains(r#"data-bin="photocraft-web-boot""#) && boot.contains(r#"data-cargo-profile="wasm-release""#), "{boot}"); + assert!(boot.contains(r#"id="photocraft_loading""#), "the element the failure message replaces"); + } +} diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index d5004334c1..eb350d17eb 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -18,8 +18,8 @@ type Inbox = Arc)>>>; /// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", - "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", - "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", + "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", + "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; const CANVAS_ID: &str = "photocraft_canvas"; diff --git a/apps/photocraft/src/app_icon.rs b/apps/photocraft/src/app_icon.rs index 90ce94ea04..54eea2d6b5 100644 --- a/apps/photocraft/src/app_icon.rs +++ b/apps/photocraft/src/app_icon.rs @@ -26,6 +26,25 @@ pub fn window_icon() -> egui::IconData { } } +/// Whether the window gets [`window_icon`] at runtime: everywhere except inside a macOS app +/// bundle. There the Dock shows the bundle's `PhotoCraft.icns` (`CFBundleIconFile`), which macOS +/// renders in its own style (Liquid Glass and its tinted and dark variants); a runtime PNG +/// replaced it with a flat picture (#5849). `exe` is the running executable. +pub fn sets_runtime_icon(exe: Option<&std::path::Path>) -> bool { + !(cfg!(target_os = "macos") && exe.is_some_and(in_app_bundle)) +} + +/// Whether `exe` is an app bundle's executable: `…/Name.app/Contents/MacOS/binary`. +fn in_app_bundle(exe: &std::path::Path) -> bool { + let name = |p: Option<&std::path::Path>| p.and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned()); + let macos = exe.parent(); + let contents = macos.and_then(std::path::Path::parent); + let bundle = contents.and_then(std::path::Path::parent); + name(macos).as_deref() == Some("MacOS") + && name(contents).as_deref() == Some("Contents") + && name(bundle).is_some_and(|n| n.len() > 4 && n.to_ascii_lowercase().ends_with(".app")) +} + #[cfg(target_os = "macos")] const PNG: &[u8] = include_bytes!("../../../assets/app-icon/photocraft-1024.png"); #[cfg(not(target_os = "macos"))] @@ -45,6 +64,22 @@ mod tests { u32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) as usize } + /// #5849: a macOS app bundle keeps its .icns in the Dock; unbundled runs (cargo run, the + /// Linux and Windows builds) still set the runtime icon. + #[test] + fn only_a_macos_app_bundle_skips_the_runtime_icon() { + use std::path::Path; + assert!(in_app_bundle(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft"))); + assert!(in_app_bundle(Path::new("/Users/a/Downloads/PhotoCraft Beta.APP/Contents/MacOS/photocraft"))); + assert!(!in_app_bundle(Path::new("/Users/a/src/photocraft/target/release/photocraft"))); + assert!(!in_app_bundle(Path::new("/opt/Contents/MacOS/photocraft")), "not inside a .app"); + assert!(!in_app_bundle(Path::new("/.app/Contents/MacOS/photocraft")), "a bare `.app` is no bundle name"); + assert!(!in_app_bundle(Path::new("photocraft"))); + assert!(sets_runtime_icon(None)); + assert!(sets_runtime_icon(Some(Path::new("/usr/bin/photocraft")))); + assert_eq!(sets_runtime_icon(Some(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft"))), !cfg!(target_os = "macos")); + } + #[test] fn window_icon_decodes() { let icon = window_icon(); diff --git a/apps/photocraft/src/control_server.rs b/apps/photocraft/src/control_server.rs index 258d89e1ec..30ae610b70 100644 --- a/apps/photocraft/src/control_server.rs +++ b/apps/photocraft/src/control_server.rs @@ -5,11 +5,12 @@ use std::io::{BufReader, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::Arc; use std::sync::mpsc::{Receiver, Sender, channel}; -use std::time::Instant; +use std::time::{Duration, Instant}; use photocraft_automation::budgets::write_reply; use photocraft_automation::security::{ - ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line, repeat_auth_reply, + AUTH_DEADLINE, AuthDeadlineStream, ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, + read_bounded_line, repeat_auth_reply, }; use photocraft_ui_egui::ControlRequest; use photocraft_ui_egui::control::REPLY_DEADLINE; @@ -47,11 +48,17 @@ pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver, ctx: egui::Context) { + serve_with_auth_deadline(stream, token, tx, ctx, AUTH_DEADLINE); +} + +/// [`serve`] for a connection that must complete `auth` within `auth_deadline` of connecting, +/// however much it sends meanwhile (#4965); after that only the idle timeout applies. +fn serve_with_auth_deadline(stream: TcpStream, token: &str, tx: Sender, ctx: egui::Context, auth_deadline: Duration) { if configure_stream(&stream).is_err() { return; } let Ok(read) = stream.try_clone() else { return }; - let mut reader = BufReader::new(read); + let mut reader = BufReader::new(AuthDeadlineStream::new(read, auth_deadline)); let mut out = stream; let mut line = String::new(); let mut authenticated = false; @@ -74,6 +81,9 @@ fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui:: if !authenticated { let (reply, ok) = authentication_reply(&line, token); authenticated = ok; + if ok && reader.get_mut().authenticated().is_err() { + break; + } if write_reply(&mut out, &reply).is_err() || out.flush().is_err() || !authenticated { break; } @@ -120,7 +130,34 @@ fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui:: mod tests { use super::*; use std::io::BufRead; - use std::time::Duration; + + /// #4965: a connection that keeps sending without authenticating is closed at the deadline + /// (releasing its connection permit), and nothing it sent is dispatched. + #[test] + fn an_unauthenticated_connection_is_closed_at_the_deadline_whatever_it_sends() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let (tx, rx) = channel::(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let started = Instant::now(); + serve_with_auth_deadline(stream, "unused", tx, egui::Context::default(), Duration::from_millis(300)); + started.elapsed() + }); + // Blank lines and partial frames, one byte every 20 ms: each read succeeds long before + // the idle timeout. + let mut stream = TcpStream::connect(addr).unwrap(); + for i in 0..150 { + let byte: &[u8] = if i % 5 == 0 { b"\n" } else { b" " }; + if stream.write_all(byte).is_err() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + let served = server.join().unwrap(); + assert!(served < Duration::from_secs(3), "the trickle did not extend the deadline: {served:?}"); + assert!(rx.try_recv().is_err(), "nothing was dispatched"); + } #[test] fn oversized_reply_preserves_framing_id_and_the_next_control_request() { diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 1739568159..de1155edbd 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -78,9 +78,11 @@ fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool { /// different offset every launch (#419). A later launch restores the saved position instead /// (`main` turns `centered` off, #4826). Wayland compositors place windows themselves. fn native_options(custom_titlebar: bool) -> eframe::NativeOptions { + let viewport = egui::ViewportBuilder::default(); + // Not inside a macOS app bundle: its Dock icon is the bundle's .icns (`app_icon`, #5849). + let viewport = if app_icon::sets_runtime_icon(std::env::current_exe().ok().as_deref()) { viewport.with_icon(app_icon::window_icon()) } else { viewport }; eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_icon(app_icon::window_icon()) + viewport: viewport .with_app_id(APP_ID) .with_title("PhotoCraft") .with_inner_size([1440.0, 900.0]) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 36c7c2ccf5..9de1c475ea 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -21,8 +21,8 @@ mod recovery_tests; /// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", - "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", - "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", + "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", + "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; @@ -53,6 +53,7 @@ fn open_filters() -> Vec<(&'static str, Vec)> { /// keeps defaulting to PSD. const SAVE_FILTERS: &[(&str, &[&str])] = &[ ("PSD Document", &["psd", "psb"]), + ("PDF", &["pdf"]), ("PhotoCraft", &["pcraft"]), ("OpenRaster", &["ora"]), ("PNG", &["png"]), @@ -725,7 +726,8 @@ mod tests { /// Every flat format Save As offers is one PhotoCraft writes, and ICO is among them (#4357). #[test] fn save_as_offers_only_writable_formats_including_ico() { - let layered = ["psd", "psb", "pcraft", "ora"]; + // Layered formats, and PDF, are written by `photocraft-io`, not a codec. + let layered = ["psd", "psb", "pcraft", "ora", "pdf"]; for (name, exts) in SAVE_FILTERS { for ext in exts.iter().filter(|e| !layered.contains(e)) { let format = photocraft_codecs::from_extension(ext).unwrap_or_else(|| panic!("{name}: .{ext} is no known format")); diff --git a/contributors/people.toml b/contributors/people.toml index 6f630a0c12..8694c72698 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -158,3 +158,7 @@ sync_github_name = false [people.yeniufuklar-com] real_name = "Ufuk Efe" sync_github_name = true + +[people.sidney] +real_name = "Sidney Markowitz" +sync_github_name = true diff --git a/crates/affinity/Cargo.toml b/crates/affinity/Cargo.toml index e686e737ef..49d859c2f8 100644 --- a/crates/affinity/Cargo.toml +++ b/crates/affinity/Cargo.toml @@ -16,7 +16,7 @@ synth = [] [dependencies] crc32fast = "1.5" flate2 = { workspace = true } -ruzstd = "0.9" +ruzstd = "0.9.1" [dev-dependencies] proptest = { workspace = true } diff --git a/crates/algo/src/content_aware.rs b/crates/algo/src/content_aware.rs index ecdb308624..76880cd5ad 100644 --- a/crates/algo/src/content_aware.rs +++ b/crates/algo/src/content_aware.rs @@ -474,8 +474,10 @@ pub fn auto_sampling_with(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bo let mut cost_fg = vec![0.0f32; sw * sh]; let mut cost_bg = vec![0.0f32; sw * sh]; let mut fixed = vec![FREE; sw * sh]; + // Always the hole's own edge, and around a thin selection each part's surroundings. + let keep = thin_band(sw, sh, &shole); for i in 0..sw * sh { - if shole[i] || dist[i] <= 1.5 { + if shole[i] || dist[i] <= keep { fixed[i] = HARD_FG; continue; } @@ -508,6 +510,22 @@ pub fn auto_sampling_with(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bo Ok(out) } +/// How far around a thin selection (strings, wires, and any small parts selected with them, all +/// at most a quarter as thick as the selection is long) the Auto area always reaches, in cells of +/// a `w × h` grid: as far as the selection is thick, so that each part, however far from the +/// rest, keeps its own surroundings to copy from. 1.5 cells for any other selection. +fn thin_band(w: usize, h: usize, hole: &[bool]) -> f32 { + let inside = crate::selection::edt(&hole.iter().map(|h| !h).collect::>(), w, h); + let thickness = 2.0 * inside.iter().zip(hole).filter(|(_, h)| **h).map(|(d, _)| *d).fold(0.0f32, f32::max); + let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0); + for (i, _) in hole.iter().enumerate().filter(|(_, h)| **h) { + let (x, y) = (i % w, i / w); + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1)); + } + let extent = x1.saturating_sub(x0).max(y1.saturating_sub(y0)) as f32; + if 4.0 * thickness <= extent { thickness.max(1.5) } else { 1.5 } +} + /// The Patch Tool's and the Content-Aware Move Tool's Color option (0–10) as the colour adaptation /// of the copied content. Measured on Photoshop 25.4 with content brighter than its new place: 0 /// keeps it as it is; 3 moves it about 0.12 (32 of 42 levels, and 31 of 102: a limit, not a @@ -751,6 +769,31 @@ mod tests { } } + #[test] + fn auto_sampling_keeps_the_surroundings_of_every_part_of_a_thin_selection() { + // A long thin selection across a grey ramp, and a small knob selected with it in a red + // corner. Each part keeps its own surroundings; a compact selection is unchanged. + let (w, h) = (900, 600); + let tex = noise(w, h, 4); + let img: Vec = (0..w * h * 3) + .map(|i| { + let (x, y, c) = ((i / 3) % w, (i / 3) / w, i % 3); + let base = if x > 690 && y > 420 { [0.7, 0.25, 0.2][c] } else { 0.2 + 0.5 * x as f32 / w as f32 }; + base + (tex[i] - 0.5) * 0.04 + }) + .collect(); + let line = |i: usize| (30..870).contains(&(i % w)) && (180..192).contains(&(i / w)); + let knob = |i: usize| ((i % w) as f32 - 795.0).hypot((i / w) as f32 - 510.0) < 10.0; + let hole: Vec = (0..w * h).map(|i| line(i) || knob(i)).collect(); + let area = auto_sampling(w, h, 3, &img, &hole); + let around_knob = (0..w * h).filter(|&i| !hole[i] && ((i % w) as f32 - 795.0).hypot((i / w) as f32 - 510.0) < 24.0); + let (n, kept) = around_knob.fold((0, 0), |(n, k), i| (n + 1, k + usize::from(area[i]))); + assert!(kept as f32 > 0.9 * n as f32, "{kept} of {n} pixels around the knob"); + assert!(thin_band(w, h, &hole) >= 20.0, "{}", thin_band(w, h, &hole)); + let disc: Vec = (0..w * h).map(|i| ((i % w) as f32 - 450.0).hypot((i / w) as f32 - 300.0) < 90.0).collect(); + assert_eq!(thin_band(w, h, &disc), 1.5); + } + #[test] fn without_colour_adaptation_the_fill_copies_pixels_exactly() { // A smooth texture with fine noise, so that every value is unique: a filled pixel equal to @@ -798,6 +841,116 @@ mod tests { assert!((shift(10) - 0.4).abs() < 0.01, "{}", shift(10)); } + /// A soft background (gradients, broad out-of-focus blobs of colour, fine noise) with thin, long bright + /// objects over it: three coiled strings 3 to 7 px wide and two straighter ones, each with an + /// anti-aliased edge and a faint halo. Returns the image; what removing the coils should give + /// (the background and the straighter strings, which stay); and the selection a user would draw + /// around the coils, `margin` px wide. + fn strings_scene(w: usize, h: usize, margin: f32) -> (Vec, Vec, Vec) { + let fine = noise(w, h, 11); + // Out-of-focus colour: broad blobs over two gradients. + let blob = noise(3, 3, 5); + let bg: Vec = (0..w * h * 3) + .map(|i| { + let (x, y, c) = (((i / 3) % w) as f32 / w as f32, ((i / 3) / w) as f32 / h as f32, i % 3); + let mut v = [0.18 + 0.25 * x, 0.22 + 0.1 * y, 0.3 - 0.1 * x][c]; + for b in blob.as_chunks::<3>().0 { + let (bx, by, s) = (b[0] * 1.6 - 0.3, b[1] * 1.6 - 0.3, 0.1 + 0.25 * b[2]); + v += (b[(c + 1) % 3] - 0.2) * 0.5 * (-((x - bx).powi(2) + (y - by).powi(2) * 0.56) / (2.0 * s * s)).exp(); + } + v + (fine[i] - 0.5) * 0.03 + }) + .collect(); + // Signed distance to each object's surface, stamped from points along it. + let stamp = |sd: &mut [f32], pts: &mut dyn Iterator, half: f32| { + for (px, py) in pts { + let reach = (half + 4.0) as i32 + 1; + for dy in -reach..=reach { + for dx in -reach..=reach { + let (x, y) = (px as i32 + dx, py as i32 + dy); + if x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h { + let i = y as usize * w + x as usize; + sd[i] = sd[i].min((x as f32 - px).hypot(y as f32 - py) - half); + } + } + } + } + }; + let mut coils = vec![f32::INFINITY; w * h]; + for (k, half) in [1.5f32, 2.5, 3.5].into_iter().enumerate() { + let (y0, r) = ((0.22 + 0.28 * k as f32) * h as f32, 10.0 + 2.0 * k as f32); + let mut pts = (0..4000).map(|j| { + let t = j as f32 / 4000.0; + let a = 8.0 * std::f32::consts::TAU * t; + (18.0 + t * (w as f32 - 60.0) + r * a.cos(), y0 + 6.0 * (6.0 * t).sin() + 0.7 * r * a.sin()) + }); + stamp(&mut coils, &mut pts, half); + } + let mut straight = vec![f32::INFINITY; w * h]; + for y in [0.07, 0.93] { + stamp(&mut straight, &mut (0..2000).map(|j| (j as f32 / 2000.0 * w as f32, y * h as f32 + 3.0 * (j as f32 / 300.0).sin())), 2.0); + } + let draw = |base: &[f32], sd: &[f32]| -> Vec { + let mut img = base.to_vec(); + for i in 0..w * h { + let cover = (0.5 - sd[i]).clamp(0.0, 1.0); + let halo = if sd[i] > 0.0 { 0.08 * (-sd[i] * sd[i] / 2.9).exp() } else { 0.0 }; + for (c, metal) in [0.9f32, 0.85, 0.7].into_iter().enumerate() { + let v = &mut img[i * 3 + c]; + *v = *v * (1.0 - cover) + metal * cover + halo * (1.0 - cover); + } + } + img + }; + let clean = draw(&bg, &straight); + let both: Vec = coils.iter().zip(&straight).map(|(a, b)| a.min(*b)).collect(); + let img = draw(&bg, &both); + let hole = coils.iter().map(|d| *d <= margin).collect(); + (img, clean, hole) + } + + /// RMS over the hole of the error averaged over 7 × 7 pixels (in levels of 255): the noise + /// cancels out, a ghost of the removed outline or a blotch of another shade does not. + fn blotch_error(w: usize, h: usize, out: &[f32], clean: &[f32], hole: &[bool]) -> f32 { + let (mut sum, mut n) = (0.0f64, 0usize); + for y in 3..h - 3 { + for x in 3..w - 3 { + if !hole[y * w + x] { + continue; + } + let mut e = [0.0f32; 3]; + for j in (y - 3..=y + 3).flat_map(|yy| (x - 3..=x + 3).map(move |xx| yy * w + xx)) { + for (c, ec) in e.iter_mut().enumerate() { + *ec += out[j * 3 + c] - clean[j * 3 + c]; + } + } + sum += f64::from(e.iter().map(|v| (v / 49.0).powi(2)).sum::() / 3.0); + n += 1; + } + } + ((sum / n.max(1) as f64).sqrt() * 255.0) as f32 + } + + /// Removing thin, long objects (guitar strings, wires) from a soft background (#1366): the + /// fill is the background, with no ghost of their outline and nothing of their colour copied + /// back in. Their selection is long but only a few pixels thick, so the fill is worked out at + /// a scale where it is still a few pixels thick rather than one where the strings' surroundings + /// have merged into a blur. + #[test] + fn thin_objects_leave_no_ghost_and_none_of_their_colour() { + let (w, h) = (360, 270); + let (img, clean, hole) = strings_scene(w, h, 2.0); + let source = auto_sampling(w, h, 3, &img, &hole); + let out = fill(w, h, 3, &img, &hole, &source, &FillOptions::default()); + let blotch = blotch_error(w, h, &out, &clean, &hole); + let luma = |v: &[f32], i: usize| (v[i * 3] + v[i * 3 + 1] + v[i * 3 + 2]) / 3.0; + let bright = (0..w * h).filter(|&i| hole[i] && luma(&out, i) > luma(&clean, i) + 0.06).count(); + // Before #1366 the outlines stayed as darker and lighter blotches (13 levels of 255) and + // 1121 pixels took the strings' colour. + assert!(blotch < 2.5, "ghosts and blotches: {blotch:.2} levels of 255"); + assert_eq!(bright, 0, "filled pixels with the strings' colour"); + } + #[test] fn variant_geometry() { let img = vec![0.5f32; 10 * 6]; diff --git a/crates/algo/src/inpaint.rs b/crates/algo/src/inpaint.rs index d7d43c032f..ee472b2296 100644 --- a/crates/algo/src/inpaint.rs +++ b/crates/algo/src/inpaint.rs @@ -16,6 +16,15 @@ //! source (the usual case), the vote copies the source pixels exactly; only along the seams //! between copied pieces does it blend. //! +//! A thin hole (a selected wire, string or hair: long, but only a few patches thick) is +//! measured by its thickness, not its bounds (#1366): the pyramid stops where it is still about +//! two patches thick. That level is big, so the onion peel starts it only where its exhaustive +//! search stays affordable; otherwise a membrane across the hole does, which over so short a +//! distance is the surroundings themselves. Shrunk to where its bounds are four patches across, +//! a long thin hole swallows its surroundings, the matches found there copy whatever happens to +//! look like the blur (a dark blotch, or a piece of another string), and the finer levels, +//! which only refine those matches, keep them: the removed object's outline stays visible. +//! //! This is what Photoshop's Content-Aware Fill does, measured as a black box on Photoshop 25.4 //! (see `content_aware`): with colour adaptation off its fill is pieces of the image copied pixel //! for pixel, typically 15–40 px across, joined by seams a few pixels wide; with colour @@ -278,6 +287,38 @@ fn hole_bbox(w: usize, h: usize, hole: &[bool]) -> Option<(usize, usize, usize, b } +/// A hole at most `1 / THIN_RATIO` as thick as its bounds are long counts as thin (a wire, a +/// string, a hair, a long brush stroke): see the module docs. +const THIN_RATIO: usize = 4; + +/// How thick the hole within bounds `hb` of a `w`-wide role grid is: twice the largest distance +/// from a hole pixel to the nearest pixel that is not one (a long, thin hole is thin however long +/// its bounds). +fn hole_thickness(w: usize, hb: Bounds, px: &[Px]) -> usize { + // The bounds and a ring of non-hole cells around them (also past the grid's edge). + let (gw, gh) = (hb.width() + 2, hb.height() + 2); + let outside: Vec = par::map(gw * gh, gw * gh >= PAR_MIN, |i| { + let (gx, gy) = (i % gw, i / gw); + if gx == 0 || gy == 0 || gx > hb.width() || gy > hb.height() { + return true; + } + px.get((hb.y0 + gy - 1) * w + hb.x0 + gx - 1) != Some(&Px::Hole) + }); + let deepest = crate::selection::edt(&outside, gw, gh).into_iter().fold(0.0f32, f32::max); + (2.0 * deepest).ceil() as usize +} + +/// Most target pixels × sources the onion peel of a thin hole may compare (its search is +/// exhaustive, though most comparisons stop early: about a second at this many). +const PEEL_BUDGET: usize = 1 << 28; + +/// Whether the onion peel of the hole cells within `hb` of a `w`-wide role grid from `sources` +/// source centres stays within [`PEEL_BUDGET`]. +fn peel_affordable(w: usize, px: &[Px], hb: Bounds, sources: usize) -> bool { + let holes: usize = (hb.y0..hb.y1).map(|y| px.get(y * w + hb.x0..y * w + hb.x1).map_or(0, |row| row.iter().filter(|k| **k == Px::Hole).count())).sum(); + holes.saturating_mul(sources) <= PEEL_BUDGET +} + /// Bounds of the hole pixels of a role grid. fn hole_bounds(w: usize, h: usize, px: &[Px]) -> Option { // Per row the first and last hole column; the union of the rows' spans. @@ -1575,14 +1616,20 @@ fn complete_cow<'a>( let r = p.patch_radius.max(1); let psz = 2 * r + 1; // Pyramid: shrink until the hole is about four patches across (a deeper pyramid decides the - // structure where the texture has averaged out, and fills flat). + // structure where the texture has averaged out, and fills flat). A thin hole stops while it + // is still about two patches thick: through it, not along it, is what counts (#1366). let mut levels = vec![Level { w, h, img, px: Cow::Borrowed(px) }]; let mut boxes = vec![hb0]; let mut ext = hb0.width().max(hb0.height()); + let thickness = hole_thickness(w, hb0, px); + let thin = thickness.saturating_mul(THIN_RATIO) <= ext; + if thin { + ext = thickness; + } let mut exts = vec![ext]; while let Some(l) = levels.last() { ctl.check()?; - if ext <= 4 * psz || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz { + if ext <= if thin { 2 * psz } else { 4 * psz } || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz { break; } let d = downsample(l, ch); @@ -1638,9 +1685,21 @@ fn complete_cow<'a>( } } } - None if sources.len() > 0 => onion_peel(lw, lh, ch, ri, &mut limg, &lpx, hb, &sources.all(&valid, lw), ctl)?, + // A thin hole, when peeling it would take too long, takes the membrane below: a few + // patches thick here, a membrane across it is its surroundings. + None if sources.len() > 0 && (!thin || peel_affordable(lw, &lpx, hb, sources.len())) => { + onion_peel(lw, lh, ch, ri, &mut limg, &lpx, hb, &sources.all(&valid, lw), ctl)? + } None => { - let unknown: Vec = lpx.iter().map(|k| matches!(k, Px::Hole | Px::Void)).collect(); + let mut unknown: Vec = lpx.iter().map(|k| matches!(k, Px::Hole | Px::Void)).collect(); + // Across a thin hole the membrane starts a patch radius out, past the + // anti-aliased fringe or halo that a selection drawn close to the object leaves. + if thin { + let hole: Vec = lpx.iter().map(|k| *k == Px::Hole).collect(); + for (u, d) in unknown.iter_mut().zip(crate::selection::edt(&hole, lw, lh)) { + *u |= d <= r as f32; + } + } let m = membrane_fill(lw, lh, ch, &limg, &unknown); for (i, k) in lpx.iter().enumerate() { if *k == Px::Hole { @@ -2114,6 +2173,43 @@ mod tests { .collect() } + #[test] + fn a_hole_is_as_thick_as_it_is_through_not_along() { + let (w, h) = (240, 120); + let all = Bounds { x0: 0, y0: 0, x1: w, y1: h }; + let thickness = |hole: &[bool]| { + let px: Vec = hole.iter().map(|&o| if o { Px::Hole } else { Px::Source }).collect(); + hole_thickness(w, hole_bounds(w, h, &px).unwrap_or(all), &px) + }; + // A band 200 px long and 9 px thick; a disc of radius 20; the band touching the edge + // counts the outside of the image as not hole. + let band: Vec = (0..w * h).map(|i| (20..220).contains(&(i % w)) && (50..59).contains(&(i / w))).collect(); + assert!((9..=11).contains(&thickness(&band)), "{}", thickness(&band)); + assert!((39..=42).contains(&thickness(&disc(w, h, 120.0, 60.0, 20.0)))); + let edge: Vec = (0..w * h).map(|i| i / w < 9).collect(); + assert!((9..=11).contains(&thickness(&edge)), "{}", thickness(&edge)); + } + + /// A long thin hole goes the thin way (a shallow pyramid that starts from a membrane): it is + /// filled, only it changes, and the result does not depend on the thread count either. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn thin_holes_are_filled_the_same_on_any_number_of_threads() { + let (w, h) = (300, 200); + let img = busy(w, h, 3, 5); + // A wavy line 6 px thick across the image. + let hole: Vec = (0..w * h).map(|i| (((i % w) as f32 * 0.05).sin() * 30.0 + 100.0 - (i / w) as f32).abs() < 3.0).collect(); + let px = roles(w, h, &hole); + let run = |threads: usize| { + let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap(); + pool.install(|| complete_masked(w, h, 3, &img, &px, &CompleteParams::default(), &photocraft_raster::Interrupt::NONE)).unwrap().unwrap() + }; + let one = run(1); + assert_eq!(bits(&one), bits(&run(7))); + assert!((0..w * h).all(|i| hole[i] || (0..3).all(|c| one[i * 3 + c].to_bits() == img[i * 3 + c].to_bits()))); + assert!((0..w * h).filter(|&i| hole[i]).all(|i| (0..3).all(|c| one[i * 3 + c].is_finite()))); + } + #[test] #[cfg(not(target_arch = "wasm32"))] fn results_do_not_depend_on_the_thread_count() { diff --git a/crates/algo/src/segment/mod.rs b/crates/algo/src/segment/mod.rs index 5c190e643a..b30ce909b7 100644 --- a/crates/algo/src/segment/mod.rs +++ b/crates/algo/src/segment/mod.rs @@ -448,6 +448,51 @@ pub fn clean_mask(mask: &[bool], w: usize, h: usize, frac: f32, hole_frac: f32) out } +/// Fills the holes of `mask` (4-connected background areas not touching the border) by what +/// they look like: a hole up to `speck_frac` of the mask's area always fills; a larger one, up +/// to `hole_frac`, only when its pixels fit the foreground model `fg` better than `bg` on +/// average, so an enclosed patch of backdrop stays open. +pub fn fill_holes_by_model(mask: &[bool], img: &RgbImage, fg: &Gmm, bg: &Gmm, speck_frac: f32, hole_frac: f32) -> Vec { + let (w, h) = (img.w, img.h); + let mut out = mask.to_vec(); + let area = out.iter().filter(|v| **v).count(); + let max_speck = (area as f32 * speck_frac) as usize; + let max_hole = (area as f32 * hole_frac) as usize; + let inv: Vec = out.iter().map(|v| !v).collect(); + let (hl, hs) = components4(&inv, w, h); + let mut touches = vec![false; hs.len()]; + for x in 0..w { + touches[hl[x] as usize] = true; + touches[hl[(h - 1) * w + x] as usize] = true; + } + for y in 0..h { + touches[hl[y * w] as usize] = true; + touches[hl[y * w + w - 1] as usize] = true; + } + // Summed data costs per hole; only holes small enough to be candidates are evaluated. + let mut cost = vec![(0.0f64, 0.0f64); hs.len()]; + for (i, l) in hl.iter().enumerate() { + let l = *l as usize; + if l == 0 || touches[l] || hs[l] > max_hole || hs[l] <= max_speck { + continue; + } + if let Some(p) = img.px.get(i) { + cost[l].0 += f64::from(fg.neg_log(*p)); + cost[l].1 += f64::from(bg.neg_log(*p)); + } + } + for (o, l) in out.iter_mut().zip(&hl) { + let l = *l as usize; + if l == 0 || touches[l] || hs[l] > max_hole { + continue; + } + if hs[l] <= max_speck || cost[l].0 <= cost[l].1 { + *o = true; + } + } + out +} + fn components4(mask: &[bool], w: usize, h: usize) -> (Vec, Vec) { let mut lab = vec![0u32; w * h]; let mut sizes = vec![0usize]; diff --git a/crates/algo/src/segment/subject.rs b/crates/algo/src/segment/subject.rs index b117bb5ef6..75a1a47a56 100644 --- a/crates/algo/src/segment/subject.rs +++ b/crates/algo/src/segment/subject.rs @@ -13,8 +13,17 @@ //! S. Süsstrunk, "Frequency-tuned Salient Region Detection", CVPR 2009): distance of the //! lightly blurred CIELAB image from its mean colour. //! 4. Otsu's threshold on the saliency map seeds a GrabCut trimap (low-saliency border pixels -//! are definite background); the result is cleaned (small islands dropped, small holes -//! filled) and re-cut at full resolution. +//! are definite background); the result is cleaned (small islands dropped). +//! 5. A second GrabCut whose background model only knows the backdrop: a colour model fitted +//! to the image's frame marks every pixel it explains as probable background, and the rest +//! (the blob, plus whatever the backdrop cannot account for) starts as probable foreground. +//! Parts of the subject whose colour is close to the backdrop (a cream shirt on a near-white +//! wall) have low saliency and in step 4 land in the background model, which then explains +//! them away; here they start as foreground and the cut keeps them (#2788). Only what +//! touches the first pass's result survives, so the pass cannot pull in unrelated things. +//! 6. Holes are filled only when their colour fits the foreground model better than the +//! background's, so a patch of backdrop enclosed by the subject (between an arm and the +//! torso) stays open; specks still fill. Then the mask is re-cut at full resolution. //! //! Works well for a distinct object on a comparatively plain background; it is not a semantic //! segmenter. @@ -25,6 +34,7 @@ use std::collections::BinaryHeap; use photocraft_geom::Rect; use photocraft_raster::{Cancelled, Interrupt}; +use super::gmm::Gmm; use super::grabcut::{self, BG, PR_BG, PR_FG}; use super::slic::{rgb_to_lab, slic_lab}; use super::{Region, Sampler}; @@ -72,13 +82,65 @@ pub fn select_subject_with(sampler: &dyn Sampler, canvas: Rect, ctl: &Interrupt) } let Some((fg, bg)) = grabcut::grabcut_with(&img, &mut trimap, 6, ctl)? else { return Ok(None) }; let low: Vec = trimap.iter().map(|v| *v == PR_FG || *v == grabcut::FG).collect(); - let low = super::clean_mask(&low, w, h, 0.2, 0.05); + let low = super::clean_mask(&low, w, h, 0.2, 0.0); if !low.iter().any(|v| *v) { return Ok(None); } + let (low, fg, bg) = backdrop_pass(&img, &low, ctl)?.unwrap_or((low, fg, bg)); + ctl.check()?; + let low = super::fill_holes_by_model(&low, &img, &fg, &bg, 0.002, 0.05); super::finish_region_with(sampler, canvas, step, &low, w, h, Some((&fg, &bg)), ctl) } +/// Step 5: GrabCut again with a background model that only knows the backdrop. A colour model +/// is fitted to the image's frame (the outer pixels, minus a margin around `blob` in case the +/// subject runs out of the picture); the frame is definite background, every other pixel it +/// explains well is probable background, and the rest (the blob, and anything the backdrop +/// cannot account for, such as a cream shirt on a near-white wall) starts as probable +/// foreground. `Ok(None)` when the frame cannot be modelled or the cut keeps nothing that +/// touches the blob; `Err(Cancelled)` once `ctl` is cancelled, as in the first pass. +fn backdrop_pass(img: &super::RgbImage, blob: &[bool], ctl: &Interrupt) -> Result, Gmm, Gmm)>, Cancelled> { + let (w, h) = (img.w, img.h); + let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize); + for (i, on) in blob.iter().enumerate() { + if *on { + let (x, y) = (i % w, i / w); + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y)); + } + } + if x1 < x0 || y1 < y0 { + return Ok(None); + } + let margin = (((x1 - x0 + 1).max(y1 - y0 + 1) as f32 * 0.15).ceil() as usize).max(4); + let (bx0, by0) = (x0.saturating_sub(margin), y0.saturating_sub(margin)); + let (bx1, by1) = ((x1 + margin).min(w - 1), (y1 + margin).min(h - 1)); + let frame = ((w.min(h) as f32 * 0.06).round() as usize).max(2); + let in_frame = |x: usize, y: usize| x < frame || y < frame || x + frame >= w || y + frame >= h; + let near_blob = |x: usize, y: usize| (bx0..=bx1).contains(&x) && (by0..=by1).contains(&y); + let samples: Vec<[f32; 3]> = (0..w * h).filter(|i| in_frame(i % w, i / w) && !near_blob(i % w, i / w)).map(|i| img.px[i]).collect(); + let Some(backdrop) = Gmm::fit(&super::subsample(&samples, 40_000), grabcut::K, super::gmm::DEFAULT_REG) else { return Ok(None) }; + // What the frame itself costs under its own model bounds "explained well". + let mut frame_costs: Vec = samples.iter().map(|p| backdrop.neg_log(*p)).collect(); + frame_costs.sort_by(|a, b| a.total_cmp(b)); + let Some(limit) = frame_costs.get(frame_costs.len() * 99 / 100).map(|c| c + 4.0) else { return Ok(None) }; + let mut trimap: Vec = (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + if in_frame(x, y) && !near_blob(x, y) { + BG + } else if blob[i] || backdrop.neg_log(img.px[i]) > limit { + PR_FG + } else { + PR_BG + } + }) + .collect(); + let Some((fg, bg)) = grabcut::grabcut_with(img, &mut trimap, 4, ctl)? else { return Ok(None) }; + let cut: Vec = trimap.iter().map(|v| *v == PR_FG || *v == grabcut::FG).collect(); + let low = super::keep_seeded(&cut, blob, w, h); + Ok(low.iter().any(|v| *v).then_some((low, fg, bg))) +} + /// Per-pixel saliency in `0..=1` (see the module docs). pub fn saliency(img: &super::RgbImage) -> Vec { let (w, h) = (img.w, img.h); diff --git a/crates/algo/src/segment/tests.rs b/crates/algo/src/segment/tests.rs index 55ddc6cafd..743c3dc460 100644 --- a/crates/algo/src/segment/tests.rs +++ b/crates/algo/src/segment/tests.rs @@ -761,3 +761,154 @@ fn object_at_on_the_background_or_off_canvas_finds_nothing() { assert!(grabcut::object_at(&s, canvas, f32::NAN, 10.0, 20_000).is_none()); assert!(grabcut::object_at(&s, canvas, 400.0, 10.0, 0).is_none()); } + +// ---------- Select Subject on the cases reported in #2788 ---------- + +/// A figure whose upper part is close to the background colour (a cream shirt on a near-white +/// backdrop): the whole figure is the subject. Returns (image, truth, the low-contrast part). +pub(crate) fn low_contrast_figure(seed: u64) -> (RgbImage, Vec, Vec) { + let (w, h) = (240usize, 200usize); + let mut rng = Rng::new(seed); + let mut img = RgbImage::new(w, h); + let mut truth = vec![false; w * h]; + let mut cream = vec![false; w * h]; + for y in 0..h { + for x in 0..w { + let body = (90..150).contains(&x) && (70..190).contains(&y); + let shoulders = (80..160).contains(&x) && (40..70).contains(&y); + let print = (105..135).contains(&x) && (100..130).contains(&y); + let n = 0.012 * rng.normal(); + let i = y * w + x; + truth[i] = body || shoulders; + cream[i] = shoulders; + img.px[i] = if print { + [0.9 + n, 0.15 + n, 0.1 + n] + } else if body { + [0.12 + n, 0.2 + n, 0.45 + n] + } else if shoulders { + // Cream, a few levels off the backdrop. + [0.90 + n, 0.88 + n, 0.80 + n] + } else { + [0.96 + n, 0.96 + n, 0.95 + n] + } + .map(|v| v.clamp(0.0, 1.0)); + } + } + (img, truth, cream) +} + +fn coverage(region: &Region, part: &[bool], w: usize, h: usize) -> f32 { + let (mut hit, mut n) = (0usize, 0usize); + for y in 0..h { + for x in 0..w { + if part[y * w + x] { + n += 1; + hit += (region.at(x as i32, y as i32) >= 0.5) as usize; + } + } + } + hit as f32 / n.max(1) as f32 +} + +#[test] +fn select_subject_keeps_parts_close_to_the_background_colour() { + let (img, truth, cream) = low_contrast_figure(7); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let reg = subject::select_subject(&s, Rect::new(0, 0, 240, 200)).unwrap(); + let v = iou(®, &truth, 240, 200); + let c = coverage(®, &cream, 240, 200); + assert!(c > 0.85, "the cream shoulders are part of the subject: covered {c}"); + assert!(v > 0.9, "IoU {v}"); +} + +#[test] +fn select_subject_leaves_a_background_coloured_gap_open() { + // A dark ring (arms on hips) around a patch of plain backdrop: the patch is not the subject. + let (w, h) = (220usize, 200usize); + let mut rng = Rng::new(11); + let mut img = RgbImage::new(w, h); + let mut truth = vec![false; w * h]; + let mut gap = vec![false; w * h]; + for y in 0..h { + for x in 0..w { + let d = (x as f32 - 110.0).hypot(y as f32 - 100.0); + let ring = (17.0..80.0).contains(&d); + let hole = d < 17.0; + let n = 0.012 * rng.normal(); + let i = y * w + x; + truth[i] = ring; + gap[i] = hole; + img.px[i] = if ring { [0.2 + n, 0.15 + n, 0.35 + n] } else { [0.95 + n, 0.94 + n, 0.92 + n] }.map(|v| v.clamp(0.0, 1.0)); + } + } + let s = ImageSampler { img: &img, origin: (0, 0) }; + let reg = subject::select_subject(&s, Rect::new(0, 0, w as i32, h as i32)).unwrap(); + let g = coverage(®, &gap, w, h); + assert!(g < 0.1, "the backdrop inside the ring stays unselected: covered {g}"); + let v = iou(®, &truth, w, h); + assert!(v > 0.9, "IoU {v}"); +} + +/// Timing on a 6 MP figure, for before/after numbers (`cargo test --release -- --ignored select_subject_timing --nocapture`). +#[test] +#[ignore = "timing only"] +fn select_subject_timing() { + let (w, h) = (3000usize, 2000usize); + let img = RgbImage::from_fn(w, h, |x, y| { + let body = (1100..1900).contains(&x) && (700..1900).contains(&y); + let shoulders = (1000..2000).contains(&x) && (400..700).contains(&y); + let t = ((x * 7 + y * 13) % 17) as f32 / 1700.0; + if body { + [0.12 + t, 0.2, 0.45] + } else if shoulders { + [0.90, 0.88 + t, 0.80] + } else { + [0.96, 0.96 + t, 0.95] + } + }); + let s = ImageSampler { img: &img, origin: (0, 0) }; + for _ in 0..2 { + let t0 = std::time::Instant::now(); + let reg = subject::select_subject(&s, Rect::new(0, 0, w as i32, h as i32)).unwrap(); + eprintln!("select_subject 6 MP: {:?} bbox {:?}", t0.elapsed(), reg.bbox); + } +} + +/// Writes the #2788 scenes and the Select Subject mask over them as PPM files into +/// `$SUBJECT_DUMP` (for the PR's before/after pictures). +#[test] +#[ignore = "writes pictures"] +fn select_subject_dump_pictures() { + let Ok(dir) = std::env::var("SUBJECT_DUMP") else { return }; + let write = |name: &str, img: &RgbImage, reg: Option<&Region>| { + let mut out = format!("P6\n{} {}\n255\n", img.w, img.h).into_bytes(); + for y in 0..img.h { + for x in 0..img.w { + let mut c = img.at(x, y); + if let Some(r) = reg + && r.at(x as i32, y as i32) >= 0.5 + { + c = [c[0] * 0.5 + 0.5, c[1] * 0.5, c[2] * 0.5 + 0.5]; + } + out.extend(c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)); + } + } + std::fs::write(format!("{dir}/{name}.ppm"), out).unwrap(); + }; + let (img, _, _) = low_contrast_figure(7); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let reg = subject::select_subject(&s, Rect::new(0, 0, 240, 200)); + write("figure", &img, None); + write("figure-selected", &img, reg.as_ref()); + let (w, h) = (220usize, 200usize); + let mut rng = Rng::new(11); + let img = RgbImage::from_fn(w, h, |x, y| { + let d = (x as f32 - 110.0).hypot(y as f32 - 100.0); + let n = 0.012 * rng.normal(); + if (17.0..80.0).contains(&d) { [0.2 + n, 0.15 + n, 0.35 + n] } else { [0.95 + n, 0.94 + n, 0.92 + n] }.map(|v| v.clamp(0.0, 1.0)) + }); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let reg = subject::select_subject(&s, Rect::new(0, 0, w as i32, h as i32)); + write("ring", &img, None); + write("ring-selected", &img, reg.as_ref()); +} diff --git a/crates/algo/src/transform.rs b/crates/algo/src/transform.rs index 3b157b56a9..050d08c322 100644 --- a/crates/algo/src/transform.rs +++ b/crates/algo/src/transform.rs @@ -281,7 +281,7 @@ fn warp( // that matters is the one at the source point landing in the clip's centre. let (cx, cy) = match clip.and_then(|c| Some((c, h.inverse()?))) { Some((c, inv)) => inv.apply((c.x0 as f64 + c.x1 as f64) / 2.0, (c.y0 as f64 + c.y1 as f64) / 2.0), - None => ((src_rect.x0 + src_rect.x1) as f64 / 2.0, (src_rect.y0 + src_rect.y1) as f64 / 2.0), + None => ((f64::from(src_rect.x0) + f64::from(src_rect.x1)) / 2.0, (f64::from(src_rect.y0) + f64::from(src_rect.y1)) / 2.0), }; let scale = h.local_scale(cx, cy); let reduced; @@ -366,10 +366,10 @@ fn warp( return Ok(None); } let fp: Vec<(f64, f64)> = poly.iter().map(|&(x, y)| inv.apply(x, y)).collect(); - let fx0 = fp.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor() as i32 - 3; - let fy0 = fp.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor() as i32 - 3; - let fx1 = fp.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil() as i32 + 3; - let fy1 = fp.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil() as i32 + 3; + let fx0 = (fp.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor() as i32).saturating_sub(3); + let fy0 = (fp.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor() as i32).saturating_sub(3); + let fx1 = (fp.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil() as i32).saturating_add(3); + let fy1 = (fp.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil() as i32).saturating_add(3); // Without a one-signed forward w, a tile straddling the inverse horizon has no bounded footprint. let straddles = side.is_none() && wc.iter().any(|w| *w <= 0.0) && wc.iter().any(|w| *w >= 0.0); let foot = if straddles { src_rect } else { Rect::new(fx0, fy0, fx1, fy1).intersect(&src_rect) }; diff --git a/crates/algo/src/warp.rs b/crates/algo/src/warp.rs index 01c54e7643..188de07d47 100644 --- a/crates/algo/src/warp.rs +++ b/crates/algo/src/warp.rs @@ -4,8 +4,10 @@ //! The source rectangle is subdivided into a fine grid whose vertices are mapped forward; each //! cell's two triangles are rasterized in destination space with the source position //! interpolated barycentrically, then the source is sampled there (premultiplied, nearest / -//! bilinear / Photoshop's Keys bicubic, like Free Transform). Cells are a few source pixels across, so -//! the piecewise-affine inverse is within a small fraction of a pixel of the true one; affine +//! bilinear / Photoshop's Keys bicubic, like Free Transform). Cells are a few pixels across in +//! both the source and the destination (a thin layer stretched far gets as many cells as its +//! warped size needs), so the piecewise-affine inverse is within a small fraction of a pixel of +//! the true one; affine //! maps (the identity included) are reproduced exactly. Where the warp shrinks the source, each //! pixel instead averages a grid of bilinear subsamples over its source footprint (about one per //! source pixel), so thin lines don't alias. Where the warp folds over itself the @@ -77,8 +79,11 @@ pub fn warp_mesh_surface(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) -> return Surface::new(PixelFormat::new(fmt.mode, fmt.sample, true)); } let (sw, sh) = (f64::from(src_rect.width()), f64::from(src_rect.height())); - let nx = ((sw / CELL).ceil() as usize).clamp(1, MAX_CELLS); - let ny = ((sh / CELL).ceil() as usize).clamp(1, MAX_CELLS); + // Cells are a few pixels across on both sides of the map: a thin source stretched far (a + // 3 px column scaled into a wide band, then bent) needs many cells, or it comes out straight. + let (lu, lv) = mapped_lengths(src_rect, f); + let cells = |s: f64, d: f64| ((s.max(d) / CELL).ceil() as usize).clamp(1, MAX_CELLS); + let (nx, ny) = (cells(sw, lu), cells(sh, lv)); // Forward-mapped vertices (and their source positions). let mut verts = Vec::with_capacity((nx + 1) * (ny + 1)); for j in 0..=ny { @@ -101,6 +106,43 @@ pub fn warp_mesh_surface(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) -> warp_triangles(src, src_rect, &verts, &tris, interp) } +/// Probe lines per axis [`mapped_lengths`] follows through the map. +const PROBE: usize = 16; + +/// The longest destination length of a source row and of a source column under `f`, measured +/// along polylines through a [`PROBE`]-step grid. Non-finite steps count as zero. +fn mapped_lengths(src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync)) -> (f64, f64) { + let (x0, y0) = (f64::from(src_rect.x0), f64::from(src_rect.y0)); + let (sw, sh) = (f64::from(src_rect.width()), f64::from(src_rect.height())); + let n = PROBE + 1; + let mut pts = Vec::with_capacity(n * n); + for j in 0..n { + for i in 0..n { + let (dx, dy) = f(x0 + sw * i as f64 / PROBE as f64, y0 + sh * j as f64 / PROBE as f64); + pts.push([dx, dy]); + } + } + let step = |a: [f64; 2], b: [f64; 2]| { + let d = (b[0] - a[0]).hypot(b[1] - a[1]); + if d.is_finite() { d } else { 0.0 } + }; + let (mut lu, mut lv) = (0.0f64, 0.0f64); + for k in 0..n { + let (mut row, mut col) = (0.0, 0.0); + for m in 1..n { + if let (Some(&a), Some(&b)) = (pts.get(k * n + m - 1), pts.get(k * n + m)) { + row += step(a, b); + } + if let (Some(&a), Some(&b)) = (pts.get((m - 1) * n + k), pts.get(m * n + k)) { + col += step(a, b); + } + } + lu = lu.max(row); + lv = lv.max(col); + } + (lu, lv) +} + /// Rasterizes textured triangles: each vertex is `(destination, source)` in document pixels and /// each triangle samples `src` (restricted to `src_rect`) through the barycentric /// interpolation of its source positions. Later triangles in `tris` draw over earlier ones (the @@ -399,6 +441,28 @@ mod tests { surf } + #[test] + fn a_thin_layer_stretched_into_a_curve_keeps_the_curve() { + // #5793: a 3 px wide column stretched 100x and bent into an arch. The grid was sized in + // source pixels (one cell across), so the arch came out as a straight band. + let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true)); + src.fill_rect(Rect::new(0, 0, 3, 40), &[1.0, 0.0, 0.0, 1.0]); + let arch = |x: f64, y: f64| { + let dx = x * 100.0; + let t = (dx - 150.0) / 150.0; + (dx, y + 40.0 * (1.0 - t * t)) + }; + let out = warp_mesh_surface(&src, Rect::new(0, 0, 3, 40), &arch, Interp::Bilinear); + let alpha = |x: i32, y: i32| { + let mut p = [0.0f32; 4]; + out.read_pixel(x, y, &mut p); + p[3] + }; + // The band follows the arch: 40 px lower in the middle, about 36 px a third of the way in. + assert!(alpha(150, 60) > 0.99 && alpha(150, 20) < 0.01, "mid {} {}", alpha(150, 60), alpha(150, 20)); + assert!(alpha(100, 55) > 0.99 && alpha(100, 20) < 0.01, "third {} {}", alpha(100, 55), alpha(100, 20)); + } + #[test] fn a_mesh_warp_far_off_canvas_writes_only_the_two_regions() { // #995: the old and warped regions were cleared as one dense `src ∪ warped` buffer, about diff --git a/crates/algo/tests/warp_clipped.rs b/crates/algo/tests/warp_clipped.rs new file mode 100644 index 0000000000..ac6497f13f --- /dev/null +++ b/crates/algo/tests/warp_clipped.rs @@ -0,0 +1,59 @@ +use photocraft_algo::transform::{Homography, Interp, warp_surface, warp_surface_clipped}; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +#[test] +fn clipped_warp_matches_the_existing_kernel_at_every_depth() { + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut source = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true)); + source.fill_rect(Rect::new(8, 12, 40, 44), &[0.8, 0.2, 0.4, 0.7]); + source.fill_rect(Rect::new(20, 24, 32, 36), &[0.1, 0.7, 0.3, 0.4]); + let clip = Rect::new(10, 10, 52, 52); + for interpolation in [Interp::Nearest, Interp::Bilinear, Interp::Bicubic] { + for h in [Homography([1.2, -0.3, 7.0, 0.3, 1.2, -5.0, 0.0, 0.0, 1.0]), Homography([0.125, 0.0, 24.0, 0.0, 0.125, 30.0, 0.0, 0.0, 1.0])] { + let full = warp_surface(&source, source.content_bounds(), &h, interpolation).unwrap(); + let bounded = warp_surface_clipped(&source, source.content_bounds(), &h, interpolation, clip).unwrap(); + assert_eq!(full.read_region(clip), bounded.read_region(clip)); + assert!(clip.contains_rect(&bounded.content_bounds())); + assert!(bounded.tiles().count() <= clip.tiles().count()); + } + } + } +} + +#[test] +fn huge_scaled_warp_allocates_only_the_destination_clip() { + let mut source = Surface::new(PixelFormat::RGBA8); + source.fill_rect(Rect::new(8, 8, 12, 12), &[0.2, 0.7, 0.4, 1.0]); + let clip = Rect::new(0, 0, 64, 64); + let h = Homography([10_000.0, 0.0, -99_968.0, 0.0, 10_000.0, -99_968.0, 0.0, 0.0, 1.0]); + let output = warp_surface_clipped(&source, source.content_bounds(), &h, Interp::Bicubic, clip).unwrap(); + assert!(clip.contains_rect(&output.content_bounds())); + assert!(output.tiles().count() <= clip.tiles().count()); + assert_eq!(output.pixel(32, 32), source.pixel(10, 10)); + assert!(warp_surface_clipped(&source, source.content_bounds(), &h, Interp::Bicubic, Rect::EMPTY).unwrap().tiles().next().is_none()); +} + +#[test] +fn inverse_footprint_handles_extreme_finite_affines() { + let mut source = Surface::new(PixelFormat::RGBA8); + source.fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 0.0, 0.0, 1.0]); + // A strongly skewed inverse maps a small visible tile beyond i32 limits. + let h = Homography([1.0, 1e12, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]); + let clip = Rect::new(0, 0, 32, 32); + let output = warp_surface_clipped(&source, source.content_bounds(), &h, Interp::Nearest, clip).unwrap(); + assert!(clip.contains_rect(&output.content_bounds())); + assert!(output.tiles().count() <= clip.tiles().count()); +} + +#[test] +fn source_center_does_not_add_extreme_coordinates_in_i32() { + let mut source = Surface::new(PixelFormat::RGBA8); + let origin = 1_500_000_000; + let bounds = Rect::new(origin, origin, origin + 4, origin + 4); + source.fill_rect(bounds, &[0.2, 0.7, 0.4, 1.0]); + let h = Homography([1.0, 0.0, -f64::from(origin), 0.0, 1.0, -f64::from(origin), 0.0, 0.0, 1.0]); + let output = warp_surface_clipped(&source, bounds, &h, Interp::Nearest, Rect::new(0, 0, 8, 8)).unwrap(); + assert_eq!(output.pixel(2, 2), source.pixel(origin + 2, origin + 2)); +} diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index dc01f0f2b4..15a0e24e30 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -29,8 +29,8 @@ use serde_json::{Value, json}; use crate::budgets::{BatchReplyBudget, write_reply}; use crate::security::{ - ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, discard_rest_of_line, - read_bounded_line, repeat_auth_reply, + AUTH_DEADLINE, AuthDeadlineStream, ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, + configure_stream, discard_rest_of_line, read_bounded_line, repeat_auth_reply, }; use crate::{AutomationError, Headless}; @@ -262,10 +262,12 @@ pub fn serve_lines(h: &Mutex, mut r: impl BufRead, mut w: impl Write) } } -fn serve_tcp_connection(h: &Mutex, stream: std::net::TcpStream, token: &str) -> std::io::Result<()> { +/// Serve one connection. It must complete `auth` within `auth_deadline` of connecting, however +/// much it sends meanwhile (#4965); after that only the idle timeout applies. +fn serve_tcp_connection(h: &Mutex, stream: std::net::TcpStream, token: &str, auth_deadline: std::time::Duration) -> std::io::Result<()> { configure_stream(&stream)?; let read = stream.try_clone()?; - let mut reader = std::io::BufReader::new(read); + let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(read, auth_deadline)); let mut out = stream; let mut line = String::new(); let mut authenticated = false; @@ -290,6 +292,9 @@ fn serve_tcp_connection(h: &Mutex, stream: std::net::TcpStream, token: } else { let (reply, ok) = authentication_reply(&line, token); authenticated = ok; + if ok { + reader.get_mut().authenticated()?; + } reply }; write_reply(&mut out, &reply)?; @@ -322,7 +327,7 @@ pub fn serve_tcp(addr: &str, h: Arc>, token: String, ready: impl let token = Arc::clone(&token); std::thread::spawn(move || { let _permit = permit; - let _ = serve_tcp_connection(&h, stream, &token); + let _ = serve_tcp_connection(&h, stream, &token, AUTH_DEADLINE); }); } Ok(()) @@ -814,6 +819,60 @@ mod tests { assert!(v["result"].as_array().unwrap().iter().any(|m| m == "batch")); } + /// #4965: a connection that keeps sending without authenticating is closed at the deadline, + /// and one that authenticated in time keeps being served after it. + #[test] + fn tcp_connections_must_authenticate_before_the_deadline_whatever_they_send() { + use std::io::{BufReader, Write as _}; + use std::time::{Duration, Instant}; + const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let h = Mutex::new(Headless::new()); + let mut results = Vec::new(); + for _ in 0..2 { + let (stream, _) = listener.accept().unwrap(); + let started = Instant::now(); + let r = serve_tcp_connection(&h, stream, TOKEN, Duration::from_millis(300)); + results.push((r.map_err(|e| e.kind()), started.elapsed())); + } + results + }); + + // Never a newline, a byte every 20 ms: each read succeeds long before the idle timeout. + let mut slow = std::net::TcpStream::connect(addr).unwrap(); + for _ in 0..150 { + if slow.write_all(b" ").is_err() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + drop(slow); + + // Authenticates at once, then asks again 300 ms after the deadline. + let mut s = std::net::TcpStream::connect(addr).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + writeln!(s, r#"{{"id":"auth","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap(); + let mut reader = BufReader::new(s.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert_eq!(serde_json::from_str::(&line).unwrap()["result"]["authenticated"], true); + std::thread::sleep(Duration::from_millis(600)); + writeln!(s, r#"{{"id":"a","method":"methods"}}"#).unwrap(); + line.clear(); + reader.read_line(&mut line).unwrap(); + assert_eq!(serde_json::from_str::(&line).unwrap()["id"], "a"); + drop(reader); + drop(s); + + let results = server.join().unwrap(); + let (slow_result, slow_time) = &results[0]; + assert!(matches!(slow_result, Err(std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock)), "{slow_result:?}"); + assert!(*slow_time < Duration::from_secs(3), "the trickle did not extend the deadline: {slow_time:?}"); + assert_eq!(results[1].0, Ok(()), "the authenticated connection ended with its client"); + } + #[test] fn tcp_repeat_auth_reports_already_authenticated_and_keeps_serving() { use std::io::{BufReader, Write as _}; diff --git a/crates/automation/src/security.rs b/crates/automation/src/security.rs index 4659a3ff17..7928420b12 100644 --- a/crates/automation/src/security.rs +++ b/crates/automation/src/security.rs @@ -1,11 +1,11 @@ //! Shared security primitives for the loopback JSON control transports. use std::fs::OpenOptions; -use std::io::{BufRead, Write}; +use std::io::{BufRead, Read, Write}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::time::Duration; +use std::time::{Duration, Instant}; use serde_json::{Value, json}; @@ -21,6 +21,10 @@ pub const MAX_CONNECTIONS: usize = 16; pub const MAX_BATCH_STEPS: usize = 256; /// Idle/read and write timeout for loopback TCP connections. pub const IO_TIMEOUT: Duration = Duration::from_secs(30); +/// How long a new TCP connection has to authenticate, however active it is. [`IO_TIMEOUT`] starts +/// again with every read, so a client that kept sending without ever completing `auth` held one +/// of the [`MAX_CONNECTIONS`] permits for as long as it liked (#4965). +pub const AUTH_DEADLINE: Duration = Duration::from_secs(10); const TOKEN_BYTES: usize = 32; const TOKEN_HEX_LEN: usize = TOKEN_BYTES * 2; @@ -242,6 +246,44 @@ pub fn configure_stream(stream: &std::net::TcpStream) -> std::io::Result<()> { stream.set_write_timeout(Some(IO_TIMEOUT)) } +/// The read half of a control connection. Until [`Self::authenticated`] is called, reads fail +/// with `TimedOut` once the authentication deadline has passed, whatever arrived in the meantime; +/// the caller then drops the connection and its [`ConnectionPermit`]. +pub struct AuthDeadlineStream { + stream: std::net::TcpStream, + /// `None` once authenticated. + deadline: Option, +} + +impl AuthDeadlineStream { + /// `stream` must authenticate within `limit` from now (normally [`AUTH_DEADLINE`]). + pub fn new(stream: std::net::TcpStream, limit: Duration) -> Self { + // A limit too large to represent expires at once rather than never. + let now = Instant::now(); + Self { stream, deadline: Some(now.checked_add(limit).unwrap_or(now)) } + } + + /// The connection authenticated: only the idle timeout applies from here on. + pub fn authenticated(&mut self) -> std::io::Result<()> { + self.deadline = None; + self.stream.set_read_timeout(Some(IO_TIMEOUT)) + } +} + +impl Read for AuthDeadlineStream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if let Some(deadline) = self.deadline { + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "not authenticated in time")); + } + // Wait no longer than what is left, so a silent client is dropped at the deadline too. + self.stream.set_read_timeout(Some(left.min(IO_TIMEOUT)))?; + } + self.stream.read(buf) + } +} + /// Environment-aware token inputs shared by the desktop app and CLI. pub fn token_inputs(supplied: Option, token_file: Option) -> (Option, Option) { let supplied = supplied.or_else(|| std::env::var("PHOTOCRAFT_CONTROL_TOKEN").ok()); @@ -327,6 +369,70 @@ mod tests { std::fs::remove_file(path).unwrap(); } + /// A connected loopback pair: the accepted (server) end and the client end. + fn tcp_pair() -> (std::net::TcpStream, std::net::TcpStream) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (server, _) = listener.accept().unwrap(); + configure_stream(&server).unwrap(); + (server, client) + } + + /// #4965: activity must not extend the time a connection may stay unauthenticated. + #[test] + fn an_active_connection_is_dropped_at_the_authentication_deadline() { + let (server, mut client) = tcp_pair(); + // A byte every 20 ms and never a newline: each read succeeds long before the idle timeout. + let trickle = std::thread::spawn(move || { + for _ in 0..150 { + if client.write_all(b"x").is_err() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + }); + let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(300))); + let mut line = String::new(); + let started = Instant::now(); + let err = read_bounded_line(&mut reader, &mut line).unwrap_err(); + assert!(matches!(err.kind(), std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock), "{err}"); + assert!(started.elapsed() >= Duration::from_millis(250), "not before the deadline: {:?}", started.elapsed()); + assert!(started.elapsed() < Duration::from_secs(3), "the trickle did not extend it: {:?}", started.elapsed()); + // It stays expired. + assert!(read_bounded_line(&mut reader, &mut line).is_err()); + drop(reader); + trickle.join().unwrap(); + } + + #[test] + fn a_silent_connection_is_dropped_at_the_authentication_deadline() { + let (server, _client) = tcp_pair(); + let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(200))); + let mut line = String::new(); + let started = Instant::now(); + assert!(read_bounded_line(&mut reader, &mut line).is_err()); + assert!(started.elapsed() < Duration::from_secs(3), "{:?}", started.elapsed()); + } + + #[test] + fn an_authenticated_connection_outlives_the_authentication_deadline() { + let (server, mut client) = tcp_pair(); + let sender = std::thread::spawn(move || { + client.write_all(b"one\n").unwrap(); + std::thread::sleep(Duration::from_millis(500)); + client.write_all(b"two\n").unwrap(); + }); + let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(200))); + let mut line = String::new(); + assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line); + assert_eq!(line, "one\n"); + reader.get_mut().authenticated().unwrap(); + // Sent 300 ms after the deadline: only the idle timeout applies now. + assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line); + assert_eq!(line, "two\n"); + sender.join().unwrap(); + } + #[test] fn connection_limiter_releases_capacity() { let limiter = ConnectionLimiter::new(1); diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index c5a0c3b180..f223bc5109 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -325,7 +325,10 @@ pub struct UiSetParams { /// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use /// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool, /// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the - /// active layer's storage tiles). Other fields are an error. + /// active layer's storage tiles). layerFilter (an object patching the Layers panel filter: + /// by: kind/name/mode; kinds: a list of pixel/adjustment/type/shape/smart; name: a string; + /// mode: a blend mode name such as Multiply or Pass Through). Omitted filter fields keep + /// their current values. Other fields are an error. pub fields: Value, } diff --git a/crates/cms/README.md b/crates/cms/README.md index 6ae6a76cfb..c48d5366fd 100644 --- a/crates/cms/README.md +++ b/crates/cms/README.md @@ -1,6 +1,6 @@ # photocraft-cms -Pure-Rust ICC colour management for Photocraft. No workspace dependencies (L0, publishable), +Pure-Rust ICC colour management for PhotoCraft. No workspace dependencies (L0, publishable), no C, no `unsafe`. Implemented from the public ICC.1:2010 (v4.3) and ICC.1:2001-04 (v2) specifications, CIE 15, and Adobe's published black point compensation paper. @@ -53,13 +53,13 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou | `rec2020` | Rec. 2020 primaries, Rec. 709 OETF | | `gray-gamma-2.2`, `sgray` | gray gamma 2.2; gray with the sRGB curve (default gray working space) | | `lab-d50` | Lab identity (v4 encoding) | -| `coated-cmyk` | **Photocraft Coated CMYK (synthetic, 300% TAC, medium GCR)** — default CMYK | +| `coated-cmyk` | **PhotoCraft Coated CMYK (synthetic, 300% TAC, medium GCR)** — default CMYK | ### The CMYK profile Adobe's CMYK profiles (U.S. Web Coated SWOP, Coated FOGRA39) are proprietary, and the freely downloadable characterisation-based profiles (ECI/FOGRA, colord, Ghostscript) carry -redistribution conditions or unclear licences, so Photocraft generates its own +redistribution conditions or unclear licences, so PhotoCraft generates its own (`src/synth.rs`): a Yule–Nielsen modified Neugebauer model (n = 2) over the 16 overprints with ISO 12647-2 coated-paper Lab aims for paper/C/M/Y/RGB overprints, parabolic dot gain (14 % CMY, 17 % K at 50 %), GCR black generation (starts at 20 % gray component, K max 95 %), total ink diff --git a/crates/cms/profiles/photocraft-coated-cmyk.icc b/crates/cms/profiles/photocraft-coated-cmyk.icc index 0d2d1da16d..b553fe9cec 100644 Binary files a/crates/cms/profiles/photocraft-coated-cmyk.icc and b/crates/cms/profiles/photocraft-coated-cmyk.icc differ diff --git a/crates/cms/src/builtin.rs b/crates/cms/src/builtin.rs index cd82883a50..8f3dc88a15 100644 --- a/crates/cms/src/builtin.rs +++ b/crates/cms/src/builtin.rs @@ -83,15 +83,15 @@ impl Builtin { match self { Builtin::Srgb => "sRGB IEC61966-2.1", Builtin::DisplayP3 => "Display P3", - Builtin::AdobeRgbCompat => "Adobe RGB (1998)-compatible (Photocraft)", - Builtin::ProPhotoCompat => "ProPhoto RGB-compatible (ROMM primaries, Photocraft)", - Builtin::LinearSrgb => "Linear sRGB (Photocraft)", - Builtin::AcesCg => "ACEScg (AP1, linear, Photocraft)", - Builtin::Rec2020 => "Rec. ITU-R BT.2020 (Photocraft)", - Builtin::GrayGamma22 => "Gray Gamma 2.2 (Photocraft)", - Builtin::SGray => "sGray (sRGB tone curve, Photocraft)", - Builtin::DotGain20Compat => "Dot Gain 20%-compatible (gamma 1.732, Photocraft)", - Builtin::LabD50 => "Lab D50 identity (Photocraft)", + Builtin::AdobeRgbCompat => "Adobe RGB (1998)-compatible (PhotoCraft)", + Builtin::ProPhotoCompat => "ProPhoto RGB-compatible (ROMM primaries, PhotoCraft)", + Builtin::LinearSrgb => "Linear sRGB (PhotoCraft)", + Builtin::AcesCg => "ACEScg (AP1, linear, PhotoCraft)", + Builtin::Rec2020 => "Rec. ITU-R BT.2020 (PhotoCraft)", + Builtin::GrayGamma22 => "Gray Gamma 2.2 (PhotoCraft)", + Builtin::SGray => "sGray (sRGB tone curve, PhotoCraft)", + Builtin::DotGain20Compat => "Dot Gain 20%-compatible (gamma 1.732, PhotoCraft)", + Builtin::LabD50 => "Lab D50 identity (PhotoCraft)", Builtin::CoatedCmyk => crate::synth::DESCRIPTION, } } @@ -146,7 +146,7 @@ pub fn srgb() -> &'static Profile { /// only assigned to such files, never offered as a working space. pub fn linear_gray() -> &'static Profile { static PROFILE: OnceLock = OnceLock::new(); - PROFILE.get_or_init(|| gray("Linear Gray (Photocraft)", Curve::Gamma(1.0))) + PROFILE.get_or_init(|| gray("Linear Gray (PhotoCraft)", Curve::Gamma(1.0))) } /// The ICC D65 used for the RGB spaces (chromaticity 0.3127, 0.3290). diff --git a/crates/cms/src/synth.rs b/crates/cms/src/synth.rs index 657c829f31..96a23dbfce 100644 --- a/crates/cms/src/synth.rs +++ b/crates/cms/src/synth.rs @@ -29,7 +29,7 @@ use crate::math; use crate::pipeline::Stage; use crate::profile::{ColorSpace, Lut, LutKind, Pcs, Profile, ProfileClass}; -pub const DESCRIPTION: &str = "Photocraft Coated CMYK (synthetic, 300% TAC, medium GCR)"; +pub const DESCRIPTION: &str = "PhotoCraft Coated CMYK (synthetic, 300% TAC, medium GCR)"; /// Parameters of the synthetic profile. #[derive(Clone, Debug)] diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 7486d46504..d4f7094894 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -47,6 +47,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?; | Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` | | FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in | | DPX | yes | yes | U8, U16 (10/12-bit code values read and written exactly through U16) | Gray, RGB, RGBA | yes | no | no | no | no | DPX header fields only (creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate, time code) | no | built in | +| Cineon | yes | **no** | U16 (10-bit code values) | Gray, RGB | no | no | no | no | no | no | n/a | built in | | AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` | | HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` | @@ -93,7 +94,15 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them big-endian, one unencoded element, at `EncodeOptions::dpx_bits` (8/10/12/16; by default 8 for U8 and 16 otherwise), packing 1 for 10 and 12 bits; gray with alpha is written as luma. Other descriptors, signed or run-length-encoded data and other bit depths return - `CodecError::Unsupported` naming the field. Cineon (`.cin`) is not supported. + `CodecError::Unsupported` naming the field. + +* **Cineon (read-only).** Kodak's 10-bit logarithmic film-scan format (the one DPX grew from), + read from the same Kodak header family: the generic 1024-byte header, big-endian, one (Gray) + or three (RGB) channels, three 10-bit samples packed into each 32-bit word, lines on a word + boundary. The 10-bit code values open as U16 `round(c × 65535 / 1023)`; the file's black/white + reference points are a log-conversion choice and are not applied. A depth other than 10 bits or + a channel count other than 1/3 returns `CodecError::Unsupported`; truncation errors rather than + panicking. There is no Cineon writer (`ASYMMETRIC_EXCEPTIONS`); save as DPX, TIFF or EXR. * **Camera raw files** (DNG, CR2, NEF, ARW… which are TIFF-structured) are recognised and refused with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes diff --git a/crates/codecs/src/codecs/deep_exr.rs b/crates/codecs/src/codecs/deep_exr.rs index 0726228be5..29f3762ebc 100644 --- a/crates/codecs/src/codecs/deep_exr.rs +++ b/crates/codecs/src/codecs/deep_exr.rs @@ -23,7 +23,7 @@ use half::f16; use crate::Format; use crate::error::CodecError; use crate::image::{ChannelLayout, DecodeWarning, DeepChannel, DeepImage, Image, SampleType}; -use crate::options::Limits; +use crate::options::{Limits, alloc_zeroed}; const F: Format = Format::OpenExr; @@ -39,9 +39,15 @@ fn unsup(reason: impl Into) -> CodecError { // Every length and offset comes from the file, so every read is bounds-checked and a short // read is a malformed file, never a panic. -fn read_exact(cur: &mut Cursor<&[u8]>, n: usize, what: &str) -> Result, CodecError> { - let mut v = vec![0u8; n]; - cur.read_exact(&mut v).map_err(|_| err(format!("truncated file while reading {what}")))?; +fn read_exact<'a>(cur: &mut Cursor<&'a [u8]>, n: usize, what: &str) -> Result<&'a [u8], CodecError> { + // Borrow packed payloads from the input. Copying them here allocates before count-table + // admission and needlessly duplicates the table even when the second pass just skips it. + let truncated = || err(format!("truncated file while reading {what}")); + let start = usize::try_from(cur.position()).map_err(|_| truncated())?; + let end = start.checked_add(n).ok_or_else(truncated)?; + let bytes = *cur.get_ref(); + let v = bytes.get(start..end).ok_or_else(truncated)?; + cur.set_position(end as u64); Ok(v) } @@ -81,10 +87,11 @@ fn differences_to_samples(buf: &mut [u8]) { } /// Undo the ZIP half-split: the stream is `[first half][second half]` with the second half -/// holding every second byte of the original; weave them back together. -fn interleave_byte_blocks(buf: &mut [u8]) { +/// holding every second byte of the original; weave them back together. The scratch copy is +/// allocated fallibly, so a buffer that passed `max_alloc` but not the allocator is an error. +fn interleave_byte_blocks(buf: &mut [u8]) -> Result<(), CodecError> { let half = buf.len().div_ceil(2); - let mut out = vec![0u8; buf.len()]; + let mut out = alloc_zeroed::(buf.len())?; let mut k = 0usize; for i in 0..buf.len() / 2 { let (a, b) = (buf.get(i).copied(), buf.get(half + i).copied()); @@ -105,12 +112,29 @@ fn interleave_byte_blocks(buf: &mut [u8]) { } } buf.copy_from_slice(&out); + Ok(()) +} + +/// Make room in `out` for `extra` more bytes, `limit` in all. Grows by doubling like `Vec`, +/// but never reserves past `limit`: callers admit exactly that size against `max_alloc`, and +/// plain doubling reserved up to twice as much. Fallible, so a failed allocation is an error. +fn reserve_up_to(out: &mut Vec, extra: usize, limit: usize, what: &str) -> Result<(), CodecError> { + let need = out.len().saturating_add(extra); + if need > limit { + return Err(err(format!("{what}: data expands past {limit} bytes"))); + } + if need > out.capacity() { + let target = out.capacity().saturating_mul(2).max(need).min(limit); + out.try_reserve_exact(target.saturating_sub(out.len())).map_err(|_| CodecError::LimitExceeded(format!("cannot allocate {target} bytes for {what}")))?; + } + Ok(()) } /// OpenEXR RLE tokens: a negative byte `n` means `-n` literal bytes follow, a non-negative /// byte `n` means the next byte repeats `n + 1` times (as `ImfRle.cpp` and the `exr` crate). fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result, CodecError> { - let mut out = Vec::with_capacity(expected.min(1 << 20)); + let mut out = Vec::new(); + reserve_up_to(&mut out, expected.min(1 << 20), expected, what)?; let mut i = 0; while i < src.len() { let count = src[i] as i8; @@ -119,6 +143,7 @@ fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result, CodecE if out.len().saturating_add(n) > expected { return Err(err(format!("{what}: RLE data expands past {expected} bytes"))); } + reserve_up_to(&mut out, n, expected, what)?; if count < 0 { let end = i.saturating_add(n); let run = src.get(i..end.min(src.len())).ok_or_else(|| err(format!("{what}: RLE run points past the compressed data")))?; @@ -143,7 +168,10 @@ fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result, CodecE /// has the uncompressed length is stored raw whatever the declared compression. fn decompress(compression: Compression, packed: &[u8], expected: usize, what: &str) -> Result, CodecError> { if packed.len() == expected { - return Ok(packed.to_vec()); + let mut out = Vec::new(); + reserve_up_to(&mut out, expected, expected, what)?; + out.extend_from_slice(packed); + return Ok(out); } match compression { Compression::Uncompressed => Err(err(format!("{what}: expected {expected} uncompressed bytes, found {}", packed.len()))), @@ -151,20 +179,38 @@ fn decompress(compression: Compression, packed: &[u8], expected: usize, what: &s // Like ZIPS, RLE runs over the predicted, byte-split stream. let mut out = rle_decode(packed, expected, what)?; differences_to_samples(&mut out); - interleave_byte_blocks(&mut out); + interleave_byte_blocks(&mut out)?; Ok(out) } Compression::ZIP1 => { - let mut out = Vec::with_capacity(expected.min(1 << 20)); - flate2::read::ZlibDecoder::new(packed) - .take(expected as u64 + 1) - .read_to_end(&mut out) - .map_err(|_| err(format!("{what}: zlib data is malformed")))?; + // Inflate straight into `out`, grown with `reserve_up_to` rather than by + // `read_to_end`, whose doubling reserves past `expected`. Once `expected` bytes are + // in, a one-byte read checks that the stream ends there. + let malformed = || err(format!("{what}: zlib data is malformed")); + let mut zlib = flate2::read::ZlibDecoder::new(packed); + let mut out = Vec::new(); + let mut filled = 0; + while filled < expected { + if filled == out.len() { + let grow = out.len().max(1 << 13).min(expected - filled); + reserve_up_to(&mut out, grow, expected, what)?; + out.resize(filled + grow, 0); + } + let n = zlib.read(out.get_mut(filled..).unwrap_or_default()).map_err(|_| malformed())?; + if n == 0 { + break; + } + filled += n; + } + out.truncate(filled); + if filled == expected && zlib.read(&mut [0u8; 1]).map_err(|_| malformed())? != 0 { + return Err(err(format!("{what}: zlib data expands past {expected} bytes"))); + } if out.len() != expected { return Err(err(format!("{what}: zlib data expands to {} bytes, expected {expected}", out.len()))); } differences_to_samples(&mut out); - interleave_byte_blocks(&mut out); + interleave_byte_blocks(&mut out)?; Ok(out) } other => Err(unsup(format!("deep data with {other:?} compression (NONE, RLE and ZIPS are supported)"))), @@ -340,13 +386,41 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res if packed_table > remaining || packed_data > remaining.saturating_sub(packed_table) { return Err(err("deep chunk sizes point past the end of the file")); } - let table_bytes = read_exact(&mut cur, packed_table as usize, "the sample-count table")?; + let what = "the sample-count table"; + let table_bytes = read_exact(&mut cur, packed_table as usize, what)?; let expected_table = block_pixels.len().checked_mul(4).ok_or_else(|| err("count table overflows"))?; + // A compressed clipped table also needs its scratch copy. The data-window check only + // budgets four bytes per channel per pixel, insufficient for a one-channel image. + // An equal-sized payload takes decompress's raw path, which allocates no scratch. + let buffers = if table_bytes.len() == expected_table { 1 } else { 2 }; + let clipped_need = (expected_table as u64).saturating_mul(buffers); + if clipped_need > limits.max_alloc { + return Err(CodecError::LimitExceeded(format!( + "{what} needs {clipped_need} bytes including decompression scratch, exceeding max_alloc {}", + limits.max_alloc + ))); + } // An edge tile's table may still be stored at the full tile size (its pixels first, in // rows of the clipped width, then unused entries), as the OpenEXR library writes it. - let full_table = tile_w.saturating_mul(tile_h).saturating_mul(4).max(expected_table); - let raw = decompress(compression, &table_bytes, full_table, "the sample-count table") - .or_else(|_| decompress(compression, &table_bytes, expected_table, "the sample-count table"))?; + // That size comes from the header's tile size alone, which nothing ties to the data + // window, so it is tried only when `max_alloc` has room for the table and for the copy + // that undoes its byte split. Otherwise only the clipped size is read. + let full_table = tile_w.checked_mul(tile_h).and_then(|n| n.checked_mul(4)); + let raw = match full_table { + // Scan lines and interior tiles: the full size is the clipped size. + Some(full) if full <= expected_table => decompress(compression, table_bytes, expected_table, what)?, + Some(full) if (full as u64).saturating_mul(2) <= limits.max_alloc => { + decompress(compression, table_bytes, full, what).or_else(|_| decompress(compression, table_bytes, expected_table, what))? + } + _ => decompress(compression, table_bytes, expected_table, what).map_err(|_| { + // Four bytes per pixel of the tile, twice: the table and its scratch copy. + let need = (tile_w as u64).saturating_mul(tile_h as u64).saturating_mul(8); + CodecError::LimitExceeded(format!( + "{what} of a {tile_w}x{tile_h} deep tile does not decode at the clipped size, and at the full tile size it needs {need} bytes with its scratch copy, exceeding max_alloc {}", + limits.max_alloc + )) + })?, + }; // The table is cumulative within each row of the block (a tile restarts at every row); // `prefix` is cumulative over the whole block, and each pixel's own count is the difference. @@ -423,7 +497,7 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res // The count table sits between the sizes and the sample data; skip its bytes. read_exact(&mut cur, packed_table as usize, "the sample-count table")?; let data_bytes = read_exact(&mut cur, chunk.packed_data as usize, "the deep sample data")?; - let raw = decompress(compression, &data_bytes, chunk.unpacked_data as usize, "the deep sample data")?; + let raw = decompress(compression, data_bytes, chunk.unpacked_data as usize, "the deep sample data")?; // Row by row (a scan line block is one row), and within a row channel by channel, as // flat tiles are laid out: each pixel's samples are contiguous within its channel. let mut in_off = 0usize; @@ -611,3 +685,82 @@ pub(crate) fn flatten(deep: &DeepImage) -> Result { img.warnings.push(DecodeWarning::DeepFlattened { format: F, max_samples_per_pixel: max_samples }); Ok(img) } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write as _; + + #[test] + fn packed_reads_borrow_input_and_reject_bad_sizes_before_allocation() { + let bytes = [1u8, 2, 3, 4]; + let mut cur = Cursor::new(bytes.as_slice()); + let prefix = read_exact(&mut cur, 3, "x").expect("three input bytes"); + assert_eq!(prefix, &[1, 2, 3]); + assert_eq!(prefix.as_ptr(), bytes.as_ptr()); + assert_eq!(cur.position(), 3); + assert!(matches!(read_exact(&mut cur, usize::MAX, "x"), Err(CodecError::Malformed { .. }))); + assert_eq!(cur.position(), 3); + assert_eq!(read_exact(&mut cur, 1, "x").expect("last byte"), &[4]); + assert!(matches!(read_exact(&mut cur, 1, "x"), Err(CodecError::Malformed { .. }))); + cur.set_position(u64::MAX); + assert!(matches!(read_exact(&mut cur, 1, "x"), Err(CodecError::Malformed { .. }))); + } + + /// What the writer stores before compressing: the byte split, then the +128 predictor. + fn predicted(raw: &[u8]) -> Vec { + let half = raw.len().div_ceil(2); + let mut out = vec![0u8; raw.len()]; + for (i, &v) in raw.iter().enumerate() { + out[if i % 2 == 0 { i / 2 } else { half + i / 2 }] = v; + } + let mut prev = 0i16; + for (i, b) in out.iter_mut().enumerate() { + let v = i16::from(*b); + if i > 0 { + *b = (v - prev + 128) as u8; + } + prev = v; + } + out + } + + /// #4958: callers admit `expected` bytes for a decompressed buffer (twice that for a count + /// table, with the copy that undoes its byte split), so decompression must not reserve + /// more. `Vec`'s doubling gave a 4 MiB ZIPS table 8 MiB of capacity and a 3 MiB RLE table + /// 4 MiB, so the "admitted" table and its copy took up to three times the table size. + #[test] + fn decompressed_buffers_reserve_no_more_than_expected() { + for n in [1usize << 20, 2 << 20, (3 << 20) + 1, 4 << 20, 5 << 20] { + let raw = vec![0u8; n]; + let p = predicted(&raw); + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + z.write_all(&p).expect("write to a Vec"); + let zips = z.finish().expect("finish a Vec"); + // RLE: the first byte as a literal, then runs of up to 128 copies of the next. + let mut rle = vec![0xFF, p[0]]; + for run in p[1..].chunks(128) { + rle.extend_from_slice(&[(run.len() - 1) as u8, run[0]]); + } + for (compression, packed) in [(Compression::ZIP1, &zips), (Compression::RLE, &rle)] { + let out = decompress(compression, packed, n, "the table").expect("decompresses"); + assert_eq!(out, raw, "{compression:?}, {n} bytes"); + assert!(out.capacity() <= n, "{compression:?}: a {n} byte buffer reserved {} bytes", out.capacity()); + } + // One byte short, the same data expands past the expected size and is refused. + for (compression, packed) in [(Compression::ZIP1, &zips), (Compression::RLE, &rle)] { + assert!(decompress(compression, packed, n - 1, "the table").is_err(), "{compression:?}, {n} bytes"); + } + } + } + + /// A size past the limit is malformed data, and a reservation the allocator refuses is an + /// error rather than an abort. + #[test] + fn reservations_past_the_limit_or_the_allocator_are_errors() { + let mut v = Vec::new(); + assert!(matches!(reserve_up_to(&mut v, 2, 1, "x"), Err(CodecError::Malformed { .. }))); + assert!(matches!(reserve_up_to(&mut v, isize::MAX as usize + 1, usize::MAX, "x"), Err(CodecError::LimitExceeded(_)))); + assert_eq!(v.capacity(), 0); + } +} diff --git a/crates/codecs/src/codecs/dpx.rs b/crates/codecs/src/codecs/dpx.rs index 8b772707cc..091403d641 100644 --- a/crates/codecs/src/codecs/dpx.rs +++ b/crates/codecs/src/codecs/dpx.rs @@ -337,6 +337,78 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result Ok(img) } +// ---------------------------------------------------------------- Cineon (Kodak .cin) +// +// Cineon is the 10-bit film-scan format DPX grew from (same Kodak header family, big-endian). +// The generic header is 1024 bytes: the file information fields, then at offset 192 the image +// orientation (1 byte), the channel count (1 byte) and up to eight 28-byte channel descriptors +// (the first carries the bits per pixel and the line/image dimensions). Image data is three +// 10-bit samples packed into each 32-bit word, lines on a word boundary — the same layout as +// DPX's 10-bit packing method A, so the unpacking is shared. + +/// The Cineon magic number (`802A5FD7` hex), big-endian. +pub(crate) fn is_cineon(b: &[u8]) -> bool { + b.get(..4) == Some(&[0x80, 0x2A, 0x5F, 0xD7]) +} + +/// The EXIF orientation (1–8) for a Cineon header's orientation code (the same 0–7 order as DPX). +pub(crate) fn cineon_orientation(b: &[u8]) -> u16 { + let Some(code) = b.get(192).copied() else { return 1 }; + match code { + 1 => 2, + 2 => 4, + 3 => 3, + 4 => 5, + 5 => 6, + 6 => 8, + 7 => 7, + _ => 1, + } +} + +/// Decode a Cineon (`.cin`) frame: 10-bit logarithmic film scans, big-endian, one (gray) or +/// three (RGB) channels. The 10-bit code values become U16 as `round(c × 65535 / 1023)`; the +/// file's black/white reference points are not applied (a log conversion is a display choice). +pub(crate) fn decode_cineon(bytes: &[u8], limits: &Limits) -> Result { + if !is_cineon(bytes) { + return Err(err("missing the Cineon magic number (802A5FD7)")); + } + let o = Order::Big; + let missing = |what: &str| err(format!("the header ends before {what}")); + let image_offset = usize::try_from(o.u32(bytes, 4).ok_or_else(|| missing("the image data offset"))?).map_err(|_| err("image data offset out of range"))?; + let channels = *bytes.get(193).ok_or_else(|| missing("the channel count"))?; + let bits = *bytes.get(198).ok_or_else(|| missing("the bits per pixel"))?; + if bits != 10 { + return Err(unsupported(format!("{bits}-bit samples (Cineon frames are 10-bit)"))); + } + let layout = match channels { + 1 => ChannelLayout::Gray, + 3 => ChannelLayout::Rgb, + n => return Err(unsupported(format!("{n} channels (Cineon frames have 1 or 3)"))), + }; + let width = o.u32(bytes, 200).ok_or_else(|| missing("the pixels per line"))?; + let height = o.u32(bytes, 204).ok_or_else(|| missing("the lines per image"))?; + limits.check(width, height, layout, SampleType::U16)?; + + let (w, h) = (width as usize, height as usize); + let per_line = w.checked_mul(layout.channels()).ok_or_else(|| CodecError::LimitExceeded("line too long".into()))?; + // Three 10-bit samples per 32-bit word, lines on a word boundary (DPX packing 1). + let line_len = line_bytes(per_line, 10, 1).ok_or_else(|| CodecError::LimitExceeded("line too long".into()))?; + let data = bytes.get(image_offset..).ok_or_else(|| err("the image data offset is past the end of the file"))?; + let need = line_len.checked_mul(h).ok_or_else(|| CodecError::LimitExceeded("image too large".into()))?; + if data.len() < need { + return Err(err("truncated image data")); + } + let mut codes = vec![0u16; per_line.checked_mul(h).ok_or_else(|| CodecError::LimitExceeded("image too large".into()))?]; + for (y, row) in codes.chunks_exact_mut(per_line).enumerate() { + let start = y * line_len; + let line = data.get(start..start + line_len).ok_or_else(|| err("truncated image data"))?; + unpack_line(line, o, 10, 1, row)?; + } + let wide: Vec = codes.iter().map(|&c| widen(c, 1023)).collect(); + Image::from_u16(width, height, layout, &wide) +} + /// The kept header fields as text metadata, in [`KEYS`] order; undefined ones are left out. fn header_text(b: &[u8], o: Order, e: Element) -> Vec<(String, String)> { let mut text = Vec::new(); @@ -653,4 +725,60 @@ mod tests { let img = decode(&b, &Limits::default()).unwrap(); assert_eq!(img.data(), &[1, 2, 3, 4, 5, 6]); } + + /// A minimal Cineon: a 1024-byte generic header, then `w × h` 10-bit samples packed three + /// to a 32-bit word (RGB in word order, or gray three pixels per word). + fn cineon(w: u32, h: u32, channels: u8, px: &[[u16; 3]]) -> Vec { + let mut b = vec![0u8; 1024]; + b[0..4].copy_from_slice(&[0x80, 0x2A, 0x5F, 0xD7]); // magic (802A5FD7) + b[4..8].copy_from_slice(&1024u32.to_be_bytes()); // image data offset + b[192] = 0; // orientation: left-to-right, top-to-bottom + b[193] = channels; + b[198] = 10; // bits per pixel (channel 1) + b[200..204].copy_from_slice(&w.to_be_bytes()); // pixels per line + b[204..208].copy_from_slice(&h.to_be_bytes()); // lines per image + let word = |a: u16, c: u16, e: u16| ((u32::from(a) << 22) | (u32::from(c) << 12) | (u32::from(e) << 2)).to_be_bytes(); + if channels == 3 { + for p in px { + b.extend_from_slice(&word(p[0], p[1], p[2])); + } + } else { + let g = |i: usize| px.get(i).map_or(0, |p| p[0]); + for i in (0..px.len()).step_by(3) { + b.extend_from_slice(&word(g(i), g(i + 1), g(i + 2))); + } + } + b + } + + #[test] + fn cineon_reads_ten_bit_rgb() { + let px = [[0, 512, 1023], [1023, 0, 256]]; + let b = cineon(2, 1, 3, &px); + assert!(is_cineon(&b)); + assert_eq!(order(&b), None, "Cineon is not DPX"); + let img = decode_cineon(&b, &Limits::default()).unwrap(); + assert_eq!((img.width(), img.height(), img.layout()), (2, 1, ChannelLayout::Rgb)); + assert_eq!( + img.to_u16_samples().unwrap(), + vec![widen(0, 1023), widen(512, 1023), widen(1023, 1023), widen(1023, 1023), widen(0, 1023), widen(256, 1023)] + ); + } + + #[test] + fn cineon_reads_grayscale_and_rejects_other_depths() { + let px = [[10, 0, 0], [20, 0, 0], [30, 0, 0], [40, 0, 0]]; + let b = cineon(4, 1, 1, &px); + let img = decode_cineon(&b, &Limits::default()).unwrap(); + assert_eq!(img.layout(), ChannelLayout::Gray); + assert_eq!(img.to_u16_samples().unwrap(), vec![widen(10, 1023), widen(20, 1023), widen(30, 1023), widen(40, 1023)]); + // A depth other than 10 is unsupported, and truncation errors rather than panicking. + let mut bad = cineon(1, 1, 3, &[[0, 0, 0]]); + bad[198] = 8; + assert!(matches!(decode_cineon(&bad, &Limits::default()), Err(CodecError::Unsupported { .. }))); + let full = cineon(4, 4, 3, &[[0; 3]; 16]); + for cut in [0, 4, 200, 1024, full.len() - 1] { + assert!(decode_cineon(&full[..cut.min(full.len())], &Limits::default()).is_err(), "cut {cut}"); + } + } } diff --git a/crates/codecs/src/codecs/fits.rs b/crates/codecs/src/codecs/fits.rs index 3aa769fb01..7e3f92ae67 100644 --- a/crates/codecs/src/codecs/fits.rs +++ b/crates/codecs/src/codecs/fits.rs @@ -530,7 +530,11 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, dir: &Ifd, limits: &Limits) -> Result 3, + 2 | 8 | 9 => 3, 5 => 4, _ => 1, }; let alpha_at = extra.iter().position(|&e| e == 1 || e == 2).filter(|&a| (color + a as u64) < spp); - let all_samples = palette.is_none() && matches!(photometric, 2 | 5) && alpha_at.is_some_and(|a| a > 0); + let all_samples = palette.is_none() && matches!(photometric, 2 | 5 | 8 | 9) && alpha_at.is_some_and(|a| a > 0); let mut patches: Vec<(u64, Vec)> = Vec::new(); let encode_u = |v: u64, len: usize| -> Vec { let b = if f.le { v.to_le_bytes() } else { v.to_be_bytes() }; @@ -557,14 +564,14 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result 2, 4 if e.count == 1 => 4, _ => return Err(err("malformed PhotometricInterpretation")), }; - patches.push((e.value_at, encode_u(1, len))); + patches.push((e.value_at, encode_u(if lab && !all_samples { 2 } else { 1 }, len))); } let tl = { @@ -585,7 +592,7 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result (ChannelLayout::Rgb, 1), tiff::ColorType::Gray(_) => (ChannelLayout::Gray, 1), tiff::ColorType::Multiband { num_samples, .. } if all_samples => { - (if photometric == 2 { ChannelLayout::Rgba } else { ChannelLayout::CmykA }, usize::from(num_samples)) + (if photometric == 5 { ChannelLayout::CmykA } else { ChannelLayout::Rgba }, usize::from(num_samples)) } tiff::ColorType::Multiband { num_samples, .. } if photometric <= 1 => { (if gray_alpha { ChannelLayout::GrayA } else { ChannelLayout::Gray }, usize::from(num_samples)) @@ -596,6 +603,12 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result (ChannelLayout::CmykA, 5), other => return Err(CodecError::unsupported(F, format!("colour type {other:?}"))), }; + let out_layout = match out_layout { + ChannelLayout::Rgb if lab => ChannelLayout::Lab, + ChannelLayout::Rgba if lab => ChannelLayout::LabA, + _ if lab => return Err(err("Lab image that does not read as three colour samples")), + l => l, + }; let chunk_spp = if planar { 1 } else { chunk_spp }; // The sample (chunky) or plane (planar) that holds the alpha: the colour samples come first. let alpha_sample = out_layout.color_channels() + alpha_at.unwrap_or(0); @@ -676,6 +689,9 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result 0 { return Err(err("the image data is missing (cut off or past the end of the file)")); } + if lab { + convert_ab(&mut data, out_layout.channels(), out, photometric == 8, false); + } let mut img = Image::from_raw(w, h, out_layout, out, data)?; if tally.missing > 0 { img.warnings.push(DecodeWarning::Truncated { format: F }); @@ -683,6 +699,35 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result data.chunks_exact_mut(channels).for_each(|px| { + px[1] ^= 0x80; + px[2] ^= 0x80; + }), + SampleType::U16 => data.chunks_exact_mut(channels * 2).for_each(|px| { + for c in [1, 2] { + let v = u16::from_ne_bytes([px[2 * c], px[2 * c + 1]]); + // The file's value as `a · 256` (signed) and back. + let file_a256 = |v: u16| if signed { f64::from(v as i16) } else { f64::from(v) - 32768.0 }; + let out = if to_file { + let a256 = (f64::from(v) - 32896.0) * 256.0 / 257.0; + let a256 = a256.round().clamp(-32768.0, 32767.0); + if signed { (a256 as i16) as u16 } else { (a256 + 32768.0) as u16 } + } else { + (file_a256(v) * 257.0 / 256.0 + 32896.0).round().clamp(0.0, 65535.0) as u16 + }; + px[2 * c..2 * c + 2].copy_from_slice(&out.to_ne_bytes()); + } + }), + _ => {} + } +} + /// A zero-filled buffer, allocated fallibly. On native targets the zeroing (and the first /// touch of every page, which dominates on a fresh multi-hundred-megabyte buffer) is spread over /// all cores. @@ -1021,6 +1066,11 @@ custom_colortype!(GrayA16, u16, PI::BlackIsZero, &[16, 16], &[SampleFormat::Uint custom_colortype!(GrayA32F, f32, PI::BlackIsZero, &[32, 32], &[SampleFormat::IEEEFP; 2], float); custom_colortype!(CmykA16, u16, PI::CMYK, &[16; 5], &[SampleFormat::Uint; 5], int); custom_colortype!(CmykA32F, f32, PI::CMYK, &[32; 5], &[SampleFormat::IEEEFP; 5], float); +// CIELab as Photoshop writes it: a* and b* signed, the sample format left unsigned. +custom_colortype!(Lab8, u8, PI::CIELab, &[8; 3], &[SampleFormat::Uint; 3], int); +custom_colortype!(Lab16, u16, PI::CIELab, &[16; 3], &[SampleFormat::Uint; 3], int); +custom_colortype!(LabA8, u8, PI::CIELab, &[8; 4], &[SampleFormat::Uint; 4], int); +custom_colortype!(LabA16, u16, PI::CIELab, &[16; 4], &[SampleFormat::Uint; 4], int); struct TagSet<'a> { icc: Option<&'a [u8]>, @@ -1119,6 +1169,20 @@ fn write_image(enc: &mut TiffEncoder<&mut Cursor>, K>, img: (L::CmykA, S::U8) => write_one::(enc, w, h, d8, tags), (L::CmykA, S::U16) => write_one::(enc, w, h, &u16s(), tags), (L::CmykA, S::F32) => write_one::(enc, w, h, &f32s(), tags), + (L::Lab | L::LabA, _) => { + // CIELab has no float form: 32-bit Lab is written with 16 bits. + let sample = if img.sample_type() == S::U8 { S::U8 } else { S::U16 }; + let mut file = img.convert(img.layout(), sample); + let channels = file.layout().channels(); + convert_ab(file.data_mut(), channels, sample, true, true); + let u16s = || file.to_u16_samples().unwrap_or_default(); + match (file.layout(), sample) { + (L::Lab, S::U8) => write_one::(enc, w, h, file.data(), tags), + (L::Lab, _) => write_one::(enc, w, h, &u16s(), tags), + (_, S::U8) => write_one::(enc, w, h, file.data(), tags), + _ => write_one::(enc, w, h, &u16s(), tags), + } + } (l, s) => Err(io::Error::new(io::ErrorKind::InvalidInput, format!("unsupported {l:?} {s:?}")).into()), } } diff --git a/crates/codecs/src/fidelity.rs b/crates/codecs/src/fidelity.rs index d5654d27a0..594735edb4 100644 --- a/crates/codecs/src/fidelity.rs +++ b/crates/codecs/src/fidelity.rs @@ -30,6 +30,10 @@ pub enum FidelityWarning { CmykConverted { to: ChannelLayout, }, + /// L*a*b* data will be converted to another model (sRGB, D50 to D65). + LabConverted { + to: ChannelLayout, + }, /// Colour data will be reduced to grayscale. ColorToGray, /// The image will be quantized to a 256-colour palette. @@ -84,6 +88,7 @@ impl fmt::Display for FidelityWarning { CmykConverted { to } => { write!(f, "CMYK will be converted to {to:?} (not colour-managed)") } + LabConverted { to } => write!(f, "Lab will be converted to {to:?} (sRGB)"), ColorToGray => write!(f, "colour will be converted to grayscale"), PaletteQuantized => write!(f, "colours will be quantized to a 256-colour palette"), LossyCompression => write!(f, "lossy compression"), @@ -129,8 +134,8 @@ fn pick_layout(src: ChannelLayout, layouts: &[ChannelLayout], alpha: bool) -> Ch if layouts.contains(&cand) { return cand; } - // CMYK falls back to RGB; gray expands to RGB. - if cand.is_cmyk() || cand.is_gray() { + // CMYK and Lab fall back to RGB; gray expands to RGB. + if cand.is_cmyk() || cand.is_lab() || cand.is_gray() { cand = if cand.has_alpha() { ChannelLayout::Rgba } else { ChannelLayout::Rgb }; } if layouts.contains(&cand) { @@ -158,6 +163,8 @@ pub(crate) fn plan(image: &Image, format: Format, opts: &EncodeOptions) -> Plan layout: if layout == ChannelLayout::GrayA { ChannelLayout::Gray } else { pick_layout(layout, c.layouts, c.alpha) }, sample: crate::codecs::dpx::plan_sample(opts.dpx_bits, sample), }, + // CIELab has no float form: float Lab is written with 16 bits. + Format::Tiff if layout.is_lab() && sample.is_float() => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: SampleType::U16 }, _ => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: pick_sample(sample, c.depths) }, } } @@ -195,7 +202,8 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption 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() { + // FITS stores floats as 0..1 (see `codecs::fits`), so out-of-range values are clipped too. + if ss.is_float() && (!p.sample.is_float() || format == Format::Fits) && image.has_out_of_range() { w.push(W::RangeClipped); } @@ -209,6 +217,9 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption if sl.is_cmyk() && !p.layout.is_cmyk() { w.push(W::CmykConverted { to: p.layout }); } + if sl.is_lab() && !p.layout.is_lab() { + w.push(W::LabConverted { to: p.layout }); + } if !sl.is_gray() && p.layout.is_gray() { w.push(W::ColorToGray); } diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index d3730c08fb..fa2e1e6cb3 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -33,6 +33,8 @@ pub enum Format { Fits, /// DPX (SMPTE 268M Digital Picture Exchange): luma, RGB or RGBA film and video frames. Dpx, + /// Kodak Cineon (`.cin`, the 10-bit film-scan format DPX grew from): read-only. + Cineon, } /// What a format can hold **and** what this crate reads/writes for it. @@ -78,6 +80,11 @@ pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[ "AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \ until a pure-Rust AV1 decoder is viable. Only enabled with the non-default `avif` feature.", ), + ( + Format::Cineon, + "Cineon frames are imported for film work; this crate has no Cineon writer, so save an \ + imported frame as DPX, TIFF or EXR instead.", + ), ( Format::Heif, "HEIC decode uses heic-rs (pure Rust, in the optional photocraft-heif crate behind the \ @@ -92,10 +99,12 @@ use SampleType as S; const RGB_GRAY: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba]; const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::Cmyk, L::CmykA]; +/// TIFF also stores CIE L*a*b* (CIELab). +const TIFF_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::Cmyk, L::CmykA, L::Lab, L::LabA]; impl Format { /// Every format known to the crate (enabled or not). - pub const ALL: [Format; 19] = [ + pub const ALL: [Format; 20] = [ Format::Png, Format::Jpeg, Format::Tiff, @@ -115,6 +124,7 @@ impl Format { Format::Heif, Format::Fits, Format::Dpx, + Format::Cineon, ]; pub fn caps(self) -> FormatCaps { @@ -148,6 +158,7 @@ impl Format { Format::Heif => "HEIF", Format::Fits => "FITS", Format::Dpx => "DPX", + Format::Cineon => "Cineon", } } @@ -174,6 +185,7 @@ impl Format { Format::Heif => &["heic", "heif", "hif"], Format::Fits => &["fits", "fit", "fts"], Format::Dpx => &["dpx"], + Format::Cineon => &["cin"], } } @@ -198,6 +210,7 @@ impl Format { Format::Heif => "image/heif", Format::Fits => "image/fits", Format::Dpx => "image/x-dpx", + Format::Cineon => "image/x-cin", } } @@ -233,7 +246,7 @@ pub fn caps(format: Format) -> FormatCaps { FormatCaps { depths: &[S::U8, S::U16], layouts: RGB_GRAY, icc: true, exif: true, xmp: true, dpi: true, text: true, animation: true, ..base } } Format::Jpeg => FormatCaps { layouts: &[L::Gray, L::Rgb, L::Cmyk], alpha: false, icc: true, exif: true, xmp: true, dpi: true, lossy: true, ..base }, - Format::Tiff => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: ALL_LAYOUTS, icc: true, exif: true, xmp: true, dpi: true, text: true, ..base }, + Format::Tiff => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: TIFF_LAYOUTS, icc: true, exif: true, xmp: true, dpi: true, text: true, ..base }, Format::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, @@ -253,6 +266,7 @@ pub fn caps(format: Format) -> FormatCaps { Format::Fits => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: &[L::Gray, L::Rgb], alpha: false, text: true, ..base }, // `text`: the DPX header fields only (see `fidelity_warnings_with`). Format::Dpx => FormatCaps { depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], text: true, ..base }, + Format::Cineon => FormatCaps { read: true, write: false, depths: &[S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base }, } } @@ -307,6 +321,9 @@ pub fn detect(bytes: &[u8]) -> Option { if b.starts_with(b"SDPX") || b.starts_with(b"XPDS") { return Some(Format::Dpx); } + if crate::codecs::dpx::is_cineon(b) { + return Some(Format::Cineon); + } if b.starts_with(b"SIMPLE =") { return Some(Format::Fits); } diff --git a/crates/codecs/src/image.rs b/crates/codecs/src/image.rs index db59d4fa6b..98168d59d3 100644 --- a/crates/codecs/src/image.rs +++ b/crates/codecs/src/image.rs @@ -21,19 +21,31 @@ pub enum ChannelLayout { /// Ink amounts, `0 = no ink`, `max = full ink` (not Adobe-inverted). Cmyk, CmykA, + /// CIE L*a*b* (D50), as PhotoCraft's Lab documents hold it: L* 0..100 as 0..1, a* and b* + /// −128..127 as `(v + 128) / 255` (Photoshop's unsigned encoding). + Lab, + LabA, } impl ChannelLayout { - pub const ALL: [ChannelLayout; 6] = - [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba, ChannelLayout::Cmyk, ChannelLayout::CmykA]; + pub const ALL: [ChannelLayout; 8] = [ + ChannelLayout::Gray, + ChannelLayout::GrayA, + ChannelLayout::Rgb, + ChannelLayout::Rgba, + ChannelLayout::Cmyk, + ChannelLayout::CmykA, + ChannelLayout::Lab, + ChannelLayout::LabA, + ]; /// 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::Rgb | ChannelLayout::Lab => 3, + ChannelLayout::Rgba | ChannelLayout::Cmyk | ChannelLayout::LabA => 4, ChannelLayout::CmykA => 5, } } @@ -44,7 +56,7 @@ impl ChannelLayout { } pub const fn has_alpha(self) -> bool { - matches!(self, ChannelLayout::GrayA | ChannelLayout::Rgba | ChannelLayout::CmykA) + matches!(self, ChannelLayout::GrayA | ChannelLayout::Rgba | ChannelLayout::CmykA | ChannelLayout::LabA) } pub const fn is_gray(self) -> bool { @@ -59,12 +71,17 @@ impl ChannelLayout { matches!(self, ChannelLayout::Cmyk | ChannelLayout::CmykA) } + pub const fn is_lab(self) -> bool { + matches!(self, ChannelLayout::Lab | ChannelLayout::LabA) + } + /// The same colour model with alpha added. pub const fn with_alpha(self) -> Self { match self { ChannelLayout::Gray | ChannelLayout::GrayA => ChannelLayout::GrayA, ChannelLayout::Rgb | ChannelLayout::Rgba => ChannelLayout::Rgba, ChannelLayout::Cmyk | ChannelLayout::CmykA => ChannelLayout::CmykA, + ChannelLayout::Lab | ChannelLayout::LabA => ChannelLayout::LabA, } } @@ -74,12 +91,13 @@ impl ChannelLayout { ChannelLayout::Gray | ChannelLayout::GrayA => ChannelLayout::Gray, ChannelLayout::Rgb | ChannelLayout::Rgba => ChannelLayout::Rgb, ChannelLayout::Cmyk | ChannelLayout::CmykA => ChannelLayout::Cmyk, + ChannelLayout::Lab | ChannelLayout::LabA => ChannelLayout::Lab, } } /// `true` if both layouts share a colour model (ignoring alpha). pub const fn same_model(self, other: Self) -> bool { - (self.is_gray() && other.is_gray()) || (self.is_rgb() && other.is_rgb()) || (self.is_cmyk() && other.is_cmyk()) + (self.is_gray() && other.is_gray()) || (self.is_rgb() && other.is_rgb()) || (self.is_cmyk() && other.is_cmyk()) || (self.is_lab() && other.is_lab()) } } @@ -723,6 +741,7 @@ pub(crate) fn convert_layout(src: &[f32], from: ChannelLayout, to: ChannelLayout ChannelLayout::Gray | ChannelLayout::GrayA => [color[0]; 3], ChannelLayout::Rgb | ChannelLayout::Rgba => [color[0], color[1], color[2]], ChannelLayout::Cmyk | ChannelLayout::CmykA => cmyk_to_rgb(color[0], color[1], color[2], color[3]), + ChannelLayout::Lab | ChannelLayout::LabA => crate::lab::lab_to_srgb([color[0], color[1], color[2]]), }; match to { ChannelLayout::Gray | ChannelLayout::GrayA => { @@ -730,6 +749,7 @@ pub(crate) fn convert_layout(src: &[f32], from: ChannelLayout, to: ChannelLayout } ChannelLayout::Rgb | ChannelLayout::Rgba => out.extend_from_slice(&rgb), ChannelLayout::Cmyk | ChannelLayout::CmykA => out.extend_from_slice(&rgb_to_cmyk(rgb[0], rgb[1], rgb[2])), + ChannelLayout::Lab | ChannelLayout::LabA => out.extend_from_slice(&crate::lab::srgb_to_lab(rgb)), } } if to.has_alpha() { diff --git a/crates/codecs/src/lab.rs b/crates/codecs/src/lab.rs new file mode 100644 index 0000000000..c53ab4c33f --- /dev/null +++ b/crates/codecs/src/lab.rs @@ -0,0 +1,56 @@ +//! CIE L*a*b* (D50) ⇄ encoded sRGB for [`crate::ChannelLayout::Lab`] samples, the same formulas +//! and constants as `photocraft-color` (this crate stays standalone). Samples are normalized as +//! PhotoCraft holds Lab: L* 0..100 as 0..1, a* and b* −128..127 as `(v + 128) / 255`. + +const D50: [f32; 3] = [0.964_22, 1.0, 0.825_21]; +/// Linear sRGB → D50 XYZ (Bradford-adapted, as in the ICC sRGB profile). +const SRGB_TO_XYZ_D50: [[f32; 3]; 3] = [[0.436_074, 0.385_064, 0.143_080], [0.222_504, 0.716_878, 0.060_618], [0.013_932, 0.097_104, 0.714_173]]; +/// D50 XYZ → linear sRGB. +const XYZ_D50_TO_SRGB: [[f32; 3]; 3] = [[3.133_856, -1.616_867, -0.490_615], [-0.978_768, 1.916_142, 0.033_454], [0.071_945, -0.228_991, 1.405_243]]; + +fn mul(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] { + m.map(|r| r[0] * v[0] + r[1] * v[1] + r[2] * v[2]) +} + +fn to_linear(v: f32) -> f32 { + if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } +} + +fn to_encoded(v: f32) -> f32 { + if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } +} + +/// Normalized Lab → encoded sRGB in 0..1 (out-of-gamut colours clipped). +pub(crate) fn lab_to_srgb(n: [f32; 3]) -> [f32; 3] { + let (l, a, b) = (n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0); + let fy = (l + 16.0) / 116.0; + let (fx, fz) = (fy + a / 500.0, fy - b / 200.0); + let finv = |t: f32| if t > 6.0 / 29.0 { t * t * t } else { 3.0 * (6.0f32 / 29.0).powi(2) * (t - 4.0 / 29.0) }; + let xyz = [finv(fx) * D50[0], finv(fy) * D50[1], finv(fz) * D50[2]]; + mul(&XYZ_D50_TO_SRGB, xyz).map(|v| to_encoded(v.clamp(0.0, 1.0))) +} + +/// Encoded sRGB → normalized Lab. +pub(crate) fn srgb_to_lab(rgb: [f32; 3]) -> [f32; 3] { + let xyz = mul(&SRGB_TO_XYZ_D50, rgb.map(|v| to_linear(v.clamp(0.0, 1.0)))); + let f = |t: f32| if t > (6.0f32 / 29.0).powi(3) { t.cbrt() } else { t / (3.0 * (6.0f32 / 29.0).powi(2)) + 4.0 / 29.0 }; + let (fx, fy, fz) = (f(xyz[0] / D50[0]), f(xyz[1] / D50[1]), f(xyz[2] / D50[2])); + let (l, a, b) = (116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)); + [(l / 100.0).clamp(0.0, 1.0), ((a + 128.0) / 255.0).clamp(0.0, 1.0), ((b + 128.0) / 255.0).clamp(0.0, 1.0)] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn white_black_and_round_trips() { + let w = srgb_to_lab([1.0; 3]); + assert!((w[0] - 1.0).abs() < 1e-3 && (w[1] - 128.0 / 255.0).abs() < 1e-3 && (w[2] - 128.0 / 255.0).abs() < 1e-3, "{w:?}"); + assert!(srgb_to_lab([0.0; 3])[0].abs() < 1e-4); + for rgb in [[0.2, 0.5, 0.8], [0.9, 0.1, 0.3], [0.5; 3]] { + let back = lab_to_srgb(srgb_to_lab(rgb)); + assert!(rgb.iter().zip(back).all(|(a, b)| (a - b).abs() < 2e-3), "{rgb:?} → {back:?}"); + } + } +} diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 3831cf654f..86611599d5 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -19,6 +19,7 @@ pub mod exif_color; mod fidelity; mod format; mod image; +mod lab; mod options; pub mod orientation; pub mod resolution; @@ -79,6 +80,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res Format::Dds => dds::decode(bytes, l), Format::Fits => fits::decode(bytes, l), Format::Dpx => dpx::decode(bytes, l), + Format::Cineon => dpx::decode_cineon(bytes, l), Format::OpenExr => exr::decode(bytes, l), Format::Heif => heif::decode(bytes, l, opts.keep_orientation), Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l), @@ -97,6 +99,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res Format::Tiff => tiff::orientation(bytes, None), Format::Heif => 1, Format::Dpx => dpx::orientation(bytes), + Format::Cineon => dpx::cineon_orientation(bytes), _ => img.meta.exif.as_deref().map_or(1, exif_orientation), }; finish_decode(img, o, opts) @@ -252,6 +255,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result Err(CodecError::unsupported(format, "DDS is import-only; save as PNG instead")), Format::Fits => fits::encode(image, plan, opts), Format::Dpx => dpx::encode(image, plan, opts), + Format::Cineon => Err(CodecError::unsupported(format, "Cineon is import-only; save as DPX, TIFF or EXR instead")), Format::OpenExr => exr::encode(image, plan, opts), Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")), Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::encode(format, image, plan, opts), diff --git a/crates/codecs/tests/deep_exr.rs b/crates/codecs/tests/deep_exr.rs index 5a6d6ae75e..f80a42f17b 100644 --- a/crates/codecs/tests/deep_exr.rs +++ b/crates/codecs/tests/deep_exr.rs @@ -118,6 +118,23 @@ fn gen_deep( tile: Option<(usize, usize)>, counts: &[u32], sample: &dyn Fn(usize, usize) -> Vec, +) -> Vec { + gen_deep_tables(w, h, channels, compression, tile, counts, sample, false) +} + +/// [`gen_deep`]; with `full_tile_tables` every tile's count table is stored at the full tile +/// size, as the OpenEXR library writes edge tiles: the block's entries first (in rows of its +/// clipped width), then zeros. +#[allow(clippy::too_many_arguments)] +fn gen_deep_tables( + w: usize, + h: usize, + channels: &[Chan], + compression: u8, + tile: Option<(usize, usize)>, + counts: &[u32], + sample: &dyn Fn(usize, usize) -> Vec, + full_tile_tables: bool, ) -> Vec { let mut out = Vec::new(); out.extend_from_slice(&0x0131_2f76u32.to_le_bytes()); @@ -216,6 +233,10 @@ fn gen_deep( table.extend_from_slice(&acc.to_le_bytes()); } } + assert_eq!(table.len(), table_len(b)); + if let (true, Some((tw, th))) = (full_tile_tables, tile) { + table.resize(tw * th * 4, 0); + } let mut data = Vec::new(); for row in b.pixels.chunks(b.row_w) { for (ci, ch) in channels.iter().enumerate() { @@ -237,7 +258,6 @@ fn gen_deep( c.extend_from_slice(&(data.len() as u64).to_le_bytes()); c.extend_from_slice(&ct); c.extend_from_slice(&cd); - assert_eq!(table.len(), table_len(b)); assert_eq!(data.len(), data_len(b)); chunks.push((bi, c)); } @@ -608,6 +628,136 @@ fn hostile_sizes_and_offsets_error() { assert!(matches!(decode_deep_exr(&far, &Limits::none()), Err(CodecError::Malformed { .. }))); } +/// #4958: a deep tile's count table may be stored at the full tile size, and that size comes +/// from the header alone: nothing ties it to the data window. One 1024×1024 tile over a 4×4 +/// image, its table stored at that size, makes a compressed file of tens of KB whose table +/// inflates to 4 MiB, plus as much again for the copy that undoes the byte split. Under a +/// budget smaller than both the table must be refused before it is allocated; with room for +/// both it decodes. Tables stored at the clipped size, and the full-size edge tables of +/// ordinary tiles, still decode under a small budget. +#[test] +fn deep_tile_count_table_is_admitted_against_max_alloc() { + let budget = |max_alloc: u64| Limits { max_alloc, ..Limits::none() }; + let tight = budget(1 << 20); + let table: u64 = 1024 * 1024 * 4; // the full tile's count table, in bytes + let scan = decode_deep_exr(&gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba), &Limits::none()).expect("scan lines"); + for compression in [0u8, 1, 2] { + let big = gen_deep_tables(4, 4, &RGBAZ, compression, Some((1024, 1024)), COUNTS_ONE, &sample_rgba, true); + assert_eq!(decode_deep_exr(&big, &Limits::none()).expect("full-size table, no limits"), scan, "compression {compression}"); + let got = decode_deep_exr(&big, &tight); + assert!( + matches!(got, Err(CodecError::LimitExceeded(_))), + "compression {compression}: a 4 MiB count table under a 1 MiB budget gave {:?}", + got.map(|d| d.total_samples()) + ); + let flat = photocraft_codecs::decode_with(&big, &photocraft_codecs::DecodeOptions { limits: tight, ..Default::default() }); + assert!(matches!(flat, Err(CodecError::LimitExceeded(_))), "compression {compression}: the flat decode gave {:?}", flat.map(|i| i.width())); + // The same tile with its table stored at the clipped size fits the small budget. + let clipped = gen_deep(4, 4, &RGBAZ, compression, Some((1024, 1024)), COUNTS_ONE, &sample_rgba); + assert_eq!(decode_deep_exr(&clipped, &tight).expect("clipped table, 1 MiB budget"), scan, "compression {compression}"); + if compression != 0 { + assert!(big.len() < 1 << 20, "compression {compression}: the file is {} bytes", big.len()); + } + // The table and its scratch copy are admitted together: 8 MiB. + assert_eq!(decode_deep_exr(&big, &budget(2 * table)).expect("room for the table and its copy"), scan, "compression {compression}"); + let got = decode_deep_exr(&big, &budget(2 * table - 1)); + assert!(matches!(got, Err(CodecError::LimitExceeded(_))), "compression {compression}: one byte short gave {:?}", got.map(|d| d.total_samples())); + } + // Ordinary tiles: full-size edge tables decode under the small budget, like clipped ones. + let counts: Vec = (0..35).map(|p| (p * 3) % 4).collect(); + let sample = |p: usize, ci: usize| -> Vec { (0..counts[p]).map(|k| [0.5, 0.25, 0.125 * k as f32, 1.0, 10.0 - k as f32][ci]).collect() }; + for compression in [0u8, 1, 2] { + let full = gen_deep_tables(7, 5, &RGBAZ, compression, Some((3, 2)), &counts, &sample, true); + let clipped = gen_deep(7, 5, &RGBAZ, compression, Some((3, 2)), &counts, &sample); + assert_eq!( + decode_deep_exr(&full, &tight).expect("full-size edge tables"), + decode_deep_exr(&clipped, &tight).expect("clipped edge tables"), + "compression {compression}" + ); + } +} + +/// #4958, the edges of that admission. A tile of the largest size exr accepts (2^30 - 2 a side, +/// a 2^62 byte count table) decodes from a clipped table under every budget, including no limits, +/// where its full size passes the check and is only tried as the data arrives. Each tile is +/// admitted on its own, because its table is freed before the next chunk: three 16×16 tiles over +/// a 48×1 image are all edge tiles, so every full-size table reaches the check (an interior tile's +/// full size equals its clipped size, so it uses the clipped check). They need 2 KiB each for +/// the table and its copy, 6 KiB in all, and decode under a budget of 4096 bytes. Half and uint +/// channels decode after full-size tables like float ones. +#[test] +fn deep_tile_table_admission_edges() { + let budget = |max_alloc: u64| Limits { max_alloc, ..Limits::none() }; + let scan = decode_deep_exr(&gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba), &Limits::none()).expect("scan lines"); + let side = (1 << 30) - 2; + for compression in [0u8, 1, 2] { + let bytes = gen_deep(4, 4, &RGBAZ, compression, Some((side, side)), COUNTS_ONE, &sample_rgba); + for limits in [Limits::none(), Limits::default(), budget(1 << 20)] { + assert_eq!(decode_deep_exr(&bytes, &limits).expect("the largest tile, clipped table"), scan, "compression {compression}, {limits:?}"); + } + } + let counts: Vec = (0..48).map(|p| (p % 3) as u32).collect(); + let half: [Chan; 5] = RGBAZ.map(|c| Chan { ty: 1, ..c }); + let [a, b, g, r, z] = RGBAZ; + let with_id = [a, b, g, r, z, Chan { name: "id", ty: 0 }]; + for channels in [&RGBAZ[..], &half[..], &with_id[..]] { + let sample = |p: usize, ci: usize| -> Vec { (0..counts[p]).map(|k| [0.5, 0.25, 0.125, 1.0, 2.0 + k as f32, 7.0][ci]).collect() }; + for compression in [0u8, 1, 2] { + let full = gen_deep_tables(48, 1, channels, compression, Some((16, 16)), &counts, &sample, true); + let clipped = gen_deep(48, 1, channels, compression, Some((16, 16)), &counts, &sample); + let what = format!("{} channels, compression {compression}", channels.len()); + let want = decode_deep_exr(&clipped, &Limits::none()).expect("clipped tables"); + assert_eq!(decode_deep_exr(&full, &budget(4096)).expect("full-size tables, admitted one at a time"), want, "{what}"); + } + } +} + +/// A one-channel data-window check admits only four bytes per pixel, but a compressed count +/// table and its scratch copy need eight. Cover scanlines, interior tiles and the clipped fallback; +/// the existing multichannel cases have enough pixel allowance to hide this gap. +#[test] +fn clipped_count_tables_include_scratch_in_the_budget() { + let channels = [Chan { name: "Y", ty: 2 }]; + let counts = vec![0u32; 64]; + let sample = |_: usize, _: usize| Vec::new(); + let budget = |max_alloc: u64| Limits { max_alloc, ..Limits::none() }; + for tile in [None, Some((64, 1)), Some((128, 2))] { + for compression in [0u8, 1, 2] { + let bytes = gen_deep(64, 1, &channels, compression, tile, &counts, &sample); + let what = format!("tile {tile:?}, compression {compression}"); + let want = decode_deep_exr(&bytes, &Limits::none()).expect("unlimited decode"); + let required = if compression == 0 { 256 } else { 512 }; + assert_eq!(decode_deep_exr(&bytes, &budget(required)).expect("table and any scratch fit exactly"), want, "{what}"); + assert!(matches!(decode_deep_exr(&bytes, &budget(required - 1)), Err(CodecError::LimitExceeded(_))), "{what}"); + let flat = photocraft_codecs::decode_with(&bytes, &photocraft_codecs::DecodeOptions { limits: budget(required - 1), ..Default::default() }); + assert!(matches!(flat, Err(CodecError::LimitExceeded(_))), "{what}"); + if compression == 0 { + assert_eq!(decode_deep_exr(&bytes, &budget(511)).expect("raw table needs no scratch"), want, "{what}"); + } + } + } +} + +/// RLE and ZIPS store incompressible tables raw too. Their actual payload, not the header's +/// compression tag, decides whether a scratch buffer is needed. +#[test] +fn raw_count_tables_need_no_scratch_even_with_compression() { + let channels = [Chan { name: "Y", ty: 2 }]; + let limits = Limits { max_alloc: 4, ..Limits::none() }; + for tile in [None, Some((1, 1)), Some((2, 2))] { + for compression in [0u8, 1, 2] { + let bytes = gen_deep(1, 1, &channels, compression, tile, &[0], &|_, _| Vec::new()); + let header_end = header_end_of(&bytes); + let chunk = u64::from_le_bytes(bytes[header_end..header_end + 8].try_into().expect("chunk offset")) as usize; + let table_size = chunk + if tile.is_some() { 16 } else { 4 }; + let packed = u64::from_le_bytes(bytes[table_size..table_size + 8].try_into().expect("table size")); + assert_eq!(packed, 4, "the four-byte count table is stored raw"); + let want = decode_deep_exr(&bytes, &Limits::none()).expect("unlimited decode"); + assert_eq!(decode_deep_exr(&bytes, &limits).expect("raw table fits"), want, "tile {tile:?}, compression {compression}"); + } + } +} + /// Walk the attributes like the format prescribes: the offset table starts after the /// header's terminating zero byte. fn header_end_of(bytes: &[u8]) -> usize { diff --git a/crates/codecs/tests/detect_caps.rs b/crates/codecs/tests/detect_caps.rs index 22bf227eb8..f2191e05f1 100644 --- a/crates/codecs/tests/detect_caps.rs +++ b/crates/codecs/tests/detect_caps.rs @@ -187,6 +187,17 @@ fn detect_magic_hdr() { assert_eq!(detect(b"#?RGBE\n"), Some(Format::Hdr)); } #[test] +fn detect_magic_cineon() { + let mut b = vec![0u8; 208]; + b[0..4].copy_from_slice(&[0x80, 0x2A, 0x5F, 0xD7]); + assert_eq!(detect(&b), Some(Format::Cineon)); + assert_eq!(from_extension("scan.CIN"), Some(Format::Cineon)); + assert!(caps(Format::Cineon).read && !caps(Format::Cineon).write, "Cineon is read-only"); + assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Cineon)); + // A bare magic is an error, never a panic. + assert!(decode_as(Format::Cineon, &b).is_err()); +} +#[test] fn detect_magic_avif() { assert_eq!(detect(b"\0\0\0\x1cftypavif\0\0\0\0avifmif1miaf"), Some(Format::Avif)); assert_eq!(detect(b"\0\0\0\x1cftypavis\0\0\0\0avismsf1miaf"), Some(Format::Avif)); diff --git a/crates/codecs/tests/fidelity.rs b/crates/codecs/tests/fidelity.rs index 32d5e21bf0..93b0236fdf 100644 --- a/crates/codecs/tests/fidelity.rs +++ b/crates/codecs/tests/fidelity.rs @@ -21,7 +21,13 @@ fn png_u16_rgba_is_clean() { fn tiff_is_clean_for_all_integer_and_f32_layouts() { for l in ChannelLayout::ALL { for s in [SampleType::U8, SampleType::U16, SampleType::F32, SampleType::F16] { - assert!(warns(&test_image(l, s), Format::Tiff).is_empty(), "{l:?} {s:?}"); + let w = warns(&test_image(l, s), Format::Tiff); + if l.is_lab() && s.is_float() { + // CIELab has no float form: it is written with 16 bits, and says so. + assert_eq!(w, vec![FidelityWarning::DepthReduced { from: s, to: SampleType::U16 }], "{l:?} {s:?}"); + } else { + assert!(w.is_empty(), "{l:?} {s:?}"); + } } } } @@ -115,6 +121,12 @@ fn in_range_float_not_clipped() { assert!(!w.contains(&W::RangeClipped)); } #[test] +fn hdr_float_to_fits_clips() { + let img = Image::from_f32(2, 1, ChannelLayout::Gray, &[0.5, 3.0]).unwrap(); + assert!(warns(&img, Format::Fits).contains(&W::RangeClipped)); + assert!(!warns(&test_image(ChannelLayout::Gray, SampleType::F32), Format::Fits).contains(&W::RangeClipped)); +} +#[test] fn float_to_exr_and_tiff_is_clean() { for f in [Format::OpenExr, Format::Tiff, Format::Pnm] { assert!(warns(&test_image(ChannelLayout::Rgb, SampleType::F32), f).is_empty(), "{f:?}"); @@ -172,6 +184,7 @@ fn every_warning_has_a_message() { W::AlphaDiscarded, W::AlphaBinarized, W::CmykConverted { to: ChannelLayout::Rgb }, + W::LabConverted { to: ChannelLayout::Rgb }, W::ColorToGray, W::PaletteQuantized, W::LossyCompression, @@ -203,6 +216,8 @@ fn warnings_match_actual_roundtrip_behaviour() { assert_eq!(w.contains(&W::AlphaDiscarded), alpha_lost, "{f:?} {l:?} {s:?}"); let cmyk_lost = l.is_cmyk() && !back.layout().is_cmyk(); assert_eq!(w.iter().any(|w| matches!(w, W::CmykConverted { .. })), cmyk_lost, "{f:?} {l:?} {s:?}"); + let lab_lost = l.is_lab() && !back.layout().is_lab(); + assert_eq!(w.iter().any(|w| matches!(w, W::LabConverted { .. })), lab_lost, "{f:?} {l:?} {s:?}"); } } } diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs index 4cb50c0057..7918d313b6 100644 --- a/crates/codecs/tests/roundtrip.rs +++ b/crates/codecs/tests/roundtrip.rs @@ -196,6 +196,14 @@ fn tiff_all_compressions_lossless() { for comp in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] { let opts = EncodeOptions { tiff_compression: comp, ..Default::default() }; let back = decode(&encode(&img, Format::Tiff, &opts).unwrap()).unwrap(); + if layout.is_lab() && sample != SampleType::U8 { + // CIELab holds a* and b* as 256·a at 16 bits and has no float form: 16-bit and + // float Lab come back within one 16-bit step (the PSD's 16-bit Lab does the same). + let (a, b) = (img.to_normalized(), back.convert(layout, sample).to_normalized()); + let worst = a.iter().zip(&b).map(|(x, y)| (x - y).abs()).fold(0.0f32, f32::max); + assert!(worst <= 1.5 / 65535.0, "{layout:?} {sample:?} {comp:?}: {worst}"); + continue; + } assert_eq!(back, img.clone().with_meta(back.meta.clone()), "{layout:?} {sample:?} {comp:?}"); } } diff --git a/crates/codecs/tests/tiff_lab.rs b/crates/codecs/tests/tiff_lab.rs new file mode 100644 index 0000000000..7076633ef5 --- /dev/null +++ b/crates/codecs/tests/tiff_lab.rs @@ -0,0 +1,76 @@ +//! CIE L*a*b* TIFFs: CIELab (PhotometricInterpretation 8) as Photoshop writes it, with a* and b* +//! signed, and ICCLab (9) with them offset, both read into the Lab layout (a* and b* as +//! `(a + 128) / 255`). + +use photocraft_codecs::*; + +/// One Lab pixel in the codec's normalized form. +fn lab(l: f32, a: f32, b: f32) -> [f32; 3] { + [l / 100.0, (a + 128.0) / 255.0, (b + 128.0) / 255.0] +} + +fn image(sample: SampleType) -> Image { + let px: Vec = [lab(50.0, 50.0, -30.0), lab(100.0, 0.0, 0.0)].concat(); + Image::from_normalized(2, 1, ChannelLayout::Lab, sample, &px).unwrap() +} + +fn uncompressed(img: &Image) -> Vec { + encode(img, Format::Tiff, &EncodeOptions { tiff_compression: TiffCompression::None, ..Default::default() }).unwrap() +} + +fn find(hay: &[u8], needle: &[u8]) -> Option { + hay.windows(needle.len()).position(|w| w == needle) +} + +#[test] +fn eight_bit_cielab_holds_a_and_b_signed() { + let file = uncompressed(&image(SampleType::U8)); + // L* 50 → 128, a* 50, b* −30 as a signed byte; then white with neutral a* and b*. + assert!(find(&file, &[128, 50, (-30i8) as u8, 255, 0, 0]).is_some(), "CIELab samples"); + let back = decode(&file).unwrap(); + assert_eq!((back.layout(), back.sample_type()), (ChannelLayout::Lab, SampleType::U8)); + assert_eq!(back.data(), image(SampleType::U8).data()); +} + +#[test] +fn sixteen_bit_cielab_holds_256_a() { + let file = uncompressed(&image(SampleType::U16)); + let le = &file[..2] == b"II"; + let u16b = |v: u16| if le { v.to_le_bytes() } else { v.to_be_bytes() }; + // a* 50 → 12800, b* −30 → −7680, white's a* and b* 0. + let want: Vec = [32768u16, 12800, (-7680i16) as u16, 65535, 0, 0].iter().flat_map(|v| u16b(*v)).collect(); + assert!(find(&file, &want).is_some(), "CIELab samples"); + let back = decode(&file).unwrap(); + assert_eq!((back.layout(), back.sample_type()), (ChannelLayout::Lab, SampleType::U16)); + let (a, b) = (image(SampleType::U16).to_normalized(), back.to_normalized()); + assert!(a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= 1.5 / 65535.0), "{a:?} {b:?}"); +} + +#[test] +fn icclab_reads_with_offset_a_and_b() { + // The CIELab file turned into ICCLab: PhotometricInterpretation 9, a* and b* + 128. + let mut file = uncompressed(&image(SampleType::U8)); + let at = find(&file, &[128, 50, (-30i8) as u8, 255, 0, 0]).unwrap(); + for i in [1, 2, 4, 5] { + file[at + i] ^= 0x80; + } + let le = &file[..2] == b"II"; + let entry: Vec = if le { [262u16.to_le_bytes(), 3u16.to_le_bytes()].concat() } else { [262u16.to_be_bytes(), 3u16.to_be_bytes()].concat() }; + let e = find(&file, &entry).expect("PhotometricInterpretation entry"); + let nine = if le { 9u16.to_le_bytes() } else { 9u16.to_be_bytes() }; + file[e + 8..e + 10].copy_from_slice(&nine); + let back = decode(&file).unwrap(); + assert_eq!(back.layout(), ChannelLayout::Lab); + assert_eq!(back.data(), image(SampleType::U8).data()); +} + +#[test] +fn lab_converts_to_rgb_for_other_formats() { + // White and a saturated orange-red; PNG has no Lab, so the encoder plan converts to RGB. + let img = image(SampleType::U8); + let png = decode(&encode(&img, Format::Png, &EncodeOptions::default()).unwrap()).unwrap(); + assert!(png.layout().is_rgb()); + let p = png.to_rgba8(); + assert_eq!(&p[4..7], &[255, 255, 255], "white"); + assert!(p[0] > 180 && p[1] < 120 && p[2] > 140, "L 50, a 50, b −30 is a magenta-ish purple: {:?}", &p[..3]); +} diff --git a/crates/compose/src/filter_mask.rs b/crates/compose/src/filter_mask.rs new file mode 100644 index 0000000000..b845720456 --- /dev/null +++ b/crates/compose/src/filter_mask.rs @@ -0,0 +1,191 @@ +//! A smart object's filter mask (#215): how the filtered pixels and the unfiltered (placed) ones +//! combine. +//! +//! Photoshop stores a smart object's layer pixels with its filters applied but not its filter +//! mask, and mixes the unfiltered pixels back in through the mask when it composites (photoshop +//! corpus `sf-filter-mask-hard.psd` and `sf-filter-mask-gradient.psd`: the stored pixels are +//! filtered everywhere, the merged image only where the mask shows the filter). Our smart objects +//! keep the mixed result as their pixels, so PSD import mixes ([`mix`]) and export unmixes +//! ([`unmix`]). + +use photocraft_doc::LayerMask; +use photocraft_raster::Surface; + +/// `filtered` mixed over `base` (the unfiltered pixels) through `mask`: premultiplied, any +/// channel layout. Pixels the mask hides entirely are `base`'s. +pub fn mix(base: &Surface, filtered: &Surface, mask: &LayerMask) -> Surface { + let fmt = base.format(); + let area = base.content_bounds().union(&filtered.content_bounds()); + let mut out = filtered.clone(); + if area.is_empty() || filtered.format() != fmt { + return out; + } + let n = fmt.channels(); + let (b, f) = (base.read_region(area), filtered.read_region(area)); + let mut k = Vec::new(); + mask.values_into(area, &mut k); + let mut o = vec![0.0f32; b.len()]; + for (i, ((bp, fp), op)) in b.chunks_exact(n).zip(f.chunks_exact(n)).zip(o.chunks_exact_mut(n)).enumerate() { + let m = k.get(i).copied().unwrap_or(1.0).clamp(0.0, 1.0); + if !fmt.alpha { + for c in 0..n { + op[c] = bp[c] + (fp[c] - bp[c]) * m; + } + continue; + } + let a = n - 1; + let oa = bp[a] + (fp[a] - bp[a]) * m; + op[a] = oa; + for c in 0..a { + let pm = bp[c] * bp[a] + (fp[c] * fp[a] - bp[c] * bp[a]) * m; + op[c] = if oa > 0.0 { (pm / oa).clamp(0.0, 1.0) } else { 0.0 }; + } + } + out.write_region(area, &o); + out.prune(); + out +} + +/// The mask value below which a pixel's filtered value is taken to be unknown: [`unmix`] gives the +/// unfiltered value there, which the mask shows (almost) none of anyway. +const HIDDEN: f32 = 1.0 / 1024.0; + +/// The inverse of [`mix`]: the filtered pixels that mix with `base` through `mask` to `mixed` +/// (clamped to 0..1). Where the mask hides the filter entirely they are `base`'s, which Photoshop +/// doesn't show there. +pub fn unmix(base: &Surface, mixed: &Surface, mask: &LayerMask) -> Surface { + let fmt = base.format(); + let area = base.content_bounds().union(&mixed.content_bounds()); + let mut out = mixed.clone(); + if area.is_empty() || mixed.format() != fmt { + return out; + } + let n = fmt.channels(); + let (b, x) = (base.read_region(area), mixed.read_region(area)); + let mut k = Vec::new(); + mask.values_into(area, &mut k); + let mut o = vec![0.0f32; b.len()]; + for (i, ((bp, xp), op)) in b.chunks_exact(n).zip(x.chunks_exact(n)).zip(o.chunks_exact_mut(n)).enumerate() { + let m = k.get(i).copied().unwrap_or(1.0).clamp(0.0, 1.0); + if m < HIDDEN { + // A transparent base pixel (Photoshop's cache keeps white there) stays empty, so the + // stored pixels don't grow past the content. + if !fmt.alpha || bp[n - 1] > 0.0 { + op.copy_from_slice(bp); + } + continue; + } + if !fmt.alpha { + for c in 0..n { + op[c] = (bp[c] + (xp[c] - bp[c]) / m).clamp(0.0, 1.0); + } + continue; + } + let a = n - 1; + let fa = (bp[a] + (xp[a] - bp[a]) / m).clamp(0.0, 1.0); + if fa <= 0.0 { + continue; + } + op[a] = fa; + for c in 0..a { + let pm = bp[c] * bp[a] + (xp[c] * xp[a] - bp[c] * bp[a]) / m; + op[c] = (pm / fa).clamp(0.0, 1.0); + } + } + out.write_region(area, &o); + out.prune(); + out +} + +#[cfg(test)] +mod tests { + use photocraft_color::{ColorMode, PixelFormat, SampleType}; + use photocraft_doc::LayerMask; + use photocraft_raster::Surface; + + use super::*; + + fn surface(fmt: PixelFormat, f: impl Fn(i32, i32) -> Vec) -> Surface { + let mut s = Surface::new(fmt); + for y in 0..4 { + for x in 0..8 { + s.write_pixel(x, y, &f(x, y)); + } + } + s + } + + /// A mask ramping from hidden (left) to shown (right). + fn ramp() -> LayerMask { + let mut m = LayerMask::reveal_all(); + m.surface = surface(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false), |x, _| vec![x as f32 / 7.0]); + m + } + + #[test] + fn unmix_inverts_mix_wherever_the_mask_shows_the_filter() { + for fmt in [PixelFormat::new(ColorMode::Rgb, SampleType::F32, true), PixelFormat::new(ColorMode::Rgb, SampleType::F32, false)] { + let base = surface(fmt, |x, y| { + let mut p = vec![0.2 + 0.05 * x as f32, 0.6, 0.1 * y as f32]; + if fmt.alpha { + p.push(0.5 + 0.06 * x as f32); + } + p + }); + let filtered = surface(fmt, |_, y| { + let mut p = vec![0.9 - 0.1 * y as f32, 0.3, 0.5]; + if fmt.alpha { + p.push(0.8); + } + p + }); + let m = ramp(); + let mixed = mix(&base, &filtered, &m); + // Hidden column: the unfiltered pixels; shown column: the filtered ones. + assert!(mixed.pixel(0, 2).iter().zip(base.pixel(0, 2)).all(|(a, b)| (a - b).abs() < 1e-6)); + assert!(mixed.pixel(7, 2).iter().zip(filtered.pixel(7, 2)).all(|(a, b)| (a - b).abs() < 1e-6)); + let back = unmix(&base, &mixed, &m); + for y in 0..4 { + for x in 1..8 { + let (g, w) = (back.pixel(x, y), filtered.pixel(x, y)); + assert!(g.iter().zip(&w).all(|(a, b)| (a - b).abs() < 1e-4), "{fmt:?} ({x}, {y}): {g:?} vs {w:?}"); + } + // Where the mask hides the filter entirely its value is unknown: the base stands in. + assert_eq!(back.pixel(0, y), base.pixel(0, y)); + } + // So mixing the unmixed pixels gives the mixed ones back everywhere. + let again = mix(&base, &back, &m); + for y in 0..4 { + for x in 0..8 { + assert!(again.pixel(x, y).iter().zip(mixed.pixel(x, y)).all(|(a, b)| (a - b).abs() < 1e-4)); + } + } + } + } + + #[test] + fn transparent_base_pixels_stay_empty() { + // Photoshop's unfiltered cache carries white in transparent pixels; where the mask hides + // the filter those must not become content of the stored pixels. + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true); + let base = surface(fmt, |x, _| if x < 4 { vec![1.0, 1.0, 1.0, 0.0] } else { vec![0.3, 0.4, 0.5, 1.0] }); + let mut hidden = LayerMask::reveal_all(); + hidden.surface = Surface::with_default(PixelFormat::GRAY8, &[0.0]); + let mixed = mix(&base, &base, &hidden); + let back = unmix(&base, &mixed, &hidden); + assert_eq!(back.pixel(1, 1), vec![0.0; 4]); + assert_eq!(back.content_bounds().x0, 4); + } + + #[test] + fn mismatched_or_empty_surfaces_are_left_alone() { + let rgba = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true); + let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, true); + let a = surface(rgba, |_, _| vec![0.5, 0.5, 0.5, 1.0]); + let b = surface(gray, |_, _| vec![0.1, 1.0]); + assert_eq!(mix(&a, &b, &ramp()), b); + assert_eq!(unmix(&a, &b, &ramp()), b); + let empty = Surface::new(rgba); + assert_eq!(unmix(&empty, &empty, &ramp()), empty); + } +} diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index b742540b95..3cf2f1dc19 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -21,6 +21,7 @@ pub mod advanced; pub mod bounds; pub mod effects; pub mod fill_layout; +pub mod filter_mask; pub mod gradient_fill; pub mod masks; pub mod multichannel; @@ -562,14 +563,14 @@ impl LayerKeys { fn identity(&self, layer: &Layer, doc: Option<&Document>) -> u64 { let addr = std::ptr::from_ref(layer) as usize; let in_doc = doc.is_some_and(|d| self.in_doc.get_or_init(|| doc_layer_addresses(d)).contains(&addr)); - if in_doc && let Some(id) = self.ids.lock().unwrap_or_else(|e| e.into_inner()).get(&addr) { - return *id; - } - let id = self.compute(layer); if in_doc { - self.ids.lock().unwrap_or_else(|e| e.into_inner()).insert(addr, id); + let mut ids = self.ids.lock().unwrap_or_else(|e| e.into_inner()); + // Computing an identity only hashes immutable layer data; it does not run Rayon + // work or re-enter this memo. Keep the miss and insertion atomic across callers. + *ids.entry(addr).or_insert_with(|| self.compute(layer)) + } else { + self.compute(layer) } - id } fn compute(&self, layer: &Layer) -> u64 { @@ -577,7 +578,7 @@ impl LayerKeys { #[cfg(test)] self.built.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let mut h = std::collections::hash_map::DefaultHasher::new(); - layer_identity(layer, &mut h); + layer_identity(layer, &mut h, &mut |_| {}); h.finish() } } @@ -1531,11 +1532,12 @@ fn split_parts(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<(Buffer, Buffer, O /// Order-independent identity of a layer's pixels, masks, effects, Advanced Blending and Blend If /// and (for groups) children. -/// Pixels are identified by their copy-on-write tile pointers; cache entries pin a clone of the -/// layer so those tiles (and their addresses) stay alive while the entry exists. -fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHasher) { +/// Pixels are identified by their copy-on-write tile pointers. `pin` sees every tile hashed: a +/// cache entry keeps a [`std::sync::Weak`] to each, which reserves the tile's address (so no +/// other tile can reuse it and match the key) without keeping its pixels alive (#4530). +fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHasher, pin: &mut dyn FnMut(&std::sync::Arc)) { use std::hash::{Hash, Hasher}; - fn surface_fp(s: &Surface) -> u64 { + fn surface_fp(s: &Surface, pin: &mut dyn FnMut(&std::sync::Arc)) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); s.format().hash(&mut h); // Missing tiles read the default pixel, so tile identity alone is not pixel identity. @@ -1543,6 +1545,7 @@ fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHash sample.to_bits().hash(&mut h); } let tiles = s.tiles().fold(s.tile_count() as u64, |acc, (c, t)| { + pin(t); let mut x = (std::sync::Arc::as_ptr(t) as usize as u64) ^ ((c.tx as u64) << 40) ^ ((c.ty as u32 as u64) << 8); x = x.wrapping_mul(0x9e37_79b9_7f4a_7c15); acc.wrapping_add(x ^ (x >> 29)) @@ -1563,7 +1566,7 @@ fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHash // composites (unlike the group's own visibility and opacity) shapes them. (c.visible, c.opacity.to_bits(), c.clipped).hash(h); format!("{:?}", c.blend).hash(h); - layer_identity(c, h); + layer_identity(c, h, pin); } format!("{:?}", g.artboard).hash(h); } @@ -1575,14 +1578,14 @@ fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHash if let Some(c) = &layer.fill_cache && c.fill == *f { - surface_fp(&c.surface).hash(h); + surface_fp(&c.surface, pin).hash(h); } } LayerContent::Adjustment(a) => format!("{a:?}").hash(h), - _ => layer.surface().map_or(0, surface_fp).hash(h), + _ => layer.surface().map_or(0, |s| surface_fp(s, pin)).hash(h), } if let Some(m) = &layer.mask { - (surface_fp(&m.surface), m.enabled, m.density.to_bits(), m.feather.to_bits()).hash(h); + (surface_fp(&m.surface, pin), m.enabled, m.density.to_bits(), m.feather.to_bits()).hash(h); } if let Some(vm) = &layer.vector_mask { format!("{vm:?}").hash(h); @@ -1597,7 +1600,8 @@ fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHash struct FxEntry { maps: std::sync::Arc, - _pin: Layer, + /// The tiles the key identifies (see [`layer_identity`]): their addresses, not their pixels. + _pin: Vec>, bytes: usize, } @@ -1716,9 +1720,13 @@ fn cached_effect_maps(layer: &Layer, region: Rect, key: u64, cx: &Ctx) -> std::s None => { let shape: Vec = if region.is_empty() { Vec::new() } else { effect_shape(layer, region, cx) }; let maps = effects::build_maps_prepared(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx), cx.patterns); - let bytes = maps.bytes(); + let mut pin = Vec::new(); + layer_identity(layer, &mut std::collections::hash_map::DefaultHasher::new(), &mut |t| pin.push(std::sync::Arc::downgrade(t))); + // The maps, plus what each reserved tile address keeps allocated. + let bytes = + maps.bytes().saturating_add(pin.len().saturating_mul(std::mem::size_of::() + 2 * std::mem::size_of::())); let maps = std::sync::Arc::new(maps); - if slot.set(FxEntry { maps: maps.clone(), _pin: layer.clone(), bytes }).is_ok() { + if slot.set(FxEntry { maps: maps.clone(), _pin: pin, bytes }).is_ok() { // Counted exactly once, by the build that filled the slot, and only while the // slot is still cached (eviction may have dropped it meanwhile). let mut c = fx_cache().lock().unwrap_or_else(|e| e.into_inner()); diff --git a/crates/compose/src/proxy.rs b/crates/compose/src/proxy.rs index 84c9348029..f48f10d83f 100644 --- a/crates/compose/src/proxy.rs +++ b/crates/compose/src/proxy.rs @@ -4,8 +4,8 @@ //! channel thumbnails, histograms, file previews), where a full-resolution composite of a 200 MP //! document would cost seconds and gigabytes. -use photocraft_doc::{Document, Layer, LayerContent, Size}; -use photocraft_geom::Rect; +use photocraft_doc::{Document, Layer, LayerContent, Size, SmartObject}; +use photocraft_geom::{Affine, Rect}; use photocraft_raster::Surface; /// Nearest-neighbour downsample of a surface by integer factor `k` (document coordinates / k). @@ -60,11 +60,30 @@ fn shrink_layer(l: &mut Layer, k: u32) { } LayerContent::Text(t) => t.cache = t.cache.as_ref().map(|s| downsample(s, k)), LayerContent::Shape(sh) => sh.cache = sh.cache.as_ref().map(|s| downsample(s, k)), - LayerContent::Smart(so) => so.cache = so.cache.as_ref().map(|s| downsample(s, k)), + LayerContent::Smart(so) => shrink_smart(so, k), LayerContent::Adjustment(_) | LayerContent::Fill(_) => {} } } +/// Scales a smart object with its proxy: the cache and filter mask like any surface, and the +/// placement too. A preview that adds a smart filter re-renders the source through the placement; +/// left in full-size document coordinates, that render came out `k` times too large on the proxy +/// canvas, so the Curves preview on a smart object zoomed in (#5766). +fn shrink_smart(so: &mut SmartObject, k: u32) { + so.cache = so.cache.as_ref().map(|s| downsample(s, k)); + if let Some(m) = &mut so.filter_mask { + m.surface = downsample(&m.surface, k); + } + let s = 1.0 / f64::from(k); + so.transform = Affine::scale(s).mul(&so.transform); + // Row-major source → document map: scaling the document side scales its first two rows. + if let Some(h) = &mut so.perspective { + for v in h.iter_mut().take(6) { + *v *= s; + } + } +} + /// A copy of `doc` scaled down by `k` (same layer ids, so adjustments can be swapped in). The /// selection scales with it, so a filter previewed on the proxy stays inside the (feathered) /// selection exactly as the full-size result does. @@ -166,6 +185,33 @@ mod tests { assert!(proxy_faithful(&doc)); } + /// #5766: a smart object re-rendered on the proxy (a smart filter preview) goes through its + /// placement, which must shrink with the canvas, cache and filter mask. + #[test] + fn proxy_scales_smart_object_placement() { + use photocraft_doc::SmartSource; + let mut doc = Document::new("smart", Size::new(64, 64), ColorMode::Rgb, SampleType::U8); + let mut cache = Surface::new(doc.pixel_format()); + cache.fill_rect(Rect::new(16, 16, 32, 48), &[0.0, 0.0, 0.0, 1.0]); + let source = SmartSource::Embedded { file_name: "s.png".into(), bytes: std::sync::Arc::new(Vec::new()) }; + let mut sm = SmartObject::new(source, Affine::translate(16.0, 16.0).mul(&Affine::scale(2.0)), Some(cache)); + let mut mask = LayerMask::hide_all(); + mask.surface.fill_rect(Rect::new(16, 16, 32, 32), &[1.0]); + sm.filter_mask = Some(mask); + sm.perspective = Some([2.0, 0.0, 16.0, 0.0, 2.0, 16.0, 0.0, 0.0, 1.0]); + doc.layers.push(Layer::new("smart", LayerContent::Smart(sm))); + let p = proxy_document(&doc, 4); + let LayerContent::Smart(sm) = &p.layers[0].content else { panic!("a smart object") }; + assert_eq!(sm.transform, Affine { m: [0.5, 0.0, 0.0, 0.5, 4.0, 4.0] }); + assert_eq!(sm.perspective, Some([0.5, 0.0, 4.0, 0.0, 0.5, 4.0, 0.0, 0.0, 1.0])); + assert_eq!(sm.cache.as_ref().unwrap().content_bounds(), Rect::new(4, 4, 8, 12)); + let mask = &sm.filter_mask.as_ref().unwrap().surface; + assert_eq!((mask.pixel(4, 4)[0], mask.pixel(8, 8)[0]), (1.0, 0.0)); + // The document keeps its full-size placement. + let LayerContent::Smart(orig) = &doc.layers[0].content else { panic!("a smart object") }; + assert_eq!(orig.transform.m[4], 16.0); + } + #[test] fn proxy_scales_the_selection() { let mut doc = Document::with_background("d", Size::new(64, 64), ColorMode::Rgb, SampleType::U8, Color::WHITE); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index d8842a24fd..ddd4871548 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -3165,3 +3165,52 @@ fn a_pass_through_clipping_base_keeps_its_adjustments_inside() { d.layers.push(clip); assert!(close4(px(&d, 1, 1), [1.0, 1.0, 1.0, 1.0]), "{:?}", px(&d, 1, 1)); } + +// A cache entry reserves the addresses of the tiles its key hashes, not their pixels (#4530): once +// the document lets go of a styled layer or group (an edit replaced its tiles, or it was +// deleted), the effect cache keeps none of its pixel memory alive, and the maps are still +// reused while the layer is unchanged. +#[test] +fn effect_cache_keeps_no_pixels_of_a_dropped_layer() { + let mut d = doc_white(300, 300); + let mut l = solid_layer("styled", Rect::new(20, 20, 280, 280), [0.9, 0.2, 0.2, 1.0]); + l.effects.items = vec![stroke(3.0, StrokePosition::Outside)]; + d.layers.push(l); + let mut g = Layer::group("g", vec![solid_layer("child", Rect::new(40, 40, 120, 120), [0.1, 0.2, 0.9, 1.0])]); + g.effects.items = vec![Effect::DropShadow(Shadow { size: 2.0, spread: 0.0, ..shadow(3.0, 90.0) })]; + d.layers.push(g); + let first = render(&d, d.bounds()); + let tile = |l: &Layer| l.surface().and_then(|s| s.tiles().next()).map(|(_, t)| t.clone()).unwrap(); + let own = tile(&d.layers[1]); + let LayerContent::Group(grp) = &d.layers[2].content else { panic!("not a group") }; + let child = tile(&grp.children[0]); + assert_eq!(render(&d, d.bounds()).px, first.px, "warm render"); + drop(d); + // Only this test's handles are left: the cache pins neither the layer's nor the child's pixels. + assert_eq!(std::sync::Arc::strong_count(&own), 1); + assert_eq!(std::sync::Arc::strong_count(&child), 1); +} + +// Concurrent callers share one memoized effect identity. +#[test] +fn concurrent_effect_identity_misses_compute_once() { + let mut doc = identity_doc(SampleType::U8); + // Make the existing pure identity hash long enough to expose overlapping misses. + doc.layers[1].effects.items = vec![Effect::DropShadow(shadow(4.0, 120.0)); 512]; + let layer = &doc.layers[1]; + let keys = LayerKeys::default(); + let start = std::sync::Barrier::new(8); + std::thread::scope(|scope| { + let handles: Vec<_> = (0..8) + .map(|_| { + scope.spawn(|| { + start.wait(); + keys.identity(layer, Some(&doc)) + }) + }) + .collect(); + let ids: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect(); + assert!(ids.iter().all(|id| *id == ids[0])); + }); + assert_eq!(keys.built.load(std::sync::atomic::Ordering::Relaxed), 1, "concurrent cache misses must compute one memoized identity"); +} diff --git a/crates/engine/src/adjust_cmds/tests.rs b/crates/engine/src/adjust_cmds/tests.rs index 9f614732de..d831fbf448 100644 --- a/crates/engine/src/adjust_cmds/tests.rs +++ b/crates/engine/src/adjust_cmds/tests.rs @@ -527,6 +527,106 @@ fn a_targeted_mask_enables_editing_it_on_any_layer() { assert_eq!(rgba(&s, 23, 3), pixels); } +/// #5335: Curves (⌘M) edits the targeted layer mask, saved channel or Quick Mask at every depth. +#[test] +fn curves_apply_to_non_colour_targets_at_every_depth() { + let area = photocraft_geom::Rect::from_xywh(0, 0, 48, 32); + for depth in DEPTHS { + for target in ["mask", "alpha", "quickMask"] { + let mut s = session(depth, "rgb"); + s.execute("edit.fill", json!({"contents": "color", "color": "#336699"})).unwrap(); + let p = match target { + "mask" => { + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + json!({"points": [[0, 0], [128, 200], [255, 255]], "target": "mask"}) + } + "alpha" => { + s.execute("channel.new", json!({"name": "Sky sel", "fill": "white"})).unwrap(); + json!({"points": [[0, 0], [128, 200], [255, 255]], "target": {"channel": 0}}) + } + _ => { + s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + json!({"points": [[0, 0], [128, 200], [255, 255]], "target": "quickMask"}) + } + }; + let edit_target = p.get("target").cloned().unwrap_or(json!(null)); + s.edit("levels", |doc, active| { + let (surf, _) = crate::channel_cmds::target_surface(doc, *active, &json!({ "target": edit_target })).unwrap(); + surf.fill_rect(area, &[0.5]); + Ok(()) + }) + .unwrap(); + let read = |s: &Session| { + let d = s.active().unwrap(); + let layer = d.doc.layer(d.active_layer.unwrap()).unwrap(); + let t = match target { + "quickMask" => d.doc.quick_mask.as_ref().unwrap().surface.sample_channel(8, 8, 0), + "alpha" => d.doc.channels[0].surface.sample_channel(8, 8, 0), + _ => layer.mask.as_ref().unwrap().surface.sample_channel(8, 8, 0), + }; + (rgba(s, 8, 8), t) + }; + let ((pixels, before), past) = (read(&s), s.active().unwrap().history.past_len()); + s.execute("image.adjustments.curves", p).unwrap(); + let (pixels_after, after) = read(&s); + let case = format!("{target} at {depth}"); + assert_eq!(pixels_after, pixels, "{case}: colour pixels stay put"); + assert!(after > before + 0.1, "{case}: the curve lifted the midtone ({before} → {after})"); + assert_eq!(s.active().unwrap().history.past_len(), past + 1, "{case}"); + s.undo(); + assert!((read(&s).1 - before).abs() < 1e-5, "{case}: undo restores the target"); + } + } +} + +/// #5335: a layer mask, saved channel or Quick Mask holds gray levels whatever the document's +/// colour mode: inverting 25% grey gives 75% grey in Lab and CMYK, not an untouched plane (Lab +/// ignores the composite) or an ink-separated result (CMYK curves the inks). +#[test] +fn curves_on_grey_targets_ignore_the_document_colour_mode() { + let area = photocraft_geom::Rect::from_xywh(0, 0, 48, 32); + for mode in ["lab", "cmyk"] { + for depth in [8u64, 16] { + for target in ["mask", "alpha", "quickMask"] { + let mut s = session(depth, mode); + let p = match target { + "mask" => { + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + json!("mask") + } + "alpha" => { + s.execute("channel.new", json!({"name": "Sky sel", "fill": "white"})).unwrap(); + json!({"channel": 0}) + } + _ => { + s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + json!("quickMask") + } + }; + s.edit("levels", |doc, active| { + let (surf, _) = crate::channel_cmds::target_surface(doc, *active, &json!({ "target": p })).unwrap(); + surf.fill_rect(area, &[0.25]); + Ok(()) + }) + .unwrap(); + let read = |s: &Session| match target { + "quickMask" => doc(s).quick_mask.as_ref().unwrap().surface.sample_channel(8, 8, 0), + "alpha" => doc(s).channels[0].surface.sample_channel(8, 8, 0), + _ => mask_at(s, 8, 8), + }; + let before = read(&s); + let pixels = rgba(&s, 8, 8); + s.execute("image.adjustments.curves", json!({"points": [[0, 255], [255, 0]], "target": p})).unwrap(); + let after = read(&s); + let case = format!("{mode} {depth}-bit {target}"); + assert!((before - 0.25).abs() < 0.01, "{case}: filled with 25% grey ({before})"); + assert!((after - 0.75).abs() < 0.02, "{case}: inverting 25% grey gives 75% grey ({after})"); + assert_eq!(rgba(&s, 8, 8), pixels, "{case}: colour pixels stay put"); + } + } + } +} + #[test] fn replace_color_localized_clusters_only_change_the_colour_near_the_sample() { // The same cream on the left (x < 8) and the right (x ≥ 40), grey between. @@ -561,3 +661,53 @@ fn replace_color_localized_clusters_only_change_the_colour_near_the_sample() { let err = s.execute("image.adjustments.replaceColor", json!({"points": [[1]]})).unwrap_err(); assert!(err.to_string().contains("bad point"), "{err}"); } + +/// #6095: ⌘I in Quick Mask inverts the whole mask, not only where it was white, so leaving Quick +/// Mask selects everything but the original rectangle, whichever way the colour reads. With +/// Color Indicates Selected Areas and nothing selected, it selects everything. A black alpha +/// channel inverts to white. +#[test] +fn invert_turns_the_black_of_quick_mask_and_alpha_channels_white() { + for depth in DEPTHS { + let mut s = session(depth, "rgb"); + let pixels = rgba(&s, 2, 2); + s.execute("select.rect", json!({"x": 10, "y": 8, "width": 20, "height": 10})).unwrap(); + s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + s.execute("image.adjustments.invert", json!({"target": "quickMask"})).unwrap(); + let q = &doc(&s).quick_mask.as_ref().unwrap().surface; + let (inside, outside) = (q.sample_channel(15, 12, 0), q.sample_channel(2, 2, 0)); + assert!(inside < 0.01 && outside > 0.99, "{depth}-bit Quick Mask inverted: inside {inside}, outside {outside}"); + s.execute("select.editInQuickMaskMode", json!({"on": false})).unwrap(); + let sel = doc(&s).selection.as_ref().expect("an inverse selection after Quick Mask"); + let (inside, outside) = (sel.sample_channel(15, 12, 0), sel.sample_channel(2, 2, 0)); + assert!(inside < 0.01 && outside > 0.99, "{depth}-bit selection: inside {inside}, outside {outside}"); + assert_eq!(rgba(&s, 2, 2), pixels, "{depth}-bit: the layer is untouched"); + + s.execute("select.deselect", json!({})).unwrap(); + s.execute("channel.new", json!({"name": "Black", "fill": "black"})).unwrap(); + s.execute("image.adjustments.invert", json!({"target": {"channel": 0}})).unwrap(); + let a = doc(&s).channels[0].surface.sample_channel(40, 30, 0); + assert!(a > 0.99, "{depth}-bit black alpha channel inverts to white ({a})"); + s.undo(); + assert!(doc(&s).channels[0].surface.sample_channel(40, 30, 0) < 0.01, "{depth}-bit: undo restores the black"); + + // Color Indicates Selected Areas: a rectangle comes back inverted, and nothing selected + // comes back as everything selected. + for (start, inside, outside) in [("rectangle", 0.0, 1.0), ("nothing", 1.0, 1.0)] { + let mut s = session(depth, "rgb"); + s.execute("channel.options", json!({"channel": "quickMask", "indicates": "selected"})).unwrap(); + if start == "rectangle" { + s.execute("select.rect", json!({"x": 10, "y": 8, "width": 20, "height": 10})).unwrap(); + } + s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + s.execute("image.adjustments.invert", json!({"target": "quickMask"})).unwrap(); + s.execute("select.editInQuickMaskMode", json!({"on": false})).unwrap(); + let sel = doc(&s).selection.as_ref().unwrap_or_else(|| panic!("{depth}-bit Selected Areas, {start}: a selection after Quick Mask")); + let got = (sel.sample_channel(15, 12, 0), sel.sample_channel(2, 2, 0)); + assert!( + (got.0 - inside).abs() < 0.01 && (got.1 - outside).abs() < 0.01, + "{depth}-bit Selected Areas, {start}: selection (inside, outside) {got:?}" + ); + } + } +} diff --git a/crates/engine/src/analysis_cmds.rs b/crates/engine/src/analysis_cmds.rs index 6ad25951db..85a6be4513 100644 --- a/crates/engine/src/analysis_cmds.rs +++ b/crates/engine/src/analysis_cmds.rs @@ -198,7 +198,7 @@ pub(crate) fn compound(s: &mut Session, label: &str, f: impl FnOnce(&mut Sess // As in `Session::edit`: undo targets what was selected just before the step (#1356). st.history.set_current_layers(prior); st.history.record(label, before, st.layer_target()); - st.history.trim(&st.doc); + st.trim_history(); st.coalesce = None; Ok(v) } diff --git a/crates/engine/src/automate_cmds.rs b/crates/engine/src/automate_cmds.rs index 6a98ee3c4f..4c78f90b34 100644 --- a/crates/engine/src/automate_cmds.rs +++ b/crates/engine/src/automate_cmds.rs @@ -24,6 +24,22 @@ fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } +/// Photoshop ExtendScript / UXP script suffixes — not PhotoCraft JSON or command lists. +fn extendscript_ext(path: &str) -> Option<&str> { + let name = path.rsplit(['/', '\\']).next().unwrap_or(path); + let dot = name.rfind('.')?; + let ext = &name[dot + 1..]; + let lower = ext.to_ascii_lowercase(); + matches!(lower.as_str(), "js" | "jsx" | "jsxbin" | "psjs" | "ccjs").then_some(ext) +} + +fn reject_extendscript(cmd: &str, path: &str) -> Result<()> { + match extendscript_ext(path) { + Some(_) => Err(bad(cmd, "PhotoCraft scripts are JSON or command lists (one command.id {json} per line), not Photoshop ExtendScript (.js/.jsx)")), + None => Ok(()), + } +} + /// Session state of the File-menu commands (not saved, not history). #[derive(Clone, Debug, Default)] pub struct FileMenuState { @@ -127,6 +143,12 @@ fn run_steps(s: &mut Session, steps: &[(String, Value)]) -> Vec { /// File › Scripts › Browse…: run a script file on the session. fn browse(s: &mut Session, p: &Value) -> Result { let cmd = "file.scripts.browse"; + if let Some(path) = p.get("path").and_then(Value::as_str) { + reject_extendscript(cmd, path)?; + } + if let Some(name) = p.get("name").and_then(Value::as_str) { + reject_extendscript(cmd, name)?; + } let steps = match (p.get("path").and_then(Value::as_str), p.get("script").and_then(Value::as_str), p.get("steps")) { (Some(path), _, _) => parse_script(&String::from_utf8_lossy(&read_file(path)?))?, (_, Some(text), _) => parse_script(text)?, diff --git a/crates/engine/src/automate_cmds/tests.rs b/crates/engine/src/automate_cmds/tests.rs index 67852534c3..06d0e88f36 100644 --- a/crates/engine/src/automate_cmds/tests.rs +++ b/crates/engine/src/automate_cmds/tests.rs @@ -54,6 +54,27 @@ fn browse_runs_a_script_file() { assert_eq!(r["results"].as_array().unwrap().len(), 1); } +#[test] +fn browse_rejects_photoshop_extendscript() { + let dir = tmp("jsx"); + let jsx = format!("{dir}/hello.jsx"); + std::fs::write(&jsx, "alert('hi');\n").unwrap(); + let mut s = Session::new(); + let err = s.execute("file.scripts.browse", json!({"path": jsx})).unwrap_err().to_string(); + assert!(err.contains("not Photoshop ExtendScript"), "{err}"); + assert!(err.contains("JSON"), "{err}"); + let js = format!("{dir}/hello.js"); + std::fs::write(&js, "alert('hi');\n").unwrap(); + assert!(s.execute("file.scripts.browse", json!({"path": js})).is_err()); + let err = s.execute("file.scripts.browse", json!({"name": "Resize.jsx", "script": "alert(1);"})).unwrap_err().to_string(); + assert!(err.contains(".js/.jsx"), "{err}"); + // A JSON script still runs even if the file name is not a Photoshop suffix. + let json = format!("{dir}/rotate.json"); + std::fs::write(&json, r#"[["file.new", {"width": 8, "height": 8}]]"#).unwrap(); + let r = s.execute("file.scripts.browse", json!({"path": json})).unwrap(); + assert_eq!(r["ok"], true); +} + #[test] fn recording_browse_script_captures_each_effect_once() { let mut s = Session::new(); diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index 00f3ec4e31..468db2698b 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -182,6 +182,82 @@ pub fn composite_name(mode: ColorMode) -> &'static str { } } +/// The Curves dialog's Channel popup lists only this channel when the Channels panel has a +/// single non-composite target (a colour component, layer mask, saved channel or Quick Mask). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IsolatedCurveChannel { + /// Channels-panel name, including a custom alpha name or "{layer} Mask". + pub name: String, + /// Curves param key: empty = composite `points`; otherwise `red`/`green`/… + pub key: &'static str, + /// Index into the tone-space histograms (composite = 0 for RGB/CMYK). + pub hist: usize, + /// Colour components keep their curve tint; masks and alphas do not. + pub tint: bool, + /// Built-in labels (colour channels, Quick Mask) follow the interface language; a custom + /// alpha name or a "{layer} Mask" keeps the layer name, so it stays exactly as entered (#5335). + pub translate: bool, + /// The gray plane this target edits, whatever the document's colour mode; its surface is + /// what the dialog's histogram must describe (#5335). None for colour components. + pub gray: Option, +} + +/// A grayscale plane Curves can target: the dialog parses and previews its curves in gray levels +/// even in Lab and CMYK documents (#5335). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GraySurface { + /// The active layer's layer mask. + LayerMask, + /// A saved channel, as an index into [`Document::channels`]. + Alpha(usize), + /// The Quick Mask. + QuickMask, +} + +/// Isolated Curves channel for the active document, matching [`inject_target`]. +pub fn isolated_curve_channel(st: &DocState) -> Option { + isolated_curve_channel_for(st, false) +} + +/// [`isolated_curve_channel`] with the Layers panel's mask target added: clicking a mask +/// thumbnail targets it without touching the Channels panel, and both ways of selecting the mask +/// must give the same Curves dropdown (#5335). +pub fn isolated_curve_channel_for(st: &DocState, layers_panel_mask: bool) -> Option { + let doc = &st.doc; + let mode = edit_mode(doc); + match st.channel_view.target { + ChannelTarget::Color(k) => { + let names = color_names(mode); + let keys = crate::adjust_params::channel_keys(crate::adjust_params::default_space(mode)); + let name = names.get(k)?.to_string(); + let key = *keys.get(k)?; + let hist = if matches!(mode, ColorMode::Lab) { k } else { k.saturating_add(1) }; + Some(IsolatedCurveChannel { name, key, hist, tint: true, translate: true, gray: None }) + } + ChannelTarget::Alpha(i) => doc.channels.get(i).map(|c| IsolatedCurveChannel { + name: c.name.clone(), + key: "", + hist: 0, + tint: false, + translate: false, + gray: Some(GraySurface::Alpha(i)), + }), + ChannelTarget::Composite if doc.quick_mask.is_some() => { + Some(IsolatedCurveChannel { name: "Quick Mask".into(), key: "", hist: 0, tint: false, translate: true, gray: Some(GraySurface::QuickMask) }) + } + ChannelTarget::LayerMask => layer_mask_curve_channel(st), + ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some() || layers_panel_mask => layer_mask_curve_channel(st), + _ => None, + } +} + +fn layer_mask_curve_channel(st: &DocState) -> Option { + let layer = st.active_layer.and_then(|id| st.doc.layer(id))?; + layer.mask.as_ref()?; + let name = format!("{} Mask", layer.name); + Some(IsolatedCurveChannel { name, key: "", hist: 0, tint: false, translate: false, gray: Some(GraySurface::LayerMask) }) +} + fn edit_mode(doc: &Document) -> ColorMode { doc.pixel_format().mode } diff --git a/crates/engine/src/channel_cmds/tests.rs b/crates/engine/src/channel_cmds/tests.rs index 1d721ee5c5..d63dc5fd0d 100644 --- a/crates/engine/src/channel_cmds/tests.rs +++ b/crates/engine/src/channel_cmds/tests.rs @@ -867,3 +867,51 @@ fn thumbnail_selection_shape_without_pixel_cache_uses_its_transparency() { assert_eq!((b.x0, b.y0, b.width(), b.height()), (20, 10, 40, 30)); } } + +/// #5335: a single targeted channel is the only Curves popup entry, named as in the Channels panel. +#[test] +fn isolated_curve_channel_matches_the_channels_panel() { + let mut s = session(); + assert!(isolated_curve_channel(s.active().unwrap()).is_none(), "composite lists every colour channel"); + s.execute("channel.target", json!({"channel": {"color": 1}})).unwrap(); + let green = isolated_curve_channel(s.active().unwrap()).expect("green"); + assert_eq!((green.name.as_str(), green.key, green.tint), ("Green", "green", true)); + assert!(green.translate && green.gray.is_none(), "built-in colour labels follow the language"); + + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.execute("channel.target", json!({"channel": "mask"})).unwrap(); + let mask = isolated_curve_channel(s.active().unwrap()).expect("mask"); + assert_eq!(mask.key, ""); + assert!(!mask.tint); + assert_eq!(mask.name, format!("{} Mask", doc(&s).layer(s.active().unwrap().active_layer.unwrap()).unwrap().name)); + + s.execute("channel.new", json!({"name": "Green", "fill": "white"})).unwrap(); + s.execute("channel.target", json!({"channel": 0})).unwrap(); + let alpha = isolated_curve_channel(s.active().unwrap()).expect("alpha"); + assert_eq!((alpha.name.as_str(), alpha.key, alpha.tint), ("Green", "", false)); + assert!(!alpha.translate, "a custom channel name stays exactly as entered"); + assert_eq!(alpha.gray, Some(GraySurface::Alpha(0))); + + let mut qm = session(); + qm.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + let row = isolated_curve_channel(qm.active().unwrap()).expect("quick mask"); + assert_eq!((row.name.as_str(), row.tint), ("Quick Mask", false)); + assert_eq!(row.gray, Some(GraySurface::QuickMask)); +} + +/// #5335: selecting the mask in the Layers panel gives the same isolated Curves channel as +/// selecting it in the Channels panel; the Quick Mask keeps priority. +#[test] +fn layers_panel_mask_target_isolates_the_curve_channel() { + let mut s = session(); + assert!(isolated_curve_channel_for(s.active().unwrap(), true).is_none(), "a layer without a mask is not targeted"); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.execute("channel.target", json!({"channel": "mask"})).unwrap(); + let by_panel = isolated_curve_channel(s.active().unwrap()).expect("mask targeted from the Channels panel"); + s.execute("channel.target", json!({"channel": "composite"})).unwrap(); + let by_layers = isolated_curve_channel_for(s.active().unwrap(), true).expect("mask targeted from the Layers panel"); + assert_eq!(by_panel, by_layers, "both ways of selecting the mask give the same dropdown"); + s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + let row = isolated_curve_channel_for(s.active().unwrap(), true).expect("quick mask wins"); + assert_eq!((row.name.as_str(), row.gray), ("Quick Mask", Some(GraySurface::QuickMask))); +} diff --git a/crates/engine/src/clip_group.rs b/crates/engine/src/clip_group.rs index 4e356efce9..31f62696a3 100644 --- a/crates/engine/src/clip_group.rs +++ b/crates/engine/src/clip_group.rs @@ -42,6 +42,11 @@ fn group_of(doc: &Document, id: LayerId) -> Vec { if group.len() > 1 { group } else { Vec::new() } } +/// The base `id` clips to (itself when it is a base), when it is in a clip group. +pub(crate) fn base_of(doc: &Document, id: LayerId) -> Option { + group_of(doc, id).first().copied() +} + /// The layers clipped to `id`: the clipped run directly above it. Empty when `id` is itself /// clipped (it is not a base) or nothing is clipped to it. fn clipped_to(doc: &Document, id: LayerId) -> Vec { @@ -51,6 +56,14 @@ fn clipped_to(doc: &Document, id: LayerId) -> Vec { } } +/// The clipped layers directly above `id` among its siblings (Release Clipping Mask releases them +/// with it). +pub(crate) fn clipped_above(doc: &Document, id: LayerId) -> Vec { + let Some(path) = doc.path_of(id) else { return Vec::new() }; + let Some((list, idx)) = siblings(doc, &path) else { return Vec::new() }; + list.iter().skip(idx.saturating_add(1)).take_while(|l| l.clipped).map(|l| l.id).collect() +} + /// The clip groups of layers about to move or be deleted, read before they leave their slots. #[derive(Default)] pub(crate) struct Before { diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 68d00a7da3..edb60b06dc 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -4,7 +4,7 @@ use photocraft_color::{BlendMode, Color, ColorMode, SampleType}; use photocraft_doc::{Adjustment, Document, Fill, Layer, LayerContent, LayerId, LayerMask, Size}; use photocraft_geom::Rect; use photocraft_raster::Surface; -use serde_json::{Value, json}; +use serde_json::{Map, Value, json}; use crate::{EngineError, Result, Session, inspect, pixels}; @@ -253,7 +253,10 @@ pub(crate) fn parse_hex(s: &str) -> Option<[f32; 4]> { } /// Layer › Create / Release Clipping Mask. With a `layer` param, just that layer. Otherwise, as in /// Photoshop, the selection (#1248): Create clips every selected layer except the lowest, which -/// becomes the base; Release releases every selected layer. One history step either way. +/// becomes the base; Release releases every selected layer. Releasing a clipped layer also +/// releases the clipped layers above it in its group, as Adobe documents ("removes the selected +/// layer and any layers above it from the clipping mask"), so none is left clipped to a layer +/// that is no longer clipped. One history step either way. fn set_clipped(s: &mut Session, p: &Value, clip: bool) -> Result { let label = if clip { "Create Clipping Mask" } else { "Release Clipping Mask" }; let ids: Vec = if p.get("layer").is_some() { @@ -267,6 +270,20 @@ fn set_clipped(s: &mut Session, p: &Value, clip: bool) -> Result { (false, all) => all.to_vec(), } }; + let ids = if clip { + ids + } else { + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let mut all = ids.clone(); + for id in ids.iter().filter(|id| doc.layer(**id).is_some_and(|l| l.clipped)) { + for above in crate::clip_group::clipped_above(doc, *id) { + if !all.contains(&above) { + all.push(above); + } + } + } + all + }; s.edit(label, |doc, _| { for id in &ids { doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?.clipped = clip; @@ -381,12 +398,12 @@ fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment, p: &Value) }); } if crate::channel_cmds::is_channel_target(p) { - // Alpha channel / Quick Mask target: the adjustment runs on the grayscale channel. + // Alpha channel / Quick Mask target: the adjustment runs on the grayscale channel as on + // a layer mask, its unstored black included, so ⌘I in Quick Mask inverts it all (#6095). return s.edit(label, |doc, _| { let sel = doc.selection.clone(); if let Some(surf) = crate::channel_cmds::channel_surface_for_filter(doc, None, p)? { - pixels::adjust_surface(surf, &adj, sel.as_ref(), ColorMode::Grayscale); - surf.prune(); + pixels::adjust_mask(surf, &adj, sel.as_ref()); } Ok(Value::Null) }); @@ -414,6 +431,31 @@ fn doc_mode(s: &Session) -> ColorMode { s.active().map_or(ColorMode::Rgb, |d| d.doc.mode) } +/// The colour mode a destructive adjustment parses its tone space in: a targeted layer mask, +/// saved channel or Quick Mask holds gray levels whatever the document's mode, so Curves and +/// Levels stay in the neutral space instead of Lab (where the composite is ignored) or CMYK +/// (where the curve runs on the inks) (#5335). +fn adjust_mode(s: &Session, p: &Value) -> ColorMode { + if is_mask_target(p) || crate::channel_cmds::is_channel_target(p) { ColorMode::Grayscale } else { doc_mode(s) } +} + +/// Tone params for a gray plane target: the document-space channel records are dropped, because +/// those keys select the Lab or CMYK tone space and a plane only holds levels (#5335). +fn gray_tone_params(p: &Value) -> Value { + match p { + Value::Object(m) => { + let mut o = Map::new(); + for (k, v) in m { + if !matches!(k.as_str(), "cyan" | "magenta" | "yellow" | "black" | "lightness" | "a" | "b") { + o.insert(k.clone(), v.clone()); + } + } + Value::Object(o) + } + other => other.clone(), + } +} + // ---------- the table ---------- #[allow(clippy::too_many_lines)] @@ -1269,7 +1311,16 @@ fn build() -> Vec { }); let adj = match eyedropped { Some(adj) => adj, - None => crate::adjust_params::from_params(&kind, p, None, doc_mode(s))?, + None => { + // A gray plane target keeps the neutral tone space even over params + // carrying Lab or CMYK records (#5335). + let tone = if is_mask_target(p) || crate::channel_cmds::is_channel_target(p) { + if matches!(kind.as_str(), "levels" | "curves") { gray_tone_params(p) } else { p.clone() } + } else { + p.clone() + }; + crate::adjust_params::from_params(&kind, &tone, None, adjust_mode(s, p))? + } }; let label = adj.label().to_string(); // A smart object records the adjustment as a smart filter (Photoshop), which diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index cda3de84d4..11e2273601 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -224,7 +224,13 @@ fn fade(s: &mut Session, p: &Value) -> Result { fn history_bytes(s: &Session, all: bool) -> usize { let docs: Vec<&crate::DocState> = if all { s.documents().iter().collect() } else { s.active().into_iter().collect() }; - docs.iter().map(|d| d.history.unique_bytes(&d.doc)).sum() + // Purge keeps the snapshots, so the tiles they share with the states aren't freed. + docs.iter() + .map(|d| { + let snaps: Vec<&photocraft_doc::Document> = d.snapshots.iter().map(|s| &*s.doc).collect(); + d.history.unique_bytes_besides(&d.doc, &snaps) + }) + .sum() } fn clip_bytes(s: &Session) -> usize { diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index 3a27c99be3..91c9a9bc1a 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -522,11 +522,16 @@ fn delete_where(s: &mut Session, label: &str, pred: fn(&Layer) -> bool) -> Resul n } let backup = doc.layers.clone(); + // The clip groups of the doomed layers, read before they go (clip_group rule 3: layers + // clipped to a deleted base are released). + let doomed: Vec = doc.walk().into_iter().filter(|(_, _, l)| pred(l)).map(|(_, _, l)| l.id).collect(); + let before = crate::clip_group::Before::read(doc, &doomed); let n = rec(&mut doc.layers, pred); if doc.layers.is_empty() { doc.layers = backup; return Err(EngineError::Other("a document must keep at least one layer".into())); } + crate::clip_group::settle(doc, &[], &before, false); if active.is_none_or(|id| doc.layer(id).is_none()) { *active = doc.top_layer(); } @@ -878,8 +883,17 @@ pub fn specs() -> Vec { ), spec!("layer.layerStyle.copyLayerStyle", "Copy Layer Style", &["Layer", "Layer Style"], None, r##"{"layer":id?}"##, has_layer, |s, p| { let id = layer_param(s, p)?; - let l = s.active().and_then(|d| d.doc.layer(id)).ok_or(EngineError::NoLayer(id))?; - let style = (l.effects.clone(), l.blend, l.fill_opacity, l.advanced); + let d = s.active().ok_or(EngineError::NoDocument)?; + let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?; + let style = crate::CopiedStyle { + effects: l.effects.clone(), + blend: l.blend, + fill: l.fill_opacity, + advanced: l.advanced, + blend_if: l.blend_if.clone(), + excluded_channels: l.excluded_channels, + mode: d.doc.mode, + }; s.style_clipboard = Some(style); Ok(Value::Null) }), @@ -895,17 +909,32 @@ pub fn specs() -> Vec { }, |s, p| { let ids = style_targets(s, p, |_| true)?; - let (mut fx, blend, fill, advanced) = s.style_clipboard.clone().ok_or(EngineError::Other("no layer style has been copied".into()))?; + let style = s.style_clipboard.clone().ok_or(EngineError::Other("no layer style has been copied".into()))?; + let mut fx = style.effects; s.edit("Paste Layer Style", |doc, _| { // A style copied from a document in another mode takes this one's colours. let mode = doc.mode; fx.items = std::mem::take(&mut fx.items).into_iter().map(|e| e.in_mode(mode)).collect(); + // Blend If's Gray range carries over to any mode; its channel ranges and the + // channel restrictions only to the mode they were set in. + let same_mode = style.mode == mode; + let blend_if = if same_mode { + style.blend_if.clone() + } else { + // Through `set`, which keeps the normalised form (no trailing full + // entries), so a full Gray range pastes as the default. + let mut gray = photocraft_doc::BlendIf::default(); + gray.set(0, style.blend_if.get(0)); + gray + }; for id in ids { let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; l.effects = fx.clone(); - l.blend = blend; - l.fill_opacity = fill; - l.advanced = advanced; + l.blend = style.blend; + l.fill_opacity = style.fill; + l.advanced = style.advanced; + l.blend_if = blend_if.clone(); + l.excluded_channels = if same_mode { style.excluded_channels } else { 0 }; } Ok(()) })?; diff --git a/crates/engine/src/filters/validation.rs b/crates/engine/src/filters/validation.rs index 127e92efe1..068d37e9da 100644 --- a/crates/engine/src/filters/validation.rs +++ b/crates/engine/src/filters/validation.rs @@ -1,4 +1,4 @@ -//! Validate new core-filter invocations, without changing how old smart-filter records decode. +//! Validate new filter invocations, without changing how old smart-filter records decode. use serde_json::Value; @@ -33,7 +33,7 @@ impl Rule { } } -// The bounds and choices are the core filters' public dialog-unit contract. Defaults remain in +// The bounds and choices are the filters' public dialog-unit contract. Defaults remain in // params_for; gallery filters are checked in gallery_cmds. fn fields(id: &str) -> Option<&'static [(&'static str, Rule)]> { use Rule::{Bool, Choice, Number, Unsigned}; @@ -48,6 +48,20 @@ fn fields(id: &str) -> Option<&'static [(&'static str, Rule)]> { ("centerX", Number(0.0, 1.0)), ("centerY", Number(0.0, 1.0)), ], + "filter.blur.smartBlur" => &[ + ("radius", Number(0.1, 100.0)), + ("threshold", Number(0.1, 100.0)), + ("quality", Choice("high|medium|low")), + ("mode", Choice("normal|edgeOnly|overlayEdge")), + ], + "filter.blur.shapeBlur" => &[("radius", Number(5.0, 1000.0)), ("shape", Choice("circle|ring|square|diamond|triangle|hexagon|star|heart|cross"))], + "filter.noise.reduceNoise" => &[ + ("strength", Number(0.0, 10.0)), + ("preserveDetails", Number(0.0, 100.0)), + ("reduceColorNoise", Number(0.0, 100.0)), + ("sharpenDetails", Number(0.0, 100.0)), + ("removeJpegArtifact", Bool), + ], "filter.blur.surfaceBlur" => &[("radius", Number(1.0, 100.0)), ("threshold", Number(2.0, 255.0))], "filter.sharpen.unsharpMask" => &[("amount", Number(1.0, 500.0)), ("radius", Number(0.1, 1000.0)), ("threshold", Number(0.0, 255.0))], "filter.sharpen.smartSharpen" => &[("amount", Number(1.0, 500.0)), ("radius", Number(0.1, 64.0)), ("reduceNoise", Number(0.0, 100.0))], @@ -92,8 +106,8 @@ fn fields(id: &str) -> Option<&'static [(&'static str, Rule)]> { }) } -// Extended filters: only their documented choices are checked for now, since `prepare` injects -// extra keys (colours, map documents) and their numeric ranges clamp in params_for. +// Remaining extended filters: only their documented choices are checked for now, since some +// accept extra keys (colours, map documents) and their numeric ranges clamp in params_for. fn choices(id: &str) -> Option<&'static [(&'static str, &'static str)]> { Some(match id { "filter.pixelate.mezzotint" => { @@ -109,11 +123,9 @@ fn choices(id: &str) -> Option<&'static [(&'static str, &'static str)]> { "filter.distort.zigZag" => &[("style", "pondRipples|outFromCenter|aroundCenter")], "filter.render.lensFlare" => &[("lens", "zoom|prime35|prime105|moviePrime")], "filter.render.lightingEffects" => &[("lightType", "spot|point|infinite"), ("texture", "none|red|green|blue|alpha|luminance")], - "filter.blur.smartBlur" => &[("quality", "high|medium|low"), ("mode", "normal|edgeOnly|overlayEdge")], "filter.blur.lensBlur" => { &[("shape", "hexagon|triangle|square|pentagon|heptagon|octagon"), ("depthMap", "none|transparency|layerMask"), ("distribution", "uniform|gaussian")] } - "filter.blur.shapeBlur" => &[("shape", "circle|ring|square|diamond|triangle|hexagon|star|heart|cross")], "filter.other.hsbHsl" => &[("inputMode", "rgb|hsb|hsl"), ("rowOrder", "hsb|hsl|rgb")], "filter.video.deInterlace" => &[("eliminate", "oddFields|evenFields"), ("createBy", "interpolation|duplication")], _ => return None, @@ -324,9 +336,7 @@ mod tests { "filter.distort.zigZag", "filter.render.lensFlare", "filter.render.lightingEffects", - "filter.blur.smartBlur", "filter.blur.lensBlur", - "filter.blur.shapeBlur", "filter.other.hsbHsl", "filter.video.deInterlace", ]; @@ -348,6 +358,124 @@ mod tests { s.execute("filter.stylize.wind", json!({"direction":"fromLeft"})).unwrap(); } + const STRICT_EXTENDED: [&str; 3] = ["filter.blur.smartBlur", "filter.blur.shapeBlur", "filter.noise.reduceNoise"]; + + #[test] + fn strict_extended_filters_reject_bad_fields_without_editing() { + let numbers = [ + ("filter.blur.smartBlur", "radius", 0.1, 100.0), + ("filter.blur.smartBlur", "threshold", 0.1, 100.0), + ("filter.blur.shapeBlur", "radius", 5.0, 1000.0), + ("filter.noise.reduceNoise", "strength", 0.0, 10.0), + ("filter.noise.reduceNoise", "preserveDetails", 0.0, 100.0), + ("filter.noise.reduceNoise", "reduceColorNoise", 0.0, 100.0), + ("filter.noise.reduceNoise", "sharpenDetails", 0.0, 100.0), + ]; + let specs = crate::filters_ext::specs(); + for depth in [8, 16, 32] { + let mut s = session(depth); + for (id, key, min, max) in numbers { + let doc = specs.iter().find(|spec| spec.id == id).unwrap().params; + assert!(doc.contains(&format!("\"{key}\":{min}..{max}=")), "{id}.{key}"); + for value in [json!("3"), json!(true), Value::Null, json!([]), json!({}), json!(min - 0.01), json!(max + 0.01), json!(1e300)] { + rejected(&mut s, id, key, value); + } + // Validate large endpoints without allocating large blur kernels. + for value in [min, max] { + validate_params(id, &json!({key:value})).unwrap(); + } + } + for id in STRICT_EXTENDED { + for (key, value) in [("radus", json!(3)), ("layer", json!("2")), ("target", json!("typo")), ("coalesce", json!(false))] { + rejected(&mut s, id, key, value); + } + for value in [json!([]), json!(false), json!(3), json!("params")] { + assert!(matches!(validate_params(id, &value), Err(EngineError::BadParams { .. }))); + } + } + for value in [json!("true"), json!(1), Value::Null, json!([]), json!({})] { + rejected(&mut s, "filter.noise.reduceNoise", "removeJpegArtifact", value); + } + } + for value in [false, true] { + validate_params("filter.noise.reduceNoise", &json!({"removeJpegArtifact":value})).unwrap(); + } + for (id, key, allowed) in [ + ("filter.blur.smartBlur", "quality", "high|medium|low"), + ("filter.blur.smartBlur", "mode", "normal|edgeOnly|overlayEdge"), + ("filter.blur.shapeBlur", "shape", "circle|ring|square|diamond|triangle|hexagon|star|heart|cross"), + ] { + let doc = specs.iter().find(|spec| spec.id == id).unwrap().params; + assert!(doc.contains(&format!("\"{key}\":\"{allowed}\"")), "{id}.{key}"); + let mut s = session(8); + for value in [json!("typo"), json!(false), json!(1), Value::Null] { + rejected(&mut s, id, key, value); + } + for value in allowed.split('|') { + validate_params(id, &json!({key:value})).unwrap(); + } + } + } + + #[test] + fn strict_extended_filters_validate_before_floating_selection_commit_and_jobs() { + let mut s = session(16); + s.execute("select.rect", json!({"x":0,"y":0,"width":4,"height":4})).unwrap(); + s.execute("select.float", json!({"dx":8})).unwrap(); + for id in STRICT_EXTENDED { + rejected(&mut s, id, "radus", json!(3)); + let doc = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.entries(); + let journal = s.journal.clone(); + let params = json!({"radus":3}); + assert!(matches!(s.start(id, params.clone()), Err(EngineError::BadParams { .. }))); + let spec = crate::commands::find(id).unwrap(); + assert!(matches!((spec.run)(&mut s, ¶ms), Err(EngineError::BadParams { .. }))); + assert!(crate::float_cmds::floating(s.active().unwrap()).is_some()); + assert!(Arc::ptr_eq(&s.active().unwrap().doc, &doc)); + assert_eq!(s.active().unwrap().history.entries(), history); + assert_eq!(s.journal, journal); + assert!(s.jobs().is_empty()); + } + } + + #[test] + fn strict_extended_filters_keep_defaults_targets_history_and_last_filter() { + for depth in [8, 16, 32] { + for id in STRICT_EXTENDED { + let mut s = session(depth); + let layer = s.active().unwrap().active_layer.unwrap(); + for params in [Value::Null, json!({}), json!({"layer":layer.0,"target":"pixels","coalesce":"extended"})] { + let before = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + s.execute(id, params).unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), history + 1); + let after = s.active().unwrap().doc.clone(); + s.undo(); + assert_eq!(*s.active().unwrap().doc, *before); + s.redo(); + assert_eq!(*s.active().unwrap().doc, *after); + } + s.execute("filter.lastFilter", json!({})).unwrap(); + // The session injects this target when editing an alpha channel. + s.execute("channel.new", json!({"fill":"white"})).unwrap(); + s.execute(id, json!({})).unwrap(); + } + } + } + + #[test] + fn stored_extended_filter_records_still_decode_tolerantly() { + use photocraft_algo::{BlurQuality, BlurShape, FilterParams, SmartBlurMode}; + let smart = + crate::filters::params_for("filter.blur.smartBlur", &json!({"radius":-2,"threshold":900,"quality":"old","mode":false,"extra":true})).unwrap(); + assert!(matches!(smart, FilterParams::SmartBlur { radius: 0.1, threshold: 100.0, quality: BlurQuality::High, mode: SmartBlurMode::Normal })); + let shape = crate::filters::params_for("filter.blur.shapeBlur", &json!({"radius":2000,"shape":"old"})).unwrap(); + assert!(matches!(shape, FilterParams::ShapeBlur { radius: 1000.0, shape: BlurShape::Circle })); + let noise = crate::filters::params_for("filter.noise.reduceNoise", &json!({"strength":-1,"preserveDetails":101,"removeJpegArtifact":"yes"})).unwrap(); + assert!(matches!(noise, FilterParams::ReduceNoise { strength: 0.0, preserve_details: 100.0, remove_jpeg_artifact: false, .. })); + } + #[test] fn invalid_filters_leave_floating_selection_and_background_jobs_alone() { let mut s = session(16); diff --git a/crates/engine/src/history_cmds.rs b/crates/engine/src/history_cmds.rs index cc18df581e..c9c91b3d14 100644 --- a/crates/engine/src/history_cmds.rs +++ b/crates/engine/src/history_cmds.rs @@ -8,8 +8,10 @@ //! states: `history.newSnapshot` takes one, `history.selectSnapshot` brings it back as a new, //! undoable state. They live on [`crate::DocState::snapshots`] for the session only, as in //! Photoshop, and are addressed by an id that is never reused. Their tiles are shared -//! copy-on-write with the document and its history, and are not counted in the history's pixel -//! budget. +//! copy-on-write with the document and its history; the tiles only snapshots hold count against +//! the history's pixel budget ([`crate::DocState::trim_history`]), so a Merged Layers snapshot +//! (a full flattened copy) makes room by dropping the oldest states rather than taking memory +//! beyond the budget unseen (#4530). Edit › Purge keeps them. use std::sync::Arc; @@ -451,6 +453,54 @@ mod tests { assert_eq!(s.active().unwrap().doc.layers.len(), 2); } + fn tile_bytes(doc: &Document) -> usize { + doc.layers.iter().filter_map(|l| l.surface()).flat_map(|s| s.tiles().map(|(_, t)| t.bytes().len())).sum() + } + + /// A Merged Layers snapshot is a flattened copy only it holds (#4530): it counts in the + /// document's memory and against the history budget, so the same edits keep one state less. + #[test] + fn merged_snapshot_counts_against_the_history_budget() { + let (mut with, mut without) = (session(), session()); + let base = with.active().unwrap().memory_bytes(); + with.execute("history.newSnapshot", json!({"from": "mergedLayers"})).unwrap(); + let st = with.active().unwrap(); + let flat = tile_bytes(&st.snapshots[0].doc); + assert!(flat > 0); + assert_eq!(st.memory_bytes(), base + flat); + // Room for the document's tiles and four fill tiles. + let cur = tile_bytes(&st.doc); + let fill = tile_bytes(&Document { layers: st.doc.layers[1..].to_vec(), ..(*st.doc).clone() }); + assert!(flat <= fill, "the flattened tile is no bigger than a fill's"); + let budget = cur + 4 * fill; + for s in [&mut with, &mut without] { + s.active_mut().unwrap().history.max_bytes = budget; + for i in 0..6 { + s.execute("edit.fill", json!({"color": format!("#0000{:02x}", 0x40 + i * 0x20)})).unwrap(); + } + } + let held = |s: &Session| s.active().unwrap().history.past_len(); + assert_eq!(held(&without), 4); + assert_eq!(held(&with), 3, "the snapshot's tile takes one state's room"); + assert!(with.active().unwrap().memory_bytes() <= budget); + } + + /// Purge › Histories keeps the snapshots, so it doesn't report the tiles they share with + /// the states as freed (#4530). + #[test] + fn purging_histories_frees_only_what_no_snapshot_keeps() { + let mut s = session(); + s.execute("history.newSnapshot", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + let st = s.active().unwrap(); + // The snapshot's green layer tile, now only in it and the last state. + let shared = tile_bytes(&Document { layers: st.snapshots[0].doc.layers[1..].to_vec(), ..(*st.doc).clone() }); + let all = st.history.unique_bytes(&st.doc); + let r = s.execute("edit.purge.histories", json!({})).unwrap(); + assert_eq!(r["bytes"], json!(all - shared), "the green tile stays with the snapshot"); + assert_eq!(s.active().unwrap().snapshots.len(), 1); + } + #[test] fn rename_and_delete_snapshots_keep_the_other_ids() { let mut s = session(); diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 697794f469..3e870aa686 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -71,11 +71,12 @@ pub(crate) fn for_each_layer(layers: &mut [Layer], f: &mut dyn FnMut(&mut Layer) } } -/// Assign new layer ids to a copied document and update every saved layer-comp reference. +/// Assign new layer ids to a copied document and update every saved layer reference. /// -/// A document copy needs fresh identities, but its comps must still target the copied -/// layers. Deliberately leave references to already-deleted layers untouched so their -/// missing-layer warnings remain meaningful. The same applies to Last Document State. +/// A document copy needs fresh identities, but its comps and variable definitions must +/// still target the copied layers. Deliberately leave references to already-deleted +/// layers untouched so their missing-layer warnings remain meaningful. The same applies +/// to Last Document State. pub(crate) fn reidentify_copied_layers(doc: &mut Document) { let mut ids = std::collections::HashMap::new(); for_each_layer(&mut doc.layers, &mut |layer| { @@ -97,6 +98,11 @@ pub(crate) fn reidentify_copied_layers(doc: &mut Document) { if let Some(last) = &mut doc.last_document_state { remap(last); } + for def in &mut doc.variables.defs { + if let Some(&new_id) = ids.get(&def.layer) { + def.layer = new_id; + } + } } /// Scale every size and distance of a style by `k` (Image Size's Scale Styles, Layer Style › @@ -735,6 +741,9 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { doc.layer_comps.clear(); doc.last_applied_comp = None; doc.last_document_state = None; + // Likewise, variables drove template layers that no longer exist: drop the whole + // binding set (defs, data sets and the active index) on the flattened copy. + doc.variables = photocraft_doc::Variables::default(); } reidentify_copied_layers(&mut doc); let name = doc.name.clone(); diff --git a/crates/engine/src/kys.rs b/crates/engine/src/kys.rs index a274a9cc6c..f996e23475 100644 --- a/crates/engine/src/kys.rs +++ b/crates/engine/src/kys.rs @@ -12,8 +12,11 @@ //! candidate (the app lists them in Photoshop's menu order). A row's first shortcut becomes the //! command's main key, unless one of its keys is already the command's default (Zoom In lists //! `Cmd++` before `Cmd+=`); its other keys become the command's alternates (#3665: Copy is -//! `Cmd+C` and `F3`, and importing it must not leave `Cmd+C` unbound). Tool keys are not -//! imported: the tool letters are fixed here. +//! `Cmd+C` and `F3`, and importing it must not leave `Cmd+C` unbound). Tool keys match our +//! tools by name ([`plan_tools`]; the app knows the tools). A set saved on Windows spells the +//! primary modifier Ctrl where a Mac set says Cmd ([`KysSet::windows`], [`to_ours`]). +//! [`write`] saves a set the same way, with our command id on each row (`pc-id`, which +//! Photoshop ignores), so PhotoCraft reads its own sets back exactly. use std::collections::{BTreeMap, HashMap}; @@ -23,10 +26,12 @@ use crate::prefs::normalize_shortcut; pub const MAX_BYTES: usize = 4 << 20; /// One `` row: Photoshop's label and its shortcuts, first one primary. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct KysCommand { pub name: String, pub shortcuts: Vec, + /// Our command id, in sets PhotoCraft wrote ([`write`]'s `pc-id`): matched before the label. + pub id: Option, } /// The parsed set: its name, its command rows and how many tools carry a key. @@ -37,6 +42,11 @@ pub struct KysSet { pub name: Option, pub commands: Vec, pub tool_keys: usize, + /// The `` rows with a key: the tool's name, its key, our id in sets PhotoCraft wrote. + pub tools: Vec, + /// Saved by Photoshop on Windows: no key spells ⌘ or Opt, and Ctrl is the primary modifier, + /// so `Ctrl` here is our `Cmd` (a Mac set's `Control` stays the Control key). See [`to_ours`]. + pub windows: bool, } /// Parse a `.kys` file (or Photoshop's live `Keyboard Shortcuts.psp`, the same XML). Rows @@ -63,16 +73,62 @@ pub fn parse(text: &str) -> Result { .filter_map(|n| n.text().map(str::trim).filter(|t| !t.is_empty()).map(str::to_string)) .collect(); if !shortcuts.is_empty() { - set.commands.push(KysCommand { name: name.to_string(), shortcuts }); + let id = node.attribute(PC_ID).map(str::trim).filter(|i| !i.is_empty()).map(str::to_string); + set.commands.push(KysCommand { name: name.to_string(), shortcuts, id }); + } + } + "tool" => { + let key = node.text().map(str::trim).filter(|t| !t.is_empty()); + let name = node.attribute("name").map(str::trim).filter(|n| !n.is_empty()); + if let Some(key) = key { + set.tool_keys += 1; + if let Some(name) = name { + let id = node.attribute(PC_ID).map(str::trim).filter(|i| !i.is_empty()).map(str::to_string); + set.tools.push(KysCommand { name: name.to_string(), shortcuts: vec![key.to_string()], id }); + } } } - "tool" if node.text().is_some_and(|t| !t.trim().is_empty()) => set.tool_keys += 1, _ => {} } } + set.windows = windows_set(&set); Ok(set) } +/// The modifier words of `sc` (`Ctrl+Alt+K` → Ctrl, Alt), whatever the key is. +fn modifiers(sc: &str) -> impl Iterator + '_ { + let mods = sc.strip_suffix("++").unwrap_or_else(|| sc.rsplit_once('+').map_or("", |(m, _)| m)); + mods.split('+').filter(|m| !m.is_empty()).map(|m| m.trim().to_ascii_lowercase()) +} + +/// Photoshop on Windows writes Ctrl for the primary modifier, where a Mac set writes Cmd (and +/// Control for the Control key): a set with no ⌘ or Opt anywhere but some Ctrl is a Windows set. +fn windows_set(set: &KysSet) -> bool { + let keys = || set.commands.iter().flat_map(|c| c.shortcuts.iter()); + let mac = keys().flat_map(|k| modifiers(k).collect::>()).any(|m| matches!(m.as_str(), "cmd" | "command" | "opt" | "option")); + let ctrl = keys().flat_map(|k| modifiers(k).collect::>()).any(|m| matches!(m.as_str(), "ctrl" | "control")); + !mac && ctrl +} + +/// A key of `set` in our notation, normalised: on a Windows set, Ctrl (or Control) is our `Cmd` +/// (Ctrl on Windows and Linux, ⌘ on the Mac), so `Ctrl+L` is the `Cmd+L` default rather than a +/// literal Control key. +pub fn to_ours(set: &KysSet, sc: &str) -> Option { + if !set.windows { + return normalize_shortcut(sc); + } + let (mods, key) = match sc.strip_suffix("++") { + Some(m) => (m, "+"), + None => sc.rsplit_once('+').unwrap_or(("", sc)), + }; + let mods: Vec<&str> = mods + .split('+') + .filter(|m| !m.is_empty()) + .map(|m| if matches!(m.trim().to_ascii_lowercase().as_str(), "ctrl" | "control") { "Cmd" } else { m }) + .collect(); + normalize_shortcut(&mods.into_iter().chain(std::iter::once(key)).collect::>().join("+")) +} + /// What an import would change. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Plan { @@ -124,18 +180,21 @@ pub fn plan(candidates: impl IntoIterator, set: &KysSet) -> Pl entry.push((c.id, c.default)); } } + let by_id: HashMap<&str, &(String, Option)> = by_label.values().flatten().map(|c| (c.0.as_str(), c)).collect(); let mut plan = Plan::default(); for row in &set.commands { - let Some(candidates) = by_label.get(&key(&row.name)) else { + // A set PhotoCraft wrote names the command itself, whatever its label says. + let own = row.id.as_deref().and_then(|id| by_id.get(id)).map(|c| vec![(*c).clone()]); + let Some(candidates) = own.as_ref().or_else(|| by_label.get(&key(&row.name))) else { plan.unknown.push(row.name.clone()); continue; }; - let Some(sc) = row.shortcuts.first().and_then(|s| normalize_shortcut(s)) else { + let Some(sc) = row.shortcuts.first().and_then(|s| to_ours(set, s)) else { plan.unreadable.push(row.name.clone()); continue; }; plan.alternates += row.shortcuts.len().saturating_sub(1); - let imported: Vec = row.shortcuts.iter().filter_map(|s| normalize_shortcut(s)).collect(); + let imported: Vec = row.shortcuts.iter().filter_map(|s| to_ours(set, s)).collect(); let pick = candidates .iter() .find(|(_, d)| d.as_deref().and_then(normalize_shortcut).is_some_and(|d| imported.contains(&d))) @@ -159,6 +218,92 @@ pub fn plan(candidates: impl IntoIterator, set: &KysSet) -> Pl plan } +/// The tool keys of `set` matched to `tools` (our tool id and Photoshop's name for it, which is +/// ours too: "Move Tool"): tool id → key (a single letter, upper case), and the names with no +/// tool here. A row PhotoCraft wrote names the tool by id. A key that isn't one letter is skipped. +pub fn plan_tools<'a>(tools: impl IntoIterator, set: &KysSet) -> (BTreeMap, Vec) { + let tools: Vec<(&str, &str)> = tools.into_iter().collect(); + let mut keys = BTreeMap::new(); + let mut unknown = Vec::new(); + for row in &set.tools { + let found = + row.id.as_deref().and_then(|id| tools.iter().find(|(t, _)| *t == id)).or_else(|| tools.iter().find(|(_, name)| key(name) == key(&row.name))); + let Some((id, _)) = found else { + unknown.push(row.name.clone()); + continue; + }; + let mut letters = row.shortcuts.first().map(|k| k.trim()).unwrap_or_default().chars(); + if let (Some(c), None) = (letters.next(), letters.next()) + && c.is_ascii_alphanumeric() + { + keys.insert((*id).to_string(), c.to_ascii_uppercase().to_string()); + } + } + (keys, unknown) +} + +/// The attribute naming our command id on the rows PhotoCraft writes (Photoshop ignores it). +pub const PC_ID: &str = "pc-id"; + +/// One row to write: a command (or tool), its label and its keys, main key first. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Entry { + pub id: String, + pub label: String, + pub tool: bool, + pub keys: Vec, +} + +/// A key in the spelling Photoshop's macOS sets use (`Control+Opt+Shift+Cmd+G`): our `Ctrl` is +/// Control, `Alt` is Opt. `None` for a key that isn't one. +pub fn photoshop_key(sc: &str) -> Option { + let n = normalize_shortcut(sc)?; + // The key is the last part; `Cmd++` ends in an empty part, so take it from the string. + let (mods, key) = match n.strip_suffix("++") { + Some(m) => (m, "+"), + None => n.rsplit_once('+').unwrap_or(("", n.as_str())), + }; + let has = |m: &str| mods.split('+').any(|x| x == m); + let mut out: Vec<&str> = + [("Ctrl", "Control"), ("Alt", "Opt"), ("Shift", "Shift"), ("Cmd", "Cmd")].iter().filter(|(ours, _)| has(ours)).map(|(_, ps)| *ps).collect(); + out.push(key); + Some(out.join("+")) +} + +fn xml_escape(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """).replace('\'', "'") +} + +/// A `.kys` file for the set `name` (Edit › Keyboard Shortcuts › Save Set): every entry with a +/// key, commands then tools, labels with Photoshop's `...`, keys in [`photoshop_key`] spelling, +/// and our id on each row so PhotoCraft reads its own sets back exactly. Rows without a key are +/// left out, and a key that isn't one is skipped. +pub fn write(name: &str, entries: &[Entry]) -> String { + let name = xml_escape(name.trim()); + let mut out = format!( + "\u{feff}\n\n" + ); + let row = |e: &Entry| -> Option { + let keys: Vec = e.keys.iter().filter_map(|k| photoshop_key(k)).collect(); + let first = keys.first()?; + let label = xml_escape(&e.label.replace('…', "...")); + let id = xml_escape(&e.id); + Some(if e.tool { + format!("\t{}\n", xml_escape(first)) + } else { + let shortcuts: String = keys.iter().map(|k| format!("{}", xml_escape(k))).collect(); + format!("\t{shortcuts}\n") + }) + }; + for e in entries.iter().filter(|e| !e.tool).chain(entries.iter().filter(|e| e.tool)) { + if let Some(r) = row(e) { + out.push_str(&r); + } + } + out.push_str("\n"); + out +} + #[cfg(test)] mod tests { use super::*; @@ -211,4 +356,103 @@ mod tests { assert!(parse(&big).unwrap_err().contains("4 MiB")); assert!(parse("").unwrap_err().contains("not a keyboard shortcut set")); } + + #[test] + fn written_sets_read_back_exactly_and_keys_use_photoshops_spelling() { + assert_eq!(photoshop_key("Cmd+Alt+Shift+Ctrl+g").as_deref(), Some("Control+Opt+Shift+Cmd+G")); + assert_eq!(photoshop_key("Cmd++").as_deref(), Some("Cmd++")); + assert_eq!(photoshop_key("F3").as_deref(), Some("F3")); + assert_eq!(photoshop_key("Cmd+Shift+Ctrl"), None); + let entries = vec![ + Entry { id: "edit.copy".into(), label: "Copy".into(), tool: false, keys: vec!["Cmd+C".into(), "F3".into()] }, + Entry { id: "x.odd".into(), label: "Levels…".into(), tool: false, keys: vec!["Cmd+Alt+L".into()] }, + Entry { id: "tools.select.move".into(), label: "Move Tool".into(), tool: true, keys: vec!["V".into()] }, + Entry { id: "none".into(), label: "Unbound".into(), tool: false, keys: vec![] }, + Entry { id: "bad".into(), label: "A <\"weird\"> & 'name'".into(), tool: false, keys: vec!["Cmd+Shift+Ctrl".into(), "F9".into()] }, + ]; + let xml = write(" My & Set ", &entries); + assert!(xml.contains("KeyboardCustomization/My & Set=My & Set"), "{xml}"); + assert!(xml.contains("Opt+Cmd+L"), "{xml}"); + assert!(!xml.contains("Unbound"), "{xml}"); + assert!(xml.find("Move Tool").unwrap() > xml.find("Levels").unwrap(), "tools after commands"); + let set = parse(&xml).unwrap(); + assert_eq!(set.name.as_deref(), Some("My & Set")); + assert_eq!(set.tool_keys, 1); + assert_eq!(set.commands.len(), 3); + assert_eq!(set.commands[0], KysCommand { name: "Copy".into(), shortcuts: vec!["Cmd+C".into(), "F3".into()], id: Some("edit.copy".into()) }); + assert_eq!(set.commands[2].name, "A <\"weird\"> & 'name'"); + assert_eq!(set.commands[2].shortcuts, vec!["F9".to_string()]); + // The id wins over the label: a renamed or translated label still finds its command. + let cands = || { + vec![ + Candidate { id: "x.odd".into(), label: "Something Else".into(), default: None }, + Candidate { id: "y.levels".into(), label: "Levels".into(), default: Some("Cmd+L".into()) }, + ] + }; + let p = plan(cands(), &set); + assert_eq!(p.set.get("x.odd").map(String::as_str), Some("Cmd+Alt+L")); + assert!(!p.set.contains_key("y.levels")); + // Without the id, the label matches as before. + let mut plain = set.clone(); + plain.commands[1].id = None; + assert!(plan(cands(), &plain).set.contains_key("y.levels")); + } + + #[test] + fn a_windows_set_means_cmd_by_ctrl_and_a_mac_set_keeps_control() { + let win = " +\tCtrl+L +\tCtrl+Alt+Shift+G +\tCtrl++Ctrl+= +"; + let set = parse(win).unwrap(); + assert!(set.windows); + assert_eq!(to_ours(&set, "Ctrl+L").as_deref(), Some("Cmd+L")); + assert_eq!(to_ours(&set, "Ctrl+Alt+Shift+G").as_deref(), normalize_shortcut("Cmd+Alt+Shift+G").as_deref()); + assert_eq!(to_ours(&set, "Ctrl++").as_deref(), Some("Cmd++")); + assert_eq!(to_ours(&set, "F3").as_deref(), Some("F3")); + let cands = vec![Candidate { id: "image.levels".into(), label: "Levels…".into(), default: Some("Cmd+L".into()) }]; + assert_eq!(plan(cands, &set).set.get("image.levels").map(String::as_str), Some("Cmd+L"), "the default, not a Control override"); + // A Mac set spells ⌘ Cmd: its Control is the Control key. + let mac = parse( + "Cmd+LControl+Opt+Cmd+GControl+K", + ) + .unwrap(); + assert!(!mac.windows); + assert_eq!(to_ours(&mac, "Control+K"), normalize_shortcut("Ctrl+K")); + // No modifiers at all: nothing to tell, nothing to change. + let plain = parse("F3").unwrap(); + assert!(!plain.windows); + } + + #[test] + fn tool_keys_match_by_name_or_id_and_take_one_letter() { + let xml = " +\tV +\tn +\tB +\tQ +\tShift+L +\t +\tZ +"; + let set = parse(xml).unwrap(); + assert_eq!((set.tool_keys, set.tools.len()), (6, 5), "a key without a name counts but can't be matched"); + let tools = [ + ("tools.select.move", "Move Tool"), + ("tools.select.pencil", "Pencil Tool"), + ("tools.select.brush", "Brush Tool"), + ("tools.select.lasso", "Lasso Tool"), + ]; + let (keys, unknown) = plan_tools(tools, &set); + assert_eq!(keys.get("tools.select.move").map(String::as_str), Some("V")); + assert_eq!(keys.get("tools.select.pencil").map(String::as_str), Some("N"), "case-insensitive, upper-cased"); + assert_eq!(keys.get("tools.select.brush").map(String::as_str), Some("B"), "by pc-id"); + assert!(!keys.contains_key("tools.select.lasso"), "a tool key is one letter"); + assert_eq!(unknown, vec!["Imaginary Tool".to_string()]); + // Our own export reads back by id. + let out = write("T", &[Entry { id: "tools.select.pencil".into(), label: "Pencil Tool".into(), tool: true, keys: vec!["F".into()] }]); + let (keys, _) = plan_tools(tools, &parse(&out).unwrap()); + assert_eq!(keys.get("tools.select.pencil").map(String::as_str), Some("F")); + } } diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 9a79a72050..87bb507e4d 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -263,7 +263,8 @@ fn can_align(s: &Session) -> std::result::Result<(), String> { let d = doc_state(s)?; match d.selected_layers().len() { 0 => Err("no layer selected".into()), - // Photoshop aligns a single layer to the canvas (or to an active selection); 2+ align to each other. + // Photoshop aligns to an active selection however many layers are selected; without one, + // a single layer aligns to the canvas and 2+ align to each other. _ => Ok(()), } } @@ -961,8 +962,9 @@ fn align(s: &mut Session, p: &Value, kind: &str) -> Result { "canvas" => doc.bounds(), "selection" => doc.selection.as_ref().map(|m| m.content_bounds()).ok_or_else(|| EngineError::Other("there is no selection to align to".into()))?, "auto" | "layers" => match (&doc.selection, sel.len()) { - // Photoshop: one layer plus an active selection aligns to the selection bounds. - (Some(m), 1) if to == "auto" => m.content_bounds(), + // Photoshop: with an active selection, the layers align to its bounds (Layer › Align + // Layers to Selection), however many are selected. + (Some(m), _) if to == "auto" && !m.content_bounds().is_empty() => m.content_bounds(), // One layer with no selection aligns to the canvas (otherwise it would align to itself). (None, 1) if to == "auto" => doc.bounds(), _ => items.iter().fold(Rect::EMPTY, |a, (_, b)| a.union(b)), @@ -1312,6 +1314,12 @@ fn merge_layers(s: &mut Session) -> Result { let fmt = PixelFormat::new(fmt.mode, fmt.sample, true); let mut merged = Layer::raster(top.name.clone(), fmt); merged.locks = top.locks; + merged.label = top.label; + // Clipped layers of one chain merge into a layer still clipped to their base. Clipped + // layers of different chains don't: the result, at the top one's slot, would clip the + // lower chain's pixels to the upper chain's base. + let base = crate::clip_group::base_of(doc, top_id); + merged.clipped = base.is_some() && solo.layers.iter().all(|l| l.clipped && crate::clip_group::base_of(doc, l.id) == base); *crate::pixels_mut(&mut merged)? = crate::pixels::composite_layers_uncropped(&solo, fmt); let mid = merged.id; // Hidden selected layers are discarded, as in Photoshop. @@ -1431,7 +1439,7 @@ pub fn specs() -> Vec { CommandSpec { id: $id, label: $label, menu: $menu, shortcut: $sc, params: $params, enabled: $enabled, journal: true, run: $run } }; } - const ALIGN: &str = r##"{"to":"auto|layers|selection|canvas"="auto"} (auto: the selection bounds with one layer and an active selection, else the selected layers' bounds)"##; + const ALIGN: &str = r##"{"to":"auto|layers|selection|canvas"="auto"} (auto: the active selection's bounds when there is one, however many layers are selected; else one layer aligns to the canvas and several to their combined bounds)"##; vec![ spec!("select.allLayers", "All Layers", &["Select"], Some("Cmd+Alt+A"), "{}", has_doc, |s, _| select_all_layers(s)), spec!("select.deselectLayers", "Deselect Layers", &["Select"], None, "{}", has_doc, |s, _| { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 75914dbdbb..2e0684de12 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -103,6 +103,7 @@ pub mod sample_cmds; pub mod sampler_cmds; pub mod select_extra_cmds; pub mod selection_cmds; +pub mod shortcut_sets; pub mod slice_cmds; pub mod smart_cmds; pub mod smartselect_cmds; @@ -234,6 +235,20 @@ pub struct DocState { } impl DocState { + /// Trim the history to its budget, counting the tiles only History panel snapshots hold + /// against it too (#4530). Returns how many states were dropped. + pub fn trim_history(&mut self) -> usize { + let snaps: Vec<&Document> = self.snapshots.iter().map(|s| &*s.doc).collect(); + self.history.trim_with(&self.doc, &snaps) + } + + /// Pixel bytes of the document, the tiles only its history holds and the tiles only its + /// History panel snapshots hold. + pub fn memory_bytes(&self) -> usize { + let snaps: Vec<&Document> = self.snapshots.iter().map(|s| &*s.doc).collect(); + self.history.pixel_bytes(&self.doc).saturating_add(self.history.extra_bytes(&self.doc, &snaps)) + } + pub fn new(doc: Document, path: Option) -> Self { let active_layer = doc.top_layer(); let mut history = History::default(); @@ -391,6 +406,21 @@ impl Default for ToolState { } } +/// Layer › Layer Style › Copy Layer Style: the effects and the Blending Options, as Photoshop +/// copies them (Paste Layer Style puts them all on each target). +#[derive(Clone, Debug)] +pub struct CopiedStyle { + pub effects: photocraft_doc::Effects, + pub blend: photocraft_color::BlendMode, + pub fill: f32, + pub advanced: photocraft_doc::AdvancedBlending, + pub blend_if: photocraft_doc::BlendIf, + pub excluded_channels: u32, + /// The colour mode it was copied in: Blend If's channel ranges and the channel restrictions + /// only carry over to a document in the same mode. + pub mode: photocraft_color::ColorMode, +} + #[derive(Default)] pub struct Session { docs: Vec, @@ -404,7 +434,7 @@ pub struct Session { pub clipboard: Option, /// Layer › Layer Style › Copy Layer Style: effects, blend mode, fill opacity and the Advanced /// Blending switches. - pub style_clipboard: Option<(photocraft_doc::Effects, photocraft_color::BlendMode, f32, photocraft_doc::AdvancedBlending)>, + pub style_clipboard: Option, /// Shape path context menu: copied vector fill and stroke styles. pub path_fill_clipboard: Option, pub path_stroke_clipboard: Option, @@ -742,7 +772,7 @@ impl Session { if key.is_none() || st.coalesce != key || !st.history.can_undo() { st.history.set_current_layers(prior); st.history.record(label, before, layers); - st.history.trim(&st.doc); + st.trim_history(); } else { st.history.set_current_layers(layers); } diff --git a/crates/engine/src/pixels.rs b/crates/engine/src/pixels.rs index e0cea4f39e..b046de03bd 100644 --- a/crates/engine/src/pixels.rs +++ b/crates/engine/src/pixels.rs @@ -415,5 +415,10 @@ pub fn merge_down(doc: &Document, lower: &Layer, upper: &Layer) -> Layer { // Merge Down replaces the lower layer, so keep its editing restrictions. // In particular, a merged Background must remain opaque and position-locked. merged.locks = lower.locks; + // And its place: two layers clipped in one chain merge into a layer still clipped to the base, + // with the lower layer's colour label and link. + merged.clipped = lower.clipped && upper.clipped; + merged.label = lower.label; + merged.link_group = lower.link_group; merged } diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index d6b40178d3..c732daeefd 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -938,6 +938,10 @@ pub struct Preferences { /// ([`ALTERNATE_SHORTCUTS`]); an empty list removes them. Settings files from before this /// map load with nothing stored, so every command keeps its defaults. pub alternate_shortcuts: BTreeMap>, + /// Edit › Keyboard Shortcuts › Set: saved shortcut sets by name (`crate::shortcut_sets`). + pub shortcut_sets: BTreeMap, + /// The saved set in use ("" for the defaults). + pub shortcut_set: String, pub menus: MenuCustomization, pub toolbar: ToolbarCustomization, /// Edit › Check Spelling: words added to the dictionary ("Add"). @@ -1553,7 +1557,7 @@ impl Session { for st in &mut self.docs { st.history.max_states = n; st.history.max_bytes = bytes; - st.history.trim(&st.doc); + st.trim_history(); } photocraft_compose::set_effect_cache_budget(budget << 20); // Without waiting for the system font scan: this runs on the UI thread at launch. @@ -1837,7 +1841,8 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { let cmd = "edit.keyboardShortcuts"; // Every change lands at once, after all of it validated: a bad key leaves the set as it was. let mut next = s.prefs().clone(); - let mut changed = false; + // A set loads first, so the call's other changes apply on top of it. + let mut changed = crate::shortcut_sets::load(&mut next, p).map_err(|e| bad(cmd, e))?; if let Some(r) = p.get("reset") { match r { Value::Bool(true) => { @@ -1887,6 +1892,7 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { "alternates": plan.alternates, "alternateKeys": plan.alternate_keys, "toolKeys": set.tool_keys, + "windows": set.windows, })); let mut with_import = p.clone(); with_import["set"] = Value::Object(merged); @@ -1951,6 +1957,8 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { } } } + // Then the sets are saved, deleted or switched, from the bindings as they now stand. + changed |= crate::shortcut_sets::store(&mut next, p).map_err(|e| bad(cmd, e))?; if changed { s.edit_prefs(|p| *p = next); } @@ -1990,6 +1998,28 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { let conflicts: Vec = conflicts(bindings.iter().map(|(id, k)| (*id, k.as_str()))).into_iter().map(|(sc, ids)| json!({"shortcut": sc, "commands": ids})).collect(); let mut out = json!({"overrides": prefs.shortcuts, "alternateOverrides": prefs.alternate_shortcuts, "commands": list, "conflicts": conflicts}); + if let (Some(o), Value::Object(sets)) = (out.as_object_mut(), crate::shortcut_sets::describe(prefs)) { + o.extend(sets); + } + // The bindings as a `.kys` file, or Photoshop's Summarize page (the engine's commands; the + // app's dialog writes both with the shell's commands and tools added). + let set_name = if prefs.shortcut_set.is_empty() { crate::shortcut_sets::DEFAULTS_NAME } else { prefs.shortcut_set.as_str() }; + if p.get("exportKys").and_then(Value::as_bool) == Some(true) { + let entries: Vec = + crate::shortcut_sets::bound(prefs).into_iter().map(|(id, label, _, keys)| crate::kys::Entry { id, label, tool: false, keys }).collect(); + out["kys"] = json!(crate::kys::write(set_name, &entries)); + } + if let Some(v) = p.get("summarize").filter(|v| !v.is_null() && *v != &json!(false)) { + let mac = v.get("mac").and_then(Value::as_bool).unwrap_or(cfg!(target_os = "macos")); + let mut sections: Vec = Vec::new(); + for (_, label, menu, keys) in crate::shortcut_sets::bound(prefs) { + match sections.iter_mut().find(|(m, _)| *m == menu) { + Some((_, rows)) => rows.push((label, keys)), + None => sections.push((menu, vec![(label, keys)])), + } + } + out["html"] = json!(crate::shortcut_sets::summarize(set_name, §ions, mac)); + } if let Some(import) = import { out["import"] = import; } @@ -2121,7 +2151,7 @@ pub fn specs() -> Vec { "Keyboard Shortcuts…", ["Edit"], Some("Cmd+Alt+Shift+K"), - r##"{"set":{"|tools.temporary.hand|zoomIn|zoomOut":"Cmd+Shift+X"|""(remove)|null(default)}?,"alternates":{"":["F3",…]|[](none)|null(default)}?,"reset":true|["",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false,"importKys":"<.kys XML>"?}"##, + r##"{"set":{"|tools.temporary.hand|zoomIn|zoomOut":"Cmd+Shift+X"|""(remove)|null(default)}?,"alternates":{"":["F3",…]|[](none)|null(default)}?,"reset":true|["",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false,"importKys":"<.kys XML>"?,"useSet":""|""(defaults)?,"saveSet":""?,"deleteSet":""?,"sets":{"":{"shortcuts":{…},"alternateShortcuts":{…}}}?,"activeSet":""|""?,"exportKys":bool?,"summarize":bool|{"mac":bool}?}"##, keyboard_shortcuts, true ), diff --git a/crates/engine/src/shortcut_sets.rs b/crates/engine/src/shortcut_sets.rs new file mode 100644 index 0000000000..c5fc4993fe --- /dev/null +++ b/crates/engine/src/shortcut_sets.rs @@ -0,0 +1,347 @@ +//! Edit › Keyboard Shortcuts › Set: named shortcut sets, as in Photoshop. The bindings in use +//! stay in [`Preferences::shortcuts`] / [`Preferences::alternate_shortcuts`] (what dispatch +//! reads); a set is a saved copy of them, `Preferences::shortcut_sets` keyed by name, and +//! `Preferences::shortcut_set` names the one in use ("" for the defaults). The bindings differ +//! from their set after an edit until it is saved ("modified", as Photoshop marks the set). +//! A set saves to and loads from a `.kys` file (`crate::kys::write` / the import), and +//! [`summarize`] writes the HTML table of Photoshop's Summarize button. +//! +//! `edit.keyboardShortcuts` takes the set parameters: `useSet` (load a set's bindings, before +//! the call's other changes), then `saveSet` (save the bindings as they end up under a name and +//! use it), `deleteSet`, `sets` (replace every saved set: the dialog's OK) and `activeSet`. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::prefs::{Preferences, alternate_keys, normalize_shortcut}; + +/// The built-in set's name: the default bindings. +pub const DEFAULTS_NAME: &str = "PhotoCraft Defaults"; +/// Saved sets are small; the cap keeps a hostile settings file or call bounded. +pub const MAX_SETS: usize = 64; +/// Longest set name, in characters. +pub const MAX_NAME: usize = 64; + +/// A saved set: the overrides of the default bindings, in the shape the preferences keep them. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ShortcutSet { + /// Command id → main key; "" removes the default key. + pub shortcuts: BTreeMap, + /// Command id → further keys, replacing the defaults. + pub alternate_shortcuts: BTreeMap>, +} + +impl ShortcutSet { + /// The bindings `p` uses now. + pub fn of(p: &Preferences) -> Self { + Self { shortcuts: p.shortcuts.clone(), alternate_shortcuts: p.alternate_shortcuts.clone() } + } + + /// Use this set's bindings in `p`. + pub fn load_into(&self, p: &mut Preferences) { + p.shortcuts = self.shortcuts.clone(); + p.alternate_shortcuts = self.alternate_shortcuts.clone(); + } + + /// Check every key (a set from a call or a hand-edited file must not hold garbage). + fn validate(&self) -> Result<(), String> { + for (id, k) in &self.shortcuts { + if !k.is_empty() && normalize_shortcut(k).is_none() { + return Err(format!("`{k}` for `{id}` is not a valid shortcut (e.g. Cmd+Shift+N, F7)")); + } + } + for (id, keys) in &self.alternate_shortcuts { + alternate_keys(&json!(keys)).map_err(|e| format!("`{id}`: {e}"))?; + } + Ok(()) + } +} + +/// A set name as given, trimmed; an error for an empty, overlong or reserved name. +pub fn set_name(name: &str) -> Result { + let n = name.trim(); + if n.is_empty() { + return Err("a set needs a name".into()); + } + if n.chars().count() > MAX_NAME { + return Err(format!("a set name is at most {MAX_NAME} characters")); + } + if n.chars().any(char::is_control) { + return Err("a set name can't contain control characters".into()); + } + if n.eq_ignore_ascii_case(DEFAULTS_NAME) { + return Err(format!("`{DEFAULTS_NAME}` is the built-in set")); + } + Ok(n.to_string()) +} + +/// The saved copy of the set in use: `None` for the defaults (or a set that no longer exists). +fn saved(p: &Preferences) -> Option<&ShortcutSet> { + p.shortcut_sets.get(&p.shortcut_set) +} + +/// Do the bindings differ from the set in use? +pub fn modified(p: &Preferences) -> bool { + let now = ShortcutSet::of(p); + match saved(p) { + Some(s) => *s != now, + None => now != ShortcutSet::default(), + } +} + +/// Before the call's binding changes: `useSet` loads a set ("" or [`DEFAULTS_NAME`]: the +/// defaults) and makes it the one in use. Returns whether it changed anything. +pub fn load(next: &mut Preferences, p: &Value) -> Result { + let Some(v) = p.get("useSet") else { return Ok(false) }; + let name = v.as_str().ok_or("`useSet` is a set name")?.trim(); + if name.is_empty() || name.eq_ignore_ascii_case(DEFAULTS_NAME) { + ShortcutSet::default().load_into(next); + next.shortcut_set = String::new(); + } else { + let set = next.shortcut_sets.get(name).cloned().ok_or_else(|| format!("no shortcut set named `{name}`"))?; + set.load_into(next); + next.shortcut_set = name.to_string(); + } + Ok(true) +} + +/// After the call's binding changes: `sets` (replace every saved set), `deleteSet`, `saveSet` +/// (save the bindings under a name and use it) and `activeSet`. Returns whether it changed +/// anything. +pub fn store(next: &mut Preferences, p: &Value) -> Result { + let mut changed = false; + if let Some(v) = p.get("sets") { + let obj = v.as_object().ok_or("`sets` maps set names to {shortcuts, alternateShortcuts}")?; + if obj.len() > MAX_SETS { + return Err(format!("at most {MAX_SETS} shortcut sets")); + } + let mut sets = BTreeMap::new(); + for (name, s) in obj { + let name = set_name(name)?; + let set: ShortcutSet = serde_json::from_value(s.clone()).map_err(|e| format!("set `{name}`: {e}"))?; + set.validate().map_err(|e| format!("set `{name}`: {e}"))?; + sets.insert(name, set); + } + next.shortcut_sets = sets; + changed = true; + } + if let Some(v) = p.get("deleteSet") { + let name = v.as_str().ok_or("`deleteSet` is a set name")?.trim(); + if next.shortcut_sets.remove(name).is_none() { + return Err(format!("no shortcut set named `{name}`")); + } + if next.shortcut_set == name { + next.shortcut_set = String::new(); + } + changed = true; + } + if let Some(v) = p.get("saveSet") { + let name = set_name(v.as_str().ok_or("`saveSet` is a set name")?)?; + if !next.shortcut_sets.contains_key(&name) && next.shortcut_sets.len() >= MAX_SETS { + return Err(format!("at most {MAX_SETS} shortcut sets")); + } + next.shortcut_sets.insert(name.clone(), ShortcutSet::of(next)); + next.shortcut_set = name; + changed = true; + } + if let Some(v) = p.get("activeSet") { + let name = v.as_str().ok_or("`activeSet` is a set name")?.trim(); + if name.is_empty() || name.eq_ignore_ascii_case(DEFAULTS_NAME) { + next.shortcut_set = String::new(); + } else if next.shortcut_sets.contains_key(name) { + next.shortcut_set = name.to_string(); + } else { + return Err(format!("no shortcut set named `{name}`")); + } + changed = true; + } + Ok(changed) +} + +/// The sets, the one in use and whether the bindings differ from it, for the command's reply. +pub fn describe(p: &Preferences) -> Value { + json!({ + "sets": p.shortcut_sets.keys().collect::>(), + "activeSet": if saved(p).is_some() { p.shortcut_set.as_str() } else { "" }, + "defaultsName": DEFAULTS_NAME, + "modified": modified(p), + }) +} + +/// A key as the Summarize table shows it on `mac` (⌘ spelled Cmd, ⌥ Option) or elsewhere +/// (Ctrl, Alt). +fn key_label(sc: &str, mac: bool) -> String { + let Some(n) = normalize_shortcut(sc) else { return sc.to_string() }; + // `Cmd++` splits into an empty part before the key, which joins back as `++`. + let mut parts: Vec<&str> = Vec::new(); + for part in n.split('+') { + let shown = match (part, mac) { + ("Ctrl", true) => "Control", + ("Cmd", false) | ("Ctrl", false) => "Ctrl", + ("Alt", true) => "Option", + (p, _) => p, + }; + // Off the Mac, ⌘ and Control are both Ctrl: say it once. + if shown == "Ctrl" && parts.contains(&"Ctrl") { + continue; + } + parts.push(shown); + } + parts.join("+") +} + +fn html_escape(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} + +/// A Summarize row: a command's label and its keys, the main one first. +pub type Row = (String, Vec); +/// A Summarize section: its title and rows. +pub type Section = (String, Vec); + +/// Photoshop's Summarize: an HTML page listing every command with a key, by section +/// (`sections` in display order: section title, then (command label, keys with the main one +/// first)). Commands without a key are left out. +pub fn summarize(set: &str, sections: &[Section], mac: bool) -> String { + let mut out = String::new(); + out.push_str("\n"); + out.push_str(&html_escape(set)); + out.push_str("\n\n\n

"); + out.push_str(&html_escape(set)); + out.push_str("

\n"); + for (title, rows) in sections { + let rows: Vec<&Row> = rows.iter().filter(|(_, keys)| !keys.is_empty()).collect(); + if rows.is_empty() { + continue; + } + out.push_str("

"); + out.push_str(&html_escape(title)); + out.push_str("

\n\n"); + for (label, keys) in rows { + let keys: Vec = keys.iter().map(|k| html_escape(&key_label(k, mac))).collect(); + out.push_str("\n"); + } + out.push_str("
CommandShortcut
"); + out.push_str(&html_escape(label)); + out.push_str(""); + out.push_str(&keys.join(", ")); + out.push_str("
\n"); + } + out.push_str("\n"); + out +} + +/// Every engine command (and temporary tool) with a key under the bindings of `p`: (id, +/// label, top-level menu, keys with the main one first). The app's dialog adds the commands +/// only the shell knows (View › Zoom In, the tools). +pub fn bound(p: &Preferences) -> Vec<(String, String, String, Vec)> { + let temporary = crate::prefs::TEMPORARY_TOOLS.iter().map(|t| (t.0, t.1, "Tools", Some(t.2))); + crate::command_specs() + .iter() + .map(|c| (c.id, c.label, c.menu.first().copied().unwrap_or("Other"), c.shortcut)) + .chain(temporary) + .filter_map(|(id, label, menu, def)| { + let main = p.shortcut(id, def); + let mut keys: Vec = main.and_then(normalize_shortcut).into_iter().collect(); + keys.extend(p.alternates(id, main)); + (!keys.is_empty()).then(|| (id.to_string(), label.to_string(), menu.to_string(), keys)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn prefs() -> Preferences { + Preferences::default() + } + + #[test] + fn save_use_modify_and_delete_a_set() { + let mut p = prefs(); + assert!(!modified(&p)); + p.shortcuts.insert("edit.copy".into(), "F9".into()); + assert!(modified(&p), "an edit to the defaults"); + assert!(store(&mut p, &json!({"saveSet": " Mine "})).unwrap()); + assert_eq!((p.shortcut_set.as_str(), modified(&p)), ("Mine", false)); + p.shortcuts.insert("edit.cut".into(), "F8".into()); + assert!(modified(&p)); + // Back to the defaults, then to the saved set as it was saved. + assert!(load(&mut p, &json!({"useSet": ""})).unwrap()); + assert!(p.shortcuts.is_empty() && p.shortcut_set.is_empty() && !modified(&p)); + load(&mut p, &json!({"useSet": "Mine"})).unwrap(); + assert_eq!(p.shortcuts.get("edit.copy").map(String::as_str), Some("F9")); + assert_eq!(p.shortcuts.get("edit.cut"), None, "the unsaved edit isn't in the set"); + assert_eq!(describe(&p)["sets"], json!(["Mine"])); + // Deleting the set in use falls back to the defaults' name; the bindings stay. + store(&mut p, &json!({"deleteSet": "Mine"})).unwrap(); + assert!(p.shortcut_sets.is_empty() && p.shortcut_set.is_empty()); + assert_eq!(describe(&p)["activeSet"], ""); + assert!(modified(&p), "F9 differs from the defaults"); + } + + #[test] + fn bad_names_sets_and_params_are_errors() { + let mut p = prefs(); + for name in ["", " ", "PhotoCraft defaults", "a\u{7}b", &"x".repeat(65)] { + assert!(store(&mut p, &json!({"saveSet": name})).is_err(), "{name:?}"); + } + assert!(load(&mut p, &json!({"useSet": "Nope"})).is_err()); + assert!(load(&mut p, &json!({"useSet": 3})).is_err()); + assert!(store(&mut p, &json!({"deleteSet": "Nope"})).is_err()); + assert!(store(&mut p, &json!({"activeSet": "Nope"})).is_err()); + assert!(store(&mut p, &json!({"sets": []})).is_err()); + assert!(store(&mut p, &json!({"sets": {"A": {"shortcuts": {"edit.copy": "Cmd+Shift+Ctrl"}}}})).is_err(), "no key"); + let nine: Vec = (1..=9).map(|i| format!("F{i}")).collect(); + assert!(store(&mut p, &json!({"sets": {"A": {"alternateShortcuts": {"edit.copy": nine}}}})).is_err()); + assert!(store(&mut p, &json!({"sets": {"A": {"shortcuts": 5}}})).is_err()); + let many: serde_json::Map = (0..=MAX_SETS).map(|i| (format!("S{i}"), json!({}))).collect(); + assert!(store(&mut p, &json!({ "sets": many })).is_err()); + for i in 0..MAX_SETS { + store(&mut p, &json!({"saveSet": format!("S{i}")})).unwrap(); + } + assert!(store(&mut p, &json!({"saveSet": "One more"})).is_err()); + assert!(store(&mut p, &json!({"saveSet": "S3"})).is_ok(), "saving over a set is fine at the cap"); + } + + #[test] + fn sets_replace_all_and_active_set_follows() { + let mut p = prefs(); + store(&mut p, &json!({"sets": {"A": {"shortcuts": {"edit.copy": "F9"}}, "B": {}}, "activeSet": "A"})).unwrap(); + assert_eq!(p.shortcut_set, "A"); + assert!(modified(&p), "activeSet names the set; it doesn't load it"); + load(&mut p, &json!({"useSet": "A"})).unwrap(); + assert!(!modified(&p)); + store(&mut p, &json!({"activeSet": "PhotoCraft Defaults"})).unwrap(); + assert_eq!(p.shortcut_set, ""); + } + + #[test] + fn summarize_lists_bound_commands_by_section_for_each_platform() { + let sections = vec![ + ("Edit".to_string(), vec![("Copy".to_string(), vec!["Cmd+C".to_string(), "F3".to_string()]), ("Unbound".to_string(), vec![])]), + ("Empty".to_string(), vec![("Nothing".to_string(), vec![])]), + ("Filter ".to_string(), vec![("Gaussian Blur & more".to_string(), vec!["Ctrl+Alt+Cmd+G".to_string()])]), + ]; + let mac = summarize("My ", §ions, true); + assert!(mac.contains("My <Set>") && mac.contains("CopyCmd+C, F3"), "{mac}"); + assert!(mac.contains("Cmd+Control+Option+G") && mac.contains("Gaussian Blur & more") && mac.contains("Filter <x>"), "{mac}"); + assert!(!mac.contains("Unbound") && !mac.contains("Empty"), "{mac}"); + let pc = summarize("S", §ions, false); + assert!(pc.contains("Ctrl+C, F3") && pc.contains("Ctrl+Alt+G"), "{pc}"); + assert_eq!(key_label("Cmd++", true), "Cmd++"); + } + + #[test] + fn bound_lists_main_keys_then_alternates() { + let p = prefs(); + let b = bound(&p); + let copy = b.iter().find(|(id, ..)| id == "edit.copy").expect("Copy has keys"); + assert_eq!(copy.3, vec!["Cmd+C".to_string(), "F3".to_string()]); + assert!(b.iter().any(|(id, ..)| id == "tools.temporary.hand")); + } +} diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index 649a154419..e2c1cf2bfa 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -298,40 +298,6 @@ fn blend_surfaces(base: &Surface, top: &Surface, mode: BlendMode, opacity: f32) out } -/// Mixes `filtered` over `base` through the filter mask (premultiplied, any channel layout). -fn mask_mix(base: &Surface, filtered: &Surface, mask: &LayerMask) -> Surface { - let fmt = base.format(); - let area = base.content_bounds().union(&filtered.content_bounds()); - let mut out = filtered.clone(); - if area.is_empty() { - return out; - } - let n = fmt.channels(); - let (b, f) = (base.read_region(area), filtered.read_region(area)); - let mut k = Vec::new(); - mask.values_into(area, &mut k); - let mut o = vec![0.0f32; b.len()]; - for (i, ((bp, fp), op)) in b.chunks_exact(n).zip(f.chunks_exact(n)).zip(o.chunks_exact_mut(n)).enumerate() { - let m = k.get(i).copied().unwrap_or(1.0).clamp(0.0, 1.0); - if !fmt.alpha { - for c in 0..n { - op[c] = bp[c] + (fp[c] - bp[c]) * m; - } - continue; - } - let a = n - 1; - let oa = bp[a] + (fp[a] - bp[a]) * m; - op[a] = oa; - for c in 0..a { - let pm = bp[c] * bp[a] + (fp[c] * fp[a] - bp[c] * bp[a]) * m; - op[c] = if oa > 0.0 { (pm / oa).clamp(0.0, 1.0) } else { 0.0 }; - } - } - out.write_region(area, &o); - out.prune(); - out -} - /// Runs the smart filter stack on the placed (unfiltered) content. pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) -> Surface { if !sm.filters_enabled || !sm.smart_filters.iter().any(|f| f.visible) { @@ -344,7 +310,7 @@ pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) -> cur = apply_smart_filter(f, &cur, canvas, extent); } match sm.filter_mask.as_ref().filter(|m| m.enabled) { - Some(m) => mask_mix(placed, &cur, m), + Some(m) => photocraft_compose::filter_mask::mix(placed, &cur, m), None => cur, } } @@ -449,7 +415,7 @@ fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Result mask_mix(&placed()?.surface, &cur.surface, m), + Some(m) => photocraft_compose::filter_mask::mix(&placed()?.surface, &cur.surface, m), None => cur.surface, })) } diff --git a/crates/engine/src/timeline_cmds.rs b/crates/engine/src/timeline_cmds.rs index 197c8e9ccc..9baa077608 100644 --- a/crates/engine/src/timeline_cmds.rs +++ b/crates/engine/src/timeline_cmds.rs @@ -12,7 +12,7 @@ use crate::{EngineError, Result, Session}; /// Longest timeline, in frames: every frame becomes a surface in a new video layer and a file in /// Render Video, so `duration` is capped well before it can exhaust memory or overflow (#1009). -const MAX_DURATION: usize = 1_000_000; +pub(crate) const MAX_DURATION: usize = 1_000_000; /// Highest frame rate; larger values only come from mistakes and become `inf` as `f32` (#1009). const MAX_FPS: f32 = 1000.0; diff --git a/crates/engine/src/video_cmds.rs b/crates/engine/src/video_cmds.rs index 2a9f8ae8b4..6fb9ab4574 100644 --- a/crates/engine/src/video_cmds.rs +++ b/crates/engine/src/video_cmds.rs @@ -11,6 +11,10 @@ use serde_json::{Value, json}; use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; +/// Maximum dense RGBA staging pixels in one animated GIF frame. A GIF also keeps the +/// quantised frame and palette while rendering, so the document canvas limit is not enough. +const MAX_GIF_FRAME_PIXELS: u64 = 64 << 20; + /// Set every video layer's displayed content to its frame at the playhead. pub fn sync(doc: &mut Document) { let cur = doc.timeline.as_ref().map_or(0, |t| t.current); @@ -416,6 +420,10 @@ pub fn render_video_with( let mut base = (*st.doc).clone(); store(&mut base); let frames = base.timeline.as_ref().map_or(1, |t| t.duration).max(1); + // Loaded timelines bypass the duration validation used by timeline.create/setProps. + if frames > crate::timeline_cmds::MAX_DURATION { + return Err(EngineError::Other(format!("the timeline is {frames} frames; Render Video is limited to {} frames", crate::timeline_cmds::MAX_DURATION))); + } let fps = base.timeline.as_ref().map_or(30.0, |t| t.fps).max(1.0); let stem = base.name.rsplit_once('.').map_or(base.name.as_str(), |(a, _)| a).to_string(); let fmt = base.pixel_format(); @@ -429,6 +437,11 @@ pub fn render_video_with( // Animated GIF: one file, every frame quantised to its own palette. if format.eq_ignore_ascii_case("gif") { + // The whole canvas is staged as f32 RGBA for each frame; reject over-budget + // dimensions before an infallible allocation can abort the process. + if (w as u64) * (h as u64) > MAX_GIF_FRAME_PIXELS { + return Err(EngineError::Other(format!("{w} × {h} is too large to render as an animated GIF (limit {MAX_GIF_FRAME_PIXELS} pixels per frame)"))); + } let delay = (100.0 / fps).round().clamp(1.0, 65535.0) as u16; let mut gframes = Vec::with_capacity(frames); for f in 0..frames { diff --git a/crates/engine/src/video_cmds/tests.rs b/crates/engine/src/video_cmds/tests.rs index 22d639d49b..c16c7f8e39 100644 --- a/crates/engine/src/video_cmds/tests.rs +++ b/crates/engine/src/video_cmds/tests.rs @@ -498,3 +498,25 @@ fn grow_duration_never_shrinks_and_keeps_the_work_area_valid() { grow_duration(&mut t, 12); assert_eq!((t.work_start, t.work_end, t.duration), (0, 6, 12)); } + +#[test] +fn render_video_rejects_a_loaded_timeline_past_the_frame_cap() { + let mut s = session(); + let mut doc = (*s.active().unwrap().doc).clone(); + doc.timeline = Some(Timeline::new(usize::MAX, 30.0)); + { + let st = s.active_mut().unwrap(); + st.doc = std::sync::Arc::new(doc); + st.revision += 1; + } + let err = s.execute("file.export.renderVideo", json!({"dir": tmpdir("overlong"), "format": "gif"})).unwrap_err(); + assert!(err.to_string().contains("limited to"), "{err}"); +} + +#[test] +fn render_video_rejects_gif_frames_too_large_to_stage() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 9000, "height": 8000})).unwrap(); + let err = s.execute("file.export.renderVideo", json!({"dir": tmpdir("large-gif"), "format": "gif"})).unwrap_err(); + assert!(err.to_string().contains("too large"), "{err}"); +} diff --git a/crates/engine/tests/clipping_semantics.rs b/crates/engine/tests/clipping_semantics.rs new file mode 100644 index 0000000000..1a4aa562a7 --- /dev/null +++ b/crates/engine/tests/clipping_semantics.rs @@ -0,0 +1,75 @@ +//! Clip groups through Release Clipping Mask, Merge Down, Merge Layers and Delete Hidden Layers, +//! as in Photoshop (layers audit L2). The stack: Background, B, C1*, C2* (C1 and C2 clipped to B). + +use photocraft_engine::Session; +use serde_json::json; + +fn stack() -> (Session, [u64; 3]) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap(); + let mut ids = [0u64; 3]; + for (i, color) in ["#ff0000", "#00ff00", "#0000ff"].iter().enumerate() { + ids[i] = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("select.rect", json!({"x": 4 * i, "y": 4, "width": 12, "height": 12})).unwrap(); + s.execute("edit.fill", json!({"color": color})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + } + for c in &ids[1..] { + s.execute("layer.setProps", json!({"layer": c, "clipped": true})).unwrap(); + } + (s, ids) +} + +fn clipped(s: &Session, id: u64) -> bool { + s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().clipped +} + +#[test] +fn releasing_a_middle_layer_releases_the_ones_above_it() { + let (mut s, [_, c1, c2]) = stack(); + let r = s.execute("layer.releaseClippingMask", json!({"layer": c1})).unwrap(); + assert!(!clipped(&s, c1) && !clipped(&s, c2), "C2 isn't left clipped to the released C1: {r}"); + // Releasing the top one leaves the one below it alone. + let (mut s, [_, c1, c2]) = stack(); + s.execute("layer.releaseClippingMask", json!({"layer": c2})).unwrap(); + assert!(clipped(&s, c1) && !clipped(&s, c2)); +} + +#[test] +fn merging_clipped_layers_keeps_the_result_clipped() { + let (mut s, [_, c1, c2]) = stack(); + s.execute("layer.select", json!({"layer": c2})).unwrap(); + let r = s.execute("layer.mergeDown", json!({})).unwrap(); + let merged = r["layer"].as_u64().unwrap_or(c1); + assert!(clipped(&s, merged), "C2 merged down into C1 stays clipped to B"); + let (mut s, [_, c1, c2]) = stack(); + s.execute("layer.select", json!({"layer": c1})).unwrap(); + s.execute("layer.select", json!({"layer": c2, "mode": "add"})).unwrap(); + let r = s.execute("layer.mergeLayers", json!({})).unwrap(); + assert!(clipped(&s, r["layer"].as_u64().unwrap()), "C1 + C2 merged stay clipped"); + // Clipped layers of two chains (Background, B, C1*, E, D*: C1 clipped to B, D to E) merge + // into an ordinary layer: kept clipped at D's slot, C1's pixels would clip to E. + let (mut s, [_, c1, e]) = stack(); + s.execute("layer.setProps", json!({"layer": e, "clipped": false})).unwrap(); + let d = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("edit.fill", json!({"color": "#808080"})).unwrap(); + s.execute("layer.setProps", json!({"layer": d, "clipped": true})).unwrap(); + s.execute("layer.select", json!({"layer": c1})).unwrap(); + s.execute("layer.select", json!({"layer": d, "mode": "add"})).unwrap(); + let r = s.execute("layer.mergeLayers", json!({})).unwrap(); + assert!(!clipped(&s, r["layer"].as_u64().unwrap()), "C1 + D come from two clip chains"); + // Merging the base with its clipped layers gives an ordinary layer. + let (mut s, [b, _, c2]) = stack(); + s.execute("layer.select", json!({"layer": b})).unwrap(); + s.execute("layer.select", json!({"layer": c2, "mode": "add"})).unwrap(); + let r = s.execute("layer.mergeLayers", json!({})).unwrap(); + assert!(!clipped(&s, r["layer"].as_u64().unwrap())); +} + +#[test] +fn deleting_a_hidden_base_releases_its_clipped_layers() { + let (mut s, [b, c1, c2]) = stack(); + s.execute("layer.setProps", json!({"layer": b, "visible": false})).unwrap(); + s.execute("layer.delete.hiddenLayers", json!({})).unwrap(); + assert!(!clipped(&s, c1) && !clipped(&s, c2), "they don't clip to the layer below the deleted base"); +} diff --git a/crates/engine/tests/duplicate_variables.rs b/crates/engine/tests/duplicate_variables.rs new file mode 100644 index 0000000000..48d197b51e --- /dev/null +++ b/crates/engine/tests/duplicate_variables.rs @@ -0,0 +1,146 @@ +//! Variable definitions must continue targeting their layers after a copied document gets +//! fresh ids (#4969), and a merged-only copy must not keep bindings to discarded layers. + +use photocraft_doc::{Document, Layer, LayerContent, LayerId, Size, TextLayer}; +use photocraft_engine::Session; +use serde_json::json; + +fn scene() -> (Session, LayerId, LayerId, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + let ids = s + .edit("setup", |doc, _| { + let fmt = doc.pixel_format(); + let mut photo = Layer::raster("photo", fmt); + photo.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 64, 48), &photocraft_raster::from_rgba(&fmt, [0.5, 0.5, 0.5, 1.0])); + let badge = Layer::raster("badge", fmt); + let title = Layer::new("title", LayerContent::Text(TextLayer { text: "Old".into(), ..Default::default() })); + let (p, b, t) = (photo.id, badge.id, title.id); + doc.layers.push(photo); + doc.layers.push(badge); + doc.layers.push(title); + Ok((p, b, t)) + }) + .unwrap(); + (s, ids.0, ids.1, ids.2) +} + +fn red_png(dir: &str) -> String { + let doc = Document::with_background( + "red", + Size::new(4, 4), + photocraft_doc::ColorMode::Rgb, + photocraft_doc::SampleType::U8, + photocraft_doc::Color::rgb(1.0, 0.0, 0.0), + ); + let bytes = photocraft_io::export(&doc, "png", &photocraft_io::ExportOptions::default()).unwrap().bytes; + let path = format!("{dir}/replacement.png"); + std::fs::write(&path, bytes).unwrap(); + path +} + +fn copied_layer(s: &Session, name: &str) -> LayerId { + s.active().unwrap().doc.walk().into_iter().find(|(_, _, l)| l.name == name).unwrap().2.id +} + +fn define_all(s: &mut Session, photo: LayerId, badge: LayerId, title: LayerId, png: &str) { + s.execute( + "image.variables.define", + json!({"defs": [ + {"name": "showBadge", "layer": badge.0, "type": "visibility"}, + {"name": "headline", "layer": title.0, "type": "textReplacement"}, + {"name": "photo", "layer": photo.0, "type": "pixelReplacement", "method": "conform"}, + ]}), + ) + .unwrap(); + s.execute( + "image.variables.dataSets", + json!({"dataSets": [ + {"name": "A", "values": [ + {"variable": "showBadge", "kind": "visibility", "value": false}, + {"variable": "headline", "kind": "text", "value": "Hello"}, + {"variable": "photo", "kind": "pixels", "value": png}, + ]}, + ]}), + ) + .unwrap(); +} + +#[test] +fn duplicated_documents_keep_variables_targeting_the_copied_layers() { + let dir = std::env::temp_dir().join(format!("pc-dup-vars-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let png = red_png(&dir.to_string_lossy()); + + for command in ["image.duplicate", "history.newDocument"] { + let (mut s, photo, badge, title) = scene(); + define_all(&mut s, photo, badge, title, &png); + s.execute(command, json!({})).unwrap(); + + let doc = &s.active().unwrap().doc; + let new_photo = copied_layer(&s, "photo"); + let new_badge = copied_layer(&s, "badge"); + let new_title = copied_layer(&s, "title"); + assert_ne!(new_photo, photo, "{command}: layers must get new identities"); + let targets: Vec = doc.variables.defs.iter().map(|d| d.layer).collect(); + assert_eq!(targets, vec![new_badge, new_title, new_photo], "{command}: variable defs follow their layers"); + + let r = s.execute("image.applyDataSet", json!({"name": "A"})).unwrap(); + assert_eq!(r["applied"], "A"); + let doc = &s.active().unwrap().doc; + assert!(!doc.layer(new_badge).unwrap().visible, "{command}: visibility drives the copy"); + match &doc.layer(new_title).unwrap().content { + LayerContent::Text(t) => assert_eq!(t.text, "Hello", "{command}: text drives the copy"), + _ => panic!("not a text layer"), + } + let px = doc.layer(new_photo).unwrap().surface().unwrap().pixel(0, 0); + assert!(px[0] > 0.9 && px[1] < 0.1, "{command}: pixel replacement drives the copy: {px:?}"); + + // The source document still targets its own layers and is untouched. + s.set_active(0); + let src = &s.active().unwrap().doc; + let src_targets: Vec = src.variables.defs.iter().map(|d| d.layer).collect(); + assert_eq!(src_targets, vec![badge, title, photo], "{command}: source defs untouched"); + assert!(src.layer(badge).unwrap().visible); + assert!(src.layer(photo).unwrap().surface().unwrap().pixel(0, 0)[0] < 0.9); + } +} + +#[test] +fn dangling_variable_targets_stay_dangling_after_duplication() { + let (mut s, photo, badge, title) = scene(); + define_all(&mut s, photo, badge, title, "unused.png"); + // A def may already refer to a deleted layer before duplication. + let missing = LayerId(u64::MAX); + s.edit("Break one def", |doc, _| { + doc.variables.defs.iter_mut().find(|d| d.name == "showBadge").unwrap().layer = missing; + Ok(()) + }) + .unwrap(); + s.execute("image.duplicate", json!({})).unwrap(); + + let doc = &s.active().unwrap().doc; + let def = doc.variables.defs.iter().find(|d| d.name == "showBadge").unwrap(); + assert_eq!(def.layer, missing, "a deleted target must not be rewritten to an unrelated new id"); + let photo_def = doc.variables.defs.iter().find(|d| d.name == "photo").unwrap(); + assert_eq!(photo_def.layer, copied_layer(&s, "photo"), "live targets still remap"); +} + +#[test] +fn merged_only_duplicate_does_not_carry_variables_for_removed_layers() { + let dir = std::env::temp_dir().join(format!("pc-dup-vars-merged-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let png = red_png(&dir.to_string_lossy()); + let (mut s, photo, badge, title) = scene(); + define_all(&mut s, photo, badge, title, &png); + + s.execute("image.duplicate", json!({"mergedOnly": true})).unwrap(); + let d = &s.active().unwrap().doc; + assert_eq!(d.layers.len(), 1, "merged-only copy is a single composite layer"); + assert!(d.variables.defs.is_empty(), "old variables cannot address discarded layers"); + assert!(d.variables.data_sets.is_empty()); + assert_eq!(d.variables.active, None); + + s.set_active(0); + assert_eq!(s.active().unwrap().doc.variables.defs.len(), 3, "original variables are preserved"); +} diff --git a/crates/engine/tests/shortcut_sets.rs b/crates/engine/tests/shortcut_sets.rs new file mode 100644 index 0000000000..c00e6a069d --- /dev/null +++ b/crates/engine/tests/shortcut_sets.rs @@ -0,0 +1,60 @@ +//! Edit › Keyboard Shortcuts › Set through `edit.keyboardShortcuts`: save, switch, delete, +//! export to `.kys` and back, Summarize, and bad params as errors. + +use photocraft_engine::Session; +use serde_json::{Value, json}; + +fn ks(s: &mut Session, p: Value) -> Value { + s.execute("edit.keyboardShortcuts", p).expect("edit.keyboardShortcuts") +} + +#[test] +fn a_set_saves_switches_exports_and_imports_back() { + let mut s = Session::new(); + let r = ks(&mut s, json!({})); + assert_eq!((r["activeSet"].as_str(), r["modified"].as_bool(), r["defaultsName"].as_str()), (Some(""), Some(false), Some("PhotoCraft Defaults"))); + // Edit, then save the bindings as they end up after this call's edits. + let r = ks(&mut s, json!({"set": {"edit.copy": "F9"}, "alternates": {"edit.copy": []}, "saveSet": "Mine"})); + assert_eq!((r["activeSet"].as_str(), r["modified"].as_bool(), r["sets"].clone()), (Some("Mine"), Some(false), json!(["Mine"]))); + // Export the set, go back to the defaults, then import the file: the bindings return. + let xml = ks(&mut s, json!({"exportKys": true}))["kys"].as_str().expect("kys").to_string(); + assert!(xml.contains("KeyboardCustomization/Mine=Mine") && xml.contains("pc-id=\"edit.copy\">F9
"), "{xml}"); + let r = ks(&mut s, json!({"useSet": ""})); + assert_eq!((r["overrides"].clone(), r["activeSet"].as_str()), (json!({}), Some(""))); + let r = ks(&mut s, json!({"reset": true, "importKys": xml})); + assert_eq!(r["overrides"]["edit.copy"], "F9"); + assert_eq!(r["alternateOverrides"]["edit.copy"], json!([]), "Copy's F3 stays removed"); + assert_eq!(r["modified"], true, "imported over the defaults"); + // Switch back to the saved set; a later unsaved edit marks it modified. + ks(&mut s, json!({"useSet": "Mine"})); + let r = ks(&mut s, json!({"set": {"edit.cut": "F8"}})); + assert_eq!((r["activeSet"].as_str(), r["modified"].as_bool()), (Some("Mine"), Some(true))); + // Summarize lists what's bound, per platform. + let html = ks(&mut s, json!({"summarize": {"mac": false}}))["html"].as_str().expect("html").to_string(); + assert!(html.contains("Mine") && html.contains("CopyF9") && html.contains("CutF8"), "{html}"); + // Delete the set in use: the defaults' name, the bindings kept. + let r = ks(&mut s, json!({"deleteSet": "Mine"})); + assert_eq!((r["sets"].clone(), r["activeSet"].as_str(), r["overrides"]["edit.copy"].as_str()), (json!([]), Some(""), Some("F9"))); +} + +#[test] +fn bad_set_params_change_nothing() { + let mut s = Session::new(); + ks(&mut s, json!({"saveSet": "Keep"})); + let before = s.prefs().clone(); + for p in [ + json!({"useSet": "Missing"}), + json!({"useSet": 7}), + json!({"saveSet": ""}), + json!({"saveSet": "PhotoCraft Defaults"}), + json!({"deleteSet": "Missing"}), + json!({"activeSet": "Missing"}), + json!({"sets": "nope"}), + json!({"sets": {"A": {"shortcuts": {"edit.copy": "Cmd+Shift+Ctrl"}}}}), + // A bad key elsewhere in the call keeps the set from being saved too. + json!({"set": {"edit.copy": "Cmd+Shift+Ctrl"}, "saveSet": "New"}), + ] { + assert!(s.execute("edit.keyboardShortcuts", p.clone()).is_err(), "{p}"); + assert_eq!(s.prefs(), &before, "{p} changed the preferences"); + } +} diff --git a/crates/engine/tests/smart_psd.rs b/crates/engine/tests/smart_psd.rs index f7b86048b9..2586f24cc9 100644 --- a/crates/engine/tests/smart_psd.rs +++ b/crates/engine/tests/smart_psd.rs @@ -615,3 +615,58 @@ fn cs2_plld_only_smart_object_saves_its_new_transform() { } assert!(photocraft_engine::smart_cmds::contents_of(&again, photocraft_doc::LayerId(aid)).is_ok(), "the contents are available"); } + +/// Photoshop stores a smart object's filtered pixels without its filter mask and mixes the +/// unfiltered ones back in through the mask (`compose::filter_mask`): the layer record we write +/// holds the filtered pixels wherever the mask shows any of the filter, and opening the file +/// mixes them back to what the document showed. +#[test] +fn a_soft_filter_mask_is_stored_the_way_photoshop_stores_it() { + use photocraft_doc::LayerMask; + use photocraft_raster::Surface; + let mut s = session(8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 4})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + // A mask ramping from hiding the blur (left) to showing it (right). + s.edit("mask", |doc, _| { + let snapshot = doc.clone(); + let l = doc.layer_mut(id).unwrap(); + let LayerContent::Smart(sm) = &mut l.content else { panic!("smart") }; + let mut m = LayerMask::reveal_all(); + m.surface = Surface::new(photocraft_color::PixelFormat::GRAY8); + let r = Rect::new(0, 0, W, H); + m.surface.write_region(r, &(0..H).flat_map(|_| (0..W).map(|x| x as f32 / (W - 1) as f32)).collect::>()); + sm.filter_mask = Some(m); + refresh_layer(&snapshot, l).map(|_| ()) + }) + .unwrap(); + let doc = s.active().unwrap().doc.clone(); + let (_, sm) = smart(&doc); + let mixed = sm.cache.clone().unwrap(); + // The same smart object with the mask switched off: filtered everywhere. + let mut unmasked = sm.clone(); + unmasked.filter_mask.as_mut().unwrap().enabled = false; + let filtered = photocraft_engine::smart_cmds::render(&doc, &unmasked).unwrap().unwrap(); + + let out = photocraft_io::export(&doc, "psd", &Default::default()).unwrap(); + let file = photocraft_psd::PsdFile::from_bytes(&out.bytes).unwrap(); + let name = doc.layer(id).unwrap().name.clone(); + let record = (0..file.layers().len()).filter_map(|i| file.layer(i)).find(|l| l.name() == name).unwrap(); + let img = record.rgba8().unwrap(); + // A column where the mask shows about half the blur, over opaque content. + let (x, y) = (W / 2, 20); + let at = (((y - img.top) as u32 * img.width + (x - img.left) as u32) * 4) as usize; + let stored = &img.data[at..at + 4]; + let want = filtered.pixel(x, y); + let half = mixed.pixel(x, y); + let gap = (0..3).map(|c| (want[c] - half[c]).abs()).fold(0.0f32, f32::max); + assert!(gap > 3.0 / 255.0, "the mask's mix must show at ({x}, {y}) for this test to mean anything"); + for c in 0..3 { + assert!((f32::from(stored[c]) / 255.0 - want[c]).abs() <= 1.5 / 255.0, "channel {c}: stored {stored:?}, filtered {want:?}, mixed {half:?}"); + } + // Opening the file mixes the unfiltered pixels back in: it shows what the document showed. + let back = photocraft_io::import("x.psd", &out.bytes).unwrap().document; + let d = max_diff(&photocraft_compose::flatten(&back).px, &photocraft_compose::flatten(&doc).px); + assert!(d <= 2.0 / 255.0, "re-opened differs by {d}"); +} diff --git a/crates/engine/tests/style_and_align.rs b/crates/engine/tests/style_and_align.rs new file mode 100644 index 0000000000..ad17330507 --- /dev/null +++ b/crates/engine/tests/style_and_align.rs @@ -0,0 +1,99 @@ +//! Copy / Paste Layer Style carries the whole style, Blending Options included (Blend If and the +//! channel restrictions); align with an active selection aligns to it however many layers are +//! selected (layers audit L2). + +use photocraft_engine::Session; +use serde_json::json; + +fn layer(s: &Session, id: u64) -> photocraft_doc::Layer { + s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().clone() +} + +fn new_doc(s: &mut Session, mode: &str) -> (u64, u64) { + s.execute("file.new", json!({"width": 64, "height": 64, "mode": mode, "background": "white"})).unwrap(); + let a = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + (a, b) +} + +#[test] +fn paste_layer_style_carries_blend_if_and_channels() { + let mut s = Session::new(); + let (a, b) = new_doc(&mut s, "rgb"); + s.execute( + "layer.layerStyle.blendingOptions", + json!({"layer": a, "blendIf": [{"channel": "gray", "underlying": [0, 128]}, {"channel": "red", "thisLayer": [10, 200]}]}), + ) + .unwrap(); + s.execute("layer.setProps", json!({"layer": a, "channels": [true, false, true]})).unwrap(); + s.execute("layer.layerStyle.copyLayerStyle", json!({"layer": a})).unwrap(); + s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": b})).unwrap(); + let (la, lb) = (layer(&s, a), layer(&s, b)); + assert_eq!(lb.blend_if, la.blend_if, "Blend If, Gray and channel ranges"); + assert_eq!(lb.excluded_channels, la.excluded_channels, "the unchecked G channel"); + // Into a CMYK document: only the Gray range carries over; channels reset to all. + let (c, _) = new_doc(&mut s, "cmyk"); + s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": c})).unwrap(); + let lc = layer(&s, c); + assert_eq!(lc.blend_if.get(0), la.blend_if.get(0)); + assert_eq!(lc.blend_if.ranges.len(), 1, "no RGB channel range on CMYK"); + assert_eq!(lc.excluded_channels, 0); +} + +#[test] +fn paste_layer_style_into_another_mode_keeps_blend_if_normalised() { + // A full Gray range and only a Red range: in CMYK nothing is left, so it is the default + // (which compares equal, so the paste isn't seen as a Blend If change). + let mut s = Session::new(); + let (a, _) = new_doc(&mut s, "rgb"); + s.execute("layer.layerStyle.blendingOptions", json!({"layer": a, "blendIf": [{"channel": "red", "thisLayer": [10, 200]}]})).unwrap(); + s.execute("layer.layerStyle.copyLayerStyle", json!({"layer": a})).unwrap(); + let (c, _) = new_doc(&mut s, "cmyk"); + s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": c})).unwrap(); + assert_eq!(layer(&s, c).blend_if, photocraft_doc::BlendIf::default()); +} + +#[test] +fn paste_layer_style_on_several_layers_is_one_undo_step() { + // 16-bit, onto a pixel layer and a group: one History step, and one Undo restores both. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 64, "depth": 16, "background": "white"})).unwrap(); + let a = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.layerStyle.blendingOptions", json!({"layer": a, "blendIf": [{"channel": "gray", "underlying": [0, 128]}]})).unwrap(); + s.execute("layer.setProps", json!({"layer": a, "fill": 0.5, "blend": "multiply"})).unwrap(); + s.execute("layer.layerStyle.copyLayerStyle", json!({"layer": a})).unwrap(); + let b = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + let g = s.execute("layer.new.group", json!({})).unwrap()["layer"].as_u64().unwrap(); + let before = (layer(&s, b), layer(&s, g)); + s.execute("layer.select", json!({"layer": b})).unwrap(); + s.execute("layer.select", json!({"layer": g, "mode": "add"})).unwrap(); + s.execute("layer.layerStyle.pasteLayerStyle", json!({})).unwrap(); + let la = layer(&s, a); + for id in [b, g] { + let l = layer(&s, id); + assert_eq!((l.blend_if.clone(), l.fill_opacity, l.blend), (la.blend_if.clone(), la.fill_opacity, la.blend), "layer {id}"); + } + s.execute("edit.undo", json!({})).unwrap(); + let after = (layer(&s, b), layer(&s, g)); + assert_eq!((after.0.blend_if, after.0.blend, after.1.blend_if, after.1.blend), (before.0.blend_if, before.0.blend, before.1.blend_if, before.1.blend)); + assert_eq!((after.0.fill_opacity, after.1.fill_opacity), (before.0.fill_opacity, before.1.fill_opacity)); +} + +#[test] +fn several_layers_align_to_an_active_selection() { + let mut s = Session::new(); + let (a, b) = new_doc(&mut s, "rgb"); + for (id, x) in [(a, 4), (b, 30)] { + s.execute("layer.select", json!({"layer": id})).unwrap(); + s.execute("select.rect", json!({"x": x, "y": 10, "width": 8, "height": 8})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + } + s.execute("select.rect", json!({"x": 40, "y": 40, "width": 20, "height": 20})).unwrap(); + s.execute("layer.select", json!({"layer": a})).unwrap(); + s.execute("layer.select", json!({"layer": b, "mode": "add"})).unwrap(); + s.execute("layer.align.leftEdges", json!({})).unwrap(); + for id in [a, b] { + let x0 = layer(&s, id).surface().unwrap().content_bounds().x0; + assert_eq!(x0, 40, "layer {id} aligned to the selection's left edge"); + } +} diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index 45d7a4ccf3..113d9ba026 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -22,7 +22,7 @@ flate2 = { workspace = true } # Same IEEE CRC-32 the ZIP container needs; already in Cargo.lock through flate2. crc32fast = "1.5" blake3 = { version = "1.8", default-features = false, features = ["pure"] } -ruzstd = "0.9" +ruzstd = "0.9.1" # Unix directory cleanup uses no-follow descriptor-relative operations; the same # dependency provides the plain-fsync fallback on Apple file systems. diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs index 51d6183baa..95d45fafb9 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -617,6 +617,15 @@ impl Loader<'_> { } } } + // Variable targets follow their layers through id remapping; a dangling one keeps a + // non-colliding placeholder so it stays a missing-layer skip rather than driving an + // unrelated layer (#4969). With preserved ids the map is the identity and defs stay as saved. + let mut variables = m.variables.clone(); + if !self.preserve_ids { + for vd in &mut variables.defs { + vd.layer = self.id_map.get(&vd.layer.0).copied().unwrap_or(LayerId(u64::MAX - vd.layer.0.min(u64::MAX / 2))); + } + } let id = if self.preserve_ids { DocId(m.id) } else { DocId::fresh() }; Ok(Document { id, @@ -641,7 +650,7 @@ impl Loader<'_> { patterns, color_table: m.color_table.clone(), duotone: m.duotone.clone(), - variables: m.variables.clone(), + variables, timeline: m.timeline.clone(), file_encoding: m.file_encoding, layer_comps, diff --git a/crates/format/tests/roundtrip.rs b/crates/format/tests/roundtrip.rs index 8b74a00c4b..f78e93947e 100644 --- a/crates/format/tests/roundtrip.rs +++ b/crates/format/tests/roundtrip.rs @@ -5,7 +5,7 @@ use common::*; use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::text::{CharStyle, ParagraphStyle, TextRun}; use photocraft_doc::text_styles::{CharacterStyleDef, ParagraphStyleDef}; -use photocraft_doc::{Document, Layer, LayerContent, LayerId, Slice, TextLayer}; +use photocraft_doc::{Document, Layer, LayerContent, LayerId, Slice, TextLayer, VarKind, VariableDef}; use photocraft_format::*; use photocraft_raster::Rgba8Image; @@ -729,3 +729,25 @@ fn a_non_finite_float_is_refused_at_save_rather_than_breaking_every_later_load() let back = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap(); assert_eq!(back, doc); } + +#[test] +fn variables_follow_layer_id_remapping() { + let mut doc = rich_doc(ColorMode::Rgb, SampleType::U8); + let id_of = |d: &Document, name: &str| d.walk().into_iter().find(|(_, _, l)| l.name == name).unwrap().2.id; + doc.variables.defs = vec![ + VariableDef { name: "head".into(), layer: id_of(&doc, "Paint"), kind: VarKind::TextReplacement }, + VariableDef { name: "badge".into(), layer: id_of(&doc, "Inner"), kind: VarKind::Visibility }, + VariableDef { name: "gone".into(), layer: LayerId(u64::MAX), kind: VarKind::Visibility }, + ]; + let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); + // Preserved ids keep every definition exactly as saved, dangling ones included. + assert_eq!(load_from_bytes(&bytes).unwrap(), doc); + // Remapped ids carry the definitions to the copied layers; a dangling target stays + // dangling and must not collide with a live layer (#4969). + let back = load_from_bytes_with(&bytes, &LoadOptions { preserve_ids: false, ..Default::default() }).unwrap(); + let defs = &back.variables.defs; + assert_eq!(defs.iter().find(|d| d.name == "head").unwrap().layer, id_of(&back, "Paint")); + assert_eq!(defs.iter().find(|d| d.name == "badge").unwrap().layer, id_of(&back, "Inner")); + let gone = defs.iter().find(|d| d.name == "gone").unwrap().layer; + assert!(back.walk().into_iter().all(|(_, _, l)| l.id != gone), "dangling target must not address a live layer"); +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 1a6b60fca3..0e76736013 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -46,7 +46,12 @@ impl Rect { 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)) + // Widen before adding: `w as i32` wraps negative for widths above `i32::MAX`, which + // would turn the far edge backwards (an empty, inverted rect) instead of clamping it + // to `i32::MAX` the way `saturating_add` alone would suggest. + let x1 = (i64::from(x) + i64::from(w)).min(i64::from(i32::MAX)) as i32; + let y1 = (i64::from(y) + i64::from(h)).min(i64::from(i32::MAX)) as i32; + Self::new(x, y, x1, y1) } pub fn from_size(size: Size) -> Self { Self::from_xywh(0, 0, size.width, size.height) @@ -117,7 +122,9 @@ impl TileCoord { 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) + // `tx`/`ty` can come from a file (this type is deserialized), so saturate instead of + // overflowing (a debug-build panic / release-build wrap) on extreme coordinates. + Rect::from_xywh(self.tx.saturating_mul(TILE_SIZE), self.ty.saturating_mul(TILE_SIZE), TILE_SIZE as u32, TILE_SIZE as u32) } } @@ -258,6 +265,25 @@ mod tests { assert_eq!(grown.y0, i32::MIN); } + #[test] + fn from_xywh_clamps_huge_sizes_instead_of_wrapping() { + // Widths above `i32::MAX` used to wrap negative through `w as i32`, producing an + // empty inverted rect instead of clamping the far edge to `i32::MAX`. + assert_eq!(Rect::from_xywh(0, 0, u32::MAX, 10), Rect::new(0, 0, i32::MAX, 10)); + assert_eq!(Rect::from_xywh(-100, 5, u32::MAX, u32::MAX), Rect::new(-100, 5, i32::MAX, i32::MAX)); + assert_eq!(Rect::from_xywh(10, 20, i32::MAX as u32 + 1, 40).width(), i32::MAX as u32 - 10); + // Ordinary sizes are unchanged. + assert_eq!(Rect::from_xywh(10, 20, 30, 40), Rect::new(10, 20, 40, 60)); + } + + #[test] + fn tile_rect_saturates_on_extreme_coords() { + // Tile coords are deserialized from files; `tx * TILE_SIZE` used to overflow. + let r = TileCoord::new(i32::MAX, i32::MIN).rect(); + assert_eq!((r.x0, r.y0), (i32::MAX, i32::MIN)); + assert_eq!(TileCoord::new(2, -3).rect(), Rect::new(512, -768, 768, -512)); + } + #[test] fn width_and_height_do_not_overflow_on_extreme_edges() { // Inverted extreme rect: empty, so 0 (x1 - x0 used to overflow and panic). diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index 2149aaa412..01e147d51a 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -579,10 +579,12 @@ pub(crate) fn paste(shape: &mut [f32], region: Rect, r: Rect, v: &[f32]) { // --------------------------------------------------------------------------------------------- // Shape identity and damage -pub(crate) type Tiles = HashMap>; +/// A shape source's tiles by address: a [`std::sync::Weak`] reserves the address (so a new tile +/// can't reuse it and pass for unchanged) without keeping the pixels alive (#4530). +pub(crate) type Tiles = HashMap>; pub(crate) fn snapshot(s: &Surface) -> Tiles { - s.tiles().map(|(c, t)| (*c, t.clone())).collect() + s.tiles().map(|(c, t)| (*c, Arc::downgrade(t))).collect() } /// Union of the tiles that differ between `old` and `s`. @@ -590,7 +592,7 @@ pub(crate) fn damage(old: &Tiles, s: &Surface) -> Rect { let mut d = Rect::EMPTY; let mut add = |r: Rect| d = if d.is_empty() { r } else { d.union(&r) }; for (c, t) in s.tiles() { - if old.get(c).is_none_or(|o| !Arc::ptr_eq(o, t)) { + if old.get(c).is_none_or(|o| !std::ptr::eq(o.as_ptr(), Arc::as_ptr(t))) { add(c.rect()); } } @@ -642,39 +644,42 @@ pub(crate) fn shape_key(layer: &Layer, canvas: Rect) -> u64 { h.finish() } -/// Deep identity of a group (children, their pixels by tile pointer, settings). -pub(crate) fn group_key(layer: &Layer, light: &GlobalLight) -> u64 { - fn surface_fp(s: &Surface, h: &mut std::collections::hash_map::DefaultHasher) { +/// Deep identity of a group (children, their pixels by tile pointer, settings). `pin` sees every +/// tile hashed, so a cache entry can reserve their addresses (see [`Tiles`]). +pub(crate) fn group_key(layer: &Layer, light: &GlobalLight, pin: &mut dyn FnMut(&Arc)) -> u64 { + type Pin<'a> = &'a mut dyn FnMut(&Arc); + fn surface_fp(s: &Surface, h: &mut std::collections::hash_map::DefaultHasher, pin: Pin) { format!("{:?}{:?}", s.format(), s.default_pixel()).hash(h); for (c, t) in s.tiles() { + pin(t); (c.tx, c.ty, Arc::as_ptr(t) as usize).hash(h); } } - fn walk(l: &Layer, h: &mut std::collections::hash_map::DefaultHasher) { + fn walk(l: &Layer, h: &mut std::collections::hash_map::DefaultHasher, pin: Pin) { l.id.0.hash(h); (l.visible, l.clipped, l.opacity.to_bits(), l.fill_opacity.to_bits(), format!("{:?}", l.blend)).hash(h); match &l.content { LayerContent::Group(g) => { for c in &g.children { - walk(c, h); + walk(c, h, pin); } } LayerContent::Fill(f) => { format!("{f:?}").hash(h); if let Some(c) = &l.fill_cache { - surface_fp(&c.surface, h); + surface_fp(&c.surface, h, pin); } } LayerContent::Adjustment(a) => format!("{a:?}").hash(h), _ => { if let Some(s) = l.surface() { - surface_fp(s, h); + surface_fp(s, h, pin); } } } if let Some(m) = &l.mask { (m.enabled, m.density.to_bits(), m.feather.to_bits()).hash(h); - surface_fp(&m.surface, h); + surface_fp(&m.surface, h, pin); } format!("{:?}{:?}", l.vector_mask, l.effects).hash(h); h.write_u8(0xfe); @@ -684,14 +689,14 @@ pub(crate) fn group_key(layer: &Layer, light: &GlobalLight) -> u64 { match &layer.content { LayerContent::Group(g) => { for c in &g.children { - walk(c, &mut h); + walk(c, &mut h, pin); } if let Some(m) = &layer.mask { (m.enabled, m.density.to_bits(), m.feather.to_bits()).hash(&mut h); - surface_fp(&m.surface, &mut h); + surface_fp(&m.surface, &mut h, pin); } } - _ => walk(layer, &mut h), + _ => walk(layer, &mut h, pin), } h.finish() } @@ -874,4 +879,39 @@ mod tests { let shadow = program(&Effect::DropShadow(s), &light, false); assert!(shadow.fields.iter().any(|(k, _)| *k == FieldKind::StrokeOutside), "the shadow spread keeps the chamfer: {:?}", shadow.fields); } + + /// Cached shapes and group keys reserve tile addresses without keeping pixels (#4530): + /// damage still tells a replaced tile from an unchanged one, and a dropped tile's pixels + /// are freed although the entry remembers it. + #[test] + fn shape_tiles_and_group_pins_hold_addresses_not_pixels() { + use photocraft_color::{ColorMode, PixelFormat, SampleType}; + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true); + let mut s = Surface::new(fmt); + // A ramp, so no two tiles share their pixels. + for x in 0..600 { + let v = x as f32 / 600.0; + s.fill_rect(Rect::new(x, 0, x + 1, 300), &[v, 0.2, 0.9, 1.0]); + } + let first = s.tiles().find(|(c, _)| c.tx == 0 && c.ty == 0).map(|(_, t)| t.clone()).unwrap(); + assert_eq!(Arc::strong_count(&first), 2); + let old = snapshot(&s); + assert_eq!(Arc::strong_count(&first), 2, "the snapshot adds no strong reference"); + assert!(damage(&old, &s).is_empty()); + s.fill_rect(Rect::new(0, 0, 4, 4), &[0.0, 0.0, 0.0, 1.0]); + let origin = s.tiles().map(|(c, _)| *c).find(|c| c.tx == 0 && c.ty == 0).unwrap(); + assert_eq!(damage(&old, &s), origin.rect(), "only the edited tile"); + drop(first); + assert!(old.values().any(|w| w.strong_count() == 0), "the replaced tile's pixels are freed"); + + let child = Layer::new("c", LayerContent::Raster(s)); + let n = child.surface().map_or(0, |s| s.tile_count()); + let group = Layer::group("g", vec![child]); + let mut pins = Vec::new(); + let key = group_key(&group, &GlobalLight::default(), &mut |t| pins.push(Arc::downgrade(t))); + assert_eq!(key, group_key(&group, &GlobalLight::default(), &mut |_| {}), "pinning doesn't change the key"); + assert_eq!(pins.len(), n); + drop(group); + assert!(pins.iter().all(|w| w.strong_count() == 0), "a group's pins keep none of its pixels"); + } } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index de6ca3e56e..8f8839b9bd 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -49,7 +49,7 @@ use std::sync::Arc; use photocraft_color::{PixelFormat, SampleType}; use photocraft_compose::effects::FieldKind; -use photocraft_doc::{DocId, Document, Layer, LayerContent, LayerId, Pattern}; +use photocraft_doc::{DocId, Document, LayerContent, LayerId, Pattern}; use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; use photocraft_raster::{Surface, Tile}; @@ -303,8 +303,8 @@ struct FxEntry { shape_key: u64, /// Tiles the shape was rendered from (content, mask): damage is their difference. tiles: [Option; 2], - /// Groups: a clone pinning the tile pointers `shape_key` hashes. - _pin: Option, + /// Groups: the tiles `shape_key` hashes, by address only (see [`fx::Tiles`]). + _pin: Vec>, /// The shape on the CPU (distance fields are computed from it) and on the GPU. shape_cpu: Vec, shape: Tex, @@ -1719,7 +1719,7 @@ impl Compositor { let layer = f.layer; let canvas = doc.bounds(); let is_group = matches!(layer.content, LayerContent::Group(_)); - let shape_key = if is_group { fx::group_key(layer, &doc.global_light) } else { fx::shape_key(layer, canvas) }; + let shape_key = if is_group { fx::group_key(layer, &doc.global_light, &mut |_| {}) } else { fx::shape_key(layer, canvas) }; let (content_src, mask_src) = if is_group { (None, None) } else { fx::shape_sources(layer) }; let frame = self.frame; let (rw, rh) = (region.width(), region.height()); @@ -1767,7 +1767,13 @@ impl Compositor { region, shape_key, tiles: [None, None], - _pin: is_group.then(|| layer.clone()), + _pin: if is_group { + let mut pin = Vec::new(); + fx::group_key(layer, &doc.global_light, &mut |t| pin.push(Arc::downgrade(t))); + pin + } else { + Vec::new() + }, shape_cpu: vec![0.0; n], shape, fields: HashMap::new(), diff --git a/crates/heif/src/lib.rs b/crates/heif/src/lib.rs index ae3aff5a74..7c88fc1b9c 100644 --- a/crates/heif/src/lib.rs +++ b/crates/heif/src/lib.rs @@ -5,6 +5,9 @@ //! EXIF and XMP. Its API is plain data ([`Info`], [`Decoded`], [`Error`]) so the crate knows //! nothing about the rest of PhotoCraft; `photocraft-codecs` adapts it behind its `heif` feature. //! +//! A grid's ICC profile is inherited from its tiles when the grid item itself carries no `colr`, +//! which is how phone cameras write it: see [`icc_for_item`]. +//! //! HEIF records orientation in the container (`irot`/`imir`, plus a `clap` crop), not in EXIF. //! [`Options::apply_transforms`] applies them; with it off, the pixels come back as coded. //! @@ -163,7 +166,7 @@ fn metadata(bytes: &[u8]) -> (Option>, Option>, Option) return (None, None, None); }; let id = ctx.meta.primary; - let icc = ctx.props(id).ok().and_then(|p| ctx.icc(&p).map(<[u8]>::to_vec)); + let icc = icc_for_item(&ctx, id); let exif = ctx.exif(id).ok().flatten().map(<[u8]>::to_vec); let xmp = ctx.xmp_item(id).and_then(|x| ctx.item_data(x).ok()).and_then(|d| match d { Cow::Borrowed(b) => String::from_utf8(b.to_vec()).ok(), @@ -172,6 +175,28 @@ fn metadata(bytes: &[u8]) -> (Option>, Option>, Option) (icc, exif, xmp.map(|x| x.trim_end_matches('\0').to_string())) } +/// The ICC profile describing `item`, inherited from the coded tiles when the item itself carries +/// none. +/// +/// `colr` is optional on a `grid` derivation, and phone cameras write it on the tiles instead: a +/// OnePlus 11 photograph carries one `prof` (Display P3) on each of its 48 `hvc1` tiles and +/// nothing on the grid. Asking the primary item alone therefore loses the profile and the photo +/// opens as if it were sRGB. The tiles are what the mosaic is composed from, so their profile is +/// the composed image's profile. +/// +/// heic-rs resolves `nclx` from the primary item in the same way and has the same gap +/// (tbraun96/heic-rs#6). The sample range cannot be recovered here as well: `DecodeOptions` has no +/// override for it, so that half has to be fixed upstream. +fn icc_for_item(ctx: &heic_rs::context::Context<'_>, item: u32) -> Option> { + let own = ctx.props(item).ok().and_then(|p| ctx.icc(&p).map(<[u8]>::to_vec)); + if own.is_some() { + return own; + } + // `Ok(None)` is a coded picture rather than a derivation: no tiles to inherit from. + let (_, tiles) = ctx.grid(item).ok()??; + tiles.iter().find_map(|&t| ctx.props(t).ok().and_then(|p| ctx.icc(&p).map(<[u8]>::to_vec))) +} + #[cfg(test)] mod tests { use super::*; @@ -215,4 +240,200 @@ mod tests { assert!(Error::Limit("x".into()).to_string().contains("limit")); assert!(Error::Malformed("x".into()).to_string().contains("malformed")); } + + // A grid whose colour description lives on its tiles, not on the grid item. + // + // The fixtures below are synthetic containers with no coded data at all: resolving a profile + // never decodes a pixel. Each test first asserts the fixture is the shape it claims to be, so + // a mistake in the builder cannot look like a passing fix. + + /// Stands in for a profile. `colr` hands its payload back without parsing it. + const TILE_ICC: &[u8] = b"tile-profile"; + const GRID_ICC: &[u8] = b"grid-profile"; + + const GRID_ID: u16 = 1; + const PICTURE_ID: u16 = 2; + + fn bx(kind: &[u8; 4], payload: &[u8]) -> Vec { + let mut v = u32::try_from(payload.len() + 8).unwrap().to_be_bytes().to_vec(); + v.extend_from_slice(kind); + v.extend_from_slice(payload); + v + } + + /// A box whose first four payload bytes are a version and 24 bits of flags. + fn full(kind: &[u8; 4], version: u8, payload: &[u8]) -> Vec { + let mut p = vec![version, 0, 0, 0]; + p.extend_from_slice(payload); + bx(kind, &p) + } + + fn hdlr() -> Vec { + let mut p = vec![0; 4]; + p.extend_from_slice(b"pict"); + p.extend_from_slice(&[0; 12]); + p.push(0); + full(b"hdlr", 0, &p) + } + + fn infe(id: u16, item_type: &[u8; 4]) -> Vec { + let mut p = id.to_be_bytes().to_vec(); + p.extend_from_slice(&0u16.to_be_bytes()); + p.extend_from_slice(item_type); + p.push(0); + full(b"infe", 2, &p) + } + + fn iref_dimg(from: u16, to: u16) -> Vec { + let mut p = from.to_be_bytes().to_vec(); + p.extend_from_slice(&1u16.to_be_bytes()); + p.extend_from_slice(&to.to_be_bytes()); + full(b"iref", 0, &bx(b"dimg", &p)) + } + + fn colr_prof(icc: &[u8]) -> Vec { + let mut p = b"prof".to_vec(); + p.extend_from_slice(icc); + bx(b"colr", &p) + } + + fn ispe(w: u32, h: u32) -> Vec { + let mut p = w.to_be_bytes().to_vec(); + p.extend_from_slice(&h.to_be_bytes()); + full(b"ispe", 0, &p) + } + + fn ipma(entries: &[(u16, &[u8])]) -> Vec { + let mut p = u32::try_from(entries.len()).unwrap().to_be_bytes().to_vec(); + for (item, assoc) in entries { + p.extend_from_slice(&item.to_be_bytes()); + p.push(u8::try_from(assoc.len()).unwrap()); + p.extend_from_slice(assoc); + } + full(b"ipma", 0, &p) + } + + /// `item`'s bytes live in `idat` (construction method 1) at offset 0. + fn iloc_in_idat(item: u16, length: u32) -> Vec { + let mut p = vec![0x44, 0x00]; + p.extend_from_slice(&1u16.to_be_bytes()); + p.extend_from_slice(&item.to_be_bytes()); + p.extend_from_slice(&1u16.to_be_bytes()); + p.extend_from_slice(&0u16.to_be_bytes()); + p.extend_from_slice(&1u16.to_be_bytes()); + p.extend_from_slice(&0u32.to_be_bytes()); + p.extend_from_slice(&length.to_be_bytes()); + full(b"iloc", 1, &p) + } + + fn file(meta: &[u8]) -> Vec { + let mut ftyp = b"heic".to_vec(); + ftyp.extend_from_slice(&0u32.to_be_bytes()); + ftyp.extend_from_slice(b"mif1heic"); + let mut out = bx(b"ftyp", &ftyp); + out.extend_from_slice(&full(b"meta", 0, meta)); + out + } + + /// A 1x1 64x64 grid, with `grid_icc` on the grid item and `tile_icc` on its single tile. + /// + /// `ipco` children are numbered from 1 in file order; an association byte's top bit marks the + /// property essential, as macOS writes it. + fn grid_file(grid_icc: Option<&[u8]>, tile_icc: Option<&[u8]>) -> Vec { + let mut ipco = ispe(64, 64); + let (mut grid_assoc, mut tile_assoc, mut index) = (vec![0x81], vec![0x81], 1u8); + if let Some(icc) = grid_icc { + index += 1; + ipco.extend_from_slice(&colr_prof(icc)); + grid_assoc.push(0x80 | index); + } + if let Some(icc) = tile_icc { + index += 1; + ipco.extend_from_slice(&colr_prof(icc)); + tile_assoc.push(0x80 | index); + } + let mut iprp = bx(b"ipco", &ipco); + iprp.extend_from_slice(&ipma(&[(GRID_ID, grid_assoc.as_slice()), (PICTURE_ID, tile_assoc.as_slice())])); + + let mut items = 2u16.to_be_bytes().to_vec(); + items.extend_from_slice(&infe(GRID_ID, b"grid")); + items.extend_from_slice(&infe(PICTURE_ID, b"hvc1")); + + // Version 0, flags 0, one row, one column, 64x64 output. + const DESCRIPTOR: &[u8] = &[0, 0, 0, 0, 0, 64, 0, 64]; + + let mut meta = hdlr(); + meta.extend_from_slice(&full(b"pitm", 0, &GRID_ID.to_be_bytes())); + meta.extend_from_slice(&full(b"iinf", 0, &items)); + meta.extend_from_slice(&iref_dimg(GRID_ID, PICTURE_ID)); + meta.extend_from_slice(&bx(b"iprp", &iprp)); + meta.extend_from_slice(&bx(b"idat", DESCRIPTOR)); + meta.extend_from_slice(&iloc_in_idat(GRID_ID, u32::try_from(DESCRIPTOR.len()).unwrap())); + file(&meta) + } + + /// One coded picture rather than a derivation, with `icc` on the item itself. + fn picture_file(icc: Option<&[u8]>) -> Vec { + let mut ipco = ispe(64, 64); + let mut assoc = vec![0x81]; + if let Some(icc) = icc { + ipco.extend_from_slice(&colr_prof(icc)); + assoc.push(0x82); + } + let mut iprp = bx(b"ipco", &ipco); + iprp.extend_from_slice(&ipma(&[(PICTURE_ID, assoc.as_slice())])); + + let mut items = 1u16.to_be_bytes().to_vec(); + items.extend_from_slice(&infe(PICTURE_ID, b"hvc1")); + + let mut meta = hdlr(); + meta.extend_from_slice(&full(b"pitm", 0, &PICTURE_ID.to_be_bytes())); + meta.extend_from_slice(&full(b"iinf", 0, &items)); + meta.extend_from_slice(&bx(b"iprp", &iprp)); + file(&meta) + } + + fn ctx_of(bytes: &[u8]) -> heic_rs::context::Context<'_> { + heic_rs::context::Context::open(bytes).expect("the synthetic container must parse") + } + + #[test] + fn a_grid_inherits_its_tiles_icc_profile() { + let bytes = grid_file(None, Some(TILE_ICC)); + let ctx = ctx_of(&bytes); + let primary = ctx.meta.primary; + assert_eq!(primary, u32::from(GRID_ID)); + let (grid, tiles) = ctx.grid(primary).unwrap().expect("a grid derivation"); + assert_eq!((grid.rows, grid.columns, tiles.len()), (1, 1, 1)); + assert!(ctx.icc(&ctx.props(primary).unwrap()).is_none(), "the grid item carries no colr of its own"); + assert_eq!(ctx.icc(&ctx.props(tiles[0]).unwrap()), Some(TILE_ICC), "the tile carries the profile"); + + assert_eq!(icc_for_item(&ctx, primary).as_deref(), Some(TILE_ICC)); + } + + #[test] + fn a_grids_own_profile_wins_over_its_tiles() { + let bytes = grid_file(Some(GRID_ICC), Some(TILE_ICC)); + let ctx = ctx_of(&bytes); + assert_eq!(icc_for_item(&ctx, ctx.meta.primary).as_deref(), Some(GRID_ICC)); + } + + #[test] + fn an_unlabelled_grid_has_no_profile() { + let bytes = grid_file(None, None); + let ctx = ctx_of(&bytes); + assert_eq!(icc_for_item(&ctx, ctx.meta.primary), None); + } + + #[test] + fn a_coded_picture_resolves_its_own_profile() { + let labelled = picture_file(Some(GRID_ICC)); + let ctx = ctx_of(&labelled); + assert!(ctx.grid(ctx.meta.primary).unwrap().is_none(), "not a derivation"); + assert_eq!(icc_for_item(&ctx, ctx.meta.primary).as_deref(), Some(GRID_ICC)); + + let bare = picture_file(None); + let ctx = ctx_of(&bare); + assert_eq!(icc_for_item(&ctx, ctx.meta.primary), None); + } } diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 54fe96090d..f61c28a40b 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -206,6 +206,7 @@ pub(crate) fn image_to_document(name: &str, img: &Image) -> Result (ColorMode::Grayscale, ChannelLayout::GrayA), ChannelLayout::Rgb | ChannelLayout::Rgba => (ColorMode::Rgb, ChannelLayout::Rgba), ChannelLayout::Cmyk | ChannelLayout::CmykA => (ColorMode::Cmyk, ChannelLayout::CmykA), + ChannelLayout::Lab | ChannelLayout::LabA => (ColorMode::Lab, ChannelLayout::LabA), }; let (depth, csample) = match img.sample_type() { CSample::U8 => (SampleType::U8, CSample::U8), @@ -249,6 +250,7 @@ pub(crate) fn image_surface(img: &Image, target_layout: ChannelLayout, csample: let mode = match target_layout { ChannelLayout::Gray | ChannelLayout::GrayA => ColorMode::Grayscale, ChannelLayout::Cmyk | ChannelLayout::CmykA => ColorMode::Cmyk, + ChannelLayout::Lab | ChannelLayout::LabA => ColorMode::Lab, _ => ColorMode::Rgb, }; let depth = match csample { @@ -301,6 +303,8 @@ pub(crate) fn layout_for(mode: ColorMode, alpha: bool) -> ChannelLayout { (ColorMode::Grayscale, true) => ChannelLayout::GrayA, (ColorMode::Cmyk, false) => ChannelLayout::Cmyk, (ColorMode::Cmyk, true) => ChannelLayout::CmykA, + (ColorMode::Lab, false) => ChannelLayout::Lab, + (ColorMode::Lab, true) => ChannelLayout::LabA, (_, false) => ChannelLayout::Rgb, (_, true) => ChannelLayout::Rgba, } @@ -316,18 +320,19 @@ pub(crate) fn csample(s: SampleType) -> CSample { /// Renders the document to a flat codec image (native pixels when possible). pub fn document_to_image(doc: &Document, warnings: &mut Vec) -> Result { - document_to_image_for(doc, warnings, true) + document_to_image_for(doc, warnings, true, false) } -fn document_to_image_for(doc: &Document, warnings: &mut Vec, cmyk_target: bool) -> Result { +/// `cmyk_target` / `lab_target`: the format stores CMYK / Lab pixels. +fn document_to_image_for(doc: &Document, warnings: &mut Vec, cmyk_target: bool, lab_target: bool) -> 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); - // Lab has no flat-format layout here: Lab documents always go through the composite, which - // flattens nothing when there is a single plain layer. + // A Lab document keeps its pixels only for a format that stores Lab (TIFF); for the others it + // goes through the sRGB composite, which flattens nothing when there is a single plain layer. let single = single_layer(doc); - let native = single.filter(|_| fmt.mode != ColorMode::Lab); + let native = single.filter(|_| fmt.mode != ColorMode::Lab || lab_target); let mut icc = doc.icc_profile.as_ref().map(|i| i.to_vec()); let img = if let Some(s) = native { // Native path: keep model and depth. The surface's encoded samples are the codec's raw @@ -526,7 +531,8 @@ pub(crate) fn export_flat_with(doc: &Document, format: Format, opts: &ExportOpti } let mut warnings = Vec::new(); let cmyk_target = format.caps().layouts.iter().any(|layout| layout.is_cmyk()); - let mut img = document_to_image_for(doc, &mut warnings, cmyk_target)?; + let lab_target = format.caps().layouts.iter().any(|layout| layout.is_lab()); + let mut img = document_to_image_for(doc, &mut warnings, cmyk_target, lab_target)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(composite_share(format)); if opts.xmp == XmpEmbed::None { diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 49e4bb60e2..a620ddcf33 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -51,6 +51,7 @@ mod multichannel_map; mod ora; pub use ora::losses as ora_losses; pub mod pattern_map; +pub mod pdf; mod pdn; pub mod pixels; mod psd_export; @@ -273,13 +274,14 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, m return Ok(ImportResult { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new(), source_read_only: false, preview_only: false }); } if is_psd(bytes) { - let file = PsdFile::from_bytes(bytes)?; + // A merged image the import reads is decoded with the parse: a ZIP one is inflated once. + let (file, merged) = PsdFile::from_bytes_and_merged(bytes, |f| psd_import::check_group_depth(f).is_ok() && psd_import::merged_needed(f))?; // Nesting past the document model's cap could never be saved; reject the file with the // actionable limit. psd_import::check_group_depth(&file)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(0.05); - let (mut document, warnings) = psd_import::psd_to_document_with(&file, ctl).ok_or(IoError::Cancelled)?; + let (mut document, warnings) = psd_import::psd_to_document_decoded(&file, ctl, merged).ok_or(IoError::Cancelled)?; document.name = name.to_string(); text_import::prepare(&mut document); return Ok(ImportResult { document, warnings, source_read_only: false, preview_only: false }); @@ -390,6 +392,9 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & if ext == "ora" { return ora::export(doc); } + if ext == "pdf" { + return pdf::export(doc); + } if ext == photocraft_format::EXTENSION { let doc = named_for_file(doc, name_or_ext); let thumbnail = Some(photocraft_compose::thumbnail(doc.as_ref(), 256)); diff --git a/crates/io/src/pattern_map.rs b/crates/io/src/pattern_map.rs index 4f570bf847..bd84d9aeb1 100644 --- a/crates/io/src/pattern_map.rs +++ b/crates/io/src/pattern_map.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use photocraft_color::{ColorMode, PixelFormat, SampleType}; use photocraft_doc::{Document, Pattern}; use photocraft_geom::Rect; -use photocraft_psd::patterns::{PsdPattern, block_key, mode_channels, parse_pattern_block, write_pattern_block}; +use photocraft_psd::patterns::{PatternBudget, PsdPattern, block_key, mode_channels, parse_pattern_block_with, write_pattern_block}; use photocraft_raster::Surface; use crate::pixels::{deinterleave, interleave, max_sample, psd_depth, sample_for_depth, zero_sample}; @@ -110,13 +110,15 @@ pub fn to_psd(p: &Pattern) -> PsdPattern { } } -/// Patterns decoded from the document's raw pattern blocks (malformed blocks are skipped). +/// Patterns decoded from the document's raw pattern blocks (malformed blocks are skipped). All +/// blocks share one memory budget, so repeating a block can't multiply it. pub fn from_global_blocks(doc: &Document) -> Vec { + let mut budget = PatternBudget::default(); doc.metadata .psd_global_blocks .iter() .filter(|(_, k, _)| PATTERN_KEYS.contains(k)) - .filter_map(|(_, _, d)| parse_pattern_block(d).ok()) + .filter_map(|(_, _, d)| parse_pattern_block_with(d, &mut budget).ok()) .flatten() .filter_map(|p| from_psd(&p)) .collect() diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs new file mode 100644 index 0000000000..4f992bc8fd --- /dev/null +++ b/crates/io/src/pdf.rs @@ -0,0 +1,219 @@ +//! The PDF writer (ISO 32000-1 basics): one raster page per file. +//! +//! A small, self-contained writer used by two paths: File › Save a Copy / Save As › PDF (#3401) +//! through [`export`], and File › Print through [`print_pdf`] (called by the engine, which adds +//! paper geometry, printer marks and labels around the image). One page holds a +//! Flate-compressed image XObject, optional printer marks as vector operators and optional +//! Helvetica text; an embedded ICC profile rides in an ICCBased colour space. Multi-page PDF, +//! layer-preserving PDF and PDF import stay open (FILE-215-10). + +use std::io::Write as _; + +use photocraft_color::{ColorMode, PixelFormat}; +use photocraft_doc::Document; + +use crate::{ExportResult, IoError}; + +/// The image placed on a page: 8-bit interleaved samples, 1 (gray), 3 (RGB) or 4 (CMYK, +/// 1 = full ink), with the profile it was authored in if any. +pub struct PrintImage { + pub width: u32, + pub height: u32, + pub channels: usize, + pub data: Vec, + pub icc: Option>, +} + +/// Printer marks around the image. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Marks { + pub corner_crop: bool, + pub center_crop: bool, + pub registration: bool, +} + +/// One page: paper (pt), image box (pt, origin bottom-left), marks and texts. +pub struct PrintPage { + pub paper: (f64, f64), + pub image: PrintImage, + pub rect: (f64, f64, f64, f64), + pub marks: Marks, + pub description: Option, + pub label: Option, +} + +fn pdf_string(s: &str) -> String { + let mut o = String::from("("); + for c in s.chars() { + match c { + '(' | ')' | '\\' => { + o.push('\\'); + o.push(c); + } + c if c.is_ascii() && !c.is_ascii_control() => o.push(c), + _ => o.push('?'), + } + } + o.push(')'); + o +} + +fn deflate(data: &[u8]) -> Vec { + // Fast: print PDFs are transient and large; level 1 is ~4× quicker than the default. + let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + let _ = e.write_all(data); + e.finish().unwrap_or_default() +} + +/// Vector marks around `(x, y, w, h)`. +fn marks_ops(m: Marks, (x, y, w, h): (f64, f64, f64, f64)) -> String { + let mut s = String::from("q 0 G 0.3 w\n"); + let (gap, len) = (6.0, 18.0); + let line = |s: &mut String, x0: f64, y0: f64, x1: f64, y1: f64| s.push_str(&format!("{x0:.2} {y0:.2} m {x1:.2} {y1:.2} l S\n")); + if m.corner_crop { + for (cx, cy, dx, dy) in [(x, y, -1.0, -1.0), (x + w, y, 1.0, -1.0), (x, y + h, -1.0, 1.0), (x + w, y + h, 1.0, 1.0)] { + line(&mut s, cx + dx * gap, cy, cx + dx * (gap + len), cy); + line(&mut s, cx, cy + dy * gap, cx, cy + dy * (gap + len)); + } + } + if m.center_crop { + let (mx, my) = (x + w / 2.0, y + h / 2.0); + line(&mut s, mx, y - gap, mx, y - gap - len); + line(&mut s, mx, y + h + gap, mx, y + h + gap + len); + line(&mut s, x - gap, my, x - gap - len, my); + line(&mut s, x + w + gap, my, x + w + gap + len, my); + } + if m.registration { + let r = 5.0; + let off = gap + len / 2.0; + for (cx, cy) in [(x + w / 2.0, y - off), (x + w / 2.0, y + h + off), (x - off, y + h / 2.0), (x + w + off, y + h / 2.0)] { + // Circle (four Béziers) plus crosshair. + let k = 0.5523 * r; + s.push_str(&format!( + "{:.2} {cy:.2} m {:.2} {:.2} {:.2} {:.2} {cx:.2} {:.2} c {:.2} {:.2} {:.2} {:.2} {:.2} {cy:.2} c {:.2} {:.2} {:.2} {:.2} {cx:.2} {:.2} c {:.2} {:.2} {:.2} {:.2} {:.2} {cy:.2} c S\n", + cx + r, cx + r, cy + k, cx + k, cy + r, cy + r, cx - k, cy + r, cx - r, cy + k, cx - r, cx - r, cy - k, cx - k, cy - r, cy - r, cx + k, cy - r, cx + r, cy - k, cx + r + )); + line(&mut s, cx - r * 1.6, cy, cx + r * 1.6, cy); + line(&mut s, cx, cy - r * 1.6, cx, cy + r * 1.6); + } + } + s.push_str("Q\n"); + s +} + +/// A one-page PDF. +pub fn print_pdf(page: &PrintPage) -> Vec { + let mut out: Vec = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec(); + let mut offsets: Vec = Vec::new(); + let mut obj = |out: &mut Vec, body: &[u8]| { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", offsets.len()).as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + }; + let img = &page.image; + let (pw, ph) = page.paper; + let (x, y, w, h) = page.rect; + // 1 catalog, 2 pages, 3 page, 4 contents, 5 image, 6 font, 7 ICC (optional). + obj(&mut out, b"<< /Type /Catalog /Pages 2 0 R >>"); + obj(&mut out, b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + obj( + &mut out, + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {pw:.2} {ph:.2}] /Resources << /XObject << /Im0 5 0 R >> /Font << /F1 6 0 R >> >> /Contents 4 0 R >>" + ) + .as_bytes(), + ); + // An explicit white page, so every viewer (and rasteriser) shows paper, not transparency. + let mut content = format!("q 1 g 0 0 {pw:.2} {ph:.2} re f Q\nq {w:.3} 0 0 {h:.3} {x:.3} {y:.3} cm /Im0 Do Q\n"); + content.push_str(&marks_ops(page.marks, page.rect)); + let any_marks = page.marks.corner_crop || page.marks.center_crop || page.marks.registration; + let pad = if any_marks { 30.0 } else { 6.0 }; + if let Some(l) = page.label.as_deref().filter(|l| !l.is_empty()) { + content.push_str(&format!("BT /F1 8 Tf 0 g {:.2} {:.2} Td {} Tj ET\n", x, y + h + pad, pdf_string(l))); + } + if let Some(d) = page.description.as_deref().filter(|d| !d.is_empty()) { + content.push_str(&format!("BT /F1 8 Tf 0 g {:.2} {:.2} Td {} Tj ET\n", x, y - pad - 8.0, pdf_string(d))); + } + let c = deflate(content.as_bytes()); + let mut body = format!("<< /Length {} /Filter /FlateDecode >>\nstream\n", c.len()).into_bytes(); + body.extend_from_slice(&c); + body.extend_from_slice(b"\nendstream"); + obj(&mut out, &body); + let device = match img.channels { + 1 => "/DeviceGray", + 4 => "/DeviceCMYK", + _ => "/DeviceRGB", + }; + let cs = if img.icc.is_some() { "[/ICCBased 7 0 R]".to_string() } else { device.to_string() }; + let z = deflate(&img.data); + let mut body = format!( + "<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace {cs} /BitsPerComponent 8 /Filter /FlateDecode /Length {} >>\nstream\n", + img.width, + img.height, + z.len() + ) + .into_bytes(); + body.extend_from_slice(&z); + body.extend_from_slice(b"\nendstream"); + obj(&mut out, &body); + obj(&mut out, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"); + if let Some(icc) = &img.icc { + let z = deflate(icc); + let mut body = format!("<< /N {} /Alternate {device} /Filter /FlateDecode /Length {} >>\nstream\n", img.channels, z.len()).into_bytes(); + body.extend_from_slice(&z); + body.extend_from_slice(b"\nendstream"); + obj(&mut out, &body); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1).as_bytes()); + for o in &offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", offsets.len() + 1).as_bytes()); + out +} + +/// File › Save As / Save a Copy › PDF (#3401): the flattened document, at its print size +/// (pixels ÷ resolution × 72 pt), as a one-page PDF. Layers and transparency are dropped — the +/// warning says so — and the embedded profile travels with the image. +pub fn export(doc: &Document) -> std::result::Result { + let (w, h) = (usize::try_from(doc.size.width).unwrap_or(0), usize::try_from(doc.size.height).unwrap_or(0)); + if w == 0 || h == 0 { + return Err(IoError::Unsupported("PDF needs a document with non-zero width and height; nothing was written".into())); + } + let device = match doc.mode { + ColorMode::Rgb => 3, + ColorMode::Grayscale => 1, + ColorMode::Cmyk => 4, + m => return Err(IoError::Unsupported(format!("PDF saves RGB, Grayscale and CMYK documents; {m:?} needs converting to RGB first (Image > Mode)"))), + }; + let fmt = doc.pixel_format(); + let n = fmt.channels().max(1); + let canvas = photocraft_geom::Rect::new(0, 0, w as i32, h as i32); + let surface = photocraft_compose::flatten_to_surface(doc, PixelFormat::new(fmt.mode, fmt.sample, true), None); + let values = surface.read_region(canvas); + let to8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + // Straight alpha, composited here over paper: white in RGB/Gray (1 = full light), zero ink + // in CMYK (0 = paper). + let paper = if doc.mode == ColorMode::Cmyk { 0.0 } else { 1.0 }; + let mut data = Vec::with_capacity((w * h).saturating_mul(device)); + for px in values.chunks(n) { + let a = px.last().copied().unwrap_or(1.0).clamp(0.0, 1.0); + for v in px.iter().take(device) { + data.push(to8(v * a + paper * (1.0 - a))); + } + } + let image = PrintImage { width: doc.size.width, height: doc.size.height, channels: device, data, icc: doc.icc_profile.as_ref().map(|p| p.to_vec()) }; + let dpi = f64::from(doc.resolution_dpi.max(1.0)); + let (pw, ph) = (f64::from(doc.size.width) * 72.0 / dpi, f64::from(doc.size.height) * 72.0 / dpi); + let page = PrintPage { paper: (pw, ph), image, rect: (0.0, 0.0, pw, ph), marks: Marks::default(), description: None, label: None }; + let mut warnings = vec!["PDF was saved flattened: layers, masks-as-editable and transparency are not preserved (#3401)".into()]; + if !doc.metadata.text.is_empty() { + warnings.push("free-form text metadata is not supported by PDF; it will be dropped".into()); + } + Ok(ExportResult { bytes: print_pdf(&page), warnings }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/io/src/pdf/tests.rs b/crates/io/src/pdf/tests.rs new file mode 100644 index 0000000000..1807b7adc0 --- /dev/null +++ b/crates/io/src/pdf/tests.rs @@ -0,0 +1,131 @@ +//! PDF export: structure, pixel oracle, colour modes and refusal paths. + +use super::*; +use photocraft_color::{PixelFormat, SampleType}; +use photocraft_doc::{Layer, LayerContent, Size}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +fn solid(fmt: PixelFormat, x0: i32, y0: i32, x1: i32, y1: i32, px: &[f32]) -> Surface { + let mut s = Surface::new(fmt); + let (w, h) = ((x1 - x0) as usize, (y1 - y0) as usize); + let data: Vec = px.iter().copied().cycle().take(w * h * px.len()).collect(); + s.write_region(Rect::from_xywh(x0, y0, w as u32, h as u32), &data); + s +} + +/// The opaque paper colour of `mode`, with alpha. +fn paper(mode: ColorMode) -> Vec { + match mode { + ColorMode::Grayscale => vec![1.0, 1.0], + ColorMode::Cmyk => vec![0.0, 0.0, 0.0, 0.0, 1.0], + _ => vec![1.0, 1.0, 1.0, 1.0], + } +} + +fn doc(mode: ColorMode) -> Document { + let fmt = PixelFormat::new(mode, SampleType::F32, true); + let bg = paper(mode); + let mut d = Document::new("t.pdf", Size { width: 4, height: 2 }, mode, SampleType::F32); + d.layers = vec![Layer::new("Paper", LayerContent::Raster(solid(fmt, 0, 0, 4, 2, &bg)))]; + d +} + +/// The bytes of the stream following `marker` (e.g. "/Subtype /Image"), inflated. +fn stream_after(pdf: &[u8], marker: &str) -> Vec { + let at = pdf.windows(marker.len()).position(|w| w == marker.as_bytes()).expect("marker"); + let body = &pdf[at..]; + let len_at = body.windows(8).position(|w| w == b"/Length ").expect("length") + 8; + let len_end = body[len_at..].iter().position(|b| !b.is_ascii_digit()).expect("length end"); + let len: usize = std::str::from_utf8(&body[len_at..len_at + len_end]).unwrap().parse().unwrap(); + let start = body.windows(7).position(|w| w == b"stream\n").expect("stream") + 7; + let mut out = Vec::new(); + std::io::Read::read_to_end(&mut flate2::read::ZlibDecoder::new(&body[start..start + len]), &mut out).unwrap(); + out +} + +fn has(pdf: &[u8], needle: &str) -> bool { + pdf.windows(needle.len()).any(|w| w == needle.as_bytes()) +} + +fn check_structure(pdf: &[u8]) { + assert!(pdf.starts_with(b"%PDF-1."), "PDF header"); + assert!(pdf.ends_with(b"%%EOF\n"), "EOF marker"); + let start = pdf.windows(9).rposition(|w| w == b"startxref").unwrap() + 9; + let off: usize = std::str::from_utf8(&pdf[start..]).unwrap().split_whitespace().next().unwrap().parse().unwrap(); + assert_eq!(&pdf[off..off + 4], b"xref", "startxref points at the xref table"); + // Every xref offset really precedes its `n 0 obj` header. The xref/trailer region is pure + // ASCII, so it is safe to decode just that slice. + let table = std::str::from_utf8(&pdf[off..]).unwrap().lines().collect::>(); + let count: usize = table[1].split_whitespace().nth(1).unwrap().parse().unwrap(); + for (i, line) in table.iter().skip(3).take(count - 1).enumerate() { + let o: usize = line[..10].parse().unwrap(); + let want = format!("{} 0 obj", i + 1); + assert_eq!(&pdf[o..o + want.len()], want.as_bytes(), "xref offset for object {}", i + 1); + } +} + +#[test] +fn a_flat_rgb_document_saves_as_one_page_pdf() { + let d = doc(ColorMode::Rgb); + let out = export(&d).unwrap(); + let pdf = &out.bytes; + check_structure(pdf); + assert!(has(pdf, "/MediaBox [0 0 4.00 2.00]"), "page = image size at 72 pt/in"); + assert!(has(pdf, "/Width 4 /Height 2 /ColorSpace /DeviceRGB"), "RGB is DeviceRGB"); + assert_eq!(stream_after(pdf, "/Subtype /Image"), vec![255u8; 4 * 2 * 3], "the white paper, 8-bit"); + assert!(out.warnings.iter().any(|w| w.contains("flattened")), "layers are not preserved"); + // Through the general dispatch as well. + let via = crate::export(&d, "out.pdf", &crate::ExportOptions::default()).unwrap(); + assert_eq!(via.bytes, *pdf); +} + +#[test] +fn layers_composite_over_white_before_the_page() { + let mut d = doc(ColorMode::Rgb); + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true); + d.layers.push(Layer::new("Half red", LayerContent::Raster(solid(fmt, 1, 0, 3, 2, &[1.0, 0.0, 0.0, 0.5])))); + let out = export(&d).unwrap(); + let img = stream_after(&out.bytes, "/Subtype /Image"); + // Pixels 1-2 of row 0: 0.5 red over white = (1.0, 0.5, 0.5); elsewhere plain white. + assert_eq!(&img[3..6], &[255, 128, 128], "the blend lands: {:?}", &img[3..6]); + assert_eq!(&img[0..3], &[255, 255, 255]); +} + +#[test] +fn greyscale_and_cmyk_pages_keep_their_channels() { + let g = export(&doc(ColorMode::Grayscale)).unwrap(); + assert!(has(&g.bytes, "/ColorSpace /DeviceGray"), "gray is DeviceGray"); + assert_eq!(stream_after(&g.bytes, "/Subtype /Image"), vec![255; 4 * 2], "one sample per pixel"); + let c = export(&doc(ColorMode::Cmyk)).unwrap(); + assert!(has(&c.bytes, "/ColorSpace /DeviceCMYK"), "cmyk is DeviceCMYK"); + assert_eq!(stream_after(&c.bytes, "/Subtype /Image"), vec![0; 4 * 2 * 4], "zero ink over the whole page"); +} + +#[test] +fn the_page_measures_at_the_document_resolution() { + let mut d = doc(ColorMode::Rgb); + d.size = Size { width: 300, height: 150 }; + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true); + d.layers = vec![Layer::new("Paper", LayerContent::Raster(solid(fmt, 0, 0, 300, 150, &[1.0, 1.0, 1.0, 1.0])))]; + d.resolution_dpi = 300.0; + let out = export(&d).unwrap(); + assert!(has(&out.bytes, "/MediaBox [0 0 72.00 36.00]"), "300 px at 300 dpi is one inch"); +} + +#[test] +fn an_embedded_profile_travels_as_iccbased() { + let mut d = doc(ColorMode::Rgb); + d.icc_profile = Some(std::sync::Arc::new(vec![1, 2, 3, 4, 5, 6, 7, 8])); + let out = export(&d).unwrap(); + assert!(has(&out.bytes, "[/ICCBased 7 0 R]"), "the image refers to the profile"); + assert_eq!(stream_after(&out.bytes, "/N 3 /Alternate"), vec![1, 2, 3, 4, 5, 6, 7, 8], "the profile bytes survive"); +} + +#[test] +fn other_modes_are_refused_with_an_actionable_error() { + let d = doc(ColorMode::Lab); + let e = export(&d).unwrap_err().to_string(); + assert!(e.contains("RGB") && e.contains("Mode"), "{e}"); + assert!(crate::export(&d, "x.pdf", &crate::ExportOptions::default()).is_err()); +} diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 47861dbcc3..8ba30d83c4 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -164,6 +164,14 @@ struct SmartOut { keep_fx: std::collections::HashSet, /// Regenerated `FEid` items. new_fx: Vec, + /// The unfiltered pixels of each smart object whose filter mask shows: its layer record holds + /// the filtered pixels without the mask, as Photoshop's do (`compose::filter_mask`). + unfiltered: std::collections::HashMap, +} + +/// Whether a smart object's filter mask shows: enabled over at least one visible, enabled filter. +fn masked(sm: &photocraft_doc::SmartObject) -> bool { + sm.filter_mask.as_ref().is_some_and(|m| m.enabled) && sm.filters_enabled && sm.smart_filters.iter().any(|f| f.visible) } /// `FEid` data padded to 4 bytes inside the block length: Photoshop counts the padding in the @@ -760,6 +768,12 @@ impl Ex { } if !stack.filters.is_empty() { self.smart.keep_fx.insert(t.placed.clone()); + if masked(sm) { + let fmt = self.fmt; + if let Some(u) = self.smart.old_fx.iter().find(|i| i.id == t.placed).and_then(|i| crate::smart_map::unfiltered_from_item(i, fmt)) { + self.smart.unfiltered.insert(l.id, u); + } + } } return; } @@ -795,7 +809,14 @@ impl Ex { None => photocraft_algo::warp::place_source(&img, img_bounds, &sm.transform, sm.warp.as_ref()), }; match unfiltered { - Ok(unfiltered) => crate::smart_map::feid_item(&placed, &unfiltered, sm.filter_mask.as_ref(), bounds, self.fmt), + Ok(unfiltered) => { + let item = crate::smart_map::feid_item(&placed, &unfiltered, sm.filter_mask.as_ref(), bounds, self.fmt); + if item.is_some() && masked(sm) { + let u = if unfiltered.format() == self.fmt { unfiltered } else { unfiltered.convert(self.fmt) }; + self.smart.unfiltered.insert(l.id, u); + } + item + } Err(e) => { warnings.push(format!("smart filter mask was not re-rendered for PSD export ({e})")); None @@ -1072,7 +1093,12 @@ impl Ex { self.records.push(r); } LayerContent::Smart(sm) => { - let r = self.record(l, extra, sm.cache.as_ref()); + // Photoshop's record holds the filtered pixels without the filter mask. + let unmixed = match (self.smart.unfiltered.get(&l.id), sm.cache.as_ref(), sm.filter_mask.as_ref()) { + (Some(u), Some(c), Some(m)) if masked(sm) => Some(photocraft_compose::filter_mask::unmix(u, c, m)), + _ => None, + }; + let r = self.record(l, extra, unmixed.as_ref().or(sm.cache.as_ref())); self.records.push(r); } } @@ -1746,9 +1772,13 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c resources.push(version_info_resource(real_merged)); let has_layers = !ex.records.is_empty(); + // Photoshop writes a merged-transparency marker (`Mtrn`, `Mt16`, `Mt32`) before the layer + // block, and won't open a file whose `Lr32` comes first (#5068): the layer block goes right + // after the last kept marker, else first. + let index = global_blocks.iter().rposition(|b| matches!(&b.key, b"Mtrn" | b"Mt16" | b"Mt32")).map_or(0, |i| i + 1); 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 }, + (true, SampleType::U16) => LayerInfoPlacement::GlobalBlock { index, signature: *b"8BIM", key: *b"Lr16", padding: None }, + (true, SampleType::F32) => LayerInfoPlacement::GlobalBlock { index, signature: *b"8BIM", key: *b"Lr32", padding: None }, _ => LayerInfoPlacement::Section, }; let records = std::mem::take(&mut ex.records); @@ -1865,6 +1895,31 @@ mod tests { assert_eq!(unique, reloaded_ids); } + /// #5068: Photoshop won't open a file whose `Lr32` comes before the `Mt32` marker it keeps for + /// a 32-bit document's merged transparency (verified in Photoshop 2026 on psd-tools' + /// `transparentbg.psd` re-saved both ways). The layer block follows a kept marker. + #[test] + fn layer_block_follows_a_kept_merged_transparency_marker() { + for (depth, key, marker) in [(SampleType::F32, *b"Lr32", *b"Mt32"), (SampleType::U16, *b"Lr16", *b"Mt16"), (SampleType::F32, *b"Lr32", *b"Mtrn")] { + let mut doc = Document::new("t", photocraft_geom::Size::new(8, 6), ColorMode::Rgb, depth); + let mut s = Surface::new(doc.pixel_format()); + s.fill_rect(photocraft_geom::Rect::new(1, 1, 5, 4), &[0.5, 0.2, 0.9, 1.0]); + doc.layers.push(Layer::new("Layer 1", LayerContent::Raster(s))); + doc.metadata.psd_global_blocks = vec![(*b"8BIM", *b"Patt", std::sync::Arc::new(Vec::new())), (*b"8BIM", marker, std::sync::Arc::new(Vec::new()))]; + let saved = PsdFile::from_bytes(&document_to_psd(&doc).to_bytes().unwrap()).unwrap(); + let LayerInfoPlacement::GlobalBlock { index, key: written, .. } = saved.layer_info_placement else { panic!("{depth:?}: a global layer block") }; + assert_eq!(written, key); + let at = saved.global_blocks.iter().position(|b| b.key == marker).expect("the marker is kept"); + assert!(at < index, "{depth:?}: {marker:?} at {at}, the layer block at {index}"); + } + // Without a marker the layer block stays first. + let mut doc = Document::new("t", photocraft_geom::Size::new(8, 6), ColorMode::Rgb, SampleType::F32); + doc.layers.push(Layer::new("Layer 1", LayerContent::Raster(Surface::new(doc.pixel_format())))); + doc.metadata.psd_global_blocks = vec![(*b"8BIM", *b"Patt", std::sync::Arc::new(Vec::new()))]; + let saved = PsdFile::from_bytes(&document_to_psd(&doc).to_bytes().unwrap()).unwrap(); + assert!(matches!(saved.layer_info_placement, LayerInfoPlacement::GlobalBlock { index: 0, .. })); + } + #[test] fn unreadable_lr16_warns_on_import_and_is_not_saved_twice() { // #2700: a 16-bit file whose Lr16 block claims five layers but holds none. diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 94c4ea9932..c20c1b7fe7 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -403,12 +403,26 @@ impl Ctx<'_> { Some(p) if !stack.filters.is_empty() => self.filter_mask(&p.placed, &stack, &name), _ => None, }; + // Photoshop stores the filtered pixels without the filter mask and mixes the unfiltered + // ones (`FEid`) back in through it; our smart objects hold the mixed result. + let stored = self.record_surface(rec, &name); + let shown = stack.enabled && stack.filters.iter().any(|f| f.visible); + let cache = match (&placed, filter_mask.as_ref().filter(|m| m.enabled && shown)) { + (Some(p), Some(m)) => { + let unfiltered = self.filter_effects.iter().find(|i| i.id == p.placed).and_then(|i| crate::smart_map::unfiltered_from_item(i, self.fmt)); + match unfiltered { + Some(u) => photocraft_compose::filter_mask::mix(&u, &stored, m), + None => stored, + } + } + _ => stored, + }; LayerContent::Smart(SmartObject { contents_id, source: SmartSource::Linked { path: id }, transform, smart_filters: stack.filters, - cache: Some(self.record_surface(rec, &name)), + cache: Some(cache), psd_raw: principal(k), filters_enabled: stack.enabled, filter_mask, @@ -737,6 +751,26 @@ pub fn psd_to_document(file: &PsdFile) -> (Document, Vec) { /// [`psd_to_document`] for a background open: checks `ctl` per layer record and reports /// progress. `None` when cancelled. pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) -> Option<(Document, Vec)> { + psd_to_document_decoded(file, ctl, None) +} + +/// Whether the import reads the merged composite: for a flattened or multichannel file (it *is* +/// the image), a file without layers, or extra alpha/spot channels behind the colour ones. +pub(crate) fn merged_needed(file: &PsdFile) -> bool { + let h = &file.header; + let mode = doc_mode(h.color_mode).unwrap_or(ColorMode::Rgb); + // A layered mode is its own pixel format, so these are the document's colour channels. + let layered = matches!(mode, ColorMode::Grayscale | ColorMode::Rgb | ColorMode::Cmyk | ColorMode::Lab) && h.depth != 1; + !layered || file.layers().is_empty() || usize::from(h.channels) > mode.color_channels() + usize::from(file.merged_has_alpha()) +} + +/// [`psd_to_document_with`] with the merged image already decoded (by +/// [`PsdFile::from_bytes_and_merged`] with [`merged_needed`]), so it is not decoded again. +pub(crate) fn psd_to_document_decoded( + file: &PsdFile, + ctl: &photocraft_raster::Interrupt, + decoded: Option>>, +) -> Option<(Document, Vec)> { let h = &file.header; let mut warnings = Vec::new(); let mode = doc_mode(h.color_mode).unwrap_or_else(|| { @@ -859,9 +893,8 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) // multichannel file (it *is* the image), or extra alpha/spot channels behind the colour // ones. A normal layered Photoshop save carries a merged composite nobody reads - roughly // half the file's bytes, decoded and thrown away on every open before this. - let extra = usize::from(h.channels) > cc + usize::from(file.merged_has_alpha()); - let merged = if !layered || file.layers().is_empty() || extra { - let m = file.decode_merged(); + let merged = if merged_needed(file) { + let m = decoded.unwrap_or_else(|| file.decode_merged()); if ctl.cancelled() { return None; } diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index 6eeda61a13..3de336e16c 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -886,6 +886,45 @@ pub fn mask_from_item(item: &FilterEffectsItem, sample: SampleType, stack: &Filt Ok(Some(LayerMask { surface, enabled: stack.mask_enabled, linked: stack.mask_linked, density: 1.0, feather: 0.0 })) } +/// The unfiltered placed pixels of an `FEid` item (the inverse of [`feid_item`]), in `fmt`: colour +/// channels from slot 0, transparency from the last slot (opaque without it). `None` when the +/// item has no colour planes, its depth isn't `fmt`'s, or a plane doesn't decode. +pub fn unfiltered_from_item(item: &FilterEffectsItem, fmt: PixelFormat) -> Option { + let depth = u16::try_from(item.depth).ok()?; + if u32::from(crate::pixels::psd_depth(fmt.sample)) != item.depth || !fmt.alpha { + return None; + } + let (w, h) = item.rect.size().ok()?; + let n = w.checked_mul(h)?; + let cc = fmt.mode.color_channels(); + let mut planes = Vec::with_capacity(cc + 1); + for slot in (0..cc).chain(std::iter::once(item.slots.len().saturating_sub(1))) { + planes.push(match item.slots.get(slot).and_then(Option::as_ref) { + Some(p) => Some(p.decode(w, h, depth).ok()?), + None if slot < cc => return None, + None => None, + }); + } + let refs: Vec> = planes.iter().map(|p| p.as_deref()).collect(); + let mut fill = vec![crate::pixels::zero_sample(fmt.sample); cc]; + // Without a transparency plane the pixels are opaque (a native-endian full sample). + fill.push(match fmt.sample { + SampleType::U8 => vec![u8::MAX], + SampleType::U16 => u16::MAX.to_ne_bytes().to_vec(), + SampleType::F32 => 1.0f32.to_ne_bytes().to_vec(), + }); + let mut invert = vec![fmt.mode == ColorMode::Cmyk; cc]; + invert.push(false); + let mut bytes = crate::pixels::interleave(&refs, &fill, n, fmt.sample, &invert); + if fmt.mode == ColorMode::Lab && fmt.sample == SampleType::U16 { + crate::pixels::lab16_chroma(&mut bytes, cc + 1, true); + } + let mut surface = Surface::new(fmt); + surface.write_interleaved(GeomRect::new(item.rect.left, item.rect.top, item.rect.right, item.rect.bottom), &bytes); + surface.prune(); + Some(surface) +} + /// An `FEid` item for a smart object with filters: the unfiltered placed pixels Photoshop /// filters from (colour channels from slot 0, transparency in the last slot; in the document's /// pixel format, CMYK inverted as in layer channels) and the filter mask over `bounds` (white diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 8a5810e922..3cd8de5b4c 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -93,6 +93,7 @@ pub(crate) fn import_layered(name: &str, img: &Image, layers: &[u8]) -> Result (PsdMode::Grayscale, 1), ChannelLayout::Rgb | ChannelLayout::Rgba => (PsdMode::Rgb, 3), ChannelLayout::Cmyk | ChannelLayout::CmykA => (PsdMode::Cmyk, 4), + ChannelLayout::Lab | ChannelLayout::LabA => (PsdMode::Lab, 3), }; let depth = match img.sample_type() { CSample::U8 => 8, diff --git a/crates/io/tests/corpus.rs b/crates/io/tests/corpus.rs index a8d81a1600..dd96d96c7f 100644 --- a/crates/io/tests/corpus.rs +++ b/crates/io/tests/corpus.rs @@ -156,7 +156,7 @@ const PSD_TOOLS: Source = Source { label: "psd-tools corpus", dir: "corpus/psd-t /// Our Photoshop-authored oracles (256 files; https://github.com/storytold/photocraft-corpus), /// grouped by feature (`smart-filters`, `effects`, `text`, `adjustments/`). -const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/photoshop", group_depth: 2, pass_floor: 185, roundtrip_floor: 258 }; +const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/photoshop", group_depth: 2, pass_floor: 188, roundtrip_floor: 258 }; /// The corpus directory; a missing corpus fails the test. fn locate(src: &Source) -> PathBuf { diff --git a/crates/io/tests/flat.rs b/crates/io/tests/flat.rs index 163ab8b3af..b82b41ffc3 100644 --- a/crates/io/tests/flat.rs +++ b/crates/io/tests/flat.rs @@ -982,14 +982,32 @@ fn tga_default_bits_follow_the_alpha_channels() { assert_eq!((TgaBits::from_bits(24), TgaBits::from_bits(32), TgaBits::from_bits(16)), (Some(TgaBits::Bits24), Some(TgaBits::Bits32), None)); } +#[test] +fn a_lab_document_is_written_as_a_lab_tiff_and_opens_as_lab() { + // #4936: TIFF stores CIE L*a*b* (CIELab): a Lab document with one layer keeps its pixels. + for depth in [SampleType::U8, SampleType::U16] { + let d = single(ColorMode::Lab, depth, false); + let r = export(&d, "tif", &ExportOptions::default()).unwrap(); + assert!(r.warnings.iter().all(|w| !w.contains("sRGB") && !w.contains("flattened")), "{depth:?}: {:?}", r.warnings); + let back = import("x.tif", &r.bytes).unwrap().document; + assert_eq!((back.mode, back.depth), (ColorMode::Lab, depth)); + pixels_eq(&d, &back, if depth == SampleType::U8 { 0.0 } else { 1.5 / 65535.0 }); + } + // Other formats still go through sRGB. + let r = export(&single(ColorMode::Lab, SampleType::U8, false), "png", &ExportOptions::default()).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("sRGB")), "{:?}", r.warnings); +} + #[test] fn a_lab_document_with_one_layer_is_not_reported_as_flattened() { - // Lab always goes through the sRGB composite, but a lone layer loses nothing to it. + // A lone layer loses nothing: TIFF keeps it as Lab, the others go through the sRGB composite. let d = single(ColorMode::Lab, SampleType::U8, false); for ext in ["tif", "png", "jpg"] { let r = export(&d, ext, &ExportOptions::default()).unwrap(); assert!(r.warnings.iter().all(|w| !w.contains("flattened")), "{ext}: {:?}", r.warnings); - assert!(r.warnings.iter().any(|w| w.contains("Lab composite written as sRGB")), "{ext}: {:?}", r.warnings); + if ext != "tif" { + assert!(r.warnings.iter().any(|w| w.contains("Lab composite written as sRGB")), "{ext}: {:?}", r.warnings); + } } // Two layers are flattened, and say so. let mut two = single(ColorMode::Lab, SampleType::U8, false); diff --git a/crates/ops/src/lib.rs b/crates/ops/src/lib.rs index a20b841c17..fdf6dcff9f 100644 --- a/crates/ops/src/lib.rs +++ b/crates/ops/src/lib.rs @@ -209,14 +209,55 @@ impl History { self.undo.iter().chain(self.redo.iter()).fold(own, |n, s| n.saturating_add(tile_bytes(&s.doc, &mut seen))) } + /// Bytes of the tiles only `extra` documents hold (History panel snapshots, #4530): not + /// `current`'s and not any state's. 0 without `extra`, without walking the states. + pub fn extra_bytes(&self, current: &Document, extra: &[&Document]) -> usize { + if extra.is_empty() { + return 0; + } + let mut seen = HashSet::new(); + tile_bytes(current, &mut seen); + for s in self.undo.iter().chain(self.redo.iter()) { + tile_bytes(&s.doc, &mut seen); + } + extra.iter().fold(0, |n, d| n.saturating_add(tile_bytes(d, &mut seen))) + } + + /// [`History::unique_bytes`] less the tiles `keep` documents also hold: what clearing the + /// history frees while they stay (Edit › Purge › Histories keeps the snapshots). + pub fn unique_bytes_besides(&self, current: &Document, keep: &[&Document]) -> usize { + let mut seen = HashSet::new(); + tile_bytes(current, &mut seen); + for d in keep { + tile_bytes(d, &mut seen); + } + self.undo.iter().chain(self.redo.iter()).fold(0, |n, s| n.saturating_add(tile_bytes(&s.doc, &mut seen))) + } + /// Keep pixel memory within [`History::max_bytes`]: the current document's tiles plus the /// tiles only history holds (newest states first). The oldest undo states that don't fit are /// dropped; the most recent one is always kept so the last step can be undone. Returns how /// many states were dropped. pub fn trim(&mut self, current: &Document) -> usize { + self.trim_with(current, &[]) + } + + /// [`History::trim`] counting the tiles only `pinned` documents hold (History panel + /// snapshots, which outlive the states) against the budget too, so they can't take memory + /// beyond it unseen (#4530). The most recent state is still always kept. + pub fn trim_with(&mut self, current: &Document, pinned: &[&Document]) -> usize { if self.max_bytes == 0 || self.undo.len() <= 1 { return 0; } + // An upper bound of what only `pinned` holds (some of it may be in states too): if the + // states fit beside it, they fit. + let pinned_bytes = if pinned.is_empty() { + 0 + } else { + let mut seen = HashSet::new(); + tile_bytes(current, &mut seen); + pinned.iter().fold(0usize, |n, d| n.saturating_add(tile_bytes(d, &mut seen))) + }; let tiles = self.tiles.get_or_insert_with(|| { let mut tiles = tiles::Tiles::default(); for state in self.undo.iter().chain(&self.redo) { @@ -224,11 +265,14 @@ impl History { } tiles }); - if tiles.fits(current, self.max_bytes) { + if tiles.fits(current, self.max_bytes.saturating_sub(pinned_bytes)) { return 0; } let mut seen = HashSet::new(); let mut total = tile_bytes(current, &mut seen); + for d in pinned { + total = total.saturating_add(tile_bytes(d, &mut seen)); + } for s in self.redo.iter().rev() { total = total.saturating_add(tile_bytes(&s.doc, &mut seen)); } @@ -248,7 +292,7 @@ impl History { } } -/// Bytes of the pixel tiles of `doc` (layers, masks, alpha channels, selection) not already in `seen`. +/// Bytes of the pixel tiles of `doc` (layers, masks, fill caches, smart filter masks, alpha channels, selection) not already in `seen`. fn tile_bytes(doc: &Document, seen: &mut HashSet) -> usize { let mut n = 0usize; tiles::surfaces(doc, |surface| { @@ -392,6 +436,42 @@ mod tests { assert!(h.undo(cur.clone()).is_some()); } + /// Snapshot-only tiles count against the budget (#4530); tiles a snapshot shares with the + /// document or a state count once, and a purge frees only what no snapshot keeps. + #[test] + fn snapshot_tiles_count_against_the_budget() { + let mut h = History::new(50); + let mut cur = Arc::new(base()); + let tile = 256 * 256 * 4; + let paint = |d: &mut Document, v: f32| { + let id = d.layers[0].id; + d.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(1, 1, &[v, 0.0, 0.0, 1.0]); + }; + // A snapshot holding a tile nothing else does, then one sharing the first state's tile. + let mut own = (*cur).clone(); + paint(&mut own, 0.9); + edit(&mut h, &mut cur, "paint 0", |d| paint(d, 0.1)); + let shared = h.state(0).unwrap(); + for i in 1..6 { + edit(&mut h, &mut cur, &format!("paint {i}"), |d| paint(d, i as f32 / 8.0)); + } + let snaps = [&own, &*shared]; + assert_eq!(h.extra_bytes(&cur, &snaps), tile, "the shared tile is history's"); + assert_eq!(h.extra_bytes(&cur, &[]), 0); + assert_eq!(h.unique_bytes(&cur), 6 * tile); + assert_eq!(h.unique_bytes_besides(&cur, &snaps), 5 * tile, "clearing keeps the shared tile"); + // Both snapshot tiles stay whatever is trimmed (the shared one too), so beside the + // current tile they leave room for two history tiles; without them, four would fit. + h.max_bytes = 5 * tile; + assert_eq!(h.trim_with(&cur, &snaps), 4); + assert_eq!(h.past_len(), 2); + assert_eq!(h.pixel_bytes(&cur) + h.extra_bytes(&cur, &snaps), 5 * tile); + // Still keeps the last step undoable. + h.max_bytes = 1; + h.trim_with(&cur, &snaps); + assert_eq!(h.past_len(), 1); + } + #[test] fn history_shares_untouched_tiles() { let mut h = History::default(); @@ -408,6 +488,42 @@ mod tests { assert_eq!(h.unique_bytes(&cur), 256 * 256 * 4); } + #[test] + fn fill_caches_and_smart_filter_masks_count_toward_history_bytes() { + use photocraft_doc::{Affine, Fill, FillCache, LayerContent, LayerMask, PixelFormat, SmartObject, SmartSource, Surface}; + let mut doc = Document::new("fills", Size::new(64, 64), ColorMode::Rgb, SampleType::U8); + let mut fill = Layer::new("Fill", LayerContent::Fill(Fill::Solid(Color::WHITE))); + let mut cache = Surface::new(PixelFormat::GRAY8); + cache.write_pixel(1, 1, &[1.0]); + fill.fill_cache = Some(FillCache { fill: Fill::Solid(Color::WHITE), surface: cache }); + doc.insert_above(None, fill); + let mut smart_obj = SmartObject::new(SmartSource::Linked { path: "a.png".into() }, Affine::IDENTITY, None); + let mut mask = LayerMask::reveal_all(); + mask.surface.write_pixel(1, 1, &[0.5]); + smart_obj.filter_mask = Some(mask); + doc.insert_above(None, Layer::new("Smart", LayerContent::Smart(smart_obj))); + let tile = 256 * 256; + let mut cur = Arc::new(doc); + let mut h = History { max_bytes: usize::MAX, ..History::default() }; + assert_eq!(h.pixel_bytes(&cur), 2 * tile, "fill cache and smart filter mask are counted"); + edit(&mut h, &mut cur, "Rename", |doc| doc.name = "renamed".into()); + assert_eq!(h.pixel_bytes(&cur), 2 * tile, "shared tiles count once"); + edit(&mut h, &mut cur, "Fill cache", |doc| { + let id = doc.layers[0].id; + if let Some(cache) = doc.layer_mut(id).and_then(|l| l.fill_cache.as_mut()) { + cache.surface.write_pixel(1, 1, &[0.0]); + } + }); + assert_eq!(h.pixel_bytes(&cur), 3 * tile, "the copied fill cache tile is history-only"); + assert_eq!(h.unique_bytes(&cur), tile); + h.trim(&cur); + assert_counter(&h, &cur); + h.max_bytes = 2 * tile; + assert_eq!(h.trim(&cur), 1, "the copied fill cache tile pushes history over budget"); + assert_eq!(h.pixel_bytes(&cur), 3 * tile, "the most recent undo state is always kept"); + assert_counter(&h, &cur); + } + #[test] fn selection_tiles_count_current_and_history_without_double_counting() { use photocraft_doc::{PixelFormat, Surface}; diff --git a/crates/ops/src/tiles.rs b/crates/ops/src/tiles.rs index b8d27775f9..ec191f301a 100644 --- a/crates/ops/src/tiles.rs +++ b/crates/ops/src/tiles.rs @@ -1,7 +1,7 @@ //! Tile references owned by history snapshots. The snapshots keep these addresses alive; //! COW edits cannot change a retained tile in place. Count occurrences (including aliases //! within one document), so removing a state releases a tile only after its last reference. -use photocraft_doc::{Document, Surface}; +use photocraft_doc::{Document, LayerContent, Surface}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -19,6 +19,14 @@ pub(crate) fn surfaces(doc: &Document, mut visit: impl FnMut(&Surface)) { if let Some(mask) = &layer.mask { visit(&mask.surface); } + if let Some(cache) = &layer.fill_cache { + visit(&cache.surface); + } + if let LayerContent::Smart(smart) = &layer.content + && let Some(mask) = &smart.filter_mask + { + visit(&mask.surface); + } if let Some(video) = &layer.video { // Undo retains the whole clip, not only the displayed frame. Visit aliases too: // Tiles tracks every owner and counts their shared allocations only once. diff --git a/crates/psd/README.md b/crates/psd/README.md index 02b76e5a7f..d745fd5bd8 100644 --- a/crates/psd/README.md +++ b/crates/psd/README.md @@ -20,6 +20,8 @@ A standalone reader and writer for Adobe Photoshop **PSD** (version 1) and **PSB ```rust // Top level PsdFile::from_bytes(&[u8]) -> Result +PsdFile::from_bytes_and_merged(&[u8], want) -> Result<(PsdFile, Option>>)> + // + decode_merged() when want(&file): ZIP inflated once PsdFile::to_bytes(&self) -> Result> PsdFile::open(path) / save(path) // native only PsdFile { header, color_mode_data, resources, layer_info, layer_info_placement, @@ -112,10 +114,12 @@ testgen::{all_cases, merged_only, layered, small, sample_descriptor, pattern_pla | Actions (`.atn`) | ✓ | ✓ | ✓ | `atn::parse` / `atn::write`: version-16 sets (Photoshop 6+): actions with function key, modifiers and colour; steps with event code or string name, panel name, enabled/dialog flags and their descriptor. Exact round trip; version-12 (Photoshop 5) files are rejected as unsupported | Pattern channel subrectangles are padded with zeroes to the tile rectangle. All channel -metadata and the total padded size are checked against `MAX_DECODED_BYTES` before any -plane is decoded. Fully populated channels reuse their decoded buffer without a second -copy; allocations for padded channels return an error on failure. There is no additional -padding-specific size limit. +metadata is checked before any plane is decoded, against one `PatternBudget` shared by every +pattern of a parse (a block, a `.pat` file): `MAX_DECODED_BYTES` decoded and 512 MiB of zero +padding in total. `parse_pattern_block_with` lets several blocks of one document share a budget; +`.abr` patterns are charged to the file's sample budget. All channels of a pattern must have the +same depth. Fully populated channels reuse their decoded buffer without a second copy; +allocations for padded channels return an error on failure. ### Preserved raw, not yet typed (parsers planned for M8) diff --git a/crates/psd/src/abr.rs b/crates/psd/src/abr.rs index 0bb5147608..c7cf3eb3cc 100644 --- a/crates/psd/src/abr.rs +++ b/crates/psd/src/abr.rs @@ -37,7 +37,7 @@ use crate::compression::{Compression, PlaneLayout, decode_planes, encode_planes} use crate::descriptor::{Descriptor, Value, VersionedDescriptor}; use crate::error::{PsdError, Result}; use crate::header::Version; -use crate::patterns::{PsdPattern, parse_pattern_block, write_pattern_block}; +use crate::patterns::{PatternBudget, PsdPattern, parse_pattern_block_with, write_pattern_block}; /// Largest sampled tip edge accepted (Photoshop's own limit is 5000 px). pub const MAX_EDGE: u32 = 8192; @@ -306,11 +306,12 @@ fn read_samp(data: &[u8], subversion: u16, out: &mut AbrFile, budget: &mut Budge fn read_desc(data: &[u8], out: &mut AbrFile) { match VersionedDescriptor::parse_prefix(data) { - Ok((vd, _)) => match vd.descriptor.get("Brsh") { + // Moves the presets out of the parsed descriptor instead of cloning each one. + Ok((vd, _)) => match vd.descriptor.items.into_iter().find(|(k, _)| k.is("Brsh")).map(|(_, v)| v) { Some(Value::List(items)) => { - for v in items.iter().take(MAX_BRUSHES) { + for v in items.into_iter().take(MAX_BRUSHES) { if let Value::Descriptor(d) = v { - out.presets.push(d.clone()); + out.presets.push(d); } } } @@ -421,11 +422,15 @@ fn apply_hierarchy(out: &mut AbrFile, phry: Option<&[u8]>) { } fn parse_v6(r: &mut Rd, out: &mut AbrFile) -> Result<()> { + parse_v6_within(r, out, &mut Budget(MAX_TOTAL_BYTES)) +} + +/// [`parse_v6`] with the samples and patterns sharing `budget`. +fn parse_v6_within(r: &mut Rd, out: &mut AbrFile, budget: &mut Budget) -> Result<()> { out.subversion = r.u16()?; if !matches!(out.subversion, 1 | 2) { return Err(PsdError::Unsupported(format!("brush file subversion {}", out.subversion))); } - let mut budget = Budget(MAX_TOTAL_BYTES); let mut hierarchy = None; while r.left() >= 12 { let sig = r.arr::<4>()?; @@ -448,11 +453,21 @@ fn parse_v6(r: &mut Rd, out: &mut AbrFile) -> Result<()> { r.p += pad; } match &key { - b"samp" => read_samp(data, out.subversion, out, &mut budget)?, - b"patt" => match parse_pattern_block(data) { - Ok(p) => out.patterns.extend(p), - Err(e) => out.warnings.push(format!("embedded patterns unreadable: {e}")), - }, + b"samp" => read_samp(data, out.subversion, out, budget)?, + b"patt" => { + // Patterns share the samples' budget, so neither can claim it twice. + let limit = u64::try_from(budget.0).unwrap_or(u64::MAX); + let mut patterns = PatternBudget::with_decoded_limit(limit); + let left = patterns.decoded_left(); + match parse_pattern_block_with(data, &mut patterns) { + Ok(p) => { + let spent = usize::try_from(left.saturating_sub(patterns.decoded_left())).unwrap_or(usize::MAX); + budget.spend(spent)?; + out.patterns.extend(p); + } + Err(e) => out.warnings.push(format!("embedded patterns unreadable: {e}")), + } + } b"desc" => read_desc(data, out), // Read once `desc` gives the preset count; `phry` may come first. b"phry" => hierarchy = Some(data), @@ -663,6 +678,35 @@ mod tests { AbrSample { id: id.into(), width: w, height: h, depth, data } } + #[test] + fn embedded_patterns_spend_the_samples_budget() { + let p = PsdPattern { + mode: 3, + width: 5, + height: 3, + name: "p".into(), + id: "id".into(), + palette: None, + depth: 8, + channels: vec![vec![1; 15], vec![2; 15], vec![3; 15]], + alpha: None, + }; + let abr = write_v6(2, &[sample("s", 10, 10, 8)], &[p.clone(), p], &[], true).unwrap(); + let parse_within = |left: usize| { + let mut out = AbrFile::default(); + let mut budget = Budget(left); + parse_v6_within(&mut Rd::new(&abr[2..]), &mut out, &mut budget).unwrap(); + (out, budget.0) + }; + // 100 sample bytes plus two 45-byte patterns. + let (out, left) = parse_within(190); + assert_eq!((out.samples.len(), out.patterns.len(), left), (1, 2, 0)); + // One byte short: the patterns no longer fit after the sample (they used not to count). + let (out, left) = parse_within(189); + assert_eq!((out.samples.len(), out.patterns.len(), left), (1, 0, 89)); + assert!(out.warnings.iter().any(|w| w.contains("embedded patterns unreadable")), "{:?}", out.warnings); + } + #[test] fn v1_v2_round_trip_raw_and_rle() { for version in [1u16, 2] { diff --git a/crates/psd/src/builder.rs b/crates/psd/src/builder.rs index 04737337ef..d419f8edac 100644 --- a/crates/psd/src/builder.rs +++ b/crates/psd/src/builder.rs @@ -84,7 +84,8 @@ impl PixelData { } fn composite_plane(&self, c: usize, n: usize) -> Vec { - // Photoshop stores transparent RGB composites against white; layer channels stay straight. + // Photoshop stores transparent RGB composites against white; layer channels stay straight, + // and so do Gray and CMYK composites here (see `PsdBuilder::composite`). match self { Self::Rgba8(v) if c < 3 => v .as_chunks::<4>() @@ -290,7 +291,9 @@ impl PsdBuilder { Ok(self) } /// Supplies the merged composite (straight samples, same format as layers, full canvas). - /// Transparent RGB is stored against white, quantized at the document depth. + /// Transparent RGB is stored against white, quantized at the document depth. Gray and CMYK + /// composites with alpha are stored straight (not matted against white as Photoshop would): + /// a test that reads them as a Photoshop merged image has to allow for that. pub fn composite(&mut self, pixels: PixelData) -> &mut Self { self.composite = Some(pixels); self diff --git a/crates/psd/src/compression.rs b/crates/psd/src/compression.rs index 7ad2296fbc..aab785dff4 100644 --- a/crates/psd/src/compression.rs +++ b/crates/psd/src/compression.rs @@ -279,10 +279,18 @@ pub(crate) fn validate_planes(compression: Compression, data: &[u8], layout: &Pl } } +#[cfg(test)] +thread_local! { + /// ZIP streams inflated on this thread (tests count passes over the merged image). + pub(crate) static INFLATES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + // Keep only the requested pixels, but consume the trailer to verify Adler-32. // A bounded surplus tolerates padding without unbounded decompression work. fn inflate_checked(data: &[u8], expected: u64, keep: bool) -> Result> { const OOM: PsdError = PsdError::LimitExceeded("not enough memory for the decoded channel data"); + #[cfg(test)] + INFLATES.with(|n| n.set(n.get() + 1)); let mut decoder = flate2::Decompress::new(true); let mut out = Vec::new(); if keep { diff --git a/crates/psd/src/descriptor.rs b/crates/psd/src/descriptor.rs index 632119a12d..8bb5f6bb35 100644 --- a/crates/psd/src/descriptor.rs +++ b/crates/psd/src/descriptor.rs @@ -364,7 +364,7 @@ impl VersionedDescriptor { /// count must not drive the up-front reservation (or abort on allocation). const MAX_PREALLOC: usize = 4096; -fn vec_for_count(n: u32) -> Vec { +pub(crate) fn vec_for_count(n: u32) -> Vec { Vec::with_capacity((n as usize).min(MAX_PREALLOC)) } @@ -422,7 +422,7 @@ fn read_typed_value(r: &mut Reader<'_>, ty: &[u8; 4], depth: usize) -> Result Result { + Self::from_bytes_and_merged(data, |_| false).map(|(file, _)| file) + } + + /// Parses like [`Self::from_bytes`] and, when `want` asks for it, decodes the merged image + /// (as [`Self::decode_merged`] would) in the same pass. `want` sees the file with everything + /// but the merged image parsed (its `image_data` is still empty). A ZIP merged image is then + /// inflated once for both the parse's check of the stream and the decode, not twice. + /// + /// Errors are those of [`Self::from_bytes`]; a merged image that passes the parse's check but + /// cannot be decoded is the inner `Err`, as [`Self::decode_merged`] would return it. + pub fn from_bytes_and_merged(data: &[u8], want: impl FnOnce(&PsdFile) -> bool) -> Result<(Self, Option>>)> { let mut r = Reader::new(data); let header = Header::read(&mut r)?; let psb = header.version.is_psb(); @@ -146,8 +158,39 @@ impl PsdFile { layer_mask_trailing = s.peek_rest().to_vec(); } } - 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 }) + let image_data = ImageData { compression: Compression::Raw, data: Vec::new() }; + let mut file = + PsdFile { header, color_mode_data, resources, layer_info, layer_info_placement, global_layer_mask, global_blocks, layer_mask_trailing, image_data }; + let merged = if want(&file) { Some(file.read_merged_decoded(&mut r)?) } else { None }; + if merged.is_none() { + file.image_data = ImageData::read(&mut r, &file.header)?; + } + Ok((file, merged)) + } + + /// Reads the merged image into `self.image_data` and decodes it. A ZIP stream within the + /// decode limit is inflated once: by the decode, whose stream check is the one + /// [`ImageData::read`] makes. Only when the decode fails is that check run on its own, to + /// tell a file the parse rejects (the outer `Err`) from one it accepts (the inner `Err`). + fn read_merged_decoded(&mut self, r: &mut Reader<'_>) -> Result>> { + let layout = ImageData::layout(&self.header); + let compression = Compression::from_u16(r.clone().u16()?); + let zip = matches!(compression, Compression::Zip | Compression::ZipPrediction); + if !zip || layout.total_bytes()? > MAX_DECODED_BYTES { + // Nothing is inflated twice here: the parse's check reads sizes only (or skips an + // over-limit stream, which the decode then refuses). + self.image_data = ImageData::read(r, &self.header)?; + return Ok(self.decode_merged()); + } + r.u16()?; + let data = r.peek_rest(); + let decoded = decode_planes(compression, data, &layout); + if decoded.is_err() { + validate_planes(compression, data, &layout)?; + } + self.image_data = ImageData { compression, data: data.to_vec() }; + r.skip(data.len())?; + Ok(decoded) } /// Serializes the file. @@ -374,6 +417,40 @@ mod write_tests { let file = PsdFile::from_bytes(&bytes).unwrap(); assert!(!file.layers().is_empty()); assert!(matches!(file.decode_merged(), Err(PsdError::LimitExceeded(_)))); + let (same, merged) = PsdFile::from_bytes_and_merged(&bytes, |_| true).unwrap(); + assert_eq!(same, file); + assert!(matches!(merged, Some(Err(PsdError::LimitExceeded(_))))); + } + + /// Parsing and decoding a ZIP merged image together inflates it once (the parse's stream + /// check is the decode's), with the same model, pixels and errors as the two steps apart. + #[test] + fn merged_zip_image_is_inflated_once_when_decoded_with_the_parse() { + use crate::compression::INFLATES; + let inflates = |f: &dyn Fn()| { + let before = INFLATES.with(std::cell::Cell::get); + f(); + INFLATES.with(std::cell::Cell::get) - before + }; + for compression in [Compression::Zip, Compression::ZipPrediction] { + let bytes = testgen::small(Version::Psd, compression).to_bytes().unwrap(); + let file = PsdFile::from_bytes(&bytes).unwrap(); + let pixels = file.decode_merged().unwrap(); + let apart = inflates(&|| drop(PsdFile::from_bytes(&bytes).unwrap().decode_merged().unwrap())); + let together = inflates(&|| { + let (parsed, merged) = PsdFile::from_bytes_and_merged(&bytes, |f| f.image_data.data.is_empty()).unwrap(); + assert_eq!(parsed, file); + assert_eq!(merged.unwrap().unwrap(), pixels); + }); + assert_eq!(together + 1, apart, "{compression:?}"); + let (parsed, merged) = PsdFile::from_bytes_and_merged(&bytes, |_| false).unwrap(); + assert_eq!((parsed, merged), (file, None)); + // A broken stream (its checksum) is still the parse's error. + let mut broken = bytes.clone(); + *broken.last_mut().unwrap() ^= 0xff; + let alone = PsdFile::from_bytes(&broken).unwrap_err().to_string(); + assert_eq!(PsdFile::from_bytes_and_merged(&broken, |_| true).unwrap_err().to_string(), alone); + } } #[test] diff --git a/crates/psd/src/image_data.rs b/crates/psd/src/image_data.rs index fee0e18dc8..864486b4ac 100644 --- a/crates/psd/src/image_data.rs +++ b/crates/psd/src/image_data.rs @@ -39,19 +39,6 @@ impl ImageData { 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 invalid = || crate::error::PsdError::invalid(format!("no merged channel {index}")); - if index >= usize::from(header.channels) { - return Err(invalid()); - } - let plane = header.row_bytes().checked_mul(header.height as usize).ok_or_else(invalid)?; - let start = index.checked_mul(plane).ok_or_else(invalid)?; - let end = start.checked_add(plane).ok_or_else(invalid)?; - let all = self.decode(header)?; - all.get(start..end).map(<[u8]>::to_vec).ok_or_else(invalid) - } - pub(crate) fn read(r: &mut Reader<'_>, header: &Header) -> Result { let compression = Compression::from_u16(r.u16()?); let data = r.peek_rest(); @@ -61,25 +48,3 @@ impl ImageData { Ok(ImageData { compression, data }) } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::header::{ColorMode, Version}; - - #[test] - fn merged_channels_reject_invalid_indices_without_decoding_or_overflow() { - let header = Header::new(Version::Psd, 2, 1, 3, 8, ColorMode::Rgb); - let data = ImageData { compression: Compression::Raw, data: vec![10, 11, 20, 21, 30, 31] }; - for (index, expected) in [(0, [10, 11]), (1, [20, 21]), (2, [30, 31])] { - assert_eq!(data.decode_channel(&header, index).unwrap(), expected.to_vec()); - } - for index in [3, 4, usize::MAX] { - assert!(data.decode_channel(&header, index).is_err(), "invalid channel {index}"); - } - // Reject an invalid index before trying to decode corrupt merged data. - let corrupt = ImageData { compression: Compression::Raw, data: vec![] }; - let err = corrupt.decode_channel(&header, usize::MAX).unwrap_err().to_string(); - assert!(err.contains("no merged channel"), "{err}"); - } -} diff --git a/crates/psd/src/layer.rs b/crates/psd/src/layer.rs index 5450ed7e67..5b11411dfb 100644 --- a/crates/psd/src/layer.rs +++ b/crates/psd/src/layer.rs @@ -5,7 +5,7 @@ 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}; +use crate::tagged::{BlockData, SectionDivider, SectionType, TaggedBlock, read_blocks, uses_long_length, write_blocks}; /// Channel id of the transparency mask. pub const CHANNEL_TRANSPARENCY: i16 = -1; @@ -272,6 +272,18 @@ impl LayerMask { Ok(LayerMask { rect, default_color, flags, parameters, real, trailing: r.peek_rest().to_vec(), real_first }) } + /// Bytes [`Self::write`] produces. + fn encoded_len(&self) -> u64 { + let real = if self.real.is_some() { 18 } else { 0 }; + let parameters = self.parameters.as_ref().map_or(0, |p| { + 1 + u64::from(p.user_density.is_some()) + + 8 * u64::from(p.user_feather.is_some()) + + u64::from(p.vector_density.is_some()) + + 8 * u64::from(p.vector_feather.is_some()) + }); + 18 + real + parameters + self.trailing.len() as u64 + } + fn write(&self, out: &mut Vec) { self.rect.write(out); out.put_u8(self.default_color); @@ -586,6 +598,36 @@ impl LayerRecord { Ok((LayerRecord { rect, channels, blend_mode, opacity, clipping, flags, filler, mask, blending_ranges, name, blocks, extra_trailing }, lens)) } + /// Bytes [`Self::write`] produces, computed without serializing. Errors where `write` would. + fn encoded_len(&self, version: Version) -> Result { + let too_big = || PsdError::LimitExceeded("section too large for 32-bit length (use PSB)"); + u16::try_from(self.channels.len()).map_err(|_| PsdError::LimitExceeded("too many channels"))?; + let channel_len: u64 = if version.is_psb() { 8 } else { 4 }; + if !version.is_psb() && self.channels.iter().any(|c| u32::try_from(c.stored_len()).is_err()) { + return Err(too_big()); + } + let mask = match &self.mask { + MaskData::None => 0, + MaskData::Mask(mask) => mask.encoded_len(), + MaskData::Raw(b) => b.len() as u64, + }; + u32::try_from(mask).map_err(|_| too_big())?; + let ranges = self.blending_ranges.data.len() as u64; + u32::try_from(ranges).map_err(|_| too_big())?; + let name = 1 + self.name.len().min(255) as u64; + let name = name.next_multiple_of(4); + let mut extra = (4 + mask).saturating_add(4 + ranges).saturating_add(name).saturating_add(self.extra_trailing.len() as u64); + for b in &self.blocks { + if !uses_long_length(version, &b.key) && u32::try_from(b.data.len()).is_err() { + return Err(too_big()); + } + extra = extra.saturating_add(b.encoded_len(version) as u64); + } + u32::try_from(extra).map_err(|_| too_big())?; + // rect, channel count, channel entries, signature + blend key, four flag bytes, extra length. + Ok((16 + 2 + self.channels.len() as u64 * (2 + channel_len) + 8 + 4 + 4).saturating_add(extra)) + } + 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"))?; @@ -667,12 +709,12 @@ impl LayerInfo { /// Length of the body (layer count, records and channel data) without /// its trailing padding. pub fn unpadded_len(&self, version: Version) -> Result { - let mut records = Vec::new(); - records.put_i16(0); + // Computed from the field sizes: serializing the records just to measure them would copy + // every tagged block. + let mut n = 2u64; for l in &self.layers { - l.write(&mut records, version)?; + n = n.saturating_add(l.encoded_len(version)?); } - let mut n = records.len() as u64; for c in self.layers.iter().flat_map(|l| &l.channels).filter(|c| c.compression.is_some()) { n = n.saturating_add(2).saturating_add(c.data.len() as u64); } @@ -1039,6 +1081,40 @@ mod tests { assert_eq!(info.layers[0].decode_channel(0, 8, Version::Psd).unwrap(), Vec::::new()); } + /// The computed length matches the bytes written for every mask form, long names, custom block + /// padding, long-length blocks and trailing bytes. + #[test] + fn unpadded_len_matches_the_written_body() { + for version in [Version::Psd, Version::Psb] { + let mut layers = vec![sample_record(version), LayerRecord::default()]; + for (params, real, real_first) in [(true, true, true), (true, true, false), (true, false, true), (false, true, true)] { + let mut mask = LayerMask::new(Rect::from_xywh(1, 2, 3, 4), 0, LayerMask::FLAG_PARAMETERS); + mask.parameters = params.then_some(MaskParameters { + flags: 0x0f, + user_density: Some(1), + user_feather: Some(2.5), + vector_density: Some(3), + vector_feather: None, + }); + mask.real = real.then_some(RealMask { flags: 0, background: 255, rect: Rect::from_xywh(0, 0, 1, 1) }); + mask.real_first = real_first; + mask.trailing = vec![0; 3]; + let mut rec = sample_record(version); + rec.mask = MaskData::Mask(mask); + rec.name = vec![b'n'; 300]; + rec.blocks.push(TaggedBlock { signature: *b"8BIM", key: *b"lsct", data: vec![1; 5], padding: Some(vec![0; 3]) }); + rec.blocks.push(TaggedBlock { signature: *b"8BIM", key: *b"Lr16", data: vec![2; 7], padding: None }); + rec.extra_trailing = vec![9; 2]; + layers.push(rec); + } + layers.push(LayerRecord { mask: MaskData::Raw(vec![5; 11]), name: b"ab".to_vec(), ..Default::default() }); + let info = LayerInfo { layers, ..Default::default() }; + let mut out = Vec::new(); + info.write_unpadded(&mut out, version).unwrap(); + assert_eq!(info.unpadded_len(version).unwrap(), out.len() as u64, "{version:?}"); + } + } + /// Padding without cloning the layers writes the same bytes as `pad_to` on a clone, and the /// channel data length hint is exact. #[test] diff --git a/crates/psd/src/metadata.rs b/crates/psd/src/metadata.rs index d105a28947..9e85d8cee7 100644 --- a/crates/psd/src/metadata.rs +++ b/crates/psd/src/metadata.rs @@ -40,7 +40,7 @@ pub fn parse_shmd(data: &[u8]) -> Result> { let mut r = Reader::new(data); let count = r.u32()?; r.check_count(u64::from(count), 16)?; - let mut out = Vec::with_capacity(count as usize); + let mut out = crate::descriptor::vec_for_count(count); for _ in 0..count { let signature = r.array()?; let key = r.array()?; diff --git a/crates/psd/src/patterns.rs b/crates/psd/src/patterns.rs index 04d5c24c24..39132df9b0 100644 --- a/crates/psd/src/patterns.rs +++ b/crates/psd/src/patterns.rs @@ -26,14 +26,42 @@ use crate::header::Version; /// Largest pattern edge accepted (Photoshop's own limit is far lower). const MAX_EDGE: u32 = 30_000; -/// Budget for zero-padded channel planes summed over one pattern. Photoshop writes every channel -/// at the pattern rectangle, so a smaller channel rectangle is only padding; without this cap a -/// few hundred bytes (1x1 channels, a huge pattern rectangle) would allocate up to -/// `MAX_DECODED_BYTES` of zeros. Matches `abr::MAX_TOTAL_BYTES`. +/// Budget for zero-padded channel planes summed over every pattern of one parse (a block, a `.pat` +/// file, an `.abr`). Photoshop writes every channel at the pattern rectangle, so a smaller channel +/// rectangle is only padding; without this cap a few hundred bytes (1x1 channels, a huge pattern +/// rectangle) would allocate up to `MAX_DECODED_BYTES` of zeros. Matches `abr::MAX_TOTAL_BYTES`. const MAX_PADDED_PATTERN_BYTES: u64 = 512 << 20; // A PackBits run packet emits at most 128 bytes from a 2-byte packet. const MAX_RLE_EXPANSION: u64 = 64; +/// Decoded bytes left for the patterns of one parse. Shared by every pattern read in one call, so +/// many patterns can't each claim the whole per-pattern allowance. +#[derive(Debug, Clone)] +pub struct PatternBudget { + decoded_left: u64, + padded_left: u64, +} + +impl Default for PatternBudget { + fn default() -> Self { + PatternBudget { decoded_left: MAX_DECODED_BYTES, padded_left: MAX_PADDED_PATTERN_BYTES } + } +} + +impl PatternBudget { + /// A budget of at most `decoded` decoded bytes (zero padding included), and never more than + /// the default limits. + pub fn with_decoded_limit(decoded: u64) -> Self { + let d = Self::default(); + PatternBudget { decoded_left: d.decoded_left.min(decoded), padded_left: d.padded_left.min(decoded) } + } + + /// Decoded bytes still available. + pub fn decoded_left(&self) -> u64 { + self.decoded_left + } +} + /// One pattern tile with planar, big-endian samples. #[derive(Debug, Clone, PartialEq)] pub struct PsdPattern { @@ -109,7 +137,7 @@ impl<'a> Rd<'a> { } } -fn read_pattern(r: &mut Rd) -> Result { +fn read_pattern(r: &mut Rd, budget: &mut PatternBudget) -> Result { let version = r.u32()?; if version != 1 { return Err(PsdError::invalid(format!("pattern version {version}"))); @@ -156,10 +184,10 @@ fn read_pattern(r: &mut Rd) -> Result { // Validate every channel and the total padded size before decoding any plane. // Otherwise a later channel can exceed the existing pattern budget only after // earlier channels have already allocated several gigabytes of zero padding. + // `budget` is shared by every pattern of the parse, so many patterns can't each pad up to the + // per-parse cap. let mut channels = Vec::new(); - let mut depth = 8u16; - let mut decoded_bytes = 0u64; - let mut padded_bytes = 0u64; + let mut first_depth: Option = None; for _ in 0..count + 2 { if v.p >= body.len() { break; @@ -183,7 +211,10 @@ fn read_pattern(r: &mut Rd) -> Result { if !matches!(d, 1 | 8 | 16 | 32) { return Err(PsdError::invalid(format!("pattern depth {d}"))); } - depth = d; + // Every plane is sized by one depth; a channel at another depth can't be written back. + if *first_depth.get_or_insert(d) != d { + return Err(PsdError::invalid("pattern channels have different depths")); + } if ct < top || cl < left || cb > bottom || cr > right || cb < ct || cr < cl { return Err(PsdError::invalid("pattern channel rectangle falls outside pattern rectangle")); } @@ -195,15 +226,10 @@ fn read_pattern(r: &mut Rd) -> Result { let channel_bytes = channel_layout.total_bytes()?; let layout = PlaneLayout { planes: 1, width: w as usize, height: h as usize, depth: d, version: Version::Psd }; let plane_bytes = layout.total_bytes()?; - decoded_bytes = decoded_bytes.checked_add(plane_bytes).ok_or(PsdError::LimitExceeded("pattern decoded size overflow"))?; - if decoded_bytes > MAX_DECODED_BYTES { - return Err(PsdError::LimitExceeded("pattern data exceeds MAX_DECODED_BYTES")); - } + budget.decoded_left = budget.decoded_left.checked_sub(plane_bytes).ok_or(PsdError::LimitExceeded("pattern data exceeds MAX_DECODED_BYTES"))?; if cw != w as usize || ch != h as usize { - padded_bytes = padded_bytes.checked_add(plane_bytes).ok_or(PsdError::LimitExceeded("pattern padded size overflow"))?; - if padded_bytes > MAX_PADDED_PATTERN_BYTES { - return Err(PsdError::LimitExceeded("padded pattern channels exceed MAX_PADDED_PATTERN_BYTES")); - } + budget.padded_left = + budget.padded_left.checked_sub(plane_bytes).ok_or(PsdError::LimitExceeded("padded pattern channels exceed MAX_PADDED_PATTERN_BYTES"))?; } let _ = layout.decoded_len()?; let compression = if comp == 1 { @@ -244,6 +270,7 @@ fn read_pattern(r: &mut Rd) -> Result { } let alpha = (planes.len() > nc).then(|| planes.remove(nc)); planes.truncate(nc); + let depth = first_depth.unwrap_or(8); Ok(PsdPattern { mode, width: w, height: h, name, id, palette, depth, channels: planes, alpha }) } @@ -394,6 +421,13 @@ fn write_pattern(p: &PsdPattern, out: &mut Vec) -> Result<()> { /// Parses the data of a `Patt` / `Pat2` / `Pat3` global block. pub fn parse_pattern_block(data: &[u8]) -> Result> { + parse_pattern_block_with(data, &mut PatternBudget::default()) +} + +/// [`parse_pattern_block`] charging `budget`, so several blocks of one document share one limit. +/// The budget is charged only for patterns that are returned. +pub fn parse_pattern_block_with(data: &[u8], budget: &mut PatternBudget) -> Result> { + let mut left = budget.clone(); let mut r = Rd { b: data, p: 0 }; let mut out = Vec::new(); while r.p + 4 <= data.len() { @@ -402,10 +436,11 @@ pub fn parse_pattern_block(data: &[u8]) -> Result> { break; } let body = r.take(len)?; - out.push(read_pattern(&mut Rd { b: body, p: 0 })?); + out.push(read_pattern(&mut Rd { b: body, p: 0 }, &mut left)?); let pad = (4 - len % 4) % 4; r.p = (r.p + pad).min(data.len()); } + *budget = left; Ok(out) } @@ -442,8 +477,9 @@ pub fn parse_pat_file(data: &[u8]) -> Result> { let _version = r.u16()?; let count = r.u32()?; let mut out = Vec::new(); + let mut budget = PatternBudget::default(); for _ in 0..count.min(100_000) { - out.push(read_pattern(&mut r)?); + out.push(read_pattern(&mut r, &mut budget)?); } Ok(out) } @@ -681,6 +717,54 @@ mod tests { assert!(matches!(parse_pattern_block(&global), Err(PsdError::LimitExceeded("padded pattern channels exceed MAX_PADDED_PATTERN_BYTES")))); } + /// A one-channel gray pattern whose 5x3 channel is padded into a `size`x`size` rectangle. + fn padded_gray_block(size: i32) -> Vec { + let mut p = pat(8, false); + p.mode = 1; + p.channels.truncate(1); + let mut block = write_pattern_block(std::slice::from_ref(&p)).unwrap(); + let vma = pattern_vma_rect_offset(4, &p); + set_i32(&mut block, vma + 8, size); + set_i32(&mut block, vma + 12, size); + block + } + + #[test] + fn padding_budget_is_shared_by_every_pattern_of_a_parse() { + // Each pattern pads 100x100 = 10000 bytes; a 15000-byte budget fits one, not two. Before + // the fix each pattern got a fresh allowance, so N tiny patterns committed N times the cap. + let one = padded_gray_block(100); + let mut budget = PatternBudget::with_decoded_limit(15_000); + assert_eq!(parse_pattern_block_with(&one, &mut budget).unwrap().len(), 1); + assert_eq!(budget.decoded_left(), 5_000); + + let two = [one.clone(), one.clone()].concat(); + let mut budget = PatternBudget::with_decoded_limit(15_000); + assert!(matches!(parse_pattern_block_with(&two, &mut budget), Err(PsdError::LimitExceeded(_)))); + // A failed parse keeps nothing, so it charges nothing. + assert_eq!(budget.decoded_left(), 15_000); + + // Two blocks of one document share a budget too. + let mut budget = PatternBudget::with_decoded_limit(15_000); + assert!(parse_pattern_block_with(&one, &mut budget).is_ok()); + assert!(parse_pattern_block_with(&one, &mut budget).is_err()); + } + + #[test] + fn channels_at_different_depths_are_rejected() { + // The second colour channel claims 8 bits in a 16-bit pattern. It used to be accepted with + // the pattern's depth taken from the last channel, so the result couldn't be written back. + let p = pat(16, false); + let mut block = write_pattern_block(std::slice::from_ref(&p)).unwrap(); + let first = pattern_vma_rect_offset(4, &p) + 20; + let first_len = u32::from_be_bytes(block[first + 4..first + 8].try_into().unwrap()) as usize; + let second = first + 8 + first_len; + block[second + 8..second + 12].copy_from_slice(&8u32.to_be_bytes()); + block[second + 28..second + 30].copy_from_slice(&8u16.to_be_bytes()); + let err = parse_pattern_block(&block).unwrap_err().to_string(); + assert!(err.contains("different depths"), "{err}"); + } + #[test] fn zero_size_pattern_rectangles_are_rejected() { let p = pat(8, false); diff --git a/crates/psd/src/slices.rs b/crates/psd/src/slices.rs index dadfb95af3..54bf4c8fcd 100644 --- a/crates/psd/src/slices.rs +++ b/crates/psd/src/slices.rs @@ -81,6 +81,7 @@ fn put_ustr(out: &mut Vec, s: &str) { // ---------- descriptor helpers (versions 7/8 and the v6 tail) ---------- +/// A number as `i64`; float casts saturate (NaN reads as 0). fn d_int(d: &Descriptor, key: &str) -> Option { match d.get(key)? { Value::Integer(v) => Some(i64::from(*v)), @@ -91,6 +92,20 @@ fn d_int(d: &Descriptor, key: &str) -> Option { } } +/// A coordinate, clamped to the `i32` range instead of wrapping. +fn d_i32(d: &Descriptor, key: &str) -> Option { + d_int(d, key).map(|v| v.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32) +} + +/// An id. A 32-bit `long` holds the id's bits (ids past `i32::MAX` are written negative); any +/// other number outside the `u32` range is no id at all rather than a wrapped one. +fn d_u32(d: &Descriptor, key: &str) -> Option { + match d.get(key)? { + Value::Integer(v) => Some(*v as u32), + _ => u32::try_from(d_int(d, key)?).ok(), + } +} + fn d_text(d: &Descriptor, key: &str) -> String { match d.get(key) { Some(Value::Text(t)) => t.to_string_lossy(), @@ -111,7 +126,7 @@ fn d_bool(d: &Descriptor, key: &str) -> bool { fn d_bounds(d: &Descriptor, key: &str) -> Option<[i32; 4]> { let Value::Descriptor(b) = d.get(key)? else { return None }; - let g = |k: &str| d_int(b, k).unwrap_or(0) as i32; + let g = |k: &str| d_i32(b, k).unwrap_or(0); // top, left, bottom, right Some([g("Top "), g("Left"), g("Btom"), g("Rght")]) } @@ -143,10 +158,10 @@ fn slice_from_descriptor(d: &Descriptor) -> SliceRecord { _ => [0; 4], }; SliceRecord { - id: d_int(d, "sliceID").unwrap_or(0) as u32, - group_id: d_int(d, "groupID").unwrap_or(0) as u32, + id: d_u32(d, "sliceID").unwrap_or(0), + group_id: d_u32(d, "groupID").unwrap_or(0), origin: d_enum(d, "origin").map_or(2, |s| origin_from(&s)), - layer_id: d_int(d, "layerID").map(|v| v as u32), + layer_id: d_u32(d, "layerID"), name: d_text(d, "Nm "), kind: d_enum(d, "Type").map_or(1, |s| kind_from(&s)), // Descriptor bounds are top/left/bottom/right; records use left/top/right/bottom. @@ -161,10 +176,10 @@ fn slice_from_descriptor(d: &Descriptor) -> SliceRecord { vertical_align: 0, color, outsets: [ - d_int(d, "topOutset").unwrap_or(0) as i32, - d_int(d, "leftOutset").unwrap_or(0) as i32, - d_int(d, "bottomOutset").unwrap_or(0) as i32, - d_int(d, "rightOutset").unwrap_or(0) as i32, + d_i32(d, "topOutset").unwrap_or(0), + d_i32(d, "leftOutset").unwrap_or(0), + d_i32(d, "bottomOutset").unwrap_or(0), + d_i32(d, "rightOutset").unwrap_or(0), ], } } @@ -261,8 +276,10 @@ impl SlicesResource { { for v in items { let Value::Descriptor(d) = v else { continue }; + // An entry without a usable id belongs to no slice (not to slice 0). + let Some(id) = d_u32(d, "sliceID") else { continue }; let extra = slice_from_descriptor(d); - if let Some(s) = slices.iter_mut().find(|s| s.id == extra.id) { + if let Some(s) = slices.iter_mut().find(|s| s.id == id) { s.outsets = extra.outsets; } } @@ -435,6 +452,33 @@ mod tests { assert_eq!(back.slices[2].outsets, [1, 2, 3, 4]); } + #[test] + fn out_of_range_descriptor_numbers_do_not_wrap() { + // sliceID 2^32+1 used to read as slice 1 and take that slice's outsets. + let wide = |key: &str| Descriptor::new("slice").with("sliceID", Value::LargeInteger((1 << 32) + 1)).with(key, Value::Integer(9)); + let mut plain = sample(); + for s in &mut plain.slices { + s.outsets = [0; 4]; + } + let mut bytes = plain.to_bytes(); + VersionedDescriptor::new(Descriptor::new("null").with("slices", Value::List(vec![Value::Descriptor(wide("topOutset"))]))).write(&mut bytes); + let back = SlicesResource::from_bytes(&bytes).unwrap(); + assert!(back.slices.iter().all(|s| s.outsets == [0; 4]), "{:?}", back.slices); + + // Descriptor form: ids out of the u32 range fall back to 0, coordinates clamp. + let d = wide("leftOutset") + .with("layerID", Value::LargeInteger(-1)) + .with("bottomOutset", Value::LargeInteger(1 << 40)) + .with("rightOutset", Value::Double(-1e12)) + .with("bounds", Value::Descriptor(Descriptor::new("Rct1").with("Top ", Value::LargeInteger((1 << 32) + 5)))); + let s = slice_from_descriptor(&d); + assert_eq!((s.id, s.layer_id), (0, None)); + assert_eq!(s.outsets, [0, 9, i32::MAX, i32::MIN]); + assert_eq!(s.rect[1], i32::MAX); + // A 32-bit long keeps the id's bits, as `to_descriptor_bytes` writes ids past i32::MAX. + assert_eq!(slice_from_descriptor(&Descriptor::new("slice").with("sliceID", Value::Integer(-1))).id, u32::MAX); + } + #[test] fn malformed_input_is_an_error() { let bytes = sample().to_bytes(); diff --git a/crates/psd/src/tagged.rs b/crates/psd/src/tagged.rs index 7d66eae920..7728d53fd1 100644 --- a/crates/psd/src/tagged.rs +++ b/crates/psd/src/tagged.rs @@ -325,13 +325,27 @@ impl TaggedBlock { } pub(crate) fn write(&self, out: &mut Vec, version: Version, alignment: usize) -> 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 { + Self::write_parts(out, self.signature, self.key, &self.data, self.padding.as_deref(), version, alignment) + } + + /// Writes a block from its parts (no owned [`TaggedBlock`] needed). `padding: None` writes + /// zeros up to `alignment`. + pub(crate) fn write_parts( + out: &mut Vec, + signature: [u8; 4], + key: [u8; 4], + data: &[u8], + padding: Option<&[u8]>, + version: Version, + alignment: usize, + ) -> Result<()> { + out.put(&signature); + out.put(&key); + out.put_len(data.len() as u64, uses_long_length(version, &key))?; + out.put(data); + match padding { Some(p) => out.put(p), - None => out.extend(std::iter::repeat_n(0u8, Self::default_padding(self.data.len(), alignment))), + None => out.extend(std::iter::repeat_n(0u8, Self::default_padding(data.len(), alignment))), } Ok(()) } diff --git a/crates/psd/src/tiff.rs b/crates/psd/src/tiff.rs index 7c8b1940d5..31c85d4885 100644 --- a/crates/psd/src/tiff.rs +++ b/crates/psd/src/tiff.rs @@ -33,7 +33,7 @@ use crate::image_data::ImageData; use crate::io::{Reader, WriteExt}; use crate::layer::{LayerInfo, LayerMask, mask_parameter_bytes}; use crate::resources::ImageResource; -use crate::tagged::{TaggedBlock, read_blocks, uses_long_length, write_blocks}; +use crate::tagged::{TaggedBlock, read_blocks, uses_long_length}; /// The byte order of a TIFF container (and so of the Photoshop data inside it). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] @@ -153,7 +153,7 @@ impl ImageSourceData { /// dropped because they could not be converted to a little-endian layout. pub fn to_bytes(&self, order: ByteOrder) -> Result<(Vec, Vec)> { let version = self.version; - let mut blocks = Vec::with_capacity(self.global_blocks.len() + 2); + let mut body = Vec::new(); if let Some(info) = &self.layer_info { // Pad to 4 without cloning the layers (and all their pixel data) just to set `padding`. let pad = info.pad_len(version, 4)?; @@ -161,21 +161,17 @@ impl ImageSourceData { data.try_reserve(info.channel_data_len().saturating_add(pad)).map_err(|_| PsdError::LimitExceeded("not enough memory for the layer data"))?; info.write_unpadded(&mut data, version)?; data.resize(data.len() + pad, 0); - blocks.push(padded_block(self.layer_info_key, data)); + padded_block(self.layer_info_key, data).write(&mut body, version, 4)?; } + // Blocks without explicit padding are padded to 4 as they are written (alignment 4), so + // the global mask and blocks are written from the model, without a copy of their data. + // The signature stays (a foreign-order marker included). if let Some(m) = &self.global_layer_mask { - blocks.push(padded_block(*b"LMsk", m.data.clone())); + TaggedBlock::write_parts(&mut body, *b"8BIM", *b"LMsk", &m.data, None, version, 4)?; } for b in &self.global_blocks { - let mut b = b.clone(); - if b.padding.is_none() { - // Padded to 4 like the others; the signature stays (a foreign-order marker included). - b.padding = Some(vec![0; (4 - b.data.len() % 4) % 4]); - } - blocks.push(b); + b.write(&mut body, version, 4)?; } - let mut body = Vec::new(); - write_blocks(&mut body, &blocks, version, 2)?; body.put(&self.trailing); let (body, warnings) = if order.is_little() { transcode::transcode_blocks(&body, version, ByteOrder::Big, ByteOrder::Little)? } else { (body, Vec::new()) }; @@ -344,6 +340,19 @@ pub mod transcode { (self.out, self.warnings) } + /// A walker over `data` (a nested structure) that writes into this walker's output: it + /// takes the buffer, so a nested structure is written once, not copied up per level. Hand + /// the buffer back with [`Self::adopt`] before returning, error or not. + fn nested<'b>(&mut self, data: &'b [u8], depth: usize) -> Tx<'b> { + let r = if self.from.is_little() { Reader::new_little(data) } else { Reader::new(data) }; + Tx { r, out: std::mem::take(&mut self.out), from: self.from, to: self.to, version: self.version, warnings: Vec::new(), depth } + } + + /// Takes the output buffer back from a [`Self::nested`] walker. + fn adopt(&mut self, child: &mut Tx<'_>) { + self.out = std::mem::take(&mut child.out); + } + fn same_order(&self) -> bool { self.from == self.to } @@ -469,15 +478,15 @@ pub mod transcode { /// Transcodes the next `len` bytes with `f`; whatever `f` leaves is copied verbatim. fn region(&mut self, len: u64, f: impl FnOnce(&mut Tx<'_>) -> Result<()>) -> Result<()> { let data = self.r.bytes_u64(len)?; - let mut child = Tx::new(data, self.version, self.from, self.to); - child.depth = self.depth; - f(&mut child)?; - child.rest()?; - if child.out.len() != data.len() { + let start = self.out.len(); + let mut child = self.nested(data, self.depth); + let res = f(&mut child).and_then(|()| child.rest()); + self.adopt(&mut child); + res?; + if self.out.len() - start != data.len() { return Err(PsdError::invalid("byte-order conversion changed a structure's length")); } self.warnings.append(&mut child.warnings); - self.out.extend_from_slice(&child.out); Ok(()) } @@ -500,13 +509,13 @@ pub mod transcode { len = len.checked_sub(4).ok_or_else(|| PsdError::invalid("length shorter than its own field"))?; } let data = self.r.bytes_u64(len)?; - let mut child = Tx::new(data, self.version, self.from, self.to); - child.depth = self.depth; - f(&mut child)?; - child.rest()?; + let start = self.out.len(); + let mut child = self.nested(data, self.depth); + let res = f(&mut child).and_then(|()| child.rest()); + self.adopt(&mut child); + res?; self.warnings.append(&mut child.warnings); - self.patch_len(at, long, child.out.len() as u64 + if inclusive { 4 } else { 0 })?; - self.out.extend_from_slice(&child.out); + self.patch_len(at, long, (self.out.len() - start) as u64 + if inclusive { 4 } else { 0 })?; Ok(()) } @@ -672,13 +681,12 @@ pub mod transcode { if !self.from.is_little() && TaggedBlock::is_foreign_order_signature(self.r.peek_rest()) { let sig = self.r.array::<4>()?; let mut key = self.r.array::<4>()?; - let long = uses_long_length(self.version, &{ - let mut k = key; - if self.to.is_little() { - k.reverse(); - } - k - }); + // Only reached with `from == Big`: the key is canonical here whatever the + // target order. `TaggedBlock::write` sizes the length field from that same key. + // Reversing it for the target order before the check + // asked about a key `PSB_LONG_KEYS` never contains, so a PSB long key's 8-byte + // length was read as 4 and every later block was copied unconverted. + let long = uses_long_length(self.version, &key); let len = self.r.len_field(long)?; let data = self.r.bytes_u64(len)?; if self.to.is_little() { @@ -704,8 +712,9 @@ pub mod transcode { let len_at = self.out.len(); self.out.extend_from_slice(if long { &[0u8; 8] } else { &[0u8; 4] }); let data = self.r.bytes_u64(len)?; - let mut child = Tx::new(data, self.version, self.from, self.to); - child.depth = self.depth + 1; + // The body is transcoded straight into this output, after the length placeholder. + let data_at = self.out.len(); + let mut child = self.nested(data, self.depth + 1); let handled = if child.depth > MAX_DEPTH { Err(PsdError::LimitExceeded("tagged block nesting too deep")) } else if data.is_empty() { @@ -713,16 +722,21 @@ pub mod transcode { } else { child.block_body(&key) }; + let rest = if matches!(handled, Ok(true)) { child.rest() } else { Ok(()) }; + self.adopt(&mut child); + rest?; + if !matches!(handled, Ok(true)) { + // A body that was not transcoded leaves nothing: it is copied as stored or dropped. + self.out.truncate(data_at); + } // Padding: the smallest k in 0..=3 after which the next block (or the end) starts. let rest = self.r.peek_rest(); let pad = (0..=3usize).find(|&k| k == rest.len() || (k < rest.len() && is_sig(&rest[k..], self.from.is_little()))).unwrap_or(0); let pad_bytes = self.r.bytes(pad)?; match handled { Ok(true) => { - child.rest()?; self.warnings.append(&mut child.warnings); - self.patch_len(len_at, long, child.out.len() as u64)?; - self.out.extend_from_slice(&child.out); + self.patch_len(len_at, long, (self.out.len() - data_at) as u64)?; self.out.extend_from_slice(pad_bytes); } Ok(false) if self.same_order() => { @@ -2196,6 +2210,25 @@ mod tests { assert_eq!(ImageSourceData::from_bytes(&big).unwrap().0.global_blocks, back.global_blocks); } + /// A PSB keeps `Mt16`/`Mt32`/`Mtrn`/`Alph` blocks of unknown layout verbatim, and their + /// length field is 8 bytes (a PSB long key). Writing them into a little-endian TIFF must + /// read that width; the old code looked the key up after reversing it for the target + /// order, which never matches `PSB_LONG_KEYS`. + #[test] + fn psb_foreign_order_long_key_roundtrips() { + let d = ImageSourceData { + version: Version::Psb, + byte_order: ByteOrder::Little, + global_blocks: vec![TaggedBlock { signature: *b"MIB8", key: *b"Mt16", data: vec![9, 8, 7, 6, 5, 4], padding: None }], + ..Default::default() + }; + let (little, w) = d.to_bytes(ByteOrder::Little).unwrap(); + assert!(w.is_empty(), "{w:?}"); + let (back, w) = ImageSourceData::from_bytes(&little).unwrap(); + assert!(w.is_empty(), "{w:?}"); + assert_eq!(back.global_blocks, d.global_blocks, "the 8-byte length and the bytes survive"); + } + #[test] fn rle_counts_follow_the_container_and_samples_do_not() { let file = testgen::layered(Version::Psd, ColorMode::Rgb, 16, Compression::Rle); @@ -2330,4 +2363,71 @@ mod tests { assert_eq!(again.global_blocks, expected.global_blocks); assert_eq!(again.layer_tree().len(), file.layer_tree().len()); } + + /// Global blocks are padded as they are written, not on a copy: the bytes are those of the + /// former path (each block cloned with explicit padding to 4, written with `write_blocks`). + #[test] + fn global_blocks_written_without_copies_keep_their_bytes() { + for version in [Version::Psd, Version::Psb] { + let mut d = ImageSourceData { version, global_layer_mask: Some(GlobalLayerMask { data: vec![0, 1, 0, 2, 0, 3, 0, 4, 9] }), ..Default::default() }; + for n in 0..8u8 { + d.global_blocks.push(TaggedBlock::new(*b"FMsk", (0..n).collect())); + } + let mut explicit = TaggedBlock::new(*b"FMsk", vec![1, 2, 3]); + explicit.padding = Some(vec![]); + d.global_blocks.push(explicit); + let mut odd = TaggedBlock::new(*b"FMsk", vec![1, 2]); + odd.padding = Some(vec![0, 0, 0, 0, 0]); + d.global_blocks.push(odd); + let mut copies = vec![padded_block(*b"LMsk", d.global_layer_mask.as_ref().unwrap().data.clone())]; + for b in &d.global_blocks { + let mut b = b.clone(); + if b.padding.is_none() { + b.padding = Some(vec![0; (4 - b.data.len() % 4) % 4]); + } + copies.push(b); + } + let mut body = Vec::new(); + crate::tagged::write_blocks(&mut body, &copies, version, 2).unwrap(); + let signature = if version.is_psb() { SIGNATURE_PSB } else { SIGNATURE }; + assert_eq!(d.to_bytes(ByteOrder::Big).unwrap().0, [signature, &body[..]].concat()); + let (little, _) = transcode_blocks(&body, version, ByteOrder::Big, ByteOrder::Little).unwrap(); + assert_eq!(d.to_bytes(ByteOrder::Little).unwrap().0, [signature, &little[..]].concat()); + } + } + + /// Nested structures are transcoded into one shared buffer. A block whose body fails + /// part-way leaves no partial bytes: dropped on the way out (with the enclosing lengths + /// patched as if it had never been there), or kept exactly as stored on the way in. + #[test] + fn a_nested_block_that_fails_part_way_leaves_nothing_behind() { + let clean = sample_layers(); + let mut fx = Vec::new(); + fx.put_u32(0); + fx.extend_from_slice(&VersionedDescriptor::new(Descriptor::new("null").with("Opct", Value::Double(1.0))).to_bytes()); + // A whole number of 4-byte units, so dropping it cannot change any padding. + fx.resize(fx.len().next_multiple_of(4), 0); + let mut d = clean.clone(); + d.layer_info.as_mut().unwrap().layers[0].blocks.insert(0, TaggedBlock::new(*b"lfx2", fx.clone())); + let (mut little, w) = d.to_bytes(ByteOrder::Little).unwrap(); + assert!(!w.iter().any(|w| w.starts_with("lfx2")), "{w:?}"); + // An unknown value type after the descriptor's header: parsing fails part-way. + let at = little.windows(4).position(|w| w == b"buod").unwrap(); + little[at..at + 4].copy_from_slice(b"????"); + let (back, w) = ImageSourceData::from_bytes(&little).unwrap(); + assert_eq!(w.iter().filter(|w| w.starts_with("lfx2 block")).count(), 1, "{w:?}"); + let kept = &back.layer_info.as_ref().unwrap().layers[0].blocks[0]; + assert!(kept.is_foreign_order()); + let stored = little.windows(4).position(|w| w == b"2xfl").unwrap() + 8; + assert_eq!(kept.data, little[stored..stored + fx.len()]); + // Big-endian data that fails part-way cannot go into a little-endian file: dropped. + let mut big_bad = d.clone(); + let mut broken = fx.clone(); + let at = broken.windows(4).position(|w| w == b"doub").unwrap(); + broken[at..at + 4].copy_from_slice(b"????"); + big_bad.layer_info.as_mut().unwrap().layers[0].blocks[0].data = broken; + let (little, w) = big_bad.to_bytes(ByteOrder::Little).unwrap(); + assert_eq!(w.iter().filter(|w| w.starts_with("lfx2 block")).count(), 1, "{w:?}"); + assert_eq!(little, clean.to_bytes(ByteOrder::Little).unwrap().0); + } } diff --git a/crates/raw/README.md b/crates/raw/README.md index b8c75ebff6..bb4815a4fc 100644 --- a/crates/raw/README.md +++ b/crates/raw/README.md @@ -67,7 +67,7 @@ camera colour tables were copied. | Format | Status | |---|---| -| DNG | Uncompressed (8–16 bit, packed or not), Deflate (compression 8, predictors 1 and 2), lossless JPEG and JPEG XL (DNG 1.7, e.g. Samsung Expert RAW; decoded by `jxl-oxide`; not in wasm32 builds, where it is `Unsupported`); strips and tiles; CFA (Bayer) and LinearRaw (three-sample colour, or one-sample monochrome such as Leica Monochrom and film-scanner DNGs, developed as neutral grey with no white balance or colour matrix); LinearizationTable, BlackLevel (+ repeat, DeltaH/V), WhiteLevel, ActiveArea, DefaultCrop, ColorMatrix1/2, CameraCalibration, ForwardMatrix, AnalogBalance, AsShotNeutral / AsShotWhiteXY, BaselineExposure, Orientation, OpcodeList2 GainMap (lens shading) | +| DNG | Uncompressed (8–16 bit, packed or not), Deflate (compression 8, predictors 1 and 2), lossless JPEG, lossy JPEG (DNG 1.4 compression 34892: 8-bit baseline JPEG tiles or strips, gray for CFA, YCbCr-coded RGB for LinearRaw; decoded by `zune-jpeg`, the stored 8-bit values then go through the same LinearizationTable / BlackLevel / WhiteLevel as every other depth) and JPEG XL (DNG 1.7, e.g. Samsung Expert RAW; decoded by `jxl-oxide`; not in wasm32 builds, where it is `Unsupported`); strips and tiles; CFA (Bayer) and LinearRaw (three-sample colour, or one-sample monochrome such as Leica Monochrom and film-scanner DNGs, developed as neutral grey with no white balance or colour matrix); LinearizationTable, BlackLevel (+ repeat, DeltaH/V), WhiteLevel, ActiveArea, DefaultCrop, ColorMatrix1/2, CameraCalibration, ForwardMatrix, AnalogBalance, AsShotNeutral / AsShotWhiteXY, BaselineExposure, Orientation, OpcodeList2 GainMap (lens shading) | | DNG (lossy JPEG, floating point; opcodes other than GainMap; ProfileGainTableMap) | Unsupported / not applied (reported) | | CR2 | Lossless JPEG with slices, borders and as-shot white balance from the maker note, black measured on the masked border. CR2 has no CFA tag and the row phase varies by model, so it is measured from the data (the green diagonal), with a Canon model-ID table as the fallback (see `src/cr2.rs`) | | CR2 sRAW / mRAW | Unsupported | @@ -83,6 +83,12 @@ camera colour tables were copied. | CR3 | Native CRX unsupported; bounded JPEG-track/PRVW preview fallback with CMT1 orientation. I/O labels the result as an 8-bit camera preview | | X-Trans and other periodic non-Bayer CFAs (up to 16×16) | Demosaiced by one pattern-agnostic edge-directed method, whatever `Demosaic` is chosen | +Nikon maker-note compression modes 6/10 select checked little-endian packed 12/14-bit +rows even when TIFF Compression is 34713. The modern lossy `0x44 0x40` curve uses +its smaller encoded domain while preserving sensor ADUs. Valid Nikon CropArea +coordinates take precedence over model fallbacks. HE/HE* and lossy-after-split +remain unsupported. Four pinned CC0 Z6 files cover both bit depths and both paths. + ## Development pipeline 1. Linearization table, per-position black level, scale to the white level. @@ -117,7 +123,8 @@ smallest preview at least 512 pixels long, fits and develops in one go. Camera Raw's tuned import fits at the default exposure and as-shot white balance first, then applies the user's Exposure, Temperature and Tint to that same rendering. The D5100's measured image area is 4928 × 3264 at (8, 8) in a 4992 × 3280 -sensor plane; it removes the border without changing levels, WB or calibration. +sensor plane; the D90's is 4288 × 2848 at (10, 10) in a 4352 × 2868 plane; each +removes the border without changing levels, WB or calibration. Measured image areas preserve an explicit file crop. For a slightly tighter camera JPEG, fitting can align the reference proxy using a bounded diff --git a/crates/raw/src/cameras.rs b/crates/raw/src/cameras.rs index 7439433c3e..84204c6bce 100644 --- a/crates/raw/src/cameras.rs +++ b/crates/raw/src/cameras.rs @@ -127,6 +127,10 @@ const PROFILES: &[Profile] = &[Profile { /// 4992 x 3280 sensor data, giving the camera JPEG's 4928 x 3264 frame. /// * **ILCE-7** (2 files): 12 x 12 pixels in from the left and top of the /// 6048 x 4024 cRAW data (36 and 12 pixels of margin on the right and bottom). +/// * **D90** (1 file): 10 x 10 pixels in from the left and top of the +/// 4352 x 2868 sensor data, giving the camera JPEG's 4288 x 2848 frame +/// (10 pixels of margin on the right and bottom; the right border reads the +/// camera's flat 255 from column 4310 on). struct ImageArea { make: &'static str, model: &'static str, @@ -137,6 +141,7 @@ struct ImageArea { } const IMAGE_AREAS: &[ImageArea] = &[ + ImageArea { make: "NIKON CORPORATION", model: "NIKON D90", size: (4352, 2868), crop: (10, 10, 4288, 2848) }, ImageArea { make: "NIKON CORPORATION", model: "NIKON D5100", size: (4992, 3280), crop: (8, 8, 4928, 3264) }, ImageArea { make: "NIKON CORPORATION", model: "NIKON D800E", size: (7424, 4924), crop: (8, 6, 7360, 4912) }, ImageArea { make: "SONY", model: "ILCE-7", size: (6048, 4024), crop: (12, 12, 6000, 4000) }, @@ -152,6 +157,11 @@ pub(crate) fn apply(s: &mut Sensor) { s.crop = Rect::new(x, y, w, h); } let Some(p) = PROFILES.iter().find(|p| make == p.make && model == p.model && (s.width, s.height) == p.size) else { return }; + // A raw with its own colour calibration (a DNG) keeps it: only the measured image area + // above applies. The measured profile stands in for files that carry no matrix themselves. + if !s.color.calibrations.is_empty() { + return; + } let (x, y, w, h) = p.crop; s.crop = Rect::new(x, y, w, h); s.white = [p.white; 3]; @@ -265,6 +275,36 @@ mod tests { assert_eq!(s.crop, Rect::new(0, 0, 7424, 4924)); } + #[test] + fn d90_image_area_matches_the_camera_jpeg() { + let mut s = nef("NIKON D90", 4352, 2868); + apply(&mut s); + assert_eq!(s.crop, Rect::new(10, 10, 4288, 2848)); + // Only the image area is measured: levels, colour and tone stay untouched. + assert!(s.color.calibrations.is_empty() && s.tone_curve.is_empty()); + assert_eq!(s.white, [16383.0; 3]); + // An explicit crop in the file wins over the measured area. + s.crop = Rect::new(0, 0, 4288, 2848); + apply(&mut s); + assert_eq!(s.crop, Rect::new(0, 0, 4288, 2848)); + } + + #[test] + fn a_file_with_its_own_calibration_keeps_it() { + // A file that carries its own colour calibration (a DNG, or any calibrated raw) keeps + // it: the measured profile must not override it (only the image area would apply). + let mut s = nef("NIKON D4", 4992, 3292); + s.color = ColorInfo { + calibrations: vec![Calibration { temperature: 6504.0, color_matrix: IDENTITY, forward_matrix: None, camera_calibration: IDENTITY }], + ..ColorInfo::default() + }; + apply(&mut s); + assert_eq!(s.color.calibrations.len(), 1); + assert_eq!(s.color.calibrations[0].temperature, 6504.0, "the file's own calibration stands"); + assert!(s.tone_curve.is_empty() && s.black_point == 0.0); + assert_eq!(s.crop, Rect::new(0, 0, 4992, 3292), "the D4 has no measured image area"); + } + #[test] fn other_cameras_and_sizes_are_left_alone() { for (model, w, h) in [("NIKON D4S", 4992, 3292), ("NIKON D4", 2496, 1646), ("NIKON D800", 7424, 4924)] { diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 0bccf8ffac..0d1a99e796 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -28,7 +28,7 @@ //! Bodies whose full profile is not measured yet can still carry a measured //! image area (the D800E, the ILCE-7). //! -//! Decoded today: DNG (uncompressed, lossless-JPEG and JPEG XL, strips and +//! Decoded today: DNG (uncompressed, deflate, lossless-JPEG, lossy-JPEG and JPEG XL, strips and //! tiles, CFA and LinearRaw), CR2 (lossless JPEG with Canon slices), uncompressed or //! lossless-JPEG TIFF/EP raws (NEF, ARW, PEF… when not vendor-compressed), //! Nikon compressed NEF (lossless and lossy), Sony compressed ARW (cRAW), diff --git a/crates/raw/src/nefc.rs b/crates/raw/src/nefc.rs index 9ca5eff5f9..db1f8b1d74 100644 --- a/crates/raw/src/nefc.rs +++ b/crates/raw/src/nefc.rs @@ -33,7 +33,9 @@ //! * Lossless (`0x0096` version `0x46`): the decoded values are the samples. //! Lossy (`0x44`): they index a curve. Type 1 (`0x44 0x10`) stores the whole //! curve, type 2 (`0x44 0x20` / `0x44 0x40`) stores `n` points spaced -//! `2^bits / (n - 1)` codes apart, linearly interpolated. +//! `2^bits / (n - 1)` codes apart, linearly interpolated. Version `0x44 0x40` +//! uses a two-bit-smaller encoded domain (1025 / 4097 levels for 12 / 14-bit +//! samples), not the sensor bit depth; see Bill Claff's NEF Compression tables. //! * Values in the maker note are in the maker note's byte order. //! //! Not decoded (reported as unsupported; the embedded preview is used @@ -50,6 +52,59 @@ pub(crate) const NIKON_COMPRESSION: u32 = 34713; /// Maker note NEFLinearizationTable. pub(crate) const NIKON_LINEARIZATION_TABLE: u16 = 0x0096; +/// Packed uncompressed NEF modes 6 (12-bit) and 10 (14-bit). Measured on CC0 +/// Z6/Z7/D850 samples: little-endian bit packing, rows optionally padded to 16 +/// bytes. Selection comes from the maker note, never from the strip's length. +pub(crate) fn read_packed(t: &Tiff, ifd: &Ifd, bits: u32, limits: &Limits) -> Result { + let w = t.tag_uint(ifd, tag::IMAGE_WIDTH).ok_or_else(|| RawError::malformed("packed NEF has no width"))? as usize; + let h = t.tag_uint(ifd, tag::IMAGE_LENGTH).ok_or_else(|| RawError::malformed("packed NEF has no height"))? as usize; + if !t.le || t.tag_uint(ifd, tag::BITS_PER_SAMPLE) != Some(bits) || t.tag_uint(ifd, tag::SAMPLES_PER_PIXEL).unwrap_or(1) != 1 { + return Err(RawError::unsupported("packed NEF byte order, bit depth or sample layout")); + } + limits.check(w as u64, h as u64, 2)?; + let offsets = t.tag_uints(ifd, tag::STRIP_OFFSETS); + let counts = t.tag_uints(ifd, tag::STRIP_BYTE_COUNTS); + let ([off], [len]) = (offsets.as_slice(), counts.as_slice()) else { + return Err(RawError::unsupported("packed NEF with multiple strips")); + }; + let src = t.bytes(*off as usize, *len as usize).ok_or_else(|| RawError::malformed("packed NEF strip lies outside the file"))?; + let data = unpack_little_rows(src, w, h, bits)?; + Ok(Plane { width: w, height: h, samples: 1, bits, data }) +} + +fn unpack_little_rows(src: &[u8], w: usize, h: usize, bits: u32) -> Result> { + let bad = || RawError::malformed("packed NEF row layout is invalid or truncated"); + if w == 0 || h == 0 || !matches!(bits, 12 | 14) { + return Err(bad()); + } + let row = w.checked_mul(bits as usize).ok_or_else(bad)?.div_ceil(8); + let padded = row.checked_add(15).ok_or_else(bad)? / 16 * 16; + let stride = if row.checked_mul(h) == Some(src.len()) { + row + } else if padded.checked_mul(h) == Some(src.len()) { + padded + } else { + return Err(bad()); + }; + let count = w.checked_mul(h).ok_or_else(bad)?; + let mut out = Vec::with_capacity(count); + let mask = (1u32 << bits) - 1; + for row in src.chunks_exact(stride) { + let mut bytes = row.iter(); + let (mut acc, mut have) = (0u32, 0u32); + for _ in 0..w { + while have < bits { + acc |= u32::from(*bytes.next().ok_or_else(bad)?) << have; + have += 8; + } + out.push((acc & mask) as u16); + acc >>= bits; + have -= bits; + } + } + Ok(out) +} + /// A code word that never occurred in any analysed sample. const UNSEEN: u8 = u8::MAX; @@ -216,7 +271,10 @@ pub(crate) fn parse_table(t: &[u8], le: bool, bits: u32) -> Result if split != 0 { return Err(RawError::unsupported(format!("Nikon \"lossy after split\" compressed NEF (split at row {split})"))); } - let range = 1usize << bits; + // The modern lossy curve has a smaller *encoded* domain while + // its output remains in sensor ADUs. Using 2^bits stretches the + // curve fourfold and puts Z6 image values below its black level. + let range = 1usize << if v1 == 0x40 { bits - 2 } else { bits }; if n - 1 > range || !range.is_multiple_of(n - 1) { return Err(RawError::unsupported(format!("Nikon compressed NEF: {n}-point curve for {bits}-bit data"))); } @@ -503,6 +561,37 @@ mod tests { assert!(parse_table(&[0x44, 0x20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0], false, 12).is_err()); } + #[test] + fn modern_lossy_curve_preserves_sensor_adus_at_both_depths() { + for bits in [12u32, 14] { + for le in [false, true] { + let put = |v: u16| if le { v.to_le_bytes() } else { v.to_be_bytes() }; + let mut t = vec![0x44, 0x40]; + for _ in 0..4 { + t.extend(put(256)); + } + t.extend(put(257)); + let white = (1u16 << bits) - 1; + for i in 0..=256u32 { + t.extend(put(((i * 64).min(u32::from(white))) as u16)); + } + t.resize(624, 0); + let parsed = parse_table(&t, le, bits).unwrap(); + let Encoding::Lossy(curve) = &parsed.encoding else { panic!() }; + let range = 1usize << (bits - 2); + assert_eq!(curve.len(), range + 1); + assert_eq!(curve[range], white); + // Stored point 16 is 1024 ADU. It belongs to encoded code 256 + // at 14 bits, code 64 at 12 bits, independent of end-point clipping. + assert_eq!(curve[range / 16], 1024.min(white)); + let indices = vec![0, (range / 16) as i32, (range / 8) as i32, (range / 32) as i32]; + let huffman = if bits == 12 { &LOSSY_12 } else { &LOSSY_14 }; + let src = encode(&indices, 2, huffman, parsed.seeds); + assert_eq!(decode(&src, 2, 2, bits, &parsed).unwrap(), vec![0, 1024, 2048, 512]); + } + } + } + /// A 14-bit lossless table (version 0x46) with the given seeds. fn lossless_table(seed: u16, le: bool) -> Vec { let mut t = vec![0x46, 0x30]; diff --git a/crates/raw/src/sensor.rs b/crates/raw/src/sensor.rs index 585191b799..f2cffdb808 100644 --- a/crates/raw/src/sensor.rs +++ b/crates/raw/src/sensor.rs @@ -214,7 +214,20 @@ pub(crate) enum JpegLayout { /// DNG 1.7 JPEG XL compression. const JPEG_XL: u32 = 52546; -/// Reads the (uncompressed, lossless-JPEG or JPEG XL) image of a raw IFD. +/// Inflates one zlib tile into at most `need` bytes. The stream must run to its end: a truncated +/// stream, or one that holds more than `need` bytes, is an error rather than a silent cut-off. +fn inflate_tile(src: &[u8], need: usize) -> Result> { + let mut out = vec![0u8; need]; + let mut d = flate2::Decompress::new(true); + let status = d.decompress(src, &mut out, flate2::FlushDecompress::Finish).map_err(|_| RawError::malformed("deflate tile does not decompress"))?; + if status != flate2::Status::StreamEnd { + return Err(RawError::malformed("deflate tile is truncated or larger than its tile")); + } + out.truncate(usize::try_from(d.total_out()).unwrap_or(need).min(need)); + Ok(out) +} + +/// Reads the (uncompressed, deflate, lossless-JPEG, lossy-JPEG or JPEG XL) image of a raw IFD. pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayout) -> Result { let width = t.tag_uint(ifd, tag::IMAGE_WIDTH).ok_or_else(|| RawError::malformed("raw image has no width"))? as usize; let height = t.tag_uint(ifd, tag::IMAGE_LENGTH).ok_or_else(|| RawError::malformed("raw image has no height"))? as usize; @@ -243,6 +256,7 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou limits.check(width as u64, height as u64, samples as u64 * 2)?; let segments = segments(t, ifd, width, height)?; let le = t.le; + let tiled = t.tag_uint(ifd, tag::TILE_WIDTH).is_some(); let decoded: Vec>> = par::map(segments.len(), |i| { let s = &segments[i]; let rows = s.h.min(height - s.y); @@ -261,10 +275,7 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou let too_big = || RawError::malformed("deflate tile is too large"); let need = s.w.checked_mul(rows).and_then(|n| n.checked_mul(samples)).and_then(|n| n.checked_mul((bits as usize).div_ceil(8))).ok_or_else(too_big)?; - let mut out = vec![0u8; need]; - let mut d = flate2::Decompress::new(true); - d.decompress(src, &mut out, flate2::FlushDecompress::Finish).map_err(|_| RawError::malformed("deflate tile does not decompress"))?; - out.truncate(usize::try_from(d.total_out()).unwrap_or(need).min(need)); + let out = inflate_tile(src, need)?; let v = unpack(&out, s.w, rows, samples, bits, le)?; // The TIFF predictor runs after unpacking, per row, per sample plane. match t.tag_uint(ifd, tag::PREDICTOR).unwrap_or(1) { @@ -276,37 +287,12 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou } // A JPEG XL segment codes only the rows inside the image (strips) or the whole tile. JPEG_XL => { - let coded_rows = if t.tag_uint(ifd, tag::TILE_WIDTH).is_some() { s.h } else { rows }; + let coded_rows = if tiled { s.h } else { rows }; let mut v = jxl::decode(src, s.w, coded_rows, samples, bits, limits)?; v.resize(s.w * s.h * samples, 0); Ok(v) } - 34892 => { - // Adobe DNG compression 34892: baseline JPEG tiles (lossy, 8-bit). The tile is - // a complete JPEG stream: a CFA part decodes to gray samples, a 3-sample - // LinearRaw part holds YCbCr, which zune-jpeg converts to RGB. The header is - // decoded and size-checked before any pixel allocation, so a hostile tile - // cannot size the decoder's buffers (up to 16384x16384) with a fake SOF. - let out = match samples { - 1 => zune_core::colorspace::ColorSpace::Luma, - 3 => zune_core::colorspace::ColorSpace::RGB, - n => return Err(RawError::unsupported(format!("lossy DNG with {n} samples per pixel (1 or 3 are supported)"))), - }; - let options = zune_core::options::DecoderOptions::default().jpeg_set_out_colorspace(out); - let mut dec = zune_jpeg::JpegDecoder::new_with_options(zune_core::bytestream::ZCursor::new(src), options); - dec.decode_headers().map_err(|e| RawError::malformed(format!("lossy JPEG tile header: {e:?}")))?; - let f = dec.info().ok_or_else(|| RawError::malformed("lossy JPEG tile has no header"))?; - if (f.width as usize, f.height as usize) != (s.w, rows) { - return Err(RawError::malformed(format!("lossy JPEG tile is {}x{}, expected {}x{}", f.width, f.height, s.w, rows))); - } - let decoded = dec.decode().map_err(|e| RawError::malformed(format!("lossy JPEG tile: {e:?}")))?; - let need = s.w * rows * samples; - if decoded.len() != need { - return Err(RawError::malformed(format!("lossy JPEG tile holds {} samples, expected {need}", decoded.len()))); - } - // The 8-bit samples widen to the document's 16-bit range (0xFF -> 0xFFFF). - Ok(decoded.into_iter().map(|b| u16::from(b) * 257).collect()) - } + 34892 => lossy_jpeg(src, s, rows, samples, bits, tiled, limits), // Compression 7 (lossless JPEG): the frame the entropy stream declares is capped // by the budget, not only by the segment, and the `Quads` scatter buffer below // comes out of the same budget. `max_alloc` counts bytes, a sample is two. @@ -391,6 +377,54 @@ pub(crate) fn segments(t: &Tiff, ifd: &Ifd, width: usize, height: usize) -> Resu Ok(out) } +/// Decodes one segment of a lossy DNG (Adobe DNG compression 34892): a complete baseline-DCT +/// JPEG stream (ITU-T T.81), 8 bits per sample, as DNG 1.4 defines it. A CFA segment is a +/// one-component (gray) JPEG; a 3-sample LinearRaw segment is a three-component one in YCbCr, +/// which the JPEG decoder turns back into the stored R, G, B. The decoded 8-bit values are the +/// stored sample values: the LinearizationTable, BlackLevel and WhiteLevel apply to them later, +/// as for every other compression, and WhiteLevel defaults to 255 for 8-bit data. +/// +/// `rows` is the number of segment rows inside the image. A tile is always coded at the full +/// tile size (edge tiles are padded); a strip is coded at its row count (the last strip is +/// shorter). Either height is accepted, the width must be the segment's. The frame header is +/// checked before any pixel is decoded, and the decoder's own size cap is the segment, so a +/// hostile SOF cannot size a buffer larger than the tile the IFD declares (which `limits` has +/// already charged). +fn lossy_jpeg(src: &[u8], s: &Segment, rows: usize, samples: usize, bits: u32, tiled: bool, limits: &Limits) -> Result> { + use zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions}; + if bits != 8 { + return Err(RawError::unsupported(format!("lossy DNG with {bits}-bit samples (lossy JPEG tiles are 8-bit)"))); + } + let out = match samples { + 1 => ColorSpace::Luma, + 3 => ColorSpace::RGB, + n => return Err(RawError::unsupported(format!("lossy DNG with {n} samples per pixel (1 or 3 are supported)"))), + }; + let options = DecoderOptions::default().jpeg_set_out_colorspace(out).set_max_width(s.w).set_max_height(s.h); + let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(src), options); + dec.decode_headers().map_err(|e| RawError::malformed(format!("lossy JPEG tile header: {e:?}")))?; + let f = dec.info().ok_or_else(|| RawError::malformed("lossy JPEG tile has no header"))?; + let (fw, fh) = (usize::from(f.width), usize::from(f.height)); + let coded_rows = if tiled { s.h } else { rows }; + if fw != s.w || (fh != coded_rows && fh != rows) { + return Err(RawError::malformed(format!("lossy JPEG tile is {fw}x{fh}, expected {}x{coded_rows}", s.w))); + } + if usize::from(f.components) != samples { + return Err(RawError::malformed(format!("lossy JPEG tile has {} components, expected {samples}", f.components))); + } + let need = fw.checked_mul(fh).and_then(|n| n.checked_mul(samples)).ok_or_else(|| RawError::malformed("lossy JPEG tile is too large"))?; + limits.check(fw as u64, fh as u64, samples as u64 * 2)?; + let decoded = dec.decode().map_err(|e| RawError::malformed(format!("lossy JPEG tile: {e:?}")))?; + if decoded.len() != need { + return Err(RawError::malformed(format!("lossy JPEG tile holds {} samples, expected {need}", decoded.len()))); + } + let mut v: Vec = decoded.into_iter().map(u16::from).collect(); + // A strip coded at its own row count still fills the segment's declared height. + let full = s.w.checked_mul(s.h).and_then(|n| n.checked_mul(samples)).ok_or_else(|| RawError::malformed("lossy JPEG tile is too large"))?; + v.resize(full, 0); + Ok(v) +} + /// Reverses TIFF predictor 2 (horizontal differencing) on interleaved samples: each sample /// gains its channel's predecessor in the row, wrapping at the sample's container (8 or 16 bits). fn undelta(v: &[u16], plane: usize, row_samples: usize, rows: usize, bits: u32) -> Vec { @@ -459,6 +493,26 @@ mod tests { assert_eq!(c.phase(0, 0), [1, 0, 2, 1]); // GRBG } + fn zlib(data: &[u8]) -> Vec { + use std::io::Write; + let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + e.write_all(data).unwrap(); + e.finish().unwrap() + } + + #[test] + fn inflate_tile_requires_stream_end() { + let data: Vec = (0..64u8).collect(); + let z = zlib(&data); + assert_eq!(inflate_tile(&z, 64).unwrap(), data); + // Shorter than the tile, but a complete stream: still accepted. + assert_eq!(inflate_tile(&z, 100).unwrap(), data); + // Truncated stream. + assert!(inflate_tile(&z[..z.len() - 6], 64).is_err()); + // More decoded output than the tile holds. + assert!(inflate_tile(&z, 32).is_err()); + } + #[test] fn unpack_12_bit() { // Two 12-bit samples 0xABC, 0x123 packed MSB first. diff --git a/crates/raw/src/testgen.rs b/crates/raw/src/testgen.rs index 2de64ae4bb..9d8b8fb94c 100644 --- a/crates/raw/src/testgen.rs +++ b/crates/raw/src/testgen.rs @@ -398,14 +398,30 @@ fn urat(v: f64) -> (u32, u32) { // ---------------------------------------------------------------- DNG +/// Encodes 8-bit samples (1 gray or 3 RGB per pixel, row-major) as a baseline JPEG at +/// quality 60, the way a lossy DNG segment is stored (an RGB segment is coded as YCbCr). +pub fn baseline_jpeg(samples: &[u8], width: usize, height: usize, per_pixel: usize) -> Vec { + use image::ImageEncoder as _; + let colour = if per_pixel == 3 { image::ExtendedColorType::Rgb8 } else { image::ExtendedColorType::L8 }; + let mut j = Vec::new(); + let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut j, 60); + enc.write_image(samples, width as u32, height as u32, colour).expect("jpeg segment"); + j +} + /// How the DNG stores its raw data. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DngStorage { /// One strip per `rows` rows, uncompressed at `bits` per sample (8, 12, 16…). Strips { rows: usize }, - /// Adobe DNG compression 34892: baseline JPEG tiles, 8-bit luma at tile size (the reader - /// decodes and widens to 16 bits). Quality 60 leaves visible-but-mild artifacts. + /// Adobe DNG compression 34892: baseline JPEG tiles at tile size (edge tiles padded), one + /// gray component for CFA and monochrome data, three (YCbCr-coded RGB) for LinearRaw. The + /// spec's `bits` should be 8 (the DNG spec's depth for lossy JPEG; the reader rejects others); + /// wider samples are written as their top 8 bits. Quality 60 leaves visible-but-mild artifacts. LossyJpegTiles { width: usize, height: usize }, + /// Adobe DNG compression 34892 in strips of this many rows: each strip is a baseline JPEG + /// of the image width by the strip's own row count (the last strip is shorter). + LossyJpegStrips { rows: usize }, /// Adobe DNG compression 8: zlib streams with the TIFF predictor (1 = none, 2 = horizontal /// differencing) applied before compressing. DeflateStrips { rows: usize, predictor: u16 }, @@ -438,6 +454,7 @@ pub struct DngSpec { pub big_endian: bool, pub black: Vec, pub black_repeat: (u16, u16), + /// WhiteLevel; 0 leaves the tag out (the reader then defaults to the bit depth's maximum). pub white: u32, /// top, left, bottom, right pub active_area: Option<[u32; 4]>, @@ -459,6 +476,10 @@ pub struct DngSpec { pub opcode_list2: Option>, /// Encoded segments for [`DngStorage::JxlStrips`] / [`DngStorage::JxlTiles`]. pub jxl_segments: Vec>, + /// Pre-encoded segments for [`DngStorage::LossyJpegTiles`] / [`DngStorage::LossyJpegStrips`] + /// (row-major), used instead of encoding `data` where present: for tiles that disagree with + /// the IFD (hostile-input tests). + pub lossy_segments: Vec>, /// Raw ProfileGainTableMap bytes, written to IFD0 (contents are not interpreted). pub profile_gain_table_map: Option>, pub orientation: u16, @@ -498,6 +519,7 @@ impl DngSpec { orientation: 1, opcode_list2: None, jxl_segments: Vec::new(), + lossy_segments: Vec::new(), profile_gain_table_map: None, make: "Photocraft".into(), model: "Synthetic".into(), @@ -515,6 +537,12 @@ impl DngSpec { self.samples == 1 && !self.monochrome } + /// The 8-bit sample a lossy JPEG segment stores for `v`: the value itself at 8 bits, else + /// its top 8 bits. + fn sample8(&self, v: u16) -> u8 { + (v >> self.bits.saturating_sub(8)) as u8 + } + fn pack(&self, rows: std::ops::Range) -> Vec { let n = self.width * self.samples; let vals = &self.data[rows.start * n..rows.end * n]; @@ -554,10 +582,12 @@ impl DngSpec { (262, Val::Short(vec![if self.is_cfa() { 32803 } else { 34892 }])), (277, Val::Short(vec![self.samples as u16])), (284, Val::Short(vec![1])), - (50717, Val::Long(vec![self.white; self.samples])), (50713, Val::Short(vec![self.black_repeat.0, self.black_repeat.1])), (50714, Val::Long(self.black.clone())), ]; + if self.white != 0 { + raw.push((50717, Val::Long(vec![self.white; self.samples]))); + } if self.is_cfa() { raw.push((33421, Val::Short(vec![2, 2]))); raw.push((33422, Val::Byte(self.cfa.to_vec()))); @@ -620,23 +650,18 @@ impl DngSpec { let s = self.samples; for ty in (0..self.height).step_by(th) { for tx in (0..self.width).step_by(tw) { - // Edge tiles are padded by repeating the last row / column. A 3-sample - // (LinearRaw) spec writes RGB tiles, a CFA spec gray ones. + // Edge tiles are padded by repeating the last row / column. let mut tile = vec![0u8; tw * th * s]; for y in 0..th { for x in 0..tw { let sy = (ty + y).min(self.height - 1); let sx = (tx + x).min(self.width - 1); for c in 0..s { - tile[(y * tw + x) * s + c] = (self.data[(sy * self.width + sx) * s + c] >> 8) as u8; + tile[(y * tw + x) * s + c] = self.sample8(self.data[(sy * self.width + sx) * s + c]); } } } - let colour = if s == 3 { image::ExtendedColorType::Rgb8 } else { image::ExtendedColorType::L8 }; - let mut j = Vec::new(); - let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut j, 60); - use image::ImageEncoder as _; - enc.write_image(&tile, tw as u32, th as u32, colour).expect("jpeg tile"); + let j = self.lossy_segments.get(offs.len()).cloned().unwrap_or_else(|| baseline_jpeg(&tile, tw, th, s)); lens.push(j.len() as u32); offs.push(t.blob(j)); } @@ -647,6 +672,22 @@ impl DngSpec { raw.push((324, Val::Blobs(offs))); raw.push((325, Val::Long(lens))); } + DngStorage::LossyJpegStrips { rows } => { + let mut offs = Vec::new(); + let mut lens = Vec::new(); + let n = self.width * self.samples; + for y in (0..self.height).step_by(rows) { + let r = (y + rows).min(self.height) - y; + let strip: Vec = self.data[y * n..(y + r) * n].iter().map(|&v| self.sample8(v)).collect(); + let j = self.lossy_segments.get(offs.len()).cloned().unwrap_or_else(|| baseline_jpeg(&strip, self.width, r, self.samples)); + lens.push(j.len() as u32); + offs.push(t.blob(j)); + } + raw.push((259, Val::Short(vec![34892]))); + raw.push((278, Val::Long(vec![rows as u32]))); + raw.push((273, Val::Blobs(offs))); + raw.push((279, Val::Long(lens))); + } DngStorage::Lj92Strips { rows } => { let mut offs = Vec::new(); let mut lens = Vec::new(); diff --git a/crates/raw/src/tiffep.rs b/crates/raw/src/tiffep.rs index 4a75491f5f..25befbddea 100644 --- a/crates/raw/src/tiffep.rs +++ b/crates/raw/src/tiffep.rs @@ -78,6 +78,18 @@ fn nikon_maker_note<'a>(t: &Tiff<'a>, ifds: &[Ifd]) -> Option<(Tiff<'a>, Ifd)> { Some((inner, ifd)) } +/// ExifTool's public Nikon tag documentation: legacy 0x93, or a u16 at offset +/// 10 of MakerNotes0x51. The latter is used by Z6/Z7, despite TIFF Compression +/// remaining 34713 even for uncompressed sensor data. +fn nikon_compression(nikon: Option<&(Tiff<'_>, Ifd)>) -> Option { + let (t, m) = nikon?; + t.tag_uint(m, 0x0093).or_else(|| { + let b = t.raw(m.get(0x0051)?)?; + let v: [u8; 2] = b.get(10..12)?.try_into().ok()?; + Some(u32::from(if t.le { u16::from_le_bytes(v) } else { u16::from_be_bytes(v) })) + }) +} + /// Four levels listed in R, G, G, B order, placed at their 2×2 CFA positions /// (data parity). pub(crate) fn rggb_by_position(cfa: &Cfa, v: &[f64]) -> Option<[f32; 4]> { @@ -130,7 +142,12 @@ pub(crate) fn decode(t: &Tiff, format: RawFormat, limits: &Limits) -> Result nefc::read_packed(t, &raw, 12, limits)?, + Some(10) => nefc::read_packed(t, &raw, 14, limits)?, + Some(13 | 14) => return Err(RawError::unsupported("Nikon High Efficiency / High Efficiency* NEF")), + _ => nefc::read_compressed(t, &raw, nikon.as_ref(), limits)?, + } } else { read_plane(t, &raw, limits, JpegLayout::Quads)? }; @@ -195,6 +212,17 @@ pub(crate) fn decode(t: &Tiff, format: RawFormat, limits: &Limits) -> Result= 0.0 { v.round() as usize } else { 0 }; let r = Rect::new(f(*ox), f(*oy), f(*cw), f(*ch)).intersect(&full); diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/dng.rs index e13fb920dd..7cc04d2a92 100644 --- a/crates/raw/tests/dng.rs +++ b/crates/raw/tests/dng.rs @@ -1,7 +1,7 @@ //! Synthetic DNG files: every storage layout decodes to the exact sensor //! samples, and development reproduces known colours. -use photocraft_raw::testgen::{DngSpec, DngStorage, Val, mosaic, scene}; +use photocraft_raw::testgen::{DngSpec, DngStorage, Val, baseline_jpeg, mosaic, scene}; use photocraft_raw::*; fn sensor(bytes: &[u8]) -> Sensor { @@ -99,8 +99,8 @@ fn monochrome_linear_raw_develops_to_neutral_grey() { ("LJ92 strips", 14, DngStorage::Lj92Strips { rows: 6 }), ("LJ92 tiles", 16, DngStorage::Lj92Tiles { width: 16, height: 8 }), ("deflate predictor 2", 16, DngStorage::DeflateStrips { rows: 7, predictor: 2 }), - // One whole tile: a partial edge tile is a separate decoder limitation. - ("lossy JPEG tile", 16, DngStorage::LossyJpegTiles { width: 37, height: 23 }), + ("lossy JPEG tiles", 8, DngStorage::LossyJpegTiles { width: 16, height: 8 }), + ("lossy JPEG strips", 8, DngStorage::LossyJpegStrips { rows: 5 }), ]; for (name, bits, storage) in cases { let white = ((1u32 << bits) - 1) as u16; @@ -109,7 +109,7 @@ fn monochrome_linear_raw_develops_to_neutral_grey() { spec.bits = bits; spec.white = u32::from(white); spec.storage = storage; - let lossy = matches!(storage, DngStorage::LossyJpegTiles { .. }); + let lossy = matches!(storage, DngStorage::LossyJpegTiles { .. } | DngStorage::LossyJpegStrips { .. }); let s = sensor(&spec.build()); assert_eq!((s.samples, s.cfa.is_none(), s.is_monochrome()), (1, true, true), "{name}"); if !lossy { @@ -712,87 +712,236 @@ fn profile_gain_table_map_is_reported() { assert!(gain_table(&sensor(&spec.build()))); } -/// Adobe DNG compression 34892 (lossy baseline JPEG, 8-bit luma tiles): decodes to the tile -/// samples widened to 16 bits, within the JPEG quality band (Q60 artifacts are mild but real). +// ---------------------------------------------------------------- lossy DNG (compression 34892) + +/// The largest difference between decoded and source samples, in stored (8-bit) levels. +fn worst_diff(got: &[u16], want: &[u16]) -> u16 { + assert_eq!(got.len(), want.len()); + got.iter().zip(want).map(|(g, w)| g.abs_diff(*w)).max().unwrap_or(0) +} + +/// An 8-bit lossy DNG around `data` (every value below 256): BitsPerSample 8, WhiteLevel 255. +fn lossy_spec(w: usize, h: usize, data: Vec, storage: DngStorage) -> DngSpec { + assert!(data.iter().all(|&v| v <= 255)); + let mut spec = DngSpec::cfa(w, h, data); + spec.bits = 8; + spec.white = 255; + spec.storage = storage; + spec +} + +/// Adobe DNG compression 34892 (DNG 1.4 lossy JPEG): a CFA image whose tiles or strips are +/// baseline JPEGs of 8-bit gray samples. They decode as the stored values (0..255, like any +/// other 8-bit raw: levels and the linearization table apply afterwards), within the JPEG +/// quality band (Q60 artifacts are mild but real), from whole and partial edge tiles and +/// from strips with a short last strip. #[test] -fn lossy_jpeg_tiles_decode() { - let (w, h) = (32, 32); +fn lossy_jpeg_cfa_tiles_and_strips_decode_stored_values() { + let (w, h) = (37, 23); // odd: partial edge tiles and a short last strip // A smooth ramp: baseline JPEG at Q60 keeps it within a few quantisation steps. - let data: Vec = (0..w * h).map(|i| 4096 + (i / w) as u16 * 16).collect(); - let mut spec = DngSpec::cfa(w, h, data.clone()); - spec.storage = DngStorage::LossyJpegTiles { width: 16, height: 16 }; - let s = sensor(&spec.build()); - assert_eq!((s.width, s.height, s.samples), (w, h, 1)); - let mut worst = 0u32; - for (got, want) in s.data.iter().zip(&data) { - // The tile was written 8-bit (>>8), so the quantised expectation is (want>>8)<<8. - let quant = u32::from(*want >> 8) << 8; - worst = worst.max(u32::from(got.abs_diff(quant as u16))); + let data: Vec = (0..w * h).map(|i| ((i % w) * 5 + (i / w) * 2) as u16).collect(); + let cases = [ + ("16x8 tiles", DngStorage::LossyJpegTiles { width: 16, height: 8 }), + ("one tile", DngStorage::LossyJpegTiles { width: 37, height: 23 }), + ("strips of 5", DngStorage::LossyJpegStrips { rows: 5 }), + ("one strip", DngStorage::LossyJpegStrips { rows: 23 }), + ]; + for (name, storage) in cases { + let bytes = lossy_spec(w, h, data.clone(), storage).build(); + assert_eq!(identify(&bytes), Some(RawFormat::Dng), "{name}"); + let s = sensor(&bytes); + assert_eq!((s.width, s.height, s.samples, s.cfa.is_some()), (w, h, 1, true), "{name}"); + assert_eq!(s.white, [255.0; 3], "{name}"); + assert!(s.data.iter().all(|&v| v <= 255), "{name}: a sample above the 8-bit range"); + let worst = worst_diff(&s.data, &data); + assert!(worst <= 4, "{name}: worst deviation from the source: {worst} levels"); + let d = develop_sensor(&s, &DevelopOptions::default()).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!((d.width as usize, d.height as usize, d.rgb.len()), (w, h, w * h * 3), "{name}"); + assert!(d.info.cfa.is_some(), "{name}"); } - assert!(worst <= 4 * 256, "worst deviation from the 8-bit quantisation: {worst}"); +} + +/// A 3-sample LinearRaw lossy DNG (as Adobe writes them: demosaiced, YCbCr-coded JPEG tiles) +/// decodes to the stored R, G, B, each channel at its own level, from tiles and strips. +#[test] +fn lossy_jpeg_linearraw_rgb_tiles_and_strips_decode_stored_values() { + let (w, h) = (37, 23); + // Channel offsets of 64 levels survive JPEG chroma quantisation at Q60; the ramp is smooth. + let data: Vec = (0..w * h * 3).map(|i| ((i / 3 % w) * 2 + i / 3 / w) as u16 + (i % 3) as u16 * 64).collect(); + let cases = [ + ("16x16 tiles", DngStorage::LossyJpegTiles { width: 16, height: 16 }), + ("one tile", DngStorage::LossyJpegTiles { width: 37, height: 23 }), + ("strips of 7", DngStorage::LossyJpegStrips { rows: 7 }), + ]; + for (name, storage) in cases { + let mut spec = lossy_spec(w, h, data.clone(), storage); + spec.samples = 3; + let s = sensor(&spec.build()); + assert_eq!((s.width, s.height, s.samples, s.cfa.is_none()), (w, h, 3, true), "{name}"); + assert!(s.data.iter().all(|&v| v <= 255), "{name}: a sample above the 8-bit range"); + let worst = worst_diff(&s.data, &data); + assert!(worst <= 8, "{name}: worst deviation from the source: {worst} levels"); + // Each channel carries its own level: R and B stay apart everywhere. + for p in 0..w * h { + let (r, b) = (s.data[p * 3], s.data[p * 3 + 2]); + assert!(b >= r + 64 - 8, "{name}: channels collapsed at pixel {p}: R {r}, B {b}"); + } + let d = develop_sensor(&s, &DevelopOptions::default()).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!((d.width as usize, d.height as usize, d.rgb.len()), (w, h, w * h * 3), "{name}"); + } +} + +/// The decoded 8-bit values are the stored samples, so WhiteLevel defaults to 255 when absent, +/// BlackLevel / WhiteLevel scale them and a LinearizationTable maps them, exactly as for an +/// uncompressed 8-bit DNG. Flat images keep the comparison inside the JPEG's DC rounding. +#[test] +fn lossy_jpeg_levels_and_linearization_apply_to_stored_values() { + let (w, h) = (24, 16); + let tiles = DngStorage::LossyJpegTiles { width: 16, height: 8 }; + let flat = |v: u16| vec![v; w * h]; + let grey = |d: &Developed| d.rgb.chunks(3).map(|p| p[0]).collect::>(); + + // No WhiteLevel: 255 is white. A flat 255 develops to full white, a flat 0 to black. + for (v, lo, hi) in [(255u16, 65000u16, 65535u16), (0, 0, 300)] { + let mut spec = DngSpec::monochrome(w, h, flat(v)); + spec.bits = 8; + spec.white = 0; + spec.storage = tiles; + let s = sensor(&spec.build()); + assert_eq!(s.white, [255.0; 3]); + let d = develop_sensor(&s, &DevelopOptions::default()).unwrap(); + assert!(grey(&d).iter().all(|&g| (lo..=hi).contains(&g)), "flat {v}: {:?}", &grey(&d)[..4]); + } + + // BlackLevel 20 / WhiteLevel 220 scale the stored values like the uncompressed path. + let levels = |storage: DngStorage| { + let mut spec = DngSpec::monochrome(w, h, flat(200)); + spec.bits = 8; + spec.black = vec![20]; + spec.white = 220; + spec.storage = storage; + let s = sensor(&spec.build()); + assert_eq!((s.white, s.black.at(0, 0, 0, 1)), ([220.0; 3], 20.0)); + grey(&develop_sensor(&s, &DevelopOptions::default()).unwrap()) + }; + let (lossy, plain) = (levels(tiles), levels(DngStorage::Strips { rows: h })); + assert!(plain[0] > 50_000 && plain[0] < 65_000, "{}", plain[0]); + let worst = worst_diff(&lossy, &plain); + assert!(worst <= 600, "levels: lossy and uncompressed differ by {worst}"); + + // A LinearizationTable indexed by the stored value. It is flat around 200, so the JPEG's + // DC rounding (199 or 200) lands on the same output: the two paths develop identically. + let table: Vec = (0..256u32).map(|v| if (190..=210).contains(&v) { 40_000 } else { (v * 257) as u16 }).collect(); + let linearized = |storage: DngStorage| { + let mut spec = DngSpec::monochrome(w, h, flat(200)); + spec.bits = 8; + spec.white = 0; + spec.linearization = Some(table.clone()); + spec.storage = storage; + let s = sensor(&spec.build()); + assert_eq!(s.linearization.as_deref(), Some(table.as_slice())); + assert_eq!(s.white, [65535.0; 3], "the table's maximum is white"); + grey(&develop_sensor(&s, &DevelopOptions::default()).unwrap()) + }; + let (lossy, plain) = (linearized(tiles), linearized(DngStorage::Strips { rows: h })); + assert!(plain[0] > 40_000 && plain[0] < 60_000, "{}", plain[0]); + assert_eq!(lossy, plain, "linearization: the lossy path differs from the uncompressed one"); } /// A lossy JPEG tile cut mid-stream fails cleanly. #[test] -fn lossy_jpeg_tiles_hostile() { +fn lossy_jpeg_truncated_tile_errors() { let (w, h) = (32, 32); - let data: Vec = (0..w * h).map(|_i| 8192u16).collect(); - let mut spec = DngSpec::cfa(w, h, data); - spec.storage = DngStorage::LossyJpegTiles { width: 32, height: 32 }; - let bytes = spec.build(); + let bytes = lossy_spec(w, h, vec![128; w * h], DngStorage::LossyJpegTiles { width: 32, height: 32 }).build(); let at = bytes.windows(2).position(|w| w == [0xFF, 0xD8]).expect("jpeg SOI") + 2; let cut = &bytes[..at + (bytes.len() - at) / 2]; - assert!(decode(cut, &Limits::default()).is_err(), "a cut JPEG tile must not decode"); + assert!(matches!(decode(cut, &Limits::default()), Err(RawError::Malformed(_))), "a cut JPEG tile must not decode"); } -/// A tile JPEG whose header lies about its size fails the header check, before any pixel -/// decode (a hostile SOF must not be able to size the decoder's buffers first). +/// A tile JPEG whose header disagrees with the tile size fails the header check, before any +/// pixel decode (a hostile SOF must not be able to size the decoder's buffers first). #[test] fn lossy_jpeg_tile_dimension_mismatch_errors() { - use image::ImageEncoder as _; let (w, h) = (32, 32); - let data: Vec = (0..w * h).map(|i| 4096 + (i / w) as u16 * 16).collect(); - let mut spec = DngSpec::cfa(w, h, data); - spec.storage = DngStorage::LossyJpegTiles { width: 32, height: 32 }; - let mut bytes = spec.build(); - // Swap the 32x32 tile JPEG for a 16x16 one, inside the same segment (the trailing bytes - // after its EOI stay; the JPEG decoder stops at EOI). - let small = { - let img = image::GrayImage::from_fn(16, 16, |x, _| image::Luma([(x as u16 * 16) as u8])); - let mut j = Vec::new(); - let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut j, 60); - enc.write_image(img.as_raw(), 16, 16, image::ExtendedColorType::L8).unwrap(); - j - }; - let soi = bytes.windows(3).position(|w| w == [0xFF, 0xD8, 0xFF]).expect("SOI"); - let eoi = soi + bytes[soi..].windows(2).position(|w| w == [0xFF, 0xD9]).expect("EOI") + 2; - bytes.splice(soi..eoi, small); - let err = decode(&bytes, &Limits::default()).unwrap_err().to_string(); + let mut spec = lossy_spec(w, h, vec![100; w * h], DngStorage::LossyJpegTiles { width: 32, height: 32 }); + let small: Vec = (0..16 * 16).map(|i| (i % 16 * 16) as u8).collect(); + spec.lossy_segments = vec![baseline_jpeg(&small, 16, 16, 1)]; + let err = decode(&spec.build(), &Limits::default()).unwrap_err().to_string(); assert!(err.contains("16x16") && err.contains("32x32"), "{err}"); + // A tile coded at the image's row count instead of the tile's is tolerated (a strip-like + // encoder); anything else is not. + let (w, h) = (16, 12); + let mut spec = lossy_spec(w, h, vec![100; w * h], DngStorage::LossyJpegTiles { width: 16, height: 16 }); + spec.lossy_segments = vec![baseline_jpeg(&[100u8; 16 * 12], 16, 12, 1)]; + let s = sensor(&spec.build()); + assert_eq!((s.width, s.height), (w, h)); + assert!(s.data.iter().all(|&v| v.abs_diff(100) <= 2)); + for (tw, th) in [(16, 14), (16, 18), (12, 16), (18, 16)] { + spec.lossy_segments = vec![baseline_jpeg(&vec![100u8; tw * th], tw, th, 1)]; + assert!(matches!(decode(&spec.build(), &Limits::default()), Err(RawError::Malformed(_))), "{tw}x{th}"); + } + // A strip must be coded at its own row count. + let mut spec = lossy_spec(w, h, vec![100; w * h], DngStorage::LossyJpegStrips { rows: 8 }); + spec.lossy_segments = vec![baseline_jpeg(&[100u8; 16 * 8], 16, 8, 1), baseline_jpeg(&[100u8; 16 * 8], 16, 8, 1)]; + assert!(matches!(decode(&spec.build(), &Limits::default()), Err(RawError::Malformed(_)))); } -/// A 3-sample LinearRaw lossy DNG (YCbCr on disk, as real camera files are) decodes to RGB -/// tiles widened to 16 bits, within the JPEG quality band; a LinearizationTable maps the -/// decoded samples in the develop stage, so the sensor sees the raw widened values. +/// A tile with the wrong number of JPEG components (a colour JPEG in a CFA DNG, a gray one in a +/// 3-sample LinearRaw DNG) is rejected rather than silently converted. #[test] -fn lossy_jpeg_rgb_linearraw_decodes() { - let (w, h) = (32, 32); - // Channel offsets of 64 8-bit steps survive JPEG chroma quantisation at Q60. - let data: Vec = (0..w * h * 3).map(|i| 4096 + ((i / (w * 3)) as u16 * 16) + (i % 3) as u16 * 64 * 256).collect(); - let mut spec = DngSpec::cfa(w, h, data.clone()); - spec.samples = 3; +fn lossy_jpeg_component_count_mismatch_errors() { + let (w, h) = (16, 16); + let mut cfa = lossy_spec(w, h, vec![90; w * h], DngStorage::LossyJpegTiles { width: 16, height: 16 }); + cfa.lossy_segments = vec![baseline_jpeg(&vec![90u8; w * h * 3], w, h, 3)]; + let err = decode(&cfa.build(), &Limits::default()).unwrap_err(); + assert!(matches!(&err, RawError::Malformed(m) if m.contains("3 components")), "{err}"); + + let mut rgb = lossy_spec(w, h, vec![90; w * h * 3], DngStorage::LossyJpegTiles { width: 16, height: 16 }); + rgb.samples = 3; + rgb.lossy_segments = vec![baseline_jpeg(&vec![90u8; w * h], w, h, 1)]; + let err = decode(&rgb.build(), &Limits::default()).unwrap_err(); + assert!(matches!(&err, RawError::Malformed(m) if m.contains("1 components")), "{err}"); +} + +/// A SOF claiming a 65535x65535 frame inside a 16x16 tile is refused at the header, under the +/// default limits as well as tight ones: the frame is never allocated. +#[test] +fn lossy_jpeg_oversized_frame_header_errors() { + let (w, h) = (16, 16); + let mut bytes = lossy_spec(w, h, vec![90; w * h], DngStorage::LossyJpegTiles { width: 16, height: 16 }).build(); + let sof = bytes.windows(2).position(|m| m == [0xFF, 0xC0]).expect("SOF0"); + // FF C0 | length (2) | precision (1) | height (2) | width (2) + bytes[sof + 5..sof + 9].copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]); + for limits in [Limits::default(), Limits { max_width: 64, max_height: 64, max_pixels: 1 << 12, max_alloc: 1 << 16 }] { + assert!(matches!(decode(&bytes, &limits), Err(RawError::Malformed(_)))); + } + // The IFD cannot grow a tile past the limits either: a tile larger than the budget is + // refused before its JPEG is looked at. + let tight = Limits { max_width: 64, max_height: 64, max_pixels: 1 << 12, max_alloc: 1 << 10 }; + let big = lossy_spec(40, 40, vec![90; 1600], DngStorage::LossyJpegTiles { width: 40, height: 40 }).build(); + assert!(decode(&big, &tight).is_err()); +} + +/// DNG 1.4 stores lossy JPEG at 8 bits per sample; other depths and sample counts are reported +/// as unsupported, not decoded as something else. +#[test] +fn lossy_jpeg_rejects_other_depths_and_sample_counts() { + let (w, h) = (16, 16); + let mut spec = DngSpec::cfa(w, h, vec![4000; w * h]); spec.storage = DngStorage::LossyJpegTiles { width: 16, height: 16 }; - let s = sensor(&spec.build()); - assert_eq!((s.width, s.height, s.samples), (w, h, 3)); - let mut worst = 0u32; - for (got, want) in s.data.iter().zip(&data) { - let quant = u32::from(*want >> 8) << 8; - worst = worst.max(u32::from(got.abs_diff(quant as u16))); + for bits in [12u16, 16] { + spec.bits = bits; + spec.white = (1 << bits) - 1; + let err = decode(&spec.build(), &Limits::default()).unwrap_err(); + assert!(matches!(&err, RawError::Unsupported(m) if m.contains(&format!("{bits}-bit"))), "{err}"); } - assert!(worst <= 8 * 256, "worst deviation from the 8-bit quantisation: {worst}"); - // Each channel carries its own level: R and B stay apart everywhere. - for p in 0..w * h { - let (r, b) = (s.data[p * 3], s.data[p * 3 + 2]); - assert!(r.abs_diff(b) >= 64 * 256 - 8 * 256, "channels collapsed at pixel {p}"); - } + // Four samples per pixel (the IFD's claim; the JPEG itself is three-component). + let mut spec = lossy_spec(w, h, vec![90; w * h * 3], DngStorage::LossyJpegTiles { width: 16, height: 16 }); + spec.samples = 3; + let mut bytes = spec.build(); + let samples_tag = 277u16.to_le_bytes(); + let at = bytes.windows(4).position(|e| e[..2] == samples_tag && e[2..] == 3u16.to_le_bytes()).expect("SamplesPerPixel entry"); + bytes[at + 8] = 4; + let err = decode(&bytes, &Limits::default()).unwrap_err(); + assert!(matches!(&err, RawError::Unsupported(m) if m.contains("4 samples")), "{err}"); } diff --git a/crates/raw/tests/hostile.rs b/crates/raw/tests/hostile.rs index 4b91f2f103..3212d554e1 100644 --- a/crates/raw/tests/hostile.rs +++ b/crates/raw/tests/hostile.rs @@ -49,6 +49,21 @@ fn samples() -> Vec> { out.push(m.build()); m.storage = DngStorage::DeflateStrips { rows: 5, predictor: 2 }; out.push(m.build()); + // Lossy DNG (compression 34892): 8-bit baseline JPEG tiles of a CFA, and strips of LinearRaw RGB. + let mut l = DngSpec::cfa(w, h, cfa.iter().map(|&v| ((u32::from(v) - 64) * 255 / (4000 - 64)) as u16).collect()); + l.bits = 8; + l.white = 255; + l.storage = DngStorage::LossyJpegTiles { width: 16, height: 8 }; + l.active_area = Some([1, 2, 11, 22]); + out.push(l.build()); + let rgb8: Vec = scene(w, h).iter().flat_map(|p| p.map(|v| (v * 255.0) as u16)).collect(); + let mut l = DngSpec::cfa(w, h, rgb8); + l.samples = 3; + l.bits = 8; + l.white = 255; + l.linearization = Some((0..256u32).map(|v| (v * v / 4) as u16).collect()); + l.storage = DngStorage::LossyJpegStrips { rows: 5 }; + out.push(l.build()); let mut data = vec![600u16; 40 * 10]; for (i, v) in data.iter_mut().enumerate() { *v = 600 + (i as u16 * 7) % 3000; diff --git a/crates/raw/tests/nef_packed.rs b/crates/raw/tests/nef_packed.rs new file mode 100644 index 0000000000..f050681857 --- /dev/null +++ b/crates/raw/tests/nef_packed.rs @@ -0,0 +1,86 @@ +use photocraft_raw::testgen::{Val, nef_compressed}; +use photocraft_raw::{Limits, RawError}; + +fn packed(values: &[u16], w: usize, bits: u32, padded: bool) -> Vec { + let mut out = Vec::new(); + for row in values.chunks_exact(w) { + let start = out.len(); + let (mut acc, mut have) = (0u32, 0u32); + for v in row { + acc |= u32::from(*v) << have; + have += bits; + while have >= 8 { + out.push(acc as u8); + acc >>= 8; + have -= 8; + } + } + if have > 0 { + out.push(acc as u8); + } + if padded { + out.resize(start + (out.len() - start).div_ceil(16) * 16, 0xff); + } + } + out +} + +#[test] +fn packed_nef_uses_maker_mode_not_the_shared_tiff_compression() { + let (w, h) = (22, 5); + for (bits, mode) in [(12u32, 6u16), (14, 10)] { + let values: Vec = (0..w * h).map(|i| ((i * 83 + 11) % (1 << bits)) as u16).collect(); + for padded in [false, true] { + for big_note in [false, true] { + let strip = packed(&values, w, bits, padded); + for modern in [false, true] { + let tag = if modern { + let mut record = b"01000400\0\0".to_vec(); + record.extend(if big_note { mode.to_be_bytes() } else { mode.to_le_bytes() }); + (0x51, Val::Undefined(record)) + } else { + (0x93, Val::Short(vec![mode])) + }; + let bytes = nef_compressed(w, h, bits as u16, strip.clone(), [0, 1, 1, 2], big_note, vec![tag]); + let s = photocraft_raw::decode(&bytes, &Limits::default()).unwrap(); + assert_eq!(s.data, values); + } + } + } + } +} + +#[test] +fn packed_nef_rejects_truncation_wrong_depth_and_unknown_modes() { + let strip = packed(&[1000; 18 * 5], 18, 14, true); + for size in 0..strip.len() { + let bytes = nef_compressed(18, 5, 14, strip[..size].to_vec(), [0, 1, 1, 2], false, vec![(0x93, Val::Short(vec![10]))]); + // The unpadded size is another valid layout; all other truncations fail. + if size != (18 * 14usize).div_ceil(8) * 5 { + assert!(photocraft_raw::decode(&bytes, &Limits::default()).is_err()); + } + } + let bytes = nef_compressed(18, 5, 12, strip.clone(), [0, 1, 1, 2], false, vec![(0x93, Val::Short(vec![10]))]); + assert!(matches!(photocraft_raw::decode(&bytes, &Limits::default()), Err(RawError::Unsupported(_)))); + for mode in [3, 4, 9, 13, 14, 65535] { + let bytes = nef_compressed(18, 5, 14, strip.clone(), [0, 1, 1, 2], false, vec![(0x93, Val::Short(vec![mode]))]); + assert!(matches!(photocraft_raw::decode(&bytes, &Limits::default()), Err(RawError::Unsupported(_)))); + } +} + +#[test] +fn nikon_crop_area_uses_sensor_coordinates_and_ignores_invalid_rectangles() { + let strip = packed(&[1000; 22 * 10], 22, 14, true); + for (rect, expected) in [ + ([2, 2, 18, 6], photocraft_raw::Rect::new(2, 2, 18, 6)), + ([2, 2, 22, 10], photocraft_raw::Rect::new(0, 0, 22, 10)), + ([2, 2, 0, 6], photocraft_raw::Rect::new(0, 0, 22, 10)), + ([u32::MAX, 2, 18, 6], photocraft_raw::Rect::new(0, 0, 22, 10)), + ] { + let bytes = nef_compressed(22, 10, 14, strip.clone(), [0, 1, 1, 2], false, vec![(0x93, Val::Short(vec![10])), (0x45, Val::Long(rect.to_vec()))]); + let sensor = photocraft_raw::decode(&bytes, &Limits::default()).unwrap(); + assert_eq!(sensor.crop, expected); + let developed = photocraft_raw::develop_sensor(&sensor, &Default::default()).unwrap(); + assert_eq!((developed.width as usize, developed.height as usize), (expected.width, expected.height)); + } +} diff --git a/crates/raw/tests/raw_corpus.rs b/crates/raw/tests/raw_corpus.rs index d90e06a6c1..afd168a38a 100644 --- a/crates/raw/tests/raw_corpus.rs +++ b/crates/raw/tests/raw_corpus.rs @@ -144,6 +144,41 @@ fn developing_produces_the_crop_in_16bit_rgb() { } } +/// These paired exposures used to decode below their recorded black levels because the +/// version 0x44 0x40 linearization curve was interpolated across the sensor's full bit range. +#[test] +fn modern_lossy_nef_preserves_brightness_at_both_depths() { + for (file, black, white) in [("DSC_0751.NEF", 1008.0, 16383.0), ("DSC_0754.NEF", 252.0, 4095.0)] { + let bytes = std::fs::read(pixls(file)).unwrap(); + let s = photocraft_raw::decode(&bytes, &Limits::default()).unwrap(); + assert_eq!(s.model.as_deref(), Some("NIKON Z 6")); + assert_eq!((s.width, s.height), (6064, 4040)); + assert_eq!(s.black.values, vec![black; 4]); + assert!(s.white.iter().all(|v| (*v - white).abs() < 100.0)); + // The same scene spans deep shadows and bright highlights at either sensor depth. + let max = s.data.iter().copied().max().unwrap(); + assert!(f32::from(max) > white * 0.9, "{file}: maximum {max}"); + let mean = s.data.iter().map(|v| f64::from(*v)).sum::() / s.data.len() as f64; + assert!(mean > f64::from(black) * 1.5, "{file}: mean {mean}"); + let d = photocraft_raw::develop_sensor(&s, &photocraft_raw::DevelopOptions::default()).unwrap(); + assert_eq!((d.width, d.height), (6048, 4024)); + assert!(d.rgb.iter().copied().max().unwrap() > 32768, "{file}: black development"); + } +} + +#[test] +fn packed_nef_opens_full_sensor_and_metadata_crop_at_both_depths() { + for (file, first) in [("DSC_0750.NEF", [3539, 3761, 3640, 3904]), ("DSC_0753.NEF", [866, 932, 881, 948])] { + let bytes = std::fs::read(pixls(file)).unwrap(); + let s = photocraft_raw::decode(&bytes, &Limits::default()).unwrap(); + assert_eq!((s.width, s.height), (6064, 4040)); + assert_eq!(&s.data[..4], &first); + assert_eq!(s.crop, photocraft_raw::Rect::new(8, 8, 6048, 4024)); + let d = photocraft_raw::develop_sensor(&s, &Default::default()).unwrap(); + assert_eq!((d.width, d.height), (6048, 4024)); + } +} + /// The E-5 file is the documented known-unsupported case: later Olympus bodies store packed /// (compressed) sensor data. The error must say so, never panic, and the file still previews. #[test] diff --git a/crates/ui-egui/src/adjust_dialog.rs b/crates/ui-egui/src/adjust_dialog.rs index eb8f89a685..be9acde2e8 100644 --- a/crates/ui-egui/src/adjust_dialog.rs +++ b/crates/ui-egui/src/adjust_dialog.rs @@ -146,7 +146,7 @@ pub fn reset(app: &PhotocraftApp, ctx: &egui::Context, fields: &mut Map Option { let kind = command.strip_prefix(PREFIX).filter(|k| adjust_editors::has_editor(k))?; let spec = photocraft_engine::commands::find(command)?; - let mode = app.session.active().map_or(photocraft_doc::ColorMode::Rgb, |s| s.doc.mode); + let mode = curve_mode(app, kind); let defaults = photocraft_engine::adjust_params::default_for(kind, mode).ok()?; let mut fields = Map::new(); fields.insert("__command".into(), json!(command)); @@ -184,19 +184,45 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { Some(app.ui.open_dialog(crate::state::DialogKind::Command, fields)) } +/// The Curves dialog's single-channel target: the Channels panel's, plus the Layers panel's mask +/// selection — both ways of selecting a mask must give the same dropdown (#5335). +fn isolated(app: &PhotocraftApp) -> Option { + let mask = app.layer_mask_targeted(); + app.session.active().and_then(|st| photocraft_engine::channel_cmds::isolated_curve_channel_for(st, mask)) +} + +/// The colour mode the Levels/Curves dialog seeds and parses in: a mask, saved channel or +/// Quick Mask holds gray levels whatever the document's mode, so its curve is a neutral one +/// and the fields carry no Lab or CMYK records (#5335). +fn curve_mode(app: &PhotocraftApp, kind: &str) -> photocraft_doc::ColorMode { + let mode = app.session.active().map_or(photocraft_doc::ColorMode::Rgb, |s| s.doc.mode); + if kind == "curves" && isolated(app).is_some_and(|c| c.gray.is_some()) { photocraft_doc::ColorMode::Grayscale } else { mode } +} + /// Dialog body: the kind's editor over the fields, then the Preview checkbox. pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map) { let kind = fields.get("__adjust").and_then(Value::as_str).unwrap_or_default().to_string(); - let mode = app.session.active().map_or(photocraft_doc::ColorMode::Rgb, |s| s.doc.mode); + let mode = curve_mode(app, &kind); if matches!(kind.as_str(), "levels" | "curves") { picker_controls(ui, fields, mode); ui.add_space(4.0); } let mut values = crate::filter_dialog::params_of(fields); let active = app.session.active().and_then(|s| s.active_layer); - let gray = app.session.active().is_some_and(|s| adjust_editors::is_gray(s.doc.mode)); + let isolated_curve = isolated(app); + // Only Curves gets an isolated single-channel target here; Levels keeps its usual fields. + let gray_plane = (kind == "curves").then(|| isolated_curve.as_ref().and_then(|c| c.gray)).flatten(); + let gray = gray_plane.is_some() || app.session.active().is_some_and(|s| adjust_editors::is_gray(s.doc.mode)); let hist = match active { - Some(id) if adjust_editors::needs_histogram(&kind) => Some(tone::histograms(app, HistSource::Layer(id), adjust_editors::space_of(&values))), + Some(id) if adjust_editors::needs_histogram(&kind) => { + // A mask or saved channel's curve edits that plane's own levels: the histogram + // describes it, not the colour layers beneath (#5335). + let source = match gray_plane { + Some(surface) => HistSource::Channel { layer: id, surface }, + None => HistSource::Layer(id), + }; + Some(tone::histograms(app, source, adjust_editors::space_of(&values))) + } _ => None, }; let cx = EditorCx { @@ -207,6 +233,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + open(h.state_mut(), "image.adjustments.curves").unwrap(); + h.run_steps(4); + h + } + + /// The Channel popup's selected value (its option labels are what it shows). + fn popup_channels(h: &mut Harness<'static, PhotocraftApp>) -> Vec { + h.query_all_by_role(egui::accesskit::Role::ComboBox).filter_map(|n| n.value().map(|s| s.to_string())).collect() + } } diff --git a/crates/ui-egui/src/adjust_editors.rs b/crates/ui-egui/src/adjust_editors.rs index 2678cc65f2..87a81b8c35 100644 --- a/crates/ui-egui/src/adjust_editors.rs +++ b/crates/ui-egui/src/adjust_editors.rs @@ -92,6 +92,8 @@ pub struct EditorCx { /// Preferences ▸ Interface ▸ Dynamic Color Sliders: colour tracks on the colour sliders /// (off: the plain track). pub dynamic_color_sliders: bool, + /// Image › Curves with a single Channels-panel target: that channel only (#5335). + pub isolated_curve: Option, } /// The Levels/Curves channel space the values address. @@ -291,14 +293,26 @@ pub fn tone_channels(space: ToneSpace, gray: bool) -> &'static [ToneChannel] { } fn channel_picker(ui: &mut egui::Ui, id: egui::Id, chans: &[ToneChannel], text: &str) -> usize { + let opts: Vec<(usize, &str)> = chans.iter().enumerate().map(|(i, c)| (i, c.label)).collect(); + channel_picker_opts(ui, id, &opts, text, true) +} + +/// The popup, with `translate` for whether the built-in labels follow the interface language; +/// a user-typed channel name is shown exactly as entered (#5335). +fn channel_picker_opts(ui: &mut egui::Ui, id: egui::Id, opts: &[(usize, &str)], text: &str, translate: bool) -> usize { let mut ch: usize = ui.data(|d| d.get_temp(id)).unwrap_or(0); - if ch >= chans.len() { + if ch >= opts.len() { ch = 0; } ui.horizontal(|ui| { label(ui, text); - let opts: Vec<(usize, &str)> = chans.iter().enumerate().map(|(i, c)| (i, c.label)).collect(); - widgets::dropdown(ui, &format!("{id:?}-ch"), &mut ch, &opts, 110.0); + let width = if opts.len() == 1 { 160.0 } else { 110.0 }; + let key = format!("{id:?}-ch"); + if translate { + widgets::dropdown(ui, &key, &mut ch, opts, width); + } else { + widgets::dropdown_literal(ui, &key, &mut ch, opts, width); + } }); ui.data_mut(|d| d.insert_temp(id, ch)); ch @@ -428,11 +442,20 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let chans = tone_channels(space, cx.gray); let state_id = cx.mem.with("curves"); let mut st: CurveUi = ui.data(|d| d.get_temp(state_id)).unwrap_or_default(); - let ch = channel_picker(ui, cx.mem.with("curves-ch"), chans, tl!("Channel:")); + let isolated = cx.isolated_curve.as_ref(); + let ch = if let Some(iso) = isolated { + channel_picker_opts(ui, cx.mem.with("curves-ch"), &[(0, iso.name.as_str())], tl!("Channel:"), iso.translate) + } else { + channel_picker(ui, cx.mem.with("curves-ch"), chans, tl!("Channel:")) + }; if ch != st.channel { st = CurveUi { channel: ch, ..Default::default() }; } - let chan = chans.get(ch).copied().unwrap_or(ToneChannel { key: "", label: "" }); + let chan = + if let Some(iso) = isolated { ToneChannel { key: iso.key, label: "" } } else { chans.get(ch).copied().unwrap_or(ToneChannel { key: "", label: "" }) }; + let overlay_others = isolated.is_none(); + let tinted = isolated.is_some_and(|c| c.tint) || (isolated.is_none() && ch != 0); + let hist_i = isolated.map_or(ch, |c| c.hist); let key = curve_key(&chan); let mut e = curves_mode_row(ui, v, key, cx.gray); let pencil = v.get("pencil").and_then(Value::as_bool).unwrap_or(false); @@ -477,7 +500,7 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let p = ui.painter_at(full.expand(2.0)); p.rect_filled(graph, 0.0, t.field); if let Some(h) = &cx.hist { - tone::draw_histogram(&p, graph, &h[ch], Color32::from_gray(if t.pro { 88 } else { 190 }).gamma_multiply(0.75)); + tone::draw_histogram(&p, graph, &h[hist_i], Color32::from_gray(if t.pro { 88 } else { 190 }).gamma_multiply(0.75)); } for i in 1..4 { let f = i as f32 / 4.0; @@ -510,7 +533,7 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let line: Vec = map.iter().enumerate().map(|(k, o)| to_scr([k as f32 / last * 255.0, *o])).collect(); p.add(egui::Shape::line(line, Stroke::new(width, color))); }; - if ch == 0 { + if overlay_others && ch == 0 { // Photoshop overlays the edited channel curves on the composite. for c in chans.iter().skip(1) { if pencil { @@ -526,7 +549,7 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { } } } - let color = if ch == 0 { t.text } else { bar_color(&chan, &t) }; + let color = if tinted { bar_color(&chan, &t) } else { t.text }; if pencil { draw_map(&map, color, 1.5); } else { @@ -1623,6 +1646,7 @@ pub fn layer_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj gradients: gradient_presets(app, kind), dialog: false, dynamic_color_sliders: app.session.prefs().interface.dynamic_color_sliders, + isolated_curve: None, }; if matches!(kind, "levels" | "curves") { crate::layer_picker::controls(app, ui, id); @@ -1676,6 +1700,7 @@ mod tests { gradients: Vec::new(), dialog: false, dynamic_color_sliders: dynamic, + isolated_curve: None, }; let stops = [Color32::BLACK, Color32::RED]; assert_eq!(track_stops(&cx(true), &stops), Some(&stops[..])); diff --git a/crates/ui-egui/src/adjust_editors_tests.rs b/crates/ui-egui/src/adjust_editors_tests.rs index 9c39088bef..d8c6e7953a 100644 --- a/crates/ui-egui/src/adjust_editors_tests.rs +++ b/crates/ui-egui/src/adjust_editors_tests.rs @@ -566,6 +566,7 @@ fn gradient_map_stop_controls_fit_a_narrow_properties_panel_in_every_theme() { gradients: Vec::new(), dialog: false, dynamic_color_sliders: true, + isolated_curve: None, }; editor(ui, "gradientMap", values, &cx); }, @@ -774,6 +775,7 @@ fn editor_harness(kind: &'static str, params: serde_json::Value) -> Harness<'sta gradients: Vec::new(), dialog: true, dynamic_color_sliders: true, + isolated_curve: None, }; editor(ui, kind, values, &cx); }, diff --git a/crates/ui-egui/src/anchor_tools.rs b/crates/ui-egui/src/anchor_tools.rs index 9dc8b6cfe5..e6f9ac72d3 100644 --- a/crates/ui-egui/src/anchor_tools.rs +++ b/crates/ui-egui/src/anchor_tools.rs @@ -7,9 +7,10 @@ //! * Delete Anchor Point: hovering an anchor highlights it; a click removes it //! (`path.deleteAnchor`), joining its neighbours. //! * ⌥ swaps the two tools and ⌘/Ctrl is Direct Selection (`direct_select`) while held. -//! * The Pen does both with Auto Add/Delete on (its options bar, on by default, #4144), on the -//! active path only and while no path is being drawn: a click on a segment adds, a click on an -//! anchor deletes, except an open subpath's end points; ⇧ turns it off while held. +//! * The Pen does both with Auto Add/Delete on (its options bar, on by default, #4144): a click on +//! a segment adds, a click on an anchor deletes, except an open subpath's end points; ⇧ turns it +//! off while held. It edits the active path, or while a path is being drawn that path (#6140), +//! and drawing carries on from its last anchor. //! //! Each click is one command and one history step; a click on nothing it can edit does nothing. @@ -48,16 +49,26 @@ fn target(app: &PhotocraftApp, tool: Tool, p: [f64; 2]) -> Option<(PathRef, Path /// The Pen's Auto Add/Delete (#4144): what a Pen click at `p` does to the active path instead of /// starting a new one, as the anchor tool that does it (Add Anchor Point on a segment, Delete -/// Anchor Point on an anchor), the path and the hit. `None` when the option is off, ⇧, ⌥ or ⌘ is -/// held, a path is being drawn, or the click hits nothing it edits. The end points of an open -/// subpath are left to the Pen, as in Photoshop. +/// Anchor Point on an anchor), the path and the hit. While a path is being drawn it edits that +/// path only (#6140): a click anywhere else places the next anchor. `None` when the option is off, +/// ⇧, ⌥ or ⌘ is held, an anchor's handles are being pulled out, or the click hits nothing it edits. +/// The end points of an open subpath are left to the Pen, as in Photoshop: on the path being +/// drawn, the first closes it and the last breaks its handle. pub fn pen_auto(app: &PhotocraftApp, tool: Tool, mods: Modifiers, p: [f64; 2]) -> Option<(Tool, PathRef, Path, Hit)> { - if tool != Tool::Pen || !app.ui.tool_options.pen_auto_add_delete || app.ui.pen.is_some() || mods.shift || mods.alt || mods.command { + if tool != Tool::Pen || !app.ui.tool_options.pen_auto_add_delete || mods.shift || mods.alt || mods.command { return None; } let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); - let (r, path, hit) = - crate::direct_select::active_paths(app).into_iter().find_map(|(r, path)| edit::hit(&path, Point::new(p[0], p[1]), tol, &[]).map(|h| (r, path, h)))?; + let at = Point::new(p[0], p[1]); + let (r, path, hit) = match app.ui.pen.as_ref() { + Some(pen) if pen.dragging || pen.curvature => return None, + Some(pen) => { + let path = crate::vector_ui::pen_path(pen); + let hit = edit::hit(&path, at, tol, &[])?; + (PathRef::Pen, path, hit) + } + None => crate::direct_select::active_paths(app).into_iter().find_map(|(r, path)| edit::hit(&path, at, tol, &[]).map(|h| (r, path, h)))?, + }; let tool = match hit { Hit::Segment(..) => Tool::AddAnchorPoint, Hit::Anchor([s, k]) => { @@ -76,10 +87,14 @@ pub fn pen_auto(app: &PhotocraftApp, tool: Tool, mods: Modifiers, p: [f64; 2]) - /// when they aren't theirs. pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool { if let ToolEvent::Down { x, y, .. } = ev - && let Some((_, r, _, hit)) = pen_auto(app, app.ui.tool, mods, [x, y]) + && let Some((_, r, path, hit)) = pen_auto(app, app.ui.tool, mods, [x, y]) { - // The release finds no Pen path to finish, so the Pen draws nothing. - apply(app, r, hit); + // The release finds no Pen drag to finish, so the Pen draws nothing. + if r == PathRef::Pen { + edit_pen(app, path, hit); + } else { + apply(app, r, hit); + } return true; } let Some(tool) = effective(app.ui.tool, mods) else { return false }; @@ -90,23 +105,28 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool } fn click(app: &mut PhotocraftApp, tool: Tool, p: [f64; 2]) { - let Some((r, mut path, hit)) = target(app, tool, p) else { return }; + let Some((r, path, hit)) = target(app, tool, p) else { return }; if r == PathRef::Pen { - // The Pen path being drawn isn't in the document yet: it takes the edit directly. - let edited = match hit { - Hit::Segment(k, t) => edit::add_anchor(&mut path, k, t).is_ok(), - Hit::Anchor(k) => edit::delete_anchor(&mut path, k).is_ok(), - Hit::Handle(..) => false, - }; - if edited { - app.ui.direct_selection = DirectSelection { target: Some(r), ..Default::default() }; - crate::vector_ui::pen_replace(app, &path); - } + edit_pen(app, path, hit); return; } apply(app, r, hit); } +/// Add or delete an anchor of the Pen path being drawn (`path`, from `pen_path`). It isn't in the +/// document yet, so it takes the edit directly; drawing carries on from its last anchor. +fn edit_pen(app: &mut PhotocraftApp, mut path: Path, hit: Hit) { + let edited = match hit { + Hit::Segment(k, t) => edit::add_anchor(&mut path, k, t).is_ok(), + Hit::Anchor(k) => edit::delete_anchor(&mut path, k).is_ok(), + Hit::Handle(..) => false, + }; + if edited { + app.ui.direct_selection = DirectSelection { target: Some(PathRef::Pen), ..Default::default() }; + crate::vector_ui::pen_replace(app, &path); + } +} + /// Add an anchor where `hit` is on a segment of `r`, or delete the anchor it is on: one command. fn apply(app: &mut PhotocraftApp, r: PathRef, hit: Hit) { let (id, mut params) = match hit { diff --git a/crates/ui-egui/src/anchor_tools_tests.rs b/crates/ui-egui/src/anchor_tools_tests.rs index 87c045eba9..c21cc5aaf6 100644 --- a/crates/ui-egui/src/anchor_tools_tests.rs +++ b/crates/ui-egui/src/anchor_tools_tests.rs @@ -230,3 +230,67 @@ fn the_pen_cursor_shows_what_auto_add_delete_would_do() { app.ui.tool_options.pen_auto_add_delete = false; assert_eq!(badge(&app, Tool::Pen, Modifiers::NONE), None); } + +fn pen_anchors(app: &PhotocraftApp) -> Vec<[f64; 2]> { + app.ui.pen.as_ref().map(|p| p.knots.iter().map(|k| k[0]).collect()).unwrap_or_default() +} + +/// #6140: while drawing, a Pen click on the path being drawn adds an anchor on a segment or +/// deletes a middle anchor, and drawing carries on from the last anchor. Nothing reaches History. +#[test] +fn the_pen_adds_and_deletes_anchors_on_the_path_being_drawn() { + let mut app = app(Tool::Pen); + let before = steps(&app); + for at in [[40.0, 40.0], [120.0, 40.0], [120.0, 120.0]] { + click(&mut app, at, Modifiers::NONE); + } + click(&mut app, [80.0, 40.0], Modifiers::NONE); + assert_eq!(pen_anchors(&app), vec![[40.0, 40.0], [80.0, 40.0], [120.0, 40.0], [120.0, 120.0]], "added on the first segment"); + click(&mut app, [40.0, 120.0], Modifiers::NONE); + assert_eq!(pen_anchors(&app).last(), Some(&[40.0, 120.0]), "the next click carries on from the last anchor"); + assert_eq!(pen_anchors(&app).len(), 5); + + click(&mut app, [120.0, 40.0], Modifiers::NONE); + assert_eq!(pen_anchors(&app), vec![[40.0, 40.0], [80.0, 40.0], [120.0, 120.0], [40.0, 120.0]], "a middle anchor is deleted"); + assert_eq!(steps(&app), before, "the path being drawn is not in the document yet"); +} + +/// The path being drawn keeps its end points (the first closes it, the last breaks its handle), ⇧ +/// or the option turned off places an anchor on its segment, and another path takes no edit. +#[test] +fn the_pen_draws_on_the_path_being_drawn_where_auto_add_delete_does_not_apply() { + let mut app = app(Tool::Pen); + with_path(&mut app); + let work_before = work(&app); + for at in [[20.0, 160.0], [180.0, 160.0], [180.0, 180.0]] { + click(&mut app, at, Modifiers::NONE); + } + click(&mut app, [180.0, 180.0], Modifiers::NONE); + assert_eq!(pen_anchors(&app).len(), 3, "a click on the last anchor breaks its handle"); + click(&mut app, [100.0, 160.0], Modifiers::SHIFT); + assert_eq!(pen_anchors(&app).len(), 4, "⇧ places an anchor"); + app.ui.tool_options.pen_auto_add_delete = false; + click(&mut app, [100.0, 160.0], Modifiers::NONE); + assert_eq!(pen_anchors(&app).len(), 5, "with the option off the Pen places an anchor"); + app.ui.tool_options.pen_auto_add_delete = true; + click(&mut app, [80.0, 120.0], Modifiers::NONE); + assert_eq!((pen_anchors(&app).len(), work(&app)), (6, work_before), "another path takes no edit while drawing"); + click(&mut app, [20.0, 160.0], Modifiers::NONE); + assert!(app.ui.pen.is_none(), "the first anchor closes the path"); +} + +#[test] +fn the_pen_cursor_shows_auto_add_delete_on_the_path_being_drawn() { + use crate::tool_feedback::{Badge, badge}; + let mut app = app(Tool::Pen); + for at in [[40.0, 40.0], [120.0, 40.0], [120.0, 120.0]] { + click(&mut app, at, Modifiers::NONE); + } + let cases = [([80.0, 40.0], Some(Badge::Add)), ([120.0, 40.0], Some(Badge::Subtract)), ([40.0, 40.0], None), ([120.0, 120.0], None), ([60.0, 100.0], None)]; + for (at, want) in cases { + app.hover_doc = Some(at); + assert_eq!(badge(&app, Tool::Pen, Modifiers::NONE), want, "{at:?}"); + } + app.hover_doc = Some([80.0, 40.0]); + assert_eq!(badge(&app, Tool::Pen, Modifiers::SHIFT), None); +} diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index cffbb5b534..670f442be2 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -399,6 +399,21 @@ fn brush_mode_dropdown_shows_outside_the_pro_themes() { assert_eq!(j[n].0, "tools.setBrush"); } +#[test] +fn brush_and_eraser_size_fields_keep_sizes_above_2500_px() { + // #5708: outside the Pro themes the Size field capped at 2500 px and clamped a bigger brush + // (set from the picker or `]`) on the next frame; Photoshop and the engine allow 5000 px. + for tool in [crate::state::Tool::Brush, crate::state::Tool::Eraser] { + let mut h = options_bar_harness(tool); + let n = h.state().session.journal.len(); + h.state_mut().session.tools.brush.size = 4000.0; + h.run_steps(4); + assert_eq!(h.state().session.tools.brush.size, 4000.0, "{tool:?}"); + let j = &h.state().session.journal; + assert_eq!(j.len(), n, "showing the bar issues nothing: {:?}", &j[n..]); + } +} + #[test] fn airbrush_smoothing_options_and_symmetry_buttons_work() { // #1177: in the default (Photoshop) theme these options-bar buttons did nothing. diff --git a/crates/ui-egui/src/brushes_tab.rs b/crates/ui-egui/src/brushes_tab.rs index 709c91c151..3f45526e52 100644 --- a/crates/ui-egui/src/brushes_tab.rs +++ b/crates/ui-egui/src/brushes_tab.rs @@ -885,6 +885,15 @@ pub fn preset_list(ui: &mut egui::Ui, presets: &[BrushPreset], current: Option<& let show = (st.show_name, st.show_stroke, st.show_tip); let collapsed = &st.collapsed; egui::ScrollArea::vertical().id_salt(layout.id).max_height(layout.max_height).auto_shrink([false, true]).show(ui, |ui| { + // A preset, group or folder dragged to the list's top or bottom edge (or past it) scrolls + // the list, as the Layers panel's rows do, so it can be dropped on rows out of view (#5741). + let dragging = egui::DragAndDrop::has_payload_of_type::(ui.ctx()) && ui.input(|i| i.pointer.primary_down()); + let pointer = ui.ctx().pointer_interact_pos(); + let delta = crate::panels::layer_drag_edge_scroll(pointer, ui.clip_rect(), dragging, ui.input(|i| i.stable_dt)); + if delta != 0.0 { + ui.scroll_with_delta(vec2(0.0, delta)); + ui.ctx().request_repaint(); + } ui.spacing_mut().item_spacing.y = 1.0; for (label, items) in grouped_presets(presets) { let items: Vec<&BrushPreset> = diff --git a/crates/ui-egui/src/brushes_tab_tests.rs b/crates/ui-egui/src/brushes_tab_tests.rs index 4672c062b4..0d0a8b790f 100644 --- a/crates/ui-egui/src/brushes_tab_tests.rs +++ b/crates/ui-egui/src/brushes_tab_tests.rs @@ -51,6 +51,43 @@ fn dragging_a_brush_shows_its_tip_preview() { } } +/// #5741: a brush dragged to the list's bottom edge (or below it) scrolls the list, so it can be +/// dropped on rows out of view; the list used to stay put, and the drag stopped at the last +/// visible row. +#[test] +fn dragging_a_brush_to_the_list_edge_scrolls_the_list() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.ui.panels.brush_settings = true; + app.ui.brush_tab = 1; + app.ui.brushes_panel.view = BrushesView::List; + app.ui.brushes_panel.collapsed.clear(); + let name = app.session.tools.presets[0].name.clone(); + let (w, h_px) = (1200.0, 520.0); + let mut h = Harness::builder().with_size(vec2(w, h_px)).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, crate::theme::ThemeKind::ALL[0]); + app + }); + h.run_steps(4); + let start = h.get_by_role_and_label(Role::Button, &name).rect(); + h.hover_at(start.center()); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: start.center(), button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerMoved(start.center() + vec2(0.0, 12.0))); + h.run_steps(2); + assert!(egui::DragAndDrop::has_payload_of_type::(&h.ctx), "the brush is being dragged"); + // Held at the bottom of the window, below the list's last visible row. + let bottom = egui::pos2(start.center().x, h_px - 2.0); + for _ in 0..12 { + h.event(egui::Event::PointerMoved(bottom)); + h.run_steps(1); + } + let moved = h.query_by_role_and_label(Role::Button, &name).map(|n| n.rect()); + assert!(moved.is_none_or(|r| r.top() < start.top() - 1.0), "the list scrolled down: {start:?} → {moved:?}"); + h.event(egui::Event::PointerButton { pos: bottom, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); +} + #[test] fn grid_columns_centre_whole_columns_past_the_indent() { // 6 × 44 + 5 × 3 = 279 of the 290 past a 4 pt indent: 5 spare on each side (rounded down). diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7a9592b692..391ad464fd 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -244,7 +244,10 @@ pub(crate) fn marquee_preview_px(o: &crate::state::ToolOptions, d: &Drag, zoom: } let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]); let (a, b) = marquee_corners(o, d, last); - let r = marquee_px(a, b); + let mut r = marquee_px(a, b); + if marquee_is_square(o, d) { + r = square_px(r, d.start, last); + } (r[2] > r[0] && r[3] > r[1]).then_some(r) } @@ -253,6 +256,37 @@ pub fn marquee_px(a: [f64; 2], b: [f64; 2]) -> [f64; 4] { [a[0].min(b[0]).floor(), a[1].min(b[1]).floor(), a[0].max(b[0]).ceil(), a[1].max(b[1]).ceil()] } +/// Whether the marquee drag `d` is constrained to a square (or circle): ⇧ during the drag with +/// the Normal style, or a Fixed Ratio of 1:1. Mirrors the branches of `chrome_ui::marquee_end`. +fn marquee_is_square(o: &crate::state::ToolOptions, d: &Drag) -> bool { + let (w, h) = (f64::from(o.marquee_width), f64::from(o.marquee_height)); + match o.marquee_style.as_str() { + "fixedSize" => false, + "fixedRatio" if w > 0.0 && h > 0.0 => w == h, + _ => d.marquee_mods().0, + } +} + +/// A constrained square `r` (`marquee_px` of its corners) with equal sides on whole pixels. +/// +/// The square is built in continuous coordinates, and snapping each axis to the pixel grid on +/// its own (floor the near edge, ceil the far one) made one side a pixel longer than the other +/// whenever the press point sat between pixels: a ⇧ drag read 101 × 102 (#5472). The shorter +/// side grows to the longer one, away from the press point (`start`, towards `end`), so the +/// square still covers every pixel the drag touched. +pub fn square_px(r: [f64; 4], start: [f64; 2], end: [f64; 2]) -> [f64; 4] { + let [x0, y0, x1, y1] = r; + let (w, h) = (x1 - x0, y1 - y0); + let n = w.max(h); + let grow = |lo: f64, hi: f64, extent: f64, from: f64, to: f64| { + let d = (n - extent).max(0.0); + if to < from { (lo - d, hi) } else { (lo, hi + d) } + }; + let (x0, x1) = grow(x0, x1, w, start[0], end[0]); + let (y0, y1) = grow(y0, y1, h, start[1], end[1]); + [x0, y0, x1, y1] +} + /// The one-pixel line `[x0, y0, x1, y1]` a Single Row (Single Column) Marquee at `p` selects: the /// pixel row (column) under `p`, kept on the canvas, across the whole `size`. `None` for other /// tools, an empty canvas or a point that is not a number. @@ -1642,7 +1676,7 @@ fn gpu_budget(app: &mut PhotocraftApp, gpu: &crate::gpu_canvas::GpuCanvas, idx: return; } let Some(st) = app.session.documents().get(idx) else { return }; - let pixels = st.history.pixel_bytes(&st.doc) as u64; + let pixels = st.memory_bytes() as u64; let budget = crate::gpu_canvas::memory_budget(allowance, pixels, crate::gpu_canvas::physical_memory()); gpu.set_memory_budget(budget); app.perf.gpu_budget_mb = budget >> 20; @@ -1699,15 +1733,29 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e #[cfg(not(target_arch = "wasm32"))] if app.background_jobs { app.filter_preview_worker.supersede(&request, crate::gpu_canvas::now_ms()); - if let Some((finished, computed)) = app.filter_preview_worker.poll() - && finished == request - { + if let Some((finished, computed)) = app.filter_preview_worker.poll() { match computed { - Ok(computed) => publish_filter_preview(app, idx, finished, computed.result, computed.ms), - Err(error) => { + // The dialog still holds these exact parameters. Publish as before, including an + // empty result: that is how a filter which no longer applies clears the canvas. + Ok(computed) if finished == request => publish_filter_preview(app, idx, finished, computed.result, computed.ms), + // Mid-drag, a frame for the value the pointer has just left is what makes the + // canvas follow the drag. Three things have to hold for it: it belongs to this + // preview, it has a frame to show (an empty one would blank a good preview), and + // it must not displace a frame that is already exact for the dialog, which is + // what a slider dragged out and back would otherwise make the canvas do. + Ok(computed) + if finished.same_target(&request) && computed.result.is_some() && !app.filter_preview.as_ref().is_some_and(|p| p.key == request) => + { + publish_filter_preview(app, idx, finished, computed.result, computed.ms); + } + Ok(_) => {} + // Errors stay on the exact request: a value the pointer has already dragged past + // must not raise a notice, and a still one will raise it when it is recomputed. + Err(error) if finished == request => { app.filter_preview = Some(crate::filter_dialog::FilterPreview { key: finished, result: None, committing: false }); crate::notices::error(app, error); } + Err(_) => {} } } let fresh = app.filter_preview.as_ref().is_some_and(|p| p.key == request); @@ -1742,15 +1790,7 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e } let preview = app.filter_preview.as_ref()?; // Keep the last preview during a drag, but never one from another source or dialog. - // Completed worker results above must match every parameter before being uploaded. - if preview.key.doc != request.doc - || preview.key.revision != request.revision - || preview.key.dialog != request.dialog - || preview.key.active != request.active - || preview.key.target != request.target - || preview.key.k != request.k - || preview.key.command != request.command - { + if !preview.key.same_target(&request) { return None; } let result = preview.result.as_ref()?; @@ -1884,14 +1924,22 @@ fn ensure_adjust_proxy(app: &mut PhotocraftApp, idx: usize, zoom: f32) -> Option /// 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, dimensions) when the proxy should be drawn. -fn ensure_proxy_preview(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u64, [u32; 2])> { +/// `zoom` is the view's (physical screen pixels per document pixel). +fn ensure_proxy_preview(app: &mut PhotocraftApp, idx: usize, zoom: f32) -> Option<(u32, u64, [u32; 2])> { 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()) }; // Low-resolution previews off: k is 1 and the canvas shows `display_doc` (full size). - let k = crate::proxy::preview_factor(&doc, crate::proxy::reduced_previews(app)); + let mut k = crate::proxy::preview_factor(&doc, crate::proxy::reduced_previews(app)); + // Zoomed in, a proxy coarser than the screen showed the drag in k-pixel blocks (#5764): when + // the wgpu compositor draws the full-size document, use a proxy only as coarse as the screen + // (none at 50 % and closer). Otherwise the full size would be a CPU composite per change. + let fine = crate::proxy::view_factor(k, zoom); + if fine < k && app.gpu.as_ref().is_some_and(|g| g.supports(&doc)) { + k = fine; + } if k <= 1 { return None; } @@ -1993,7 +2041,9 @@ fn documents(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } if n == 0 && !opening { // Auto show the Home Screen is off: an empty workspace, like Photoshop. - paint_dots(ui, ui.available_rect_before_wrap(), app.session.prefs().interface.show_canvas_dots); + let area = ui.available_rect_before_wrap(); + paint_dots(ui, area, app.session.prefs().interface.show_canvas_dots); + open_on_double_click(app, ui, area); return; } if !app.ui.view.hides_tabs() || opening { @@ -2628,10 +2678,20 @@ pub fn mode_label(doc: &Document) -> &'static str { } } +/// With no document open, double-clicking the empty workspace shows File › Open, as in Photoshop +/// (#5367), and so does the Home screen's blank background. Registered before anything drawn over +/// `area`, so the buttons and links there keep their own clicks; not a keyboard focus stop. +fn open_on_double_click(app: &mut PhotocraftApp, ui: &egui::Ui, area: Rect) { + if app.session.documents().is_empty() && ui.interact(area, ui.id().with("empty-workspace"), Sense::CLICK).double_clicked() { + let _ = app.open_dialog_file(); + } +} + 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, app.session.prefs().interface.show_canvas_dots); + open_on_double_click(app, ui, area); // File › Open Recent, newest first (on the web there are no paths to reopen). let show_recent = app.session.prefs().interface.show_home_recent_files; let recent: Vec = @@ -2910,7 +2970,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if app.gpu.is_some() && gpu_ok && let Some((k, key, preview_size)) = - ensure_adjust_proxy(app, idx, view.zoom).or_else(|| ensure_filter_preview(app, idx, &ctx)).or_else(|| ensure_proxy_preview(app, idx)) + ensure_adjust_proxy(app, idx, view.zoom).or_else(|| ensure_filter_preview(app, idx, &ctx)).or_else(|| ensure_proxy_preview(app, idx, view.zoom)) { on_gpu = true; app.navigator_gpu.remove(&doc.id); @@ -3083,8 +3143,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: (px(y1.ceil()).div_euclid(q) + 2) * q, ) }; + let bounds_key = if k > 1 { u64::MAX / 2 - doc.id.0 } else { u64::MAX - doc.id.0 }; + let carried_bounds = carried.then(|| app.cached_bounds(bounds_key, sel).intersect(&vis)); + let step = carried_bounds.map_or(step, |b| crate::outline::bounded_step(b, step)); let key = crate::surface_fingerprint(sel) - ^ (step as u64) << 56 + ^ u64::from(step).rotate_left(56) ^ doc.id.0.rotate_left(17) ^ (vis.x0 as u64) << 8 ^ (vis.y0 as u64) << 24 @@ -3094,8 +3157,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let fresh = matches!(&app.outline_cache, Some((d, k, _)) if *d == doc.id && *k == key); if !fresh { let t0 = crate::gpu_canvas::now_ms(); - let bounds_key = if k > 1 { u64::MAX / 2 - doc.id.0 } else { u64::MAX - doc.id.0 }; - let b = app.cached_bounds(bounds_key, sel).intersect(&vis); + let b = carried_bounds.unwrap_or_else(|| app.cached_bounds(bounds_key, sel).intersect(&vis)); // Filters can occupy every worker in Rayon's global pool. A parallel scan here would // block the UI thread waiting for those workers, preventing job cancellation (#1554). let mut segs = @@ -3141,12 +3203,14 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let bars_id = egui::Id::new(("pc-canvas-bars-hover", idx)); let over_bars = ctx.data(|d| d.get_temp::(bars_id)).unwrap_or(false); let nav_hovered = response.hovered() || free_hover || over_bars; - let wheel = crate::wheel_nav::read( + // Interface › Enable Animations off: the wheel moves the canvas without a glide (#5721). + let wheel = crate::wheel_nav::read_with( &ctx, app.session.prefs().general.zoom_with_scroll_wheel, app.session.prefs().enhanced_controls.zoom_with_trackpad_pinch, response.id.with("wheel"), nav_hovered, + app.session.prefs().interface.enable_animations, ); if nav_hovered { let pointer = ui.input(|i| i.pointer.hover_pos()); @@ -3248,7 +3312,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: primary && app.ui.dialogs.last().is_some_and(|d| crate::select_mask_view::owns(&d.fields) && crate::select_mask_view::brush_on(&d.fields)); // Field Blur's pins are placed and dragged on the image (`blur_pins`). let pinning = primary && app.ui.dialogs.last().is_some_and(|d| crate::blur_pins::owns(&d.fields)); - let hand = app.ui.tool == Tool::Hand && !picking && !range_picking && !refine_brushing && !pinning; + // Layer Style on a Drop or Inner Shadow page: a drag on the image moves the shadow (#5701). + let shadow_drag = + !picking && app.ui.dialogs.last().is_some_and(|d| d.kind == crate::state::DialogKind::LayerStyle && crate::layer_style::drags_shadow(&d.fields)); + let hand = app.ui.tool == Tool::Hand && !picking && !range_picking && !refine_brushing && !pinning && !shadow_drag; if let Some(d) = crate::dialogs::pan_delta(&ctx, rect, hand) { let d = xf.unmap_vec(d) / (view.zoom / ppp); view.center[0] -= d.x; @@ -3256,6 +3323,15 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: ctx.set_cursor_icon(crate::tool_cursor::hand(&ctx, true)); } else if free_hover && (space_pan || hand) { ctx.set_cursor_icon(crate::tool_cursor::hand(&ctx, false)); + } else if shadow_drag && !middle && crate::dialogs::free_pointer_over(&ctx, rect).is_some() { + ctx.set_cursor_icon(egui::CursorIcon::Move); + if let Some(p) = crate::dialogs::free_press(&ctx, rect) { + let (start, origin) = ctx.input(|i| (i.pointer.primary_pressed(), i.pointer.press_origin().unwrap_or(p))); + let total = xf.unmap_vec(p - origin) / (view.zoom / ppp); + if let Some(d) = app.ui.dialogs.last_mut() { + crate::layer_style::drag_shadow(&mut d.fields, start, [f64::from(total.x), f64::from(total.y)]); + } + } } else if picking && let Some(p) = crate::dialogs::free_pointer_over(&ctx, rect) { if app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise { ctx.set_cursor_icon(egui::CursorIcon::Crosshair); @@ -3766,8 +3842,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // corner badge, as Photoshop's corner radius cursor. ui.ctx().set_cursor_icon(egui::CursorIcon::Default); crate::icons::cursor_badge(&ctx, "scan", p); - } else if let Some((r, p, c)) = - response.hover_pos().and_then(|p| transform_controls_rect(app, &xf).and_then(|r| transform_controls_cursor(r, p).map(|c| (r, p, c)))) + } else if let Some((r, p, c)) = response + .hover_pos() + .filter(|_| !transform_controls_several(app)) + .and_then(|p| transform_controls_rect(app, &xf).and_then(|r| transform_controls_cursor(r, p).map(|c| (r, p, c)))) { // The Move tool draws handles without opening a Free Transform session. // Give them their resize/rotate cursors before the first pointer press (#2871). @@ -3937,7 +4015,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: { egui::CursorIcon::Crosshair } - Tool::Move => egui::CursorIcon::Move, + // Photoshop's Move tool keeps the standard arrow pointer (#5646): the movable + // layer under it is marked by the hover outline, not by a different cursor. + Tool::Move => egui::CursorIcon::Default, // Dragging is handled with the pan itself, above. Tool::Hand => crate::tool_cursor::hand(ui.ctx(), false), Tool::RotateView => crate::rotate_view::cursor(response.dragged()), @@ -4153,9 +4233,12 @@ pub(crate) fn marching_ants_mapped(painter: &egui::Painter, segs: &[crate::outli /// The part of the line `a`–`b` inside `r` (Liang–Barsky), if any. fn clip_line(r: Rect, a: Pos2, b: Pos2) -> Option<(Pos2, Pos2)> { - let d = b - a; - let (mut t0, mut t1) = (0.0f32, 1.0f32); - for (p, q) in [(-d.x, a.x - r.min.x), (d.x, r.max.x - a.x), (-d.y, a.y - r.min.y), (d.y, r.max.y - a.y)] { + // Perspective can put endpoints billions of pixels away. f32 subtraction/interpolation + // loses viewport-sized differences there, clipping visible ants to the wrong endpoints. + let (ax, ay) = (f64::from(a.x), f64::from(a.y)); + let (dx, dy) = (f64::from(b.x) - ax, f64::from(b.y) - ay); + let (mut t0, mut t1) = (0.0f64, 1.0f64); + for (p, q) in [(-dx, ax - f64::from(r.min.x)), (dx, f64::from(r.max.x) - ax), (-dy, ay - f64::from(r.min.y)), (dy, f64::from(r.max.y) - ay)] { if p == 0.0 { if q < 0.0 { return None; @@ -4166,7 +4249,27 @@ fn clip_line(r: Rect, a: Pos2, b: Pos2) -> Option<(Pos2, Pos2)> { t1 = t1.min(q / p); } } - (t0 <= t1).then(|| (a + d * t0, a + d * t1)) + let at = |t: f64| pos2((ax + dx * t) as f32, (ay + dy * t) as f32); + (t0 <= t1).then(|| (at(t0), at(t1))) +} + +#[cfg(test)] +mod transform_outline_clip_tests { + use super::*; + + #[test] + fn near_horizon_lines_keep_viewport_precision() { + let r = Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 600.0)); + for (a, b, start, end) in [ + (pos2(-1e9, 100.0), pos2(1e9, 100.0), pos2(0.0, 100.0), pos2(800.0, 100.0)), + (pos2(100.0, -1e9), pos2(100.0, 1e9), pos2(100.0, 0.0), pos2(100.0, 600.0)), + (pos2(-1e9, -1e9), pos2(1e9, 1e9), pos2(0.0, 0.0), pos2(600.0, 600.0)), + ] { + let (x, y) = clip_line(r, a, b).unwrap(); + assert!(x.distance(start) < 0.01 && y.distance(end) < 0.01, "{x:?}–{y:?}, expected {start:?}–{end:?}"); + } + assert!(clip_line(r, pos2(-1e9, -100.0), pos2(1e9, -100.0)).is_none()); + } } /// Marching ants along lines already placed on screen (a warped outline is a run of short ones). @@ -4576,23 +4679,36 @@ pub(crate) fn layer_edges_rect(app: &PhotocraftApp, idx: usize) -> Option Option { - // A corner radius drag hides the box, as in Photoshop. +/// The layers Move › Show Transform Controls frames: every selected layer but the Background. +/// A corner radius drag hides the box, as in Photoshop. +fn transform_controls_layers(app: &PhotocraftApp) -> Vec<&photocraft_doc::Layer> { if app.ui.tool != Tool::Move || !app.ui.tool_options.move_show_transform || app.ui.transform.is_some() || app.drag.is_some() || app.ui.corner_radius.is_some() { - return None; + return Vec::new(); } + let Some(st) = app.session.active() else { return Vec::new() }; + st.selected_layers().into_iter().filter_map(|id| st.doc.layer(id)).filter(|l| !crate::doc_props_ui::is_background(&st.doc, l)).collect() +} + +/// Several layers are selected: Show Transform Controls frames them all, as Photoshop does, but +/// Free Transform works on one layer, so the frame has no handles to scale or rotate them. +fn transform_controls_several(app: &PhotocraftApp) -> bool { + transform_controls_layers(app).len() > 1 +} + +/// Move tool › Show Transform Controls: the boxes Free Transform would open on the selected +/// layers, joined into one. +fn transform_controls_rect(app: &PhotocraftApp, xf: &ViewXform) -> Option { let st = app.session.active()?; - let l = st.active_layer.and_then(|id| st.doc.layer(id))?; - if crate::doc_props_ui::is_background(&st.doc, l) { - return None; - } - let b = photocraft_engine::transform_cmds::transform_frame(&st.doc, l); + let b = transform_controls_layers(app) + .into_iter() + .map(|l| photocraft_engine::transform_cmds::transform_frame(&st.doc, l)) + .filter(|b| !b.is_empty()) + .reduce(|a, b| a.union(&b))?; let (dx, dy) = crate::move_ui::floating_offset(app).unwrap_or((0, 0)); let b = b.translate(dx, dy); if b.is_empty() { @@ -4649,6 +4765,9 @@ fn transform_controls_hit(r: Rect, p: Pos2) -> bool { /// Enter the existing Free Transform session when a Move-tool transform control is pressed. fn begin_transform_controls_at(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXform, p: Pos2) -> bool { + if transform_controls_several(app) { + return false; + } let Some(r) = transform_controls_rect(app, xf) else { return false }; if !transform_controls_hit(r, p) { return false; @@ -4662,11 +4781,21 @@ fn begin_transform_controls_at(app: &mut PhotocraftApp, ctx: &egui::Context, xf: } } -/// Move tool › Show Transform Controls: the active layer's bounding box with its eight handles. +/// Move tool › Show Transform Controls: the selected layers' bounding box, with its eight handles +/// for one layer, and the outline of every selected shape layer, as in Photoshop. fn draw_transform_controls(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { let Some(r) = transform_controls_rect(app, xf) else { return }; let accent = crate::theme::Tokens::get(painter.ctx()).accent; + let to_scr = |q: [f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32); + for l in transform_controls_layers(app) { + if let LayerContent::Shape(sh) = &l.content { + crate::vector_ui::draw_outline(painter, &sh.path, &to_scr, accent); + } + } painter.rect_stroke(r, 0.0, Stroke::new(1.0, accent), egui::StrokeKind::Middle); + if transform_controls_several(app) { + return; + } for p in [r.left_top(), r.center_top(), r.right_top(), r.right_center(), r.right_bottom(), r.center_bottom(), r.left_bottom(), r.left_center()] { let h = Rect::from_center_size(p, vec2(7.0, 7.0)); painter.rect_filled(h, 0.0, Color32::WHITE); @@ -5898,6 +6027,22 @@ mod tabs_tests; #[cfg(test)] mod tests { + /// #5472: a ⇧ square pressed between pixels snapped to 101 × 102; the short side grows to + /// match, on the side the drag points to. + #[test] + fn square_px_equalises_the_sides_away_from_the_press_point() { + use super::{marquee_px, square_px}; + // Down-right drag: the continuous square (50.3, 50.7)..(150.8, 151.2) snaps unevenly. + let r = marquee_px([50.3, 50.7], [150.8, 151.2]); + assert_eq!(r, [50.0, 50.0, 151.0, 152.0], "uneven before the fix"); + assert_eq!(square_px(r, [50.3, 50.7], [150.8, 90.0]), [50.0, 50.0, 152.0, 152.0]); + // Up-left drag: the short side grows towards lower coordinates. + let r = marquee_px([150.8, 151.2], [50.3, 50.7]); + assert_eq!(square_px(r, [150.8, 151.2], [60.0, 50.7]), [49.0, 50.0, 151.0, 152.0]); + // Already square: unchanged. + assert_eq!(square_px([10.0, 20.0, 30.0, 40.0], [10.0, 20.0], [30.0, 40.0]), [10.0, 20.0, 30.0, 40.0]); + } + #[test] fn display_image_matches_egui_unmultiplied_conversion() { // Several parallel pieces and a ragged one; translucent, out-of-range and NaN values. @@ -5991,57 +6136,225 @@ mod tests { assert!(app.filter_preview.as_ref().unwrap().result.is_some()); } + // Starts a worker job that computes a real preview for the dialog as it stands, but only + // finishes when the returned sender is signalled. Gating it here keeps these tests off the + // clock: `supersede` cancels a running job once the values have been still for SETTLE_MS, so a + // test that relies on a real blur beating a 150 ms timer is a test that fails on a loaded box. + #[cfg(not(target_arch = "wasm32"))] + fn gated_blur_preview(app: &mut PhotocraftApp, ctx: &egui::Context) -> (std::sync::mpsc::Sender<()>, f64) { + let d = app.ui.dialogs.first().expect("a dialog is open"); + let st = app.session.active().expect("a document is active"); + let command = "filter.blur.gaussianBlur"; + let params = app.with_mask_target(command, crate::filter_dialog::params_of(&d.fields)); + assert_eq!( + crate::filter_dialog::preview_factor(command, ¶ms, 1), + 1, + "a 64x48 document previews at full resolution, and these tests rely on k staying 1" + ); + let key = crate::filter_dialog::FilterPreviewKey { + doc: st.doc.id, + revision: st.revision, + dialog: d.id, + active: st.active_layer, + target: st.channel_view.target, + command: command.into(), + params, + k: 1, + }; + let doc = app.session.documents()[0].doc.clone(); + let documents: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect(); + let radius = key.params["radius"].as_f64().expect("the dialog seeds a radius"); + let (release, wait) = std::sync::mpsc::channel::<()>(); + app.filter_preview_worker + .start(key.clone(), ctx.clone(), move |_| { + wait.recv_timeout(std::time::Duration::from_secs(60)).expect("the test releases the job"); + let result = + crate::filter_dialog::preview_document_for_target(&doc, key.active, &key.command, &key.params, key.k, None, key.target, &documents).map( + |d| { + let buffer = photocraft_compose::flatten(&d); + (std::sync::Arc::new(d), buffer) + }, + ); + crate::filter_preview_worker::Computed { result, ms: 0.0 } + }) + .expect("the worker starts"); + (release, radius) + } + + // The worker hands its result to the channel only after `compute` returns, so a signal sent + // from inside `compute` would still race that send. These tests drive the canvas until the + // state they need appears instead of assuming a timing. + #[cfg(not(target_arch = "wasm32"))] + fn drive_until(app: &mut PhotocraftApp, ctx: &egui::Context, what: &str, done: impl Fn(&PhotocraftApp) -> bool) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while !done(app) { + assert!(std::time::Instant::now() < deadline, "timed out waiting for {what}"); + ensure_filter_preview(app, 0, ctx); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + } + #[cfg(not(target_arch = "wasm32"))] #[test] - fn indexed_preview_discards_a_finished_job_after_slider_change_or_reopening() { - for reopen in [false, true] { - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); - app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); - app.background_jobs = true; - crate::filter_dialog::open(&mut app, "image.mode.indexedColor").unwrap(); - let d = app.ui.dialogs.first().unwrap(); - let st = app.session.active().unwrap(); - let old = crate::filter_dialog::FilterPreviewKey { - doc: st.doc.id, - revision: st.revision, - dialog: d.id, - active: st.active_layer, - target: st.channel_view.target, - command: "image.mode.indexedColor".into(), - params: app.with_mask_target("image.mode.indexedColor", crate::filter_dialog::params_of(&d.fields)), - k: 1, - }; - let (release, wait) = std::sync::mpsc::channel(); - let ctx = egui::Context::default(); - app.filter_preview_worker - .start(old.clone(), ctx.clone(), move |_| { - wait.recv_timeout(std::time::Duration::from_secs(5)).unwrap(); - crate::filter_preview_worker::Computed { result: None, ms: 0.0 } - }) - .unwrap(); - if reopen { - app.ui.dialogs.clear(); - crate::filter_preview_worker::discard_closed(&mut app); - crate::filter_dialog::open(&mut app, "image.mode.indexedColor").unwrap(); - } else { - app.ui.dialogs.first_mut().unwrap().fields.insert("colors".into(), json!(16)); - } - assert!(ensure_filter_preview(&mut app, 0, &ctx).is_none()); - assert!(app.filter_preview.is_none()); - release.send(()).unwrap(); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - while app.filter_preview.is_none() { - assert!(std::time::Instant::now() < deadline); - ensure_filter_preview(&mut app, 0, &ctx); - if let Some(preview) = &app.filter_preview { - assert_ne!(preview.key, old, "an outdated preview was published"); - } - std::thread::sleep(std::time::Duration::from_millis(1)); - } - assert_eq!(app.session.active().unwrap().doc.mode, photocraft_color::ColorMode::Rgb); + fn a_preview_computed_mid_drag_reaches_the_canvas_and_then_converges() { + // A drag used to paint nothing at all: every completion was dropped for carrying stale + // parameters, so the canvas only ever showed an effect once the pointer had come to rest. + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.background_jobs = true; + crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + let ctx = egui::Context::default(); + let (release, first) = gated_blur_preview(&mut app, &ctx); + + // The pointer moves on before the frame lands. + let moved = first + 4.0; + app.ui.dialogs[0].fields.insert("radius".into(), json!(moved)); + release.send(()).expect("released"); + drive_until(&mut app, &ctx, "the mid-drag frame", |a| a.filter_preview.is_some()); + + // The finished frame is painted even though the dialog has moved on. + let shown = app.filter_preview.as_ref().expect("the mid-drag frame was published"); + assert_eq!(shown.key.params["radius"].as_f64(), Some(first), "the frame is the one the worker finished"); + assert!(shown.result.is_some()); + assert_eq!(app.ui.dialogs[0].fields["radius"].as_f64(), Some(moved), "the slider kept moving"); + // The display gate lets it through, so the canvas has something to draw: this is what + // re-adding `params` to that gate would break. + assert!(ensure_filter_preview(&mut app, 0, &ctx).is_some(), "the canvas draws the mid-drag frame"); + + // And the preview still converges on the value the dialog holds. `fresh` has to stay an + // exact match for that: relaxed, the canvas would believe it already had the current + // request after the first frame of a drag and never compute again. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while app.filter_preview.as_ref().is_none_or(|p| p.key.params["radius"].as_f64() != Some(moved)) { + assert!(std::time::Instant::now() < deadline, "the preview never converged on the current radius"); + ensure_filter_preview(&mut app, 0, &ctx); + std::thread::sleep(std::time::Duration::from_millis(1)); } } + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_mid_drag_frame_does_not_replace_a_frame_that_is_already_exact() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.background_jobs = true; + crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + let ctx = egui::Context::default(); + + // A frame for the radius the dialog holds right now reaches the canvas. This also proves + // the hand-built key is the one the canvas builds: it can only be published by the exact + // arm, which requires `finished == request`. + let (release, first) = gated_blur_preview(&mut app, &ctx); + release.send(()).expect("released"); + drive_until(&mut app, &ctx, "the first frame", |a| a.filter_preview.is_some()); + assert_eq!(app.filter_preview.as_ref().expect("published").key.params["radius"].as_f64(), Some(first)); + + // The slider moves away and a frame for the new radius is computed... + let moved = first + 4.0; + app.ui.dialogs[0].fields.insert("radius".into(), json!(moved)); + let (release_moved, _) = gated_blur_preview(&mut app, &ctx); + // ...and back to `first` before that frame lands. + app.ui.dialogs[0].fields.insert("radius".into(), json!(first)); + release_moved.send(()).expect("released"); + + // The frame for `moved` arrives late. It belongs to this preview, but the frame on screen + // is already exact for the dialog, so it must never be shown in place of it. Checked on + // every pass: a wrong frame published and then overwritten before the loop ends would + // otherwise never be seen. + drive_until(&mut app, &ctx, "the late frame to arrive", |a| { + if let Some(shown) = &a.filter_preview { + assert_ne!(shown.key.params["radius"].as_f64(), Some(moved), "a late frame replaced an exact one"); + } + !a.filter_preview_worker.busy() + }); + let shown = app.filter_preview.as_ref().expect("a frame is on screen"); + assert_eq!(shown.key.params["radius"].as_f64(), Some(first), "a late frame replaced an exact one"); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_mid_drag_empty_result_leaves_the_preview_alone() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.background_jobs = true; + crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + let ctx = egui::Context::default(); + + let (release, first) = gated_blur_preview(&mut app, &ctx); + release.send(()).expect("released"); + drive_until(&mut app, &ctx, "the first frame", |a| a.filter_preview.is_some()); + assert!(app.filter_preview.as_ref().expect("published").result.is_some()); + + // The slider moves and a job for the new radius is started, then the slider moves again + // before that job lands: its result is stale by the time it arrives. + let moved = first + 4.0; + app.ui.dialogs[0].fields.insert("radius".into(), json!(moved)); + let d = app.ui.dialogs.first().expect("a dialog is open"); + let st = app.session.active().expect("a document is active"); + let key = crate::filter_dialog::FilterPreviewKey { + doc: st.doc.id, + revision: st.revision, + dialog: d.id, + active: st.active_layer, + target: st.channel_view.target, + command: "filter.blur.gaussianBlur".into(), + params: json!({"radius": moved}), + k: 1, + }; + let (release_empty, wait) = std::sync::mpsc::channel::<()>(); + app.filter_preview_worker + .start(key, ctx.clone(), move |_| { + wait.recv_timeout(std::time::Duration::from_secs(60)).expect("released"); + crate::filter_preview_worker::Computed { result: None, ms: 0.0 } + }) + .expect("the worker starts"); + app.ui.dialogs[0].fields.insert("radius".into(), json!(moved + 4.0)); + release_empty.send(()).expect("released"); + + // Storing an empty result here would drop the canvas back to the unfiltered layer for a + // frame. The exact request may still store one; a stale one may not. Checked on every + // pass, because the reopened preview that follows would otherwise repair the symptom. + drive_until(&mut app, &ctx, "the empty frame to arrive", |a| { + if let Some(shown) = &a.filter_preview { + assert!(shown.result.is_some(), "an empty mid-drag result blanked the canvas"); + } + !a.filter_preview_worker.busy() + }); + assert!(app.filter_preview.as_ref().expect("a preview is still shown").result.is_some(), "an empty mid-drag result blanked the canvas"); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_reopened_dialog_never_paints_the_previous_dialogs_preview() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.background_jobs = true; + crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + let ctx = egui::Context::default(); + let first_dialog = app.ui.dialogs.first().expect("a dialog is open").id; + let (release, _) = gated_blur_preview(&mut app, &ctx); + + // Close that dialog and open another one for the same layer and the same command. Only the + // dialog id differs, which is exactly what `same_target` has to notice. + app.ui.dialogs.clear(); + crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + let second_dialog = app.ui.dialogs.first().expect("a dialog is open").id; + assert_ne!(second_dialog, first_dialog, "a reopened dialog gets a new id"); + release.send(()).expect("released"); + + // The first dialog's frame arrives late and must not be painted under the second one. + // `discard_closed` usually reclaims it a frame earlier, which is why this drives the gate + // directly: the id check is the part that has to hold when that cleanup misses. The + // invariant is checked on every pass, not only at the end, because a wrongly published + // frame can still be replaced by the reopened dialog's own preview before we stop. + drive_until(&mut app, &ctx, "the first dialog's frame to arrive", |a| { + if let Some(preview) = &a.filter_preview { + assert_ne!(preview.key.dialog, first_dialog, "a reopened dialog painted the previous dialog's preview"); + } + !a.filter_preview_worker.busy() + }); + } + #[test] fn windows_uses_a_visible_crosshair() { // #737: Windows' inverting crosshair vanishes over mid-grey; use a black/white glyph. @@ -7824,6 +8137,37 @@ mod transform_controls_tests { use super::*; use serde_json::json; + #[test] + fn several_selected_layers_share_one_frame_without_handles() { + // As in Photoshop: Show Transform Controls frames every selected layer. Free Transform + // works on one layer, so the shared frame doesn't start it. + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.session.execute("shape.create", json!({"kind": "rect", "rect": [20, 30, 40, 40], "fill": "#ff0000"})).unwrap(); + let a = app.session.active().unwrap().active_layer.unwrap(); + app.session.execute("shape.create", json!({"kind": "rect", "rect": [100, 120, 50, 30], "fill": "#ff0000"})).unwrap(); + app.sync_views(); + app.ui.tool = Tool::Move; + app.ui.tool_options.move_show_transform = true; + let xf = + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let one = transform_controls_rect(&app, &xf).unwrap(); + assert!(!transform_controls_several(&app)); + app.session.execute("layer.select", json!({"layer": a.0, "mode": "add"})).unwrap(); + assert!(transform_controls_several(&app)); + let both = transform_controls_rect(&app, &xf).unwrap(); + assert!(both.contains_rect(one) && both.width() > one.width() && both.height() > one.height(), "{both:?} frames both"); + let (lt, rb) = (xf.to_doc(both.left_top()), xf.to_doc(both.right_bottom())); + assert!((lt[0] - 20.0).abs() < 1e-3 && (lt[1] - 30.0).abs() < 1e-3 && (rb[0] - 150.0).abs() < 1e-3 && (rb[1] - 150.0).abs() < 1e-3, "{lt:?} {rb:?}"); + let ctx = egui::Context::default(); + assert!(!begin_transform_controls_at(&mut app, &ctx, &xf, both.right_bottom())); + assert!(app.ui.transform.is_none()); + // The Background never joins the frame. + let bg = app.session.active().unwrap().doc.layers[0].id; + app.session.execute("layer.select", json!({"layer": bg.0, "mode": "add"})).unwrap(); + assert_eq!(transform_controls_layers(&app).len(), 2); + } + #[test] fn move_transform_controls_start_free_transform_for_vector_shapes() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/channels_panel.rs b/crates/ui-egui/src/channels_panel.rs index f232384139..7f175b6209 100644 --- a/crates/ui-egui/src/channels_panel.rs +++ b/crates/ui-egui/src/channels_panel.rs @@ -330,7 +330,11 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } let load = icons::button(ui, "circle-dashed", 26.0, false, tl!("Load channel as selection")); if load.clicked() { - actions.push(("select.loadSelection".into(), json!({ "channel": target_ref }))); + // The selected mask row can coexist with a Composite engine target while mask + // painting is active. The footer follows the row the user sees as targeted. + let params = + if mask_targeted { json!({ "channel": "mask", "layer": masked.as_ref().map(|layer| layer.id.0) }) } else { json!({ "channel": target_ref }) }; + actions.push(("select.loadSelection".into(), params)); } actions.extend(drop(ui, &load, Footer::Load)); }); @@ -615,6 +619,68 @@ mod tests { assert!(h.state().session.active().unwrap().doc.selection.is_some(), "the white channel selects everything"); } + /// The footer follows the targeted row shown by the panel when mask painting is active, + /// even though the engine's channel-view target remains Composite. Other channel targets + /// continue to load their own values, and each load remains one undoable selection change. + #[test] + fn footer_loads_the_selected_layer_mask_and_preserves_color_alpha_targets() { + let (mut h, _) = channels_harness(); + h.state_mut().run("select.rect", json!({"x": 0, "y": 0, "width": 32, "height": 48})).unwrap(); + h.state_mut().run("layer.layerMask.hideAll", json!({})).unwrap(); + h.state_mut().run("layer.layerMask.revealSelection", json!({})).unwrap(); + h.state_mut().run("channel.target", json!({"channel": "composite"})).unwrap(); + h.state_mut().ui.mask_target = true; + h.run_steps(2); + + let click_load = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>| { + use egui::{Modifiers, PointerButton}; + let p = at(h, "Load channel as selection"); + h.hover_at(p); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + }; + let sample = + |h: &egui_kittest::Harness<'_, PhotocraftApp>, x, y| h.state().session.active().unwrap().doc.selection.as_ref().unwrap().sample_channel(x, y, 0); + + assert_eq!(h.state().session.active().unwrap().channel_view.target, ChannelTarget::Composite); + click_load(&mut h); + assert_eq!((sample(&h, 8, 8), sample(&h, 48, 8)), (1.0, 0.0), "mask row selection wins over the Composite engine target"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert!(h.state().session.active().unwrap().doc.selection.is_none()); + h.state_mut().run("edit.redo", json!({})).unwrap(); + assert_eq!((sample(&h, 8, 8), sample(&h, 48, 8)), (1.0, 0.0)); + + h.state_mut().ui.mask_target = false; + h.state_mut().run("channel.target", json!({"channel": "composite"})).unwrap(); + click_load(&mut h); + assert_eq!((sample(&h, 8, 8), sample(&h, 48, 8)), (1.0, 1.0), "composite luminosity remains available"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!((sample(&h, 8, 8), sample(&h, 48, 8)), (1.0, 0.0)); + h.state_mut().run("edit.redo", json!({})).unwrap(); + assert_eq!((sample(&h, 8, 8), sample(&h, 48, 8)), (1.0, 1.0)); + + h.state_mut().run("select.rect", json!({"x": 0, "y": 0, "width": 32, "height": 48})).unwrap(); + h.state_mut().run("channel.new", json!({"fill": "selection"})).unwrap(); + h.state_mut().run("select.deselect", json!({})).unwrap(); + h.state_mut().run("channel.target", json!({"channel": "red"})).unwrap(); + click_load(&mut h); + assert_eq!((sample(&h, 8, 8), sample(&h, 48, 8)), (1.0, 1.0), "color channel still loads its own plane"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.state_mut().run("edit.redo", json!({})).unwrap(); + assert_eq!((sample(&h, 8, 8), sample(&h, 48, 8)), (1.0, 1.0)); + + h.state_mut().run("channel.target", json!({"channel": 1})).unwrap(); + click_load(&mut h); + assert_eq!((sample(&h, 8, 8), sample(&h, 48, 8)), (1.0, 0.0), "alpha channel still loads its own plane"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!((sample(&h, 8, 8), sample(&h, 48, 8)), (1.0, 1.0)); + h.state_mut().run("edit.redo", json!({})).unwrap(); + assert_eq!((sample(&h, 8, 8), sample(&h, 48, 8)), (1.0, 0.0)); + } + /// A plain click on a row still targets it, and a drag released elsewhere does nothing. #[test] fn clicks_still_target_and_a_drop_elsewhere_does_nothing() { diff --git a/crates/ui-egui/src/chrome_ui.rs b/crates/ui-egui/src/chrome_ui.rs index b20e913a7d..bbdce8fa78 100644 --- a/crates/ui-egui/src/chrome_ui.rs +++ b/crates/ui-egui/src/chrome_ui.rs @@ -339,7 +339,7 @@ mod tests { let mut gray = doc(); gray.mode = photocraft_doc::ColorMode::Grayscale; gray.icc_profile = Some(photocraft_engine::color_cmds::working_profile(gray.mode).to_bytes()); - assert_eq!(profile_name(&gray), "Dot Gain 20%-compatible (gamma 1.732, Photocraft)"); + assert_eq!(profile_name(&gray), "Dot Gain 20%-compatible (gamma 1.732, PhotoCraft)"); let mut custom = (*photocraft_engine::color_cmds::working_profile(photocraft_doc::ColorMode::Cmyk)).clone(); custom.description = "PhotoCraft Studio CMYK".into(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2930889bab..013c79e825 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -135,6 +135,26 @@ fn show_modal( }) } +/// Whether dialogs have a close box (×) in their title bar (#5496). Photoshop's modal dialogs have +/// one on Windows, where clicking it is Cancel, and none on macOS; Linux and the web follow Windows. +pub(crate) const CLOSE_BOX: bool = !cfg!(target_os = "macos"); + +/// The side of the close box's square, in points. +const CLOSE_BOX_SIZE: f32 = 20.0; + +/// The close box's square: flush with the title band's right end `right`, centred on the title +/// row at height `centre_y`. +fn close_box_rect(right: f32, centre_y: f32) -> egui::Rect { + egui::Rect::from_min_size(egui::pos2(right - CLOSE_BOX_SIZE, centre_y - CLOSE_BOX_SIZE / 2.0), egui::vec2(CLOSE_BOX_SIZE, CLOSE_BOX_SIZE)) +} + +/// Draws a title bar's close box in `rect`, the same × icon button the floating panels close with, +/// and says whether it was clicked. Its own child `Ui`, so the title row's layout is unchanged. +pub(crate) fn close_box(ui: &mut egui::Ui, rect: egui::Rect) -> bool { + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(egui::Layout::right_to_left(egui::Align::Center))); + crate::icons::button(&mut child, "x", CLOSE_BOX_SIZE, false, tl!("Close")).clicked() +} + pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { crate::layer_style::color_picker::prune(app); crate::type_panels_ui::color_picker::prune(app); @@ -163,6 +183,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let place = place_id(&d); let start = pinned.or_else(|| ctx.data_mut(|m| m.get_persisted::(place))).or_else(|| crate::blur_pins::first_place(&d.fields, ctx)); let mut drag = egui::Vec2::ZERO; + let mut close_clicked = false; let mut sizing = false; let area = match start { Some(offset) => egui::Modal::default_area(id).anchor(egui::Align2::LEFT_TOP, offset), @@ -242,8 +263,15 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let t = if crate::color_picker_ui::owns(&fields) { ui.add_sized(egui::vec2(ui.available_width(), 22.0), label).rect } else { ui.add(label).rect }; // The whole title band drags (#1921): the full width plus a little of the popup's // padding around it and the gap above the hairline, but none of the controls below. - let bar = egui::Rect::from_min_max(t.min - egui::vec2(7.0, 7.0), egui::pos2(ui.max_rect().right() + 7.0, t.bottom() + 4.0)); + // The close box (#5496) sits at the band's right end and is left out of the drag area: + // egui would let a drag that starts on a button move the dialog. + let close_rect = CLOSE_BOX.then(|| close_box_rect(ui.max_rect().right(), t.center().y)); + let bar_right = close_rect.map_or(ui.max_rect().right() + 7.0, |r| r.left()); + let bar = egui::Rect::from_min_max(t.min - egui::vec2(7.0, 7.0), egui::pos2(bar_right, t.bottom() + 4.0)); drag = ui.interact(bar, id.with("title"), egui::Sense::drag()).drag_delta(); + if let Some(r) = close_rect { + close_clicked = close_box(ui, r); + } ui.add_space(4.0); crate::widgets::hairline(ui); ui.add_space(8.0); @@ -458,11 +486,13 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { ctx.data_mut(|m| m.insert_persisted(place, offset)); } } - // Esc cancels (topmost dialog, no popup open). A click outside does nothing: Photoshop keeps - // the dialog, and the pointer may be panning or zooming the canvas under it. + // Esc and the title bar's close box cancel (topmost dialog, no popup open for Esc). A click + // outside does nothing: Photoshop keeps the dialog, and the pointer may be panning or + // zooming the canvas under it. if interactive && outcome.is_none() - && (modal.response.should_close() + && (close_clicked + || modal.response.should_close() || (modal.is_top_modal && !modal.any_popup_open && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)))) { outcome = Some(false); @@ -735,6 +765,83 @@ mod tests { assert_eq!(crate::canvas::committed_filter_preview(&mut app, 0), None); } + /// #5496: a pointer click on the title bar's close box does what Cancel does: the dialog + /// closes, the live preview goes and nothing reaches the document or the journal. On macOS + /// dialogs have no close box. + #[test] + fn the_title_bar_close_box_cancels_like_cancel() { + use egui_kittest::kittest::Queryable; + let close = |button: &str| { + let (mut app, _) = filter_dialog_with_preview(false); + let revision = app.session.active().unwrap().revision; + let journal = app.session.journal.len(); + // The fonts are set up after the harness's first frame: the dialog appears after it. + let dialogs = std::mem::take(&mut app.ui.dialogs); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state(|ui, app| show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.state_mut().ui.dialogs = dialogs; + h.run_steps(4); + assert!(h.state().filter_preview.is_some(), "the preview is on screen"); + if !CLOSE_BOX { + assert_eq!(h.query_all_by_label("Close").count(), 0, "no close box on macOS"); + } + let target = h.query_by_label(button).map(|n| n.rect().center())?; + h.hover_at(target); + h.drag_at(target); + h.drop_at(target); + h.run_steps(3); + let app = h.state(); + Some(( + app.ui.dialogs.is_empty(), + app.filter_preview.is_none(), + app.session.active().unwrap().revision == revision, + app.session.journal.len() == journal, + )) + }; + let cancel = close("Cancel"); + assert_eq!(cancel, Some((true, true, true, true)), "Cancel closes, drops the preview and changes nothing"); + if CLOSE_BOX { + assert_eq!(close("Close"), cancel, "the × is Cancel"); + } + } + + /// #5496: the close box sits at the title band's right end, and the band stays a drag handle + /// around it: a drag that starts on the × neither moves nor closes the dialog, and the band + /// just left of the × still moves it. + #[test] + fn the_close_box_and_the_title_drag_keep_apart() { + use egui_kittest::kittest::Queryable; + if !CLOSE_BOX { + return; + } + let mut h = dialog_harness(egui::vec2(1280.0, 800.0), "filter.blur.gaussianBlur"); + let title = h.get_by_label("Gaussian Blur").rect(); + let x = h.get_by_label("Close").rect(); + let frame = rects(&h.ctx)[0]; + assert!((x.center().y - title.center().y).abs() < 2.0 && x.left() > title.right(), "the × is on the title row: {x:?}, {title:?}"); + assert!(frame.right() - x.right() < 16.0, "at its right end: {x:?} in {frame:?}"); + let drag = |h: &mut egui_kittest::Harness<'static, PhotocraftApp>, from: egui::Pos2, to: egui::Pos2| { + h.hover_at(from); + h.drag_at(from); + h.run_steps(2); + for i in 1..=8 { + h.hover_at(from + (to - from) * (i as f32 / 8.0)); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(3); + }; + drag(&mut h, x.center(), x.center() + egui::vec2(-80.0, 60.0)); + assert_eq!(h.state().ui.dialogs.len(), 1, "a drag from the × doesn't close the dialog"); + assert!((h.get_by_label("Gaussian Blur").rect().min - title.min).length() < 1.0, "nor move it"); + let x = h.get_by_label("Close").rect(); + let beside = x.left_center() - egui::vec2(4.0, 0.0); + drag(&mut h, beside, beside + egui::vec2(-120.0, 40.0)); + assert_eq!(h.state().ui.dialogs.len(), 1, "a title drag doesn't close the dialog"); + let moved = h.get_by_label("Gaussian Blur").rect().min - title.min; + assert!((moved - egui::vec2(-120.0, 40.0)).length() < 2.0, "the band beside the × moved the dialog: {moved:?}"); + } + #[test] fn color_picker_has_inset_content_and_actions_at_the_right_edge() { use egui_kittest::{Harness, kittest::Queryable}; diff --git a/crates/ui-egui/src/direct_select.rs b/crates/ui-egui/src/direct_select.rs index 26fba5dadc..b27d9ea466 100644 --- a/crates/ui-egui/src/direct_select.rs +++ b/crates/ui-egui/src/direct_select.rs @@ -94,6 +94,11 @@ pub enum Edit { } impl Edit { + /// The drag's end with ⇧ held: moving anchors locks to multiples of 45°, as in Photoshop (#5475). + fn constrain(&self, start: [f64; 2], p: [f64; 2], shift: bool) -> [f64; 2] { + if shift && matches!(self, Edit::Anchors(_)) { crate::stroke_constraint::snap45(start, p) } else { p } + } + /// The edit for a drag from `start` to `end`, as a command and its params (without target). fn command(&self, start: [f64; 2], end: [f64; 2]) -> (&'static str, Value) { let d = [end[0] - start[0], end[1] - start[1]]; @@ -238,7 +243,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool let ds = &mut app.ui.direct_selection; match &mut ds.drag { Some(Drag::Edit { base, edit, start, end, preview, .. }) => { - *end = [x, y]; + *end = edit.constrain(*start, [x, y], mods.shift); *preview = edit.preview(base, *start, *end); } Some(Drag::Marquee { end, .. }) => *end = [x, y], @@ -248,7 +253,8 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool } ToolEvent::Up { x, y } => match app.ui.direct_selection.drag.take() { Some(Drag::Edit { target, base, edit, start, .. }) => { - release(app, target, &base, &edit, start, [x, y]); + let end = edit.constrain(start, [x, y], mods.shift); + release(app, target, &base, &edit, start, end); app.ui.direct_selection.coalesce = None; true } diff --git a/crates/ui-egui/src/direct_select_tests.rs b/crates/ui-egui/src/direct_select_tests.rs index 0f3a5ba990..31a16bfcda 100644 --- a/crates/ui-egui/src/direct_select_tests.rs +++ b/crates/ui-egui/src/direct_select_tests.rs @@ -326,6 +326,28 @@ fn arrow_keys_nudge_the_selected_anchors() { assert_eq!(work(&app), before); } +/// #5475: ⇧ during an anchor drag locks it to 45°, whether it's held from the press or added later. +#[test] +fn shift_locks_an_anchor_drag_to_45_degrees() { + let mut app = app(Tool::DirectSelection); + with_path(&mut app); + tool_event(&mut app, ToolEvent::Down { x: 40.0, y: 40.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 70.0, y: 44.0, pressure: 1.0 }, Modifiers::SHIFT); + let Some(Drag::Edit { preview: Some(p), .. }) = &app.ui.direct_selection.drag else { panic!("dragging") }; + assert_eq!(p.subpaths[0].knots[0].anchor, Point::new(70.0, 40.0), "the preview is locked too"); + tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 47.0 }, Modifiers::SHIFT); + assert_eq!(anchor(&app, 0), Point::new(100.0, 40.0)); + + // Diagonal, and without ⇧ the drag stays free. + // (⇧ at the press would deselect the anchor, so it comes once the drag is under way.) + tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 40.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 131.0, y: 69.0 }, Modifiers::SHIFT); + let a = anchor(&app, 0); + assert!((a.x - 130.0).abs() < 1e-9 && (a.y - 70.0).abs() < 1e-9, "{a:?}"); + drag(&mut app, [130.0, 70.0], [140.0, 73.0], Modifiers::NONE); + assert_eq!(anchor(&app, 0), Point::new(140.0, 73.0)); +} + #[test] fn the_curvature_pen_drags_an_anchor_of_the_active_path_without_a_modifier() { // As in Photoshop: with no path being drawn, pressing an anchor and dragging moves it. diff --git a/crates/ui-egui/src/discard_ui.rs b/crates/ui-egui/src/discard_ui.rs index 23248cd9ae..30e5f47deb 100644 --- a/crates/ui-egui/src/discard_ui.rs +++ b/crates/ui-egui/src/discard_ui.rs @@ -192,7 +192,8 @@ fn save(app: &mut PhotocraftApp, ctx: &egui::Context, doc: DocId) -> bool { return false; } match saved { - Ok(_) => !app.save_options_open() && !app.saving(), + // A Save Document script may have edited the document after it was written: still dirty. + Ok(_) => !app.save_options_open() && !app.saving() && index_of(app, doc).is_some_and(|i| !app.session.documents()[i].is_dirty()), // Backing out of the file dialog is the user's choice, not an error. Err(e) if e == crate::file_dialog::CANCELLED => false, Err(e) => { @@ -264,7 +265,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { vec![(ButtonRole::Default, "Yes", Some(Key::Y), 84.0, Answer::Save), (ButtonRole::Alternate, "No", Some(Key::N), 84.0, Answer::Discard), cancel] }; let mut answer = ctx.input_mut(|i| buttons.iter().find(|b| b.2.is_some_and(|k| i.consume_key(egui::Modifiers::NONE, k))).map(|b| b.4)); - let labels: Vec = buttons.iter().map(|b| button_label(mac, b.1, b.2)).collect(); + let labels = button_labels(mac, &buttons.iter().map(|b| (b.1, b.2)).collect::>()); let row: Vec = buttons.iter().zip(&labels).map(|(b, label)| DialogButton::new(b.0, label, b.3)).collect(); let modal = egui::Modal::new(egui::Id::new("discard-prompt")).show(ctx, |ui| { ui.set_max_width(420.0); @@ -360,17 +361,30 @@ enum Answer { } /// macOS keeps the plain button wording even though the keyboard shortcuts still work. +#[cfg(test)] fn button_label(mac: bool, label: &str, key: Option) -> String { - if mac { tl!(label).to_string() } else { key.map_or_else(|| tl!(label).to_string(), |k| mnemonic(label, k)) } + button_labels(mac, &[(label, key)]).into_iter().next().unwrap_or_default() } -/// "(S)ave": the key in parentheses, or appended ("Guardar (S)") when the translation doesn't start with it. -fn mnemonic(label: &str, key: Key) -> String { - let (label, k) = (tl!(label), key.name()); - match label.split_at_checked(1) { - Some((first, rest)) if first.eq_ignore_ascii_case(k) => format!("({first}){rest}"), - _ => format!("{label} ({k})"), - } +/// The labels of one dialog's buttons, each with its key in the same style: "(Y)es" when every +/// translation starts with its key, otherwise appended to all ("Ja (Y)", "Nein (N)") (#5564). +fn button_labels(mac: bool, buttons: &[(&str, Option)]) -> Vec { + let starts_with_key = |label: &str, k: Key| label.split_at_checked(1).is_some_and(|(first, _)| first.eq_ignore_ascii_case(k.name())); + let inline = buttons.iter().all(|&(label, key)| key.is_none_or(|k| starts_with_key(tl!(label), k))); + buttons + .iter() + .map(|&(label, key)| { + let text = tl!(label); + match key { + None => text.to_string(), + Some(_) if mac => text.to_string(), + Some(k) => match text.split_at_checked(1) { + Some((first, rest)) if inline => format!("({first}){rest}"), + _ => format!("{text} ({})", k.name()), + }, + } + }) + .collect() } #[cfg(test)] @@ -395,6 +409,32 @@ mod tests { app.session.documents()[i].doc.id } + #[test] + fn a_save_document_script_that_edits_the_document_keeps_the_prompt_up() { + use photocraft_engine::automate_cmds::ScriptBinding; + let mut app = app_with_docs(1); + let ctx = egui::Context::default(); + make_dirty(&mut app, 0); + app.session.active_mut().unwrap().path = Some("/pics/kept.psd".into()); + app.services.export = Some(Box::new(|_: &photocraft_doc::Document, _: &str, _: &crate::ExportSettings| Ok((b"out".to_vec(), Vec::new())))); + app.services.write = Some(Box::new(|_: &str, _: &[u8]| Ok(()))); + app.session.edit_prefs(|prefs| { + prefs.script_events.enabled = true; + prefs.script_events.bindings = vec![ScriptBinding { + event: "saveDocument".into(), + script: None, + steps: Some(json!([["layer.new.layer", {"name": "From the script"}]])), + name: "Binding".into(), + }]; + }); + let doc = doc_id(&app, 0); + assert!(!save(&mut app, &ctx, doc), "the script's layer was never written"); + assert!(app.session.documents()[0].is_dirty()); + // Without the script the same save releases the prompt. + app.session.edit_prefs(|prefs| prefs.script_events.enabled = false); + assert!(save(&mut app, &ctx, doc)); + } + #[test] fn clean_documents_close_without_asking() { let mut app = app_with_docs(2); @@ -502,9 +542,9 @@ mod tests { #[test] fn mnemonic_labels_bracket_the_key() { - assert_eq!(mnemonic("Don't Save", Key::D), "(D)on't Save"); - assert_eq!(mnemonic("Guardar", Key::S), "Guardar (S)"); - assert_eq!(mnemonic("保存", Key::S), "保存 (S)"); + assert_eq!(button_label(false, "Don't Save", Some(Key::D)), "(D)on't Save"); + assert_eq!(button_label(false, "Guardar", Some(Key::S)), "Guardar (S)"); + assert_eq!(button_label(false, "保存", Some(Key::S)), "保存 (S)"); } type Prompted = egui_kittest::Harness<'static, PhotocraftApp>; @@ -641,6 +681,19 @@ mod tests { assert_eq!(button_label(false, "Cancel", Some(Key::C)), "(C)ancel"); } + /// German "Ja" doesn't start with Y, so every button appends its key rather than mixing + /// "Ja (Y)" with "(N)ein" (#5564). + #[test] + fn one_dialog_shows_every_key_in_the_same_style() { + let buttons = [("Yes", Some(Key::Y)), ("No", Some(Key::N)), ("Cancel", Some(Key::C))]; + assert_eq!(button_labels(false, &buttons), ["(Y)es", "(N)o", "(C)ancel"]); + let german = crate::i18n::Lang::from_code("de").unwrap(); + crate::i18n::with_language(german, || { + assert_eq!(button_labels(false, &buttons), ["Ja (Y)", "Nein (N)", "Abbrechen (C)"]); + assert_eq!(button_labels(true, &buttons), ["Ja", "Nein", "Abbrechen"]); + }); + } + #[test] fn macos_asks_dont_save_cancel_save_with_the_default_last() { let mut h = prompt_on(egui::os::OperatingSystem::Mac); diff --git a/crates/ui-egui/src/doc_props_ui.rs b/crates/ui-egui/src/doc_props_ui.rs index 8c10b8c442..fbdbc9a955 100644 --- a/crates/ui-egui/src/doc_props_ui.rs +++ b/crates/ui-egui/src/doc_props_ui.rs @@ -100,7 +100,7 @@ fn committed_field(ui: &mut egui::Ui, id: &str, current: f32, u: crate::layer_pr // Shown in the ruler unit (cm when the rulers are in centimeters); 100% is the current size. let extent = f64::from(current); let mut v = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| u.shown(extent, extent)); - let r = widgets::value_field(ui, &mut v, 0.01..=300_000.0, u.unit.suffix(), 74.0); + let r = widgets::value_field(ui, &mut v, 0.0..=300_000.0, u.unit.suffix(), 74.0); if r.dragged() || r.has_focus() { ui.data_mut(|d| d.insert_temp(key, v)); return None; @@ -318,6 +318,30 @@ mod tests { assert_eq!(canvas_resize_params((2400, 1500), Some(2400.2), None, false), None); } + /// #5661: a 1 x 1 px document at 300 ppi is 0.0033 in wide. With the rulers in inches the + /// Width/Height fields clamped that to 0.01 in (3 px) and resized the document unasked. + #[test] + fn tiny_document_in_inches_is_not_resized_by_showing_it() { + use egui_kittest::Harness; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 1, "height": 1, "resolution": 300, "background": "white"})).unwrap(); + app.session.prefs.edit(|p| p.units_and_rulers.rulers = photocraft_engine::prefs::Unit::Inches); + let undo = app.session.active().unwrap().history.entries().len(); + let mut h = Harness::builder().with_size(vec2(320.0, 600.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + properties(app, ui); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(3); + let st = h.state().session.active().unwrap(); + assert_eq!((st.doc.size.width, st.doc.size.height), (1, 1)); + assert_eq!(st.history.entries().len(), undo, "no history step"); + } + #[test] fn document_dropdowns_map_to_live_mode_commands() { for id in MODES.iter().map(|m| m.2).chain(DEPTHS.iter().map(|d| d.2)) { diff --git a/crates/ui-egui/src/empty_workspace_tests.rs b/crates/ui-egui/src/empty_workspace_tests.rs new file mode 100644 index 0000000000..f7473dbccc --- /dev/null +++ b/crates/ui-egui/src/empty_workspace_tests.rs @@ -0,0 +1,89 @@ +//! With no document open, double-clicking the empty workspace shows File › Open, as in Photoshop +//! (#5367): with Auto Show the Home Screen on (the Home screen's blank background) and off (the +//! plain workspace). A single click does nothing, and the Home screen's own buttons keep theirs. + +use std::cell::RefCell; +use std::rc::Rc; + +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +use crate::file_dialog::{FileDialogReply, FileDialogRequest}; +use crate::{PhotocraftApp, Services}; + +type Requests = Rc>>; + +fn harness(home: bool) -> (Harness<'static, PhotocraftApp>, Requests) { + let requests: Requests = Default::default(); + let held = requests.clone(); + let services = Services { file_dialog: Some(Box::new(move |request, _parent, reply| held.borrow_mut().push((request, reply)))), ..Default::default() }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + app.session.edit_prefs(|p| p.general.auto_show_home_screen = home); + let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + // Fonts set up after the first frame only apply from the next one. + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + app.poll_file_dialog(&ctx, None); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + (h, requests) +} + +fn button(h: &mut Harness<'static, PhotocraftApp>, at: Pos2, pressed: bool) { + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); +} + +/// `count` clicks two frames apart at `at`, after letting the clock move past earlier clicks. +fn clicks(h: &mut Harness<'static, PhotocraftApp>, at: Pos2, count: usize) { + h.hover_at(at); + for _ in 0..40 { + h.step(); + } + for _ in 0..count { + button(h, at, true); + h.step(); + button(h, at, false); + h.step(); + } + h.run_steps(3); +} + +fn opens(requests: &Requests) -> usize { + requests.borrow().iter().filter(|(r, _)| matches!(r, FileDialogRequest::Open { multiple: true, .. })).count() +} + +#[test] +fn double_clicking_the_empty_workspace_shows_file_open() { + for home in [false, true] { + let (mut h, requests) = harness(home); + assert!(h.state().session.documents().is_empty()); + // Well away from the Home screen's centred card. + let blank = pos2(40.0, 760.0); + clicks(&mut h, blank, 1); + assert_eq!(opens(&requests), 0, "home {home}: a single click opens nothing"); + clicks(&mut h, blank, 2); + assert_eq!(opens(&requests), 1, "home {home}: a double-click shows File › Open"); + } +} + +#[test] +fn the_home_screens_own_widgets_keep_their_double_clicks() { + let (mut h, requests) = harness(true); + // The subtitle is selectable text: a double-click there selects a word, not File › Open. + let at = h.get_by_label_contains("Create a new document or open an existing file").rect().center(); + clicks(&mut h, at, 2); + assert_eq!(opens(&requests), 0, "the label took the double-click"); + // The New document button opens the New Document dialog, not File › Open. + let at = h.get_by_label_contains("New document").rect().center(); + clicks(&mut h, at, 1); + assert_eq!(opens(&requests), 0); + assert_eq!(h.state().ui.dialogs.len(), 1, "the New Document dialog"); +} diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index bd7852ac38..2a325ea2ce 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -329,10 +329,23 @@ fn scripts_browse_lists_only_script_files() { assert!(matches!( open.borrow().first().map(|(r, _)| r), Some(FileDialogRequest::Open { multiple: false, extensions: Some(exts), .. }) - if exts.iter().map(String::as_str).collect::>() == ["psjs", "ccjs", "jsx", "js", "jsxbin", "json", "txt"] + if exts.iter().map(String::as_str).collect::>() == ["psjs", "ccjs", "jsx", "js", "jsxbin", "json", "txt", "pcdroplet"] )); } +#[test] +fn scripts_browse_extendscript_picks_report_the_error() { + // Photoshop scripts stay in the picker (matching its filter) and choosing one shows why it won't run. + let (mut app, open, _) = app(); + let ctx = egui::Context::default(); + menus::invoke(&mut app, &ctx, "file.scripts.browse", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, Some(FileDialogAnswer::Contents("resize.jsx".into(), b"alert('hi');".to_vec()))); + app.poll_file_dialog(&ctx, None); + assert!(app.ui.status_error); + assert!(app.ui.status.contains("not Photoshop ExtendScript"), "{}", app.ui.status); +} + #[test] fn last_used_dir_takes_the_most_recent_parent() { assert_eq!(last_used_dir(&[]), None); @@ -912,3 +925,41 @@ fn save_as_openraster_of_what_it_cannot_hold_is_a_copy() { assert!(err.contains("OpenRaster"), "{err}"); assert_eq!(written.borrow().len(), 1); } + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn render_video_menu_asks_for_directory_and_writes_frames_only_after_confirmation() { + let (mut app, open, _) = app(); + let ctx = egui::Context::default(); + let dir = std::env::temp_dir().join(format!( + "pc-render-video-ui-{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + let path = dir.to_string_lossy().into_owned(); + + let result = menus::invoke(&mut app, &ctx, "file.export.renderVideo", json!({})).unwrap(); + assert_eq!(result["fileDialog"], "folder"); + app.poll_file_dialog(&ctx, None); + assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Folder { .. }, _)])); + assert!(!dir.exists(), "a menu click must not create an output directory"); + + answer(&open, None); + app.poll_file_dialog(&ctx, None); + assert!(!dir.exists(), "cancelling the picker must not render video"); + + menus::invoke(&mut app, &ctx, "file.export.renderVideo", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, Some(FileDialogAnswer::FolderTo(path.clone()))); + app.poll_file_dialog(&ctx, None); + let rendered: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|entry| entry.unwrap().path()).collect(); + assert_eq!(rendered.len(), 1, "a document without a timeline exports one frame"); + assert_eq!(rendered[0].extension().and_then(|ext| ext.to_str()), Some("png")); + std::fs::remove_dir_all(&dir).unwrap(); + + // Commands that already supply a directory must not open a file picker. + let result = menus::invoke(&mut app, &ctx, "file.export.renderVideo", json!({"dir": path})).unwrap(); + assert!(result["frames"].as_u64().is_some()); + assert!(!app.file_dialog_open()); + std::fs::remove_dir_all(&dir).unwrap(); +} diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index bdefab6229..b1eb1e7b72 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -572,6 +572,29 @@ fn a_drop_slot_counts_documents_not_the_tabs_shown() { assert_eq!(crate::canvas::TabStrip { rect: strip.rect, tabs: vec![] }.slot(10.0), 0); } +/// #5687: files dropped from another app's window bring PhotoCraft to the front (a focus request +/// in the frame that takes the drop), so the source window ends up behind it. +#[test] +fn a_drop_brings_the_window_to_the_front() { + let dir = std::env::temp_dir().join(format!("photocraft-drop-focus-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("photo.png"); + std::fs::write(&file, png_bytes(8, 8)).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(800.0, 600.0)).with_max_steps(8).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(photocraft_engine::Session::new(), Services::default()) + }); + h.run_steps(2); + let focus = |h: &egui_kittest::Harness<'_, PhotocraftApp>| { + h.output().viewport_output.get(&egui::ViewportId::ROOT).is_some_and(|v| v.commands.contains(&egui::ViewportCommand::Focus)) + }; + assert!(!focus(&h), "no focus request without a drop"); + h.input_mut().dropped_files.push(dropped(file, Err("read from the path".into()))); + h.step(); + assert!(focus(&h), "the drop brings the window forward"); + let _ = std::fs::remove_dir_all(&dir); +} + /// The whole app (eframe harness, real layout): over the tab strip, the pointer read from the OS /// service picks a slot; files dropped there open (in the background) at it; dropped on the canvas they're placed in Free Transform, and Esc takes the place back. #[test] diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index c3b8abc4d7..3f87f6339c 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -55,8 +55,10 @@ fn form(app: &mut PhotocraftApp, command: &str, label: &str, fields: Value, choi json!({"dialog": app.ui.open_dialog(DialogKind::Command, f)}) } -/// File › Scripts › Browse… lists these script files (plus the JSON/text the engine parses). -const SCRIPT_EXTENSIONS: &[&str] = &["psjs", "ccjs", "jsx", "js", "jsxbin", "json", "txt"]; +/// File › Scripts › Browse… lists Photoshop's script types (matching its filter, #2452) plus the +/// JSON/text/droplet files the engine parses; selecting a Photoshop script gets the engine's +/// "not Photoshop ExtendScript" error instead of hiding those files from the picker. +const SCRIPT_EXTENSIONS: &[&str] = &["psjs", "ccjs", "jsx", "js", "jsxbin", "json", "txt", "pcdroplet"]; /// Menu items fronted here (dialogs before the engine command). `None` when not ours. pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Value) -> Option> { @@ -104,7 +106,7 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &V "file.scripts.statistics" => json!({"dialog": crate::stack_dialog::open_statistics(app)}), "file.scripts.browse" => { return Some(app.pick_file_bytes_filtered(SCRIPT_EXTENSIONS, |app, name, bytes| { - let r = app.run("file.scripts.browse", json!({"script": String::from_utf8_lossy(&bytes)})); + let r = app.run("file.scripts.browse", json!({"name": name, "script": String::from_utf8_lossy(&bytes)})); if r.is_ok() { let template = "Ran script {name}"; app.ui.status = crate::i18n::fmt(tl!(template), &[("name", &name)]); diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 53885a45a2..fa7cb9524d 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -822,6 +822,11 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { ui.horizontal(|ui| { ui.label(egui::RichText::new(label(&p.key)).color(t.text_dim)); crate::widgets::value_field(ui, &mut v, -30000.0..=30000.0, "", 80.0); + // A seed gets a fresh random value in one click (#5710): Clouds, Add Noise, + // Fibers… then render a new pattern, as each run of Photoshop's does. + if p.key == "seed" && ui.button(tl!("Random")).clicked() { + v = random_seed(ui.input(|i| i.time).to_bits(), v as i64) as f32; + } }); f.insert(p.key, json!(v.round() as i64)); } @@ -839,6 +844,15 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { } } +/// A new seed for a dialog's Random button: 0..=30000 (what the field takes), never `current`. +/// `entropy` varies per click (the input time); the process's random hasher keys mix it. +fn random_seed(entropy: u64, current: i64) -> i64 { + use std::hash::BuildHasher; + let h = std::collections::hash_map::RandomState::new().hash_one(entropy); + let seed = (h % 30_001) as i64; + if seed == current { (seed + 1) % 30_001 } else { seed } +} + /// User-facing params (strip the dialog's private `__` keys). pub fn params_of(f: &Map) -> Value { // A document picker left at "None" (-1) means "not given". @@ -962,7 +976,13 @@ pub struct FilterPreview { pub committing: bool, } -/// Match the full request before accepting a worker result, including a reopened dialog. +/// Identity of one filter dialog's live preview. +/// +/// `PartialEq` is the whole request: it says a worker result is exactly what the dialog is asking +/// for now. [`Self::same_target`] is the looser question of whether a result belongs to this +/// preview at all; two keys that agree on it name the same dialog, layer, document and preview +/// resolution, and differ only in parameters — which is what a slider drag produces, since the +/// worker computed for the value the pointer passed a moment ago. #[derive(Clone, Debug, PartialEq)] pub struct FilterPreviewKey { pub doc: photocraft_doc::DocId, @@ -975,10 +995,52 @@ pub struct FilterPreviewKey { pub k: u32, } +impl FilterPreviewKey { + /// Everything except the parameters: same dialog, document, revision, layer, channel target, + /// command and proxy factor. + /// + /// The canvas shows whatever this preview was computed for, so a result whose parameters are + /// a few frames old still belongs on screen while a slider is moving; matching on everything + /// would mean no frame of a drag is ever shown, because every completion lands after the + /// parameters moved on. A reopened dialog has a new id, a different command or a different + /// document, and a committed document a new revision, so neither survives this test. + pub fn same_target(&self, other: &Self) -> bool { + self.doc == other.doc + && self.revision == other.revision + && self.dialog == other.dialog + && self.active == other.active + && self.target == other.target + && self.command == other.command + && self.k == other.k + } +} + #[cfg(test)] mod tests { use super::*; + /// #5710: the seed field's Random button always gives a different seed the field can hold. + #[test] + fn random_seeds_stay_in_range_and_change() { + let mut seen = std::collections::HashSet::new(); + for i in 0..200u64 { + let s = random_seed(i, 7); + assert!((0..=30_000).contains(&s), "{s}"); + assert_ne!(s, 7, "a click always changes the seed"); + seen.insert(s); + } + assert!(seen.len() > 100, "seeds spread out: {}", seen.len()); + assert_ne!(random_seed(1, 30_000), 30_000); + // Every seed dialog shows the button: Clouds, Add Noise and the like carry a `seed` param. + let specs = photocraft_engine::command_specs(); + assert!( + specs + .iter() + .filter(|c| c.id.starts_with("filter.")) + .any(|c| parse_spec(c.params).iter().any(|p| p.key == "seed" && matches!(p.kind, Kind::Int { .. }))) + ); + } + /// Preset names such as "iPhone 14 Pro Max" (the artboard dialog's choices) are text, not /// camelCase keys: they used to read "I Phone 14 Pro Max". #[test] @@ -988,6 +1050,46 @@ mod tests { assert_eq!(source_label("centerX"), "Center X"); } + fn key() -> FilterPreviewKey { + FilterPreviewKey { + doc: photocraft_doc::DocId(7), + revision: 11, + dialog: 3, + active: Some(photocraft_doc::LayerId(2)), + target: Default::default(), + command: "filter.blur.gaussianBlur".into(), + params: json!({"radius": 4.0}), + k: 1, + } + } + + #[test] + fn same_target_ignores_parameters_but_nothing_else() { + // A drag only changes the parameters, and that must not hide the finished frame. + let base = key(); + let mut moved = base.clone(); + moved.params = json!({"radius": 40.0}); + assert!(base.same_target(&moved)); + assert!(moved.same_target(&base)); + + // Anything else names a different preview and must not be shown. + for break_it in [ + |k: &mut FilterPreviewKey| k.doc = photocraft_doc::DocId(8), + |k: &mut FilterPreviewKey| k.revision = 12, + |k: &mut FilterPreviewKey| k.dialog = 4, + |k: &mut FilterPreviewKey| k.active = Some(photocraft_doc::LayerId(5)), + |k: &mut FilterPreviewKey| k.active = None, + |k: &mut FilterPreviewKey| k.target = photocraft_engine::channel_cmds::ChannelTarget::LayerMask, + |k: &mut FilterPreviewKey| k.command = "filter.blur.motionBlur".into(), + |k: &mut FilterPreviewKey| k.k = 2, + ] { + let mut other = base.clone(); + break_it(&mut other); + assert!(!other.same_target(&base), "{other:?} must not pass as {base:?}"); + assert!(!base.same_target(&other)); + } + } + #[test] fn only_direction_angles_get_the_dial() { // Every listed command still has a degree `angle` range, so the list cannot go stale. @@ -1830,6 +1932,42 @@ mod tests { assert_eq!(photocraft_compose::flatten(&shown).px, photocraft_compose::flatten(&full).px); } + /// #5766: Curves on a smart object adds a smart filter, which re-renders the source through + /// the object's placement. On a reduced proxy that placement stayed in full-size document + /// coordinates, so the preview showed the object `k` times too large until OK. + #[test] + fn smart_object_curves_preview_keeps_its_size_on_a_reduced_proxy() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 16, "y": 16, "width": 16, "height": 16})).unwrap(); + s.execute("edit.fill", json!({"contents": "black"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let st = s.active().unwrap(); + let (doc, layer) = ((*st.doc).clone(), st.active_layer.unwrap()); + let alpha = |d: &Document, x: i32, y: i32| match &d.layer(layer).unwrap().content { + photocraft_doc::LayerContent::Smart(sm) => sm.cache.as_ref().unwrap().pixel(x, y).last().copied().unwrap(), + _ => panic!("a smart object"), + }; + let k = 4; + let shown = preview_document(&doc, Some(layer), "image.adjustments.curves", &json!({"points": [[0, 20], [90, 150], [255, 230]]}), k).unwrap(); + let photocraft_doc::LayerContent::Smart(sm) = &shown.layer(layer).unwrap().content else { panic!("a smart object") }; + assert_eq!(sm.smart_filters.len(), 1, "the preview adds the Curves smart filter"); + assert_eq!(shown.size, crate::proxy::proxy_document(&doc, k).size); + // The 16 px square at (16, 16) is a 4 px square at (4, 4) on the proxy, not 16 px at (16, 16). + // Its corners are partly covered by the smart object's resampling (220/255), its inside fully. + for (x, y) in [(5, 5), (5, 6), (6, 6)] { + assert!(alpha(&shown, x, y) > 0.99, "({x}, {y}) is inside the reduced square"); + } + for (x, y) in [(4, 4), (7, 7)] { + assert!(alpha(&shown, x, y) > 0.5, "({x}, {y}) is a corner of the reduced square"); + } + for (x, y) in [(2, 2), (9, 9), (12, 12), (15, 15)] { + assert!(alpha(&shown, x, y) < 0.01, "({x}, {y}) is outside the reduced square"); + } + } + /// #2063: a pixel-sized parameter divided by the proxy factor was clamped to the command's /// minimum (Pointillize's 5 px cells became 3 px proxy cells = 12 px), so the preview factor /// drops until every pixel-sized value fits its range. diff --git a/crates/ui-egui/src/filter_preview_worker.rs b/crates/ui-egui/src/filter_preview_worker.rs index 48f29400fb..3006323c20 100644 --- a/crates/ui-egui/src/filter_preview_worker.rs +++ b/crates/ui-egui/src/filter_preview_worker.rs @@ -160,7 +160,7 @@ mod tests { let (old, result) = finish(&mut worker); assert!(result.is_ok()); let latest = key(1, 0, 256); - assert_ne!(old, latest); // The canvas must discard this result before uploading. + assert_ne!(old, latest); // The canvas paints this as the drag's frame; the newest one is computed next. assert!(worker.start(latest.clone(), ctx, |_| Computed { result: None, ms: 1.0 }).unwrap()); assert_eq!(finish(&mut worker).0, latest); } diff --git a/crates/ui-egui/src/guide_layout_ui.rs b/crates/ui-egui/src/guide_layout_ui.rs index 8d47e628c8..894535e687 100644 --- a/crates/ui-egui/src/guide_layout_ui.rs +++ b/crates/ui-egui/src/guide_layout_ui.rs @@ -194,6 +194,9 @@ fn number(ui: &mut egui::Ui, f: &mut Map, key: &str, count: bool) let mut value = f.get(key).and_then(Value::as_f64).unwrap_or(0.0) as f32; if widgets::value_field(ui, &mut value, (if count { 1.0 } else { 0.0 })..=if count { 1000.0 } else { 300_000.0 }, if count { "" } else { "px" }, 68.0) .changed() + // A disabled field still clamps its value into range (a zero count to 1); writing that + // back would switch the axis on through its `positive` fallback (#5625). + && ui.is_enabled() { f.insert(key.into(), if count { json!(value.round() as u64) } else { json!(value) }); } diff --git a/crates/ui-egui/src/guide_layout_ui/tests.rs b/crates/ui-egui/src/guide_layout_ui/tests.rs index 299b3fb83e..673f1663bf 100644 --- a/crates/ui-egui/src/guide_layout_ui/tests.rs +++ b/crates/ui-egui/src/guide_layout_ui/tests.rs @@ -104,6 +104,32 @@ fn tab_skips_disabled_rows_and_margins() { assert_eq!(f["gutter"], 4.0); } +/// The disabled Number field must not clamp a zero count to 1 and so switch its axis on (#5625). +#[test] +fn disabled_number_fields_do_not_turn_their_axis_on() { + let mut app = app(); + let id = open(&mut app); + let mut h = harness(app); + h.run_steps(3); + let f = &h.state().ui.dialogs.first().unwrap().fields; + assert_eq!(f["rows"], 0, "the default layout has no rows"); + assert!(!on(f, "__rowsEnabled", positive(f, "rows")), "Rows stays off"); + let mut app = h.into_state(); + crate::dialogs::confirm(&mut app, id).unwrap(); + let guides = &app.session.active().unwrap().doc.guides; + assert!(!guides.vertical.is_empty(), "the columns were created"); + assert!(guides.horizontal.is_empty(), "no row guides at the top and bottom"); + + let mut app = self::app(); + let id = open(&mut app); + app.ui.dialog_mut(id).unwrap().fields.insert("columns".into(), json!(0)); + let mut h = harness(app); + h.run_steps(3); + let f = &h.state().ui.dialogs.first().unwrap().fields; + assert_eq!(f["columns"], 0); + assert!(!on(f, "__columnsEnabled", positive(f, "columns")), "Columns stays off"); +} + /// New Guide Layout offers New Guide's colours, by value, and keeps another one as Custom. #[test] fn color_list_is_new_guides() { diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5cfc0f8ff5..6c8f21418a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3783,3 +3783,17 @@ navigator Zoom Zvětšení # View › Guides › right-click a guide Edit Selected Guides Upravit vybraná vodítka Clear Selected Guides Vymazat vybraná vodítka + Delete this set Smazat tuto sadu + New Set… Nová sada… + PhotoCraft Defaults Výchozí PhotoCraft + Save a list of these shortcuts as a web page Uložit seznam těchto zkratek jako webovou stránku + Save the changes to this set Uložit změny do této sady + Save these shortcuts as a .kys file Uložit tyto zkratky jako soubor .kys + Save these shortcuts as a new set Uložit tyto zkratky jako novou sadu + Set deleted. Sada smazána. + Set saved. Sada uložena. + Set: Sada: + Summarize… Shrnout… + There are too many sets: delete one first. Sad je příliš mnoho: nejprve jednu smažte. + set name název sady + {set} (modified) {set} (změněno) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 05ac77803c..2bb1c57f19 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3177,3 +3177,17 @@ curves Smooth Glätten # View › Guides › right-click a guide Edit Selected Guides Ausgewählte Hilfslinien bearbeiten Clear Selected Guides Ausgewählte Hilfslinien löschen + Delete this set Diesen Satz löschen + New Set… Neuer Satz… + PhotoCraft Defaults PhotoCraft-Standard + Save a list of these shortcuts as a web page Liste dieser Kurzbefehle als Webseite speichern + Save the changes to this set Änderungen in diesem Satz speichern + Save these shortcuts as a .kys file Diese Kurzbefehle als .kys-Datei speichern + Save these shortcuts as a new set Diese Kurzbefehle als neuen Satz speichern + Set deleted. Satz gelöscht. + Set saved. Satz gespeichert. + Set: Satz: + Summarize… Zusammenfassen… + There are too many sets: delete one first. Zu viele Sätze: Löschen Sie zuerst einen. + set name Name des Satzes + {set} (modified) {set} (geändert) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index c6c5ff8f66..48d23d13b9 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3875,3 +3875,17 @@ curves Smooth Εξομάλυνση # View › Guides › right-click a guide Edit Selected Guides Επεξεργασία επιλεγμένων οδηγών Clear Selected Guides Απαλοιφή επιλεγμένων οδηγών + Delete this set Διαγραφή αυτού του συνόλου + New Set… Νέο σύνολο… + PhotoCraft Defaults Προεπιλογές PhotoCraft + Save a list of these shortcuts as a web page Αποθήκευση λίστας αυτών των συντομεύσεων ως ιστοσελίδας + Save the changes to this set Αποθήκευση των αλλαγών σε αυτό το σύνολο + Save these shortcuts as a .kys file Αποθήκευση αυτών των συντομεύσεων ως αρχείου .kys + Save these shortcuts as a new set Αποθήκευση αυτών των συντομεύσεων ως νέου συνόλου + Set deleted. Το σύνολο διαγράφηκε. + Set saved. Το σύνολο αποθηκεύτηκε. + Set: Σύνολο: + Summarize… Σύνοψη… + There are too many sets: delete one first. Υπάρχουν πάρα πολλά σύνολα: διαγράψτε πρώτα ένα. + set name όνομα συνόλου + {set} (modified) {set} (τροποποιημένο) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e24171195a..a7338795aa 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4137,3 +4137,17 @@ navigator Zoom Zoom # View › Guides › right-click a guide Edit Selected Guides Editar guías seleccionadas Clear Selected Guides Borrar guías seleccionadas + Delete this set Eliminar este conjunto + New Set… Nuevo conjunto… + PhotoCraft Defaults Valores por defecto de PhotoCraft + Save a list of these shortcuts as a web page Guardar una lista de estos métodos abreviados como página web + Save the changes to this set Guardar los cambios en este conjunto + Save these shortcuts as a .kys file Guardar estos métodos abreviados como archivo .kys + Save these shortcuts as a new set Guardar estos métodos abreviados como un conjunto nuevo + Set deleted. Conjunto eliminado. + Set saved. Conjunto guardado. + Set: Conjunto: + Summarize… Resumir… + There are too many sets: delete one first. Hay demasiados conjuntos: elimine uno primero. + set name nombre del conjunto + {set} (modified) {set} (modificado) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index db40d97abf..ba1de7b706 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3179,3 +3179,18 @@ curves Smooth Lisser # View › Guides › right-click a guide Edit Selected Guides Modifier les repères sélectionnés Clear Selected Guides Effacer les repères sélectionnés + Delete this set Supprimer ce jeu + New Set… Nouveau jeu… + PhotoCraft Defaults Valeurs par défaut de PhotoCraft + Save a list of these shortcuts as a web page Enregistrer la liste de ces raccourcis en page web + Save the changes to this set Enregistrer les modifications de ce jeu + Save these shortcuts as a .kys file Enregistrer ces raccourcis dans un fichier .kys + Save these shortcuts as a new set Enregistrer ces raccourcis comme nouveau jeu + Set deleted. Jeu supprimé. + Set saved. Jeu enregistré. + Set: Jeu : + Summarize… Résumer… + There are too many sets: delete one first. Il y a trop de jeux : supprimez-en un d’abord. + set name nom du jeu + {set} (modified) {set} (modifié) + Random Aléatoire diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 0404b429ce..e26f3ec3b8 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1194,7 +1194,7 @@ Capture Brush Size in Preset Ecsetméret rögzítése az előbeállításban Include Tool Settings Eszközbeállítások belefoglalása Include Color Szín belefoglalása - Enter a name for the brush preset Adjon nevet az ecset-előbeállításnak + Enter a name for the brush preset Adj nevet az ecset-előbeállításnak A brush preset with this name already exists Már van ilyen nevű ecset-előbeállítás New Crop Preset… Új körbevágási előbeállítás… New Tool Preset Új eszköz-előbeállítás @@ -2490,8 +2490,8 @@ navigator Zoom Nagyítás mértéke Camera Raw Filter ({layer}) Camera Raw szűrő ({layer}) Camera Raw isn't open A Camera Raw nincs megnyitva Cancelled opening {name} Megnyitás megszakítva: {name} - The document “{name}” does not have an embedded {mode} profile. A(z) „{name}” dokumentumnak nincs beágyazott {mode}-profilja. - The document “{name}” has an embedded color profile that does not match the current {mode} working space. A(z) „{name}” dokumentum beágyazott színprofilja nem egyezik az aktuális {mode}-munkaszíntérrel. + The document “{name}” does not have an embedded {mode} profile. Nincs beágyazott profil ({mode}) ebben a dokumentumban: „{name}” + The document “{name}” has an embedded color profile that does not match the current {mode} working space. A dokumentum beágyazott színprofilja nem egyezik az aktuális munkaszíntérrel ({mode}): „{name}” Working: {working} Munkaszíntér: {working} Cancelled {label} Megszakítva: {label} Clarity Tisztaság @@ -4018,7 +4018,7 @@ cameraRaw Yellow Sárga The layer or its mask changed. Drag the mask onto the Trash again. A réteg vagy a maszkja megváltozott. Húzd újra a maszkot a Kukába. Transform On Drop Átalakítás elengedéskor Vanishing Point is not active Az Enyészpont nem aktív - Vanishing Point: click four corners to create a plane Enyészpont: sík létrehozásához kattintson a négy sarokra + Vanishing Point: click four corners to create a plane Enyészpont: sík létrehozásához kattints a négy sarokra Response curve Válaszgörbe Show home recent files Legutóbbi fájlok megjelenítése a kezdőképernyőn Clear Brush Controls Ecsetvezérlők törlése @@ -4051,7 +4051,7 @@ cameraRaw Yellow Sárga Drag to pan · Scroll to zoom · Double-click to fit Húzás: mozgatás · Görgetés: nagyítás · Dupla kattintás: igazítás Gradient Preset Options Színátmenet-előbeállítás beállításai Import .GRD .GRD importálása - Choose a gradient file (.grd). Válasszon színátmenetfájlt (.grd). + Choose a gradient file (.grd). Válassz színátmenetfájlt (.grd). Save Settings… Beállítások mentése… Load Settings… Beállítások betöltése… Save these settings as a Camera Raw .xmp file A beállítások mentése Camera Raw .xmp-fájlként @@ -4078,7 +4078,7 @@ cameraRaw Yellow Sárga Use the embedded profile ({profile}) A beágyazott profil használata ({profile}) Convert to the working space ({profile}) Átalakítás a munkaszíntérbe ({profile}) Remember this choice for future images A választás megjegyzése a későbbi képekhez - Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. A kérdés a Szerkesztés › Színbeállítások Ask On Mismatch beállításával kapcsolható vissza. + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. A kérdést a Szerkesztés › Színbeállítások „Profileltérés: rákérdezés megnyitáskor” beállításával kapcsolhatod vissza. Button Mode Gomb mód Button Mode On Gomb mód be @@ -4094,3 +4094,17 @@ cameraRaw Yellow Sárga # View › Guides › right-click a guide Edit Selected Guides Kijelölt segédvonalak szerkesztése Clear Selected Guides Kijelölt segédvonalak törlése + Delete this set Készlet törlése + New Set… Új készlet… + PhotoCraft Defaults PhotoCraft alapértelmezések + Save a list of these shortcuts as a web page A billentyűparancsok listájának mentése weboldalként + Save the changes to this set A készlet változásainak mentése + Save these shortcuts as a .kys file A billentyűparancsok mentése .kys fájlként + Save these shortcuts as a new set A billentyűparancsok mentése új készletként + Set deleted. Készlet törölve. + Set saved. Készlet mentve. + Set: Készlet: + Summarize… Összegzés… + There are too many sets: delete one first. Túl sok készlet van: előbb töröljön egyet. + set name készlet neve + {set} (modified) {set} (módosítva) diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 544ead05a4..d13db9f0cf 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3166,3 +3166,18 @@ curves Smooth Haluskan # View › Guides › right-click a guide Edit Selected Guides Edit Panduan Terpilih Clear Selected Guides Hapus Panduan Terpilih + Delete this set Hapus set ini + New Set… Set Baru… + PhotoCraft Defaults Bawaan PhotoCraft + Save a list of these shortcuts as a web page Simpan daftar pintasan ini sebagai halaman web + Save the changes to this set Simpan perubahan ke set ini + Save these shortcuts as a .kys file Simpan pintasan ini sebagai file .kys + Save these shortcuts as a new set Simpan pintasan ini sebagai set baru + Set deleted. Set dihapus. + Set saved. Set disimpan. + Set: Set: + Summarize… Ringkas… + There are too many sets: delete one first. Terlalu banyak set: hapus satu terlebih dahulu. + set name nama set + {set} (modified) {set} (diubah) + Random Acak diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 40b468926e..1779c1259e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4296,3 +4296,17 @@ shape Appearance Aspetto # View › Guides › right-click a guide Edit Selected Guides Modifica guide selezionate Clear Selected Guides Cancella guide selezionate + Delete this set Elimina questo set + New Set… Nuovo set… + PhotoCraft Defaults Predefiniti di PhotoCraft + Save a list of these shortcuts as a web page Salva un elenco di queste scorciatoie come pagina web + Save the changes to this set Salva le modifiche in questo set + Save these shortcuts as a .kys file Salva queste scorciatoie come file .kys + Save these shortcuts as a new set Salva queste scorciatoie come nuovo set + Set deleted. Set eliminato. + Set saved. Set salvato. + Set: Set: + Summarize… Riepiloga… + There are too many sets: delete one first. Ci sono troppi set: eliminane prima uno. + set name nome del set + {set} (modified) {set} (modificato) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1d64bf868c..dd0cca727f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3851,3 +3851,17 @@ curves Smooth スムーズ # View › Guides › right-click a guide Edit Selected Guides 選択したガイドを編集 Clear Selected Guides 選択したガイドを消去 + Delete this set このセットを削除 + New Set… 新規セット… + PhotoCraft Defaults PhotoCraft 初期設定 + Save a list of these shortcuts as a web page これらのショートカットの一覧を Web ページとして保存 + Save the changes to this set このセットへの変更を保存 + Save these shortcuts as a .kys file これらのショートカットを .kys ファイルとして保存 + Save these shortcuts as a new set これらのショートカットを新規セットとして保存 + Set deleted. セットを削除しました。 + Set saved. セットを保存しました。 + Set: セット: + Summarize… 概要… + There are too many sets: delete one first. セットが多すぎます。先に 1 つ削除してください。 + set name セット名 + {set} (modified) {set}(変更あり) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index e76a3c9826..f5c243d1e5 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3353,3 +3353,17 @@ curves Smooth 매끄럽게 # View › Guides › right-click a guide Edit Selected Guides 선택한 안내선 편집 Clear Selected Guides 선택한 안내선 지우기 + Delete this set 이 세트 삭제 + New Set… 새 세트… + PhotoCraft Defaults PhotoCraft 기본값 + Save a list of these shortcuts as a web page 이 단축키 목록을 웹 페이지로 저장 + Save the changes to this set 이 세트에 변경 사항 저장 + Save these shortcuts as a .kys file 이 단축키를 .kys 파일로 저장 + Save these shortcuts as a new set 이 단축키를 새 세트로 저장 + Set deleted. 세트를 삭제했습니다. + Set saved. 세트를 저장했습니다. + Set: 세트: + Summarize… 요약… + There are too many sets: delete one first. 세트가 너무 많습니다. 먼저 하나를 삭제하세요. + set name 세트 이름 + {set} (modified) {set}(수정됨) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 2c9416c707..aea44757ca 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3935,3 +3935,17 @@ curves Smooth Vloeiend maken # View › Guides › right-click a guide Edit Selected Guides Geselecteerde hulplijnen bewerken Clear Selected Guides Geselecteerde hulplijnen wissen + Delete this set Deze set verwijderen + New Set… Nieuwe set… + PhotoCraft Defaults PhotoCraft-standaard + Save a list of these shortcuts as a web page Een lijst van deze sneltoetsen als webpagina opslaan + Save the changes to this set De wijzigingen in deze set opslaan + Save these shortcuts as a .kys file Deze sneltoetsen als .kys-bestand opslaan + Save these shortcuts as a new set Deze sneltoetsen als nieuwe set opslaan + Set deleted. Set verwijderd. + Set saved. Set opgeslagen. + Set: Set: + Summarize… Samenvatten… + There are too many sets: delete one first. Er zijn te veel sets: verwijder er eerst een. + set name naam van de set + {set} (modified) {set} (gewijzigd) diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index eee1e61a90..17feb459aa 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3773,3 +3773,17 @@ curves Smooth Wygładź # View › Guides › right-click a guide Edit Selected Guides Edytuj zaznaczone prowadnice Clear Selected Guides Usuń zaznaczone prowadnice + Delete this set Usuń ten zestaw + New Set… Nowy zestaw… + PhotoCraft Defaults Domyślne PhotoCraft + Save a list of these shortcuts as a web page Zapisz listę tych skrótów jako stronę internetową + Save the changes to this set Zapisz zmiany w tym zestawie + Save these shortcuts as a .kys file Zapisz te skróty jako plik .kys + Save these shortcuts as a new set Zapisz te skróty jako nowy zestaw + Set deleted. Zestaw usunięty. + Set saved. Zestaw zapisany. + Set: Zestaw: + Summarize… Podsumuj… + There are too many sets: delete one first. Zestawów jest za dużo: najpierw usuń jeden. + set name nazwa zestawu + {set} (modified) {set} (zmieniony) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3e10fe7dbb..0e9559c80e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3965,3 +3965,17 @@ layerLabel No Color Sem cor # View › Guides › right-click a guide Edit Selected Guides Editar guias selecionadas Clear Selected Guides Limpar guias selecionadas + Delete this set Excluir este conjunto + New Set… Novo conjunto… + PhotoCraft Defaults Padrões do PhotoCraft + Save a list of these shortcuts as a web page Salvar uma lista destes atalhos como página da web + Save the changes to this set Salvar as alterações neste conjunto + Save these shortcuts as a .kys file Salvar estes atalhos como arquivo .kys + Save these shortcuts as a new set Salvar estes atalhos como um novo conjunto + Set deleted. Conjunto excluído. + Set saved. Conjunto salvo. + Set: Conjunto: + Summarize… Resumir… + There are too many sets: delete one first. Há conjuntos demais: exclua um primeiro. + set name nome do conjunto + {set} (modified) {set} (modificado) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 862788630b..78e10a10c4 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3144,3 +3144,18 @@ curves Smooth Сгладить # View › Guides › right-click a guide Edit Selected Guides Изменить выбранные направляющие Clear Selected Guides Удалить выбранные направляющие + Delete this set Удалить этот набор + New Set… Новый набор… + PhotoCraft Defaults Стандартные PhotoCraft + Save a list of these shortcuts as a web page Сохранить список этих комбинаций как веб-страницу + Save the changes to this set Сохранить изменения в этом наборе + Save these shortcuts as a .kys file Сохранить эти комбинации как файл .kys + Save these shortcuts as a new set Сохранить эти комбинации как новый набор + Set deleted. Набор удалён. + Set saved. Набор сохранён. + Set: Набор: + Summarize… Сводка… + There are too many sets: delete one first. Слишком много наборов: сначала удалите один. + set name имя набора + {set} (modified) {set} (изменён) + Random Случайно diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index b91b67b3eb..f4a8552fb0 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4248,3 +4248,17 @@ shape Appearance Izgled # View › Guides › right-click a guide Edit Selected Guides Uredi izabrane vodilje Clear Selected Guides Ukloni izabrane vodilje + Delete this set Obriši ovaj skup + New Set… Novi skup… + PhotoCraft Defaults Podrazumevano za PhotoCraft + Save a list of these shortcuts as a web page Sačuvaj listu ovih prečica kao veb stranicu + Save the changes to this set Sačuvaj izmene u ovom skupu + Save these shortcuts as a .kys file Sačuvaj ove prečice kao .kys datoteku + Save these shortcuts as a new set Sačuvaj ove prečice kao novi skup + Set deleted. Skup je obrisan. + Set saved. Skup je sačuvan. + Set: Skup: + Summarize… Rezime… + There are too many sets: delete one first. Previše skupova: prvo obrišite jedan. + set name ime skupa + {set} (modified) {set} (izmenjen) diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 4c8f06ccc1..b11f51a807 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3905,3 +3905,17 @@ cameraData Lens Lens # View › Guides › right-click a guide Edit Selected Guides Seçili Kılavuzları Düzenle Clear Selected Guides Seçili Kılavuzları Temizle + Delete this set Bu Kümeyi Sil + New Set… Yeni Küme… + PhotoCraft Defaults PhotoCraft Varsayılanları + Save a list of these shortcuts as a web page Bu kısayolların listesini web sayfası olarak kaydet + Save the changes to this set Bu kümedeki değişiklikleri kaydet + Save these shortcuts as a .kys file Bu kısayolları .kys dosyası olarak kaydet + Save these shortcuts as a new set Bu kısayolları yeni bir küme olarak kaydet + Set deleted. Küme silindi. + Set saved. Küme kaydedildi. + Set: Küme: + Summarize… Özetle… + There are too many sets: delete one first. Çok fazla küme var: önce birini silin. + set name küme adı + {set} (modified) {set} (değiştirildi) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 39597c15cb..e563354fdf 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3874,3 +3874,17 @@ curves Smooth Згладити # View › Guides › right-click a guide Edit Selected Guides Редагувати вибрані напрямні Clear Selected Guides Очистити вибрані напрямні + Delete this set Видалити цей набір + New Set… Новий набір… + PhotoCraft Defaults Стандартні PhotoCraft + Save a list of these shortcuts as a web page Зберегти список цих клавіатурних скорочень як вебсторінку + Save the changes to this set Зберегти зміни в цьому наборі + Save these shortcuts as a .kys file Зберегти ці клавіатурні скорочення як файл .kys + Save these shortcuts as a new set Зберегти ці клавіатурні скорочення як новий набір + Set deleted. Набір видалено. + Set saved. Набір збережено. + Set: Набір: + Summarize… Зведення… + There are too many sets: delete one first. Забагато наборів: спершу видаліть один. + set name назва набору + {set} (modified) {set} (змінено) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index ac7123d850..6907be96aa 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3661,3 +3661,17 @@ curves Smooth Làm mượt # View › Guides › right-click a guide Edit Selected Guides Sửa đường căn đã chọn Clear Selected Guides Xóa đường căn đã chọn + Delete this set Xóa bộ này + New Set… Bộ mới… + PhotoCraft Defaults Mặc định của PhotoCraft + Save a list of these shortcuts as a web page Lưu danh sách các phím tắt này thành trang web + Save the changes to this set Lưu thay đổi vào bộ này + Save these shortcuts as a .kys file Lưu các phím tắt này thành tệp .kys + Save these shortcuts as a new set Lưu các phím tắt này thành bộ mới + Set deleted. Đã xóa bộ. + Set saved. Đã lưu bộ. + Set: Bộ: + Summarize… Tóm tắt… + There are too many sets: delete one first. Có quá nhiều bộ: hãy xóa một bộ trước. + set name tên bộ + {set} (modified) {set} (đã sửa đổi) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2307739ed3..3962f46d65 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2790,3 +2790,18 @@ curves Smooth 平滑 # View › Guides › right-click a guide Edit Selected Guides 编辑所选参考线 Clear Selected Guides 清除所选参考线 + Delete this set 删除此组 + New Set… 新建组… + PhotoCraft Defaults PhotoCraft 默认值 + Save a list of these shortcuts as a web page 将这些快捷键的列表存储为网页 + Save the changes to this set 将更改存储到此组 + Save these shortcuts as a .kys file 将这些快捷键存储为 .kys 文件 + Save these shortcuts as a new set 将这些快捷键存储为新组 + Set deleted. 已删除组。 + Set saved. 已存储组。 + Set: 组: + Summarize… 摘要… + There are too many sets: delete one first. 组太多:请先删除一个。 + set name 组名称 + {set} (modified) {set}(已修改) + Random 随机 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 478489f8c7..005c992e23 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2775,3 +2775,18 @@ curves Smooth 平滑 # View › Guides › right-click a guide Edit Selected Guides 編輯所選參考線 Clear Selected Guides 清除所選參考線 + Delete this set 刪除此組合 + New Set… 新增組合… + PhotoCraft Defaults PhotoCraft 預設值 + Save a list of these shortcuts as a web page 將這些快速鍵的清單儲存為網頁 + Save the changes to this set 將變更儲存到此組合 + Save these shortcuts as a .kys file 將這些快速鍵儲存為 .kys 檔案 + Save these shortcuts as a new set 將這些快速鍵儲存為新組合 + Set deleted. 已刪除組合。 + Set saved. 已儲存組合。 + Set: 組合: + Summarize… 摘要… + There are too many sets: delete one first. 組合太多:請先刪除一個。 + set name 組合名稱 + {set} (modified) {set}(已修改) + Random 隨機 diff --git a/crates/ui-egui/src/kys_import.rs b/crates/ui-egui/src/kys_import.rs index c0344ee8c7..0b9165abd5 100644 --- a/crates/ui-egui/src/kys_import.rs +++ b/crates/ui-egui/src/kys_import.rs @@ -32,12 +32,20 @@ pub fn plan(app: &PhotocraftApp, set: &KysSet) -> Plan { photocraft_engine::kys::plan(menu.chain(specs), set) } +/// The set's tool keys matched to our tools by name (Photoshop's names are ours): tool +/// shortcut id (`tools.select.move`) → letter, and the tools PhotoCraft doesn't have. +pub fn plan_tools(set: &KysSet) -> (BTreeMap, Vec) { + let tools: Vec<(String, &str)> = crate::state::Tool::ALL.into_iter().map(|t| (crate::tool_shortcuts::id(t), t.label())).collect(); + photocraft_engine::kys::plan_tools(tools.iter().map(|(id, name)| (id.as_str(), *name)), set) +} + /// Put the plan into the open Keyboard Shortcuts dialog (opening one when none is), as /// overrides the OK button applies: a key equal to the command's default clears its override. /// Returns the dialog id and the plan as JSON. pub fn import_text(app: &mut PhotocraftApp, file_name: &str, text: &str) -> Result { let set = parse(text).map_err(|e| format!("{file_name}: {e}"))?; let plan = plan(app, &set); + let (tool_keys, unknown_tools) = plan_tools(&set); let id = match app.ui.dialogs.iter().find(|d| d.fields.get("__prefsui").and_then(Value::as_str) == Some("shortcuts")) { Some(d) => d.id, None => crate::prefs_ui::open_shortcuts(app, 0), @@ -52,6 +60,17 @@ pub fn import_text(app: &mut PhotocraftApp, file_name: &str, text: &str) -> Resu overrides.insert(cmd.clone(), sc.clone()); } } + // Tool keys (#5036): a letter other than the tool's own becomes its override; its own + // letter clears one, so the tool keeps cycling with the others that share it. + for tool in crate::state::Tool::ALL { + let id = crate::tool_shortcuts::id(tool); + let Some(key) = tool_keys.get(&id) else { continue }; + if crate::tool_shortcuts::default(tool).is_some_and(|d| d.eq_ignore_ascii_case(key)) { + overrides.remove(&id); + } else { + overrides.insert(id, key.clone()); + } + } // A row's other keys are the command's further keys (#3665): Copy's F3 beside ⌘C. let mut alternates: BTreeMap> = f.get("alternates").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); for (cmd, keys) in &plan.alternate_keys { @@ -63,10 +82,10 @@ pub fn import_text(app: &mut PhotocraftApp, file_name: &str, text: &str) -> Resu } } let shown = std::path::Path::new(file_name).file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| file_name.to_string()); - let n = plan.set.len().to_string(); + let n = (plan.set.len() + tool_keys.len()).to_string(); let mut message = crate::i18n::fmt(tl!("Imported {n} shortcuts from {file}. Click OK to keep them."), &[("n", &n), ("file", &shown)]); - if !plan.unknown.is_empty() { - let u = plan.unknown.len().to_string(); + if !plan.unknown.is_empty() || !unknown_tools.is_empty() { + let u = (plan.unknown.len() + unknown_tools.len()).to_string(); message.push(' '); message.push_str(&crate::i18n::fmt(tl!("{n} commands are not in PhotoCraft."), &[("n", &u)])); } @@ -87,6 +106,9 @@ pub fn import_text(app: &mut PhotocraftApp, file_name: &str, text: &str) -> Resu "alternates": plan.alternates, "alternateKeys": plan.alternate_keys, "toolKeys": set.tool_keys, + "tools": tool_keys, + "unknownTools": unknown_tools, + "windows": set.windows, })) } diff --git a/crates/ui-egui/src/kys_import_tests.rs b/crates/ui-egui/src/kys_import_tests.rs index 08523da79b..b31ee98e68 100644 --- a/crates/ui-egui/src/kys_import_tests.rs +++ b/crates/ui-egui/src/kys_import_tests.rs @@ -61,7 +61,7 @@ fn app() -> PhotocraftApp { fn parses_rows_shortcuts_entities_and_tool_keys() { let set = parse(SAMPLE).unwrap(); assert_eq!(set.commands.len(), 11); - assert_eq!(set.commands[2], KysCommand { name: "Undo".into(), shortcuts: vec!["Cmd+Z".into(), "F1".into()] }); + assert_eq!(set.commands[2], KysCommand { name: "Undo".into(), shortcuts: vec!["Cmd+Z".into(), "F1".into()], id: None }); assert_eq!(set.commands[7].name, "Black & White..."); assert_eq!(set.tool_keys, 1, "the empty Default Colors row carries no key"); } @@ -104,7 +104,12 @@ fn rows_match_our_commands_by_label_with_photoshop_tie_breaks() { #[test] fn an_unreadable_shortcut_is_reported_not_imported() { let app = app(); - let set = super::KysSet { name: None, commands: vec![KysCommand { name: "Undo".into(), shortcuts: vec!["Cmd+Shift+Ctrl".into()] }], tool_keys: 0 }; + let set = super::KysSet { + name: None, + commands: vec![KysCommand { name: "Undo".into(), shortcuts: vec!["Cmd+Shift+Ctrl".into()], id: None }], + tool_keys: 0, + ..Default::default() + }; let p = plan(&app, &set); assert!(p.set.is_empty()); assert_eq!(p.unreadable, vec!["Undo".to_string()]); @@ -143,7 +148,9 @@ fn import_button_fills_the_dialog_and_ok_applies_the_set() { assert!(overrides.get("select.all").is_none(), "already the default: {overrides}"); assert!(overrides.get("view.zoomIn").is_none(), "Cmd+= is among the keys, so the default stays: {overrides}"); let message = field(&h, "message"); - assert_eq!(message, json!("Imported 9 shortcuts from texcuts.kys. Click OK to keep them. 2 commands are not in PhotoCraft.")); + // 9 commands and the Move Tool's V (its own letter: no override). + assert_eq!(message, json!("Imported 10 shortcuts from texcuts.kys. Click OK to keep them. 2 commands are not in PhotoCraft.")); + assert!(overrides.get("tools.select.move").is_none(), "{overrides}"); assert_eq!(field(&h, "capture"), json!(false)); assert_eq!(field(&h, "messageWarning"), json!(false), "an import clears the earlier shortcut conflict warning"); // Nothing is applied until OK. @@ -320,3 +327,31 @@ fn an_oversized_file_is_refused() { assert!(e.contains("larger than 4 MiB"), "{e}"); assert!(app.ui.dialogs.is_empty()); } + +#[test] +fn a_windows_set_with_tool_keys_imports_as_photoshop_meant_it() { + // Photoshop on Windows writes Ctrl for the primary modifier; the tool rows give letters. + let set = " +\tCtrl+CF3 +\tCtrl+Shift+Alt+L +\tV +\tN +\tQ +"; + let mut app = app(); + let r = super::import_text(&mut app, "win.kys", set).unwrap(); + assert_eq!((r["windows"].as_bool(), r["unknownTools"].clone()), (Some(true), json!(["Imaginary Tool"]))); + assert_eq!(r["tools"], json!({"tools.select.move": "V", "tools.select.pencil": "N"})); + let id = r["dialog"].as_u64().unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert!(f["overrides"].get("edit.copy").is_none(), "Ctrl+C on Windows is the default ⌘C: {}", f["overrides"]); + assert!(f["overrides"].get("tools.select.move").is_none(), "Move's own V keeps it cycling"); + assert_eq!(f["overrides"]["tools.select.pencil"], json!("N")); + let levels = f["overrides"].as_object().unwrap().iter().find(|(id, _)| id.contains("levels")).map(|(_, v)| v.clone()); + assert_eq!( + levels.as_ref().and_then(Value::as_str).and_then(photocraft_engine::prefs::normalize_shortcut), + photocraft_engine::prefs::normalize_shortcut("Cmd+Alt+Shift+L") + ); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(crate::tool_shortcuts::shortcut(&app, crate::state::Tool::Pencil).as_deref(), Some("N")); +} diff --git a/crates/ui-egui/src/layer_reveal_tests.rs b/crates/ui-egui/src/layer_reveal_tests.rs index e2c65a3222..181e42dc17 100644 --- a/crates/ui-egui/src/layer_reveal_tests.rs +++ b/crates/ui-egui/src/layer_reveal_tests.rs @@ -132,3 +132,32 @@ fn reveal_survives_odd_documents() { h.state_mut().sync_views(); h.run_steps(3); } +#[test] +fn layers_scroll_position_survives_document_switches() { + for theme in [ThemeKind::ProMedium, ThemeKind::Studio] { + let (app, inner, _, _, _) = tall_doc(); + let mut h = harness(app, theme); + h.state_mut().run("layer.select", json!({"layer": inner.0})).unwrap(); + h.run_steps(4); + let before = row(&h, "Deep inside").unwrap(); + assert!(in_view(&h, before)); + + // A short second document clamps its own scroll offset to zero. That must + // not erase the first document's position or trigger a selection change. + h.state_mut().run("file.new", json!({"width": 20, "height": 20})).unwrap(); + h.state_mut().sync_views(); + h.run_steps(4); + for _ in 0..3 { + h.state_mut().session.set_active(0); + h.state_mut().sync_views(); + h.run_steps(4); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(inner)); + let restored = row(&h, "Deep inside").unwrap(); + assert!(in_view(&h, restored), "{theme:?}: restored bottom row: {restored:?}"); + assert!((restored.top() - before.top()).abs() < 0.5, "{theme:?}: restored exact scroll position"); + h.state_mut().session.set_active(1); + h.state_mut().sync_views(); + h.run_steps(4); + } + } +} diff --git a/crates/ui-egui/src/layer_row_ui/edit_tests.rs b/crates/ui-egui/src/layer_row_ui/edit_tests.rs index 829df351c7..0c217dd3d0 100644 --- a/crates/ui-egui/src/layer_row_ui/edit_tests.rs +++ b/crates/ui-egui/src/layer_row_ui/edit_tests.rs @@ -174,7 +174,7 @@ fn edit_type_command_rejects_missing_or_non_type_layers_without_changes() { } #[test] -fn reentering_type_edit_keeps_the_session_and_cancel_restores_cached_pixels() { +fn layer_reselection_commits_type_edit_and_cancel_preserves_committed_text() { let mut s = session(); let id = s.execute("type.create", json!({"text": "Hello", "size": 48, "x": 100, "y": 180})).unwrap()["layer"].as_u64().unwrap(); // Stand-in for a PSD's retained Photoshop rendering, displaced from our glyph layout. @@ -197,10 +197,13 @@ fn reentering_type_edit_keeps_the_session_and_cancel_restores_cached_pixels() { let key = h.state().ui.text_edit.as_ref().expect("inline text editor").session.clone(); h.event(Event::Text("Changed".into())); h.run_steps(2); + // The first row click commits the edit; the second opens a new session (#5584). double_click(&mut h, at); - assert_eq!(h.state().ui.text_edit.as_ref().unwrap().session, key); + assert_ne!(h.state().ui.text_edit.as_ref().unwrap().session, key); assert_eq!(h.state().session.active().unwrap().history.entries().len(), steps + 1); crate::type_tool::cancel(h.state_mut()); + assert_eq!(text(&h, id), "Changed"); + assert!(h.state_mut().session.undo()); assert_eq!(text(&h, id), "Hello"); let st = h.state().session.active().unwrap(); let LayerContent::Text(t) = &st.doc.layer(LayerId(id)).unwrap().content else { panic!("text") }; diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs index c2781157a3..d876c18278 100644 --- a/crates/ui-egui/src/layer_style.rs +++ b/crates/ui-egui/src/layer_style.rs @@ -415,14 +415,15 @@ fn set_param(f: &mut Map, id: &str, key: &str, value: Value) { /// A fresh instance id: one past the highest numbered `fxN` in the list. fn next_id(f: &Map) -> String { - let max = effects_of(f) + let used: std::collections::HashSet = effects_of(f) .iter() .filter_map(|e| e.get("id").and_then(Value::as_str)) .filter_map(|id| id.strip_prefix("fx")) .filter_map(|n| n.parse::().ok()) - .max() - .unwrap_or(0); - format!("fx{}", max + 1) + .collect(); + let max = used.iter().copied().max().unwrap_or(0); + let next = max.checked_add(1).or_else(|| (1_u64..).find(|n| !used.contains(n))).unwrap_or(1); + format!("fx{next}") } /// Adds an instance of `kind`, right after the last one of its kind (the list, @@ -460,6 +461,39 @@ fn remove_instance(f: &mut Map, id: &str) { /// the layer's order), the blending options and the selected page. `select` /// names a kind to open: its first instance is selected (and enabled), or one /// is created. `light` is the document's global light angle. +/// Whether the selected page is a Drop Shadow or Inner Shadow, which a drag on the image moves +/// while the dialog is open, as in Photoshop (#5701; [`drag_shadow`]). +pub fn drags_shadow(f: &Map) -> bool { + let selected = f.get("selected").and_then(Value::as_str).unwrap_or_default(); + entry(f, selected).and_then(|e| e.get("kind")).and_then(Value::as_str).is_some_and(|k| matches!(k, "dropShadow" | "innerShadow")) +} + +/// A drag on the image with a shadow page selected: `start` is true on the press (it records the +/// shadow's angle and distance then), `total` the pointer's movement since in document pixels. +/// The shadow's offset follows the pointer, kept as Photoshop's whole-degree angle and whole-pixel +/// distance (offset = (−cos angle, sin angle) × distance, the light opposite the shadow); with +/// Use Global Light the shared angle moves too ([`set_param`]). +pub fn drag_shadow(f: &mut Map, start: bool, total: [f64; 2]) { + const KEY: &str = "__shadowDrag"; + let Some(id) = f.get("selected").and_then(Value::as_str).map(str::to_string).filter(|_| drags_shadow(f)) else { return }; + let Some(p) = entry(f, &id).and_then(|e| e.get("params")) else { return }; + if start || f.get(KEY).is_none() { + let angle = p.get("angle").and_then(Value::as_f64).unwrap_or(120.0); + let distance = p.get("distance").and_then(Value::as_f64).unwrap_or(0.0); + f.insert(KEY.into(), json!([angle, distance])); + } + let Some([angle, distance]) = f.get(KEY).and_then(Value::as_array).and_then(|a| Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?])) else { return }; + let a = angle.to_radians(); + let (ox, oy) = (-a.cos() * distance + total[0], a.sin() * distance + total[1]); + let d = ox.hypot(oy); + if !d.is_finite() { + return; + } + let new_angle = if d >= 0.5 { oy.atan2(-ox).to_degrees().round() } else { angle }; + set_param(f, &id, "angle", json!(new_angle)); + set_param(f, &id, "distance", json!(d.round().clamp(0.0, 30000.0))); +} + pub fn initial_fields(layer: &Layer, select: Option<&str>, light: f32) -> Map { let mut f = Map::new(); f.insert("layer".into(), json!(layer.id.0)); @@ -1345,6 +1379,38 @@ mod tests { use super::*; use photocraft_doc::StrokeFx; + /// #5701: with a shadow page selected, a drag on the image moves the shadow: its offset + /// follows the pointer, as whole degrees and pixels, the global light angle with it. + #[test] + fn dragging_on_the_image_moves_the_selected_shadow() { + let shadow = |id: &str, kind: &str, global: bool| json!({"id": id, "kind": kind, "on": true, "params": {"angle": 120.0, "distance": 10.0, "useGlobalLight": global}}); + let mut f = Map::new(); + f.insert("selected".into(), json!("fx1")); + f.insert("globalLight".into(), json!(120.0)); + f.insert("effects".into(), json!([shadow("fx1", "dropShadow", true), shadow("fx2", "innerShadow", true), shadow("fx3", "dropShadow", false)])); + assert!(drags_shadow(&f)); + let params = |f: &Map, id: &str| { + let p = &entry(f, id).unwrap()["params"]; + (p["angle"].as_f64().unwrap(), p["distance"].as_f64().unwrap()) + }; + // The press keeps the shadow where it is: (5, 8.66) px. + drag_shadow(&mut f, true, [0.0, 0.0]); + assert_eq!(params(&f, "fx1"), (120.0, 10.0)); + // Dragged to (15, 0): straight right of the layer, the light from the left (180°). + drag_shadow(&mut f, false, [10.0, -8.660_254]); + assert_eq!(params(&f, "fx1"), (180.0, 15.0)); + assert_eq!(params(&f, "fx2").0, 180.0, "the global light angle is shared"); + assert_eq!(params(&f, "fx3"), (120.0, 10.0), "an instance with its own angle is left alone"); + // Back onto the layer: no offset, and the angle the drag started with. + drag_shadow(&mut f, false, [-5.0, -8.660_254]); + assert_eq!(params(&f, "fx1"), (120.0, 0.0)); + // Not on a shadow page: nothing moves. + f.insert("selected".into(), json!(BLENDING)); + assert!(!drags_shadow(&f)); + drag_shadow(&mut f, true, [50.0, 50.0]); + assert_eq!(params(&f, "fx1"), (120.0, 0.0)); + } + fn session() -> photocraft_engine::Session { let mut s = photocraft_engine::Session::new(); s.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); @@ -1438,6 +1504,25 @@ mod tests { } } + #[test] + fn adding_an_effect_after_maximum_instance_id_uses_an_unused_id() { + // Imported and scripted effects can provide arbitrary instance IDs. + let layer = Layer::raster("x", photocraft_doc::PixelFormat::RGBA8); + let mut fields = initial_fields(&layer, None, 120.0); + fields.insert( + "effects".into(), + json!([ + {"id": "fx1", "kind": "stroke", "on": true, "params": {}}, + {"id": format!("fx{}", u64::MAX), "kind": "stroke", "on": false, "params": {}} + ]), + ); + add_instance(&mut fields, "stroke"); + + let ids: Vec<&str> = effects_of(&fields).iter().filter_map(|e| e.get("id").and_then(Value::as_str)).collect(); + assert_eq!(ids, ["fx1", "fx18446744073709551615", "fx2"]); + assert_eq!(fields["selected"], json!("fx2")); + } + #[test] fn initial_fields_lists_every_instance_and_selects() { let l = Layer::raster("x", photocraft_doc::PixelFormat::RGBA8); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8ffdb19d32..e910652092 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1087,6 +1087,10 @@ impl PhotocraftApp { id, "layer.vectorMask.add" | "layer.vectorMask.fromPath" | "layer.vectorMask.revealAll" | "layer.vectorMask.hideAll" | "layer.vectorMask.currentPath" ) && on_active_layer; + // A draft Pen path belongs to the current document, not the one about to be created. + if id == "file.new" && self.ui.pen.is_some() { + vector_ui::pen_commit(self, false); + } let r = if id == "actions.play" { actions::play(self, ¶ms) } else if photocraft_engine::actions_cmds::shell_view_command(id) { @@ -1768,6 +1772,7 @@ impl eframe::App for PhotocraftApp { // Caps Lock state read from the OS once per frame (see `Services::caps_lock`): the canvas // cursor block needs it before the frame renders. `None` (Wayland/web) keeps `false`. self.caps_lock = self.services.caps_lock.as_mut().is_some_and(|f| f()); + tool_cursor::set_wayland(ctx, self.services.is_wayland); if !self.styled { Self::setup_context(ctx, self.ui.theme); self.styled = true; @@ -1836,6 +1841,9 @@ impl eframe::App for PhotocraftApp { if !dropped.is_empty() { let at = self.services.cursor_pos.as_mut().and_then(|f| f(ctx)); self.open_dropped(ctx, dropped, at); + // Files dragged in from another app's window: PhotoCraft comes to the front and takes + // the keyboard, as Photoshop does, instead of staying behind the source (#5687). + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); } self.place_next_dropped(ctx); // The control transport wakes the UI on arrival (ctx.request_repaint); only poll while a @@ -1964,7 +1972,7 @@ impl eframe::App for PhotocraftApp { notices::show(self, &ctx); gpu_status::show_fallback(self, &ctx); kys_import::show_offer(self, &ctx); - if chrome && self.ui.panels.menu_bar && self.custom_titlebar && !self.ui.dialogs.is_empty() { + if self.titlebar_modal_drag(chrome) { titlebar::modal_drag_overlay(&ctx); } if self.custom_titlebar { @@ -2035,6 +2043,14 @@ fn read_dropped(_f: &dyn egui::DroppedFile) -> Result, String> { } impl PhotocraftApp { + /// Whether the title bar's free gap gets the drag overlay this frame: the app draws its title + /// strip (`chrome`: not hidden by the screen mode) and a dialog's modal layer covers it. Not + /// only with the custom title bar: on macOS the strip under the traffic lights is the window's + /// title bar too, and an open dialog (Embedded Profile Mismatch…) left it undraggable (#5826). + pub(crate) fn titlebar_modal_drag(&self, chrome: bool) -> bool { + chrome && self.ui.panels.menu_bar && !self.ui.dialogs.is_empty() + } + /// `params` aimed at the active layer's mask (`"target":"mask"`) when the Layers panel targets /// it and command `id` edits the target (adjustments, filters, fills) without naming one: /// ⌘I then inverts the mask, as in Photoshop (#780). A targeted alpha channel or Quick Mask @@ -2602,6 +2618,9 @@ mod profile_prompt_tests; #[cfg(test)] mod input_tests; +#[cfg(test)] +mod empty_workspace_tests; + #[cfg(test)] mod pen_variants_tests; @@ -2986,3 +3005,6 @@ mod channel_tint_tests { assert_eq!(channel_tint(Rgb, 0, 77, false), Color32::from_gray(77)); } } + +#[cfg(test)] +mod options_hit_area_tests; diff --git a/crates/ui-egui/src/marquee_tests.rs b/crates/ui-egui/src/marquee_tests.rs index 51b16916e5..169d4588b9 100644 --- a/crates/ui-egui/src/marquee_tests.rs +++ b/crates/ui-egui/src/marquee_tests.rs @@ -147,6 +147,23 @@ fn shift_during_the_drag_draws_a_square_and_the_readout_follows() { assert!(h.query_by_label("W:").is_none() && h.query_by_label_contains(" px").is_none(), "the readout goes away with the drag"); } +/// #5472: a ⇧ square pressed between pixels read 101 × 102 and selected that, because each axis +/// snapped to the pixel grid on its own. The readout and the selection are square, and the +/// Elliptical Marquee's circle too. +#[test] +fn shift_square_pressed_between_pixels_has_equal_sides() { + for tool in [Tool::RectMarquee, Tool::EllipseMarquee] { + let mut h = harness(tool); + press_at(&mut h, 50.3, 50.7, Modifiers::NONE); + mods(&mut h, Modifiers::SHIFT); + move_to(&mut h, 150.8, 90.0); + assert!(readout(&h, 102, 102), "{tool:?}: the square covers every pixel it touched, on both axes"); + release_at(&mut h, 150.8, 90.0, Modifiers::SHIFT); + mods(&mut h, Modifiers::NONE); + assert_eq!(selection(&h), Rect::new(50, 50, 152, 152), "{tool:?}"); + } +} + /// #204: Preferences › Tools › Show Transformation Values off hides the readout, not the marquee. #[test] fn show_transformation_values_off_hides_the_readout() { @@ -529,6 +546,15 @@ fn mouse_drag_inside_the_selection_moves_it() { assert_eq!(selection(&h), Rect::new(120, 90, 220, 170), "moved by (20, 10)"); } +/// #5646: the Move tool keeps Photoshop's arrow pointer instead of the 4-way move cursor (the +/// hover outline still marks the layer under the pointer). +#[test] +fn the_move_tool_keeps_the_arrow_pointer() { + let mut h = harness(Tool::Move); + move_to(&mut h, 200.0, 150.0); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Default); +} + /// #1428: hovering inside the ants with a marquee shows the move cursor (a press there drags the /// outline); outside it is the marquee's own cursor. #[test] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 376467a50a..7f47e6ea64 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -423,6 +423,23 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: } "file.export.exportAs" => Ok(json!({"dialog": crate::export_dialog::open(app)?})), "file.export.quickExportAsPng" => crate::export_dialog::quick_export_png(app), + // Render Video exports a sequence of frames into a folder, not a named file. + // Keep explicit directory arguments from automation and the control channel unchanged. + "file.export.renderVideo" if params.get("dir").is_none() => { + #[cfg(target_arch = "wasm32")] + { + Err("Render Video to a local directory is unavailable on the web".into()) + } + #[cfg(not(target_arch = "wasm32"))] + { + let doc = app.session.active().ok_or("no document")?.doc.id; + let mut render_params = params.as_object().cloned().unwrap_or_default(); + app.ask_folder(move |app, dir| { + render_params.insert("dir".into(), json!(dir)); + app.with_document(doc, |app| app.run("file.export.renderVideo", Value::Object(render_params))) + }) + } + } // Layer › Export As… / Quick Export as PNG: the export pipeline on just the active layer. l if matches!(l, "layer.exportAs" | "layer.quickExportAsPng") && params.as_object().is_none_or(|o| o.is_empty()) => { let layer = app.session.active().and_then(|d| d.active_layer).ok_or("no active layer")?; diff --git a/crates/ui-egui/src/move_auto_select_tests.rs b/crates/ui-egui/src/move_auto_select_tests.rs index 0f01222a57..403ab28cd1 100644 --- a/crates/ui-egui/src/move_auto_select_tests.rs +++ b/crates/ui-egui/src/move_auto_select_tests.rs @@ -152,6 +152,32 @@ fn every_theme_shows_the_auto_select_toggle() { } } +#[test] +fn every_theme_shows_the_transform_controls_toggle() { + // #5740: outside the Pro themes the Move tool's options bar had no Show Transform Controls, so + // a click on a layer never showed its box and handles there. + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + for theme in crate::theme::ThemeKind::ALL { + let (app, _, _) = app(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 60.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::panels::options_bar(app, ui); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, theme); + h.run_steps(4); + let before = h.state().ui.tool_options.move_show_transform; + h.get_by_label("Show Transform Controls").click(); + h.run_steps(2); + assert_ne!(h.state().ui.tool_options.move_show_transform, before, "{theme:?}: the toggle flips Show Transform Controls"); + } +} + #[test] fn every_theme_shows_the_type_options() { // #1387: outside the Pro themes the Type tool's options bar was only the hint "Click to add @@ -210,3 +236,24 @@ fn every_theme_shows_the_gradient_options() { assert!(combos.iter().any(|c| c.contains("Normal")), "{theme:?}: the blend mode picker shows; combo boxes: {combos:?}"); } } + +#[test] +fn shift_click_adds_shape_layers_one_after_another() { + // Shapes, as in Photoshop: a click selects one, each ⇧-click adds the one under it. + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + for rect in [[10, 10, 40, 40], [80, 10, 40, 40], [10, 100, 40, 40]] { + app.session.execute("shape.create", json!({"kind": "rect", "rect": rect, "fill": "#ff0000"})).unwrap(); + } + app.sync_views(); + app.ui.tool = Tool::Move; + app.ui.extras.snap = false; + app.ui.view.show.smart_guides = false; + let ids: Vec = app.session.active().unwrap().doc.layers.iter().skip(1).map(|l| l.id).collect(); + drag(&mut app, [30.0, 30.0], [0.0, 0.0], egui::Modifiers::NONE); + drag(&mut app, [100.0, 30.0], [0.0, 0.0], egui::Modifiers::SHIFT); + drag(&mut app, [30.0, 120.0], [0.0, 0.0], egui::Modifiers::SHIFT); + let mut selected = app.session.active().unwrap().selected_layers(); + selected.sort_by_key(|l| l.0); + assert_eq!(selected, ids); +} diff --git a/crates/ui-egui/src/new_doc_ui.rs b/crates/ui-egui/src/new_doc_ui.rs index c9bf902c67..e615620d4c 100644 --- a/crates/ui-egui/src/new_doc_ui.rs +++ b/crates/ui-egui/src/new_doc_ui.rs @@ -633,7 +633,7 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = UNITS.iter().map(|u| (u.0.to_string(), u.1)).collect(); @@ -645,7 +645,7 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map)| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + body(app, ui, f); + } + }, + (app, f), + ); + crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(3); + let p = command_params(&h.state().1); + assert_eq!((p["width"].as_u64(), p["height"].as_u64()), (Some(1), Some(1))); + } + /// #1147: the Orientation buttons swap the typed values too: 841 x 1189 mm becomes 1189 x 841. #[test] fn orientation_swap_keeps_the_typed_values() { diff --git a/crates/ui-egui/src/options_hit_area_tests.rs b/crates/ui-egui/src/options_hit_area_tests.rs new file mode 100644 index 0000000000..d444f06473 --- /dev/null +++ b/crates/ui-egui/src/options_hit_area_tests.rs @@ -0,0 +1,199 @@ +//! #5052: the actual Brush options caption and field form one scrub target. +use crate::PhotocraftApp; +use egui::{Modifiers, Pos2, Rect, pos2, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; + +fn harness() -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.tool = crate::state::Tool::Brush; + app.session.tools.brush.hardness = 0.5; + let mut h = Harness::builder().with_size(vec2(1400.0, 60.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + crate::panels::options_bar(app, ui); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(4); + h +} + +fn geometry(h: &Harness<'_, PhotocraftApp>) -> (Rect, Rect) { + let label = h.get_by_label("Hardness").rect(); + let field = h + .get_all_by_role(egui::accesskit::Role::SpinButton) + .map(|n| n.rect()) + .filter(|r| r.left() >= label.right()) + .min_by(|a, b| a.left().total_cmp(&b.left())) + .unwrap(); + (label, field) +} + +fn drag(h: &mut Harness<'_, PhotocraftApp>, at: Pos2, dx: f32, mods: Modifiers) { + h.hover_at(at); + h.run(); + h.drag_at(at); + h.run(); + for i in 1..=4 { + h.event(egui::Event::ModifiersChanged(mods)); + h.hover_at(at + vec2(dx * i as f32 / 4.0, 0.0)); + h.run(); + } + h.drop_at(at + vec2(dx, 0.0)); + h.run_steps(2); +} + +#[test] +fn options_hardness_label_drag_cannot_copy_selected_caption() { + let mut h = harness(); + let (l, _) = geometry(&h); + drag(&mut h, pos2(l.left() + 2.0, l.center().y), 20.0, Modifiers::NONE); + assert!(!h.ctx.plugin::().lock().has_selection(), "scrubbing Hardness must not create label text selection"); + h.event(egui::Event::Copy); + h.step(); + assert!( + !h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(s) if s.contains("Hard"))), + "a scrub caption must not select text for copying" + ); + assert!(h.state().session.tools.brush.hardness > 0.5); +} + +#[test] +fn options_hardness_caption_upper_and_lower_margins_scrub() { + for dy in [-10.0, 10.0] { + let mut h = harness(); + let (l, _) = geometry(&h); + drag(&mut h, l.center() + vec2(0.0, dy), 12.0, Modifiers::NONE); + assert!((h.state().session.tools.brush.hardness - 0.62).abs() < 0.0001, "24pt caption margin dy={dy}: {}", h.state().session.tools.brush.hardness); + assert_eq!(h.state().session.journal.len(), 1, "one command for the whole gesture"); + } +} + +#[test] +fn options_hardness_gap_and_suffix_scrub() { + for suffix in [false, true] { + let mut h = harness(); + let (l, f) = geometry(&h); + let x = if suffix { f.right() + 8.0 } else { (l.right() + f.left() - 4.0) / 2.0 }; + drag(&mut h, pos2(x, l.center().y), 12.0, Modifiers::NONE); + assert!((h.state().session.tools.brush.hardness - 0.62).abs() < 0.0001, "new region suffix={suffix}: {}", h.state().session.tools.brush.hardness); + assert_eq!(h.state().session.journal.len(), 1); + } +} + +fn click(h: &mut Harness<'_, PhotocraftApp>, at: Pos2) { + h.hover_at(at); + h.run(); + h.drag_at(at); + h.run(); + h.drop_at(at); + h.run_steps(2); +} + +#[test] +fn options_numeric_editing_and_focused_text_selection_keep_priority() { + let mut h = harness(); + let (_, f) = geometry(&h); + click(&mut h, f.center()); + let id = h.ctx.memory(|m| m.focused()).expect("click focuses the numeric editor"); + drag(&mut h, pos2(f.left() + 2.0, f.center().y), f.width() - 4.0, Modifiers::NONE); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(id), "text drag keeps numeric focus"); + assert_eq!(h.state().session.tools.brush.hardness, 0.5, "selecting digits must not scrub"); + let state = egui::TextEdit::load_state(&h.ctx, id).expect("focused text state"); + assert!(!state.cursor.char_range().unwrap().is_empty(), "digit selection still works"); + h.key_press_modifiers(Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("25+5".into())); + h.run(); + h.key_press(egui::Key::Enter); + h.run_steps(2); + assert!((h.state().session.tools.brush.hardness - 0.3).abs() < 0.0001); +} + +#[test] +fn options_expanded_hit_click_does_not_edit_or_request_focus() { + let mut h = harness(); + let (_, f) = geometry(&h); + click(&mut h, f.center()); + let before_focus = h.ctx.memory(|m| m.focused()).expect("numeric editor focus"); + let before_journal = h.state().session.journal.len(); + let l = h.get_by_label("Hardness").rect(); + click(&mut h, l.center() + vec2(0.0, 10.0)); + assert_eq!(h.state().session.tools.brush.hardness, 0.5); + println!( + "focus_before={before_focus:?} focus_after={:?} value={} journal_before={before_journal} journal_after={}", + h.ctx.memory(|m| m.focused()), + h.state().session.tools.brush.hardness, + h.state().session.journal.len() + ); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(before_focus), "matches actual main: background retains numeric focus without acquiring it"); + assert_eq!(h.state().session.journal.len(), before_journal); +} + +#[test] +fn options_added_regions_preserve_modifiers_and_numeric_drag_rate() { + for (mods, dx, want) in [(Modifiers::SHIFT, 3.0, 0.8), (Modifiers::ALT, 20.0, 0.52)] { + let mut h = harness(); + let (l, f) = geometry(&h); + drag(&mut h, pos2(f.right() + 8.0, l.center().y), dx, mods); + assert!((h.state().session.tools.brush.hardness - want).abs() < 0.0001); + } + let mut h = harness(); + let (_, f) = geometry(&h); + drag(&mut h, f.center(), 20.0, Modifiers::NONE); + assert!((h.state().session.tools.brush.hardness - 0.6).abs() < 0.0001, "numeric drag retains its own rate"); +} + +#[test] +fn options_disabled_eraser_caption_and_added_regions_do_nothing() { + let mut h = harness(); + h.state_mut().ui.tool = crate::state::Tool::Eraser; + h.state_mut().ui.tool_options.eraser_mode = "block".into(); + h.run_steps(3); + let (l, f) = geometry(&h); + for at in [l.center(), pos2(f.right() + 8.0, l.center().y), l.center() + vec2(0.0, 10.0)] { + drag(&mut h, at, 12.0, Modifiers::NONE); + assert_eq!(h.state().session.tools.brush.hardness, 0.5); + assert!(h.state().session.journal.is_empty()); + } +} + +#[test] +fn options_field_outer_top_and_bottom_margins_scrub() { + for top in [true, false] { + let mut h = harness(); + let (_, f) = geometry(&h); + let y = if top { f.top() - 0.5 } else { f.bottom() + 0.5 }; + drag(&mut h, pos2(f.center().x, y), 12.0, Modifiers::NONE); + assert!((h.state().session.tools.brush.hardness - 0.62).abs() < 0.0001, "field margin top={top}"); + assert_eq!(h.state().session.journal.len(), 1); + } +} + +#[test] +fn options_gesture_keeps_its_start_owner_when_crossing_regions() { + let mut h = harness(); + let (l, _) = geometry(&h); + drag(&mut h, pos2(l.right() - 2.0, l.center().y), 24.0, Modifiers::NONE); + assert!((h.state().session.tools.brush.hardness - 0.74).abs() < 0.0001); + assert_eq!(h.state().session.journal.len(), 1); + let mut h = harness(); + let (_, f) = geometry(&h); + drag(&mut h, pos2(f.left() + 2.0, f.center().y), -30.0, Modifiers::NONE); + assert!((h.state().session.tools.brush.hardness - 0.35).abs() < 0.0001); + assert_eq!(h.state().session.journal.len(), 1); +} + +#[test] +fn options_tab_moves_from_hardness_to_opacity_without_a_caption_stop() { + let mut h = harness(); + let (_, f) = geometry(&h); + click(&mut h, f.center()); + h.key_press(egui::Key::Tab); + h.run_steps(2); + h.key_press(egui::Key::ArrowDown); + h.run_steps(2); + assert_eq!(h.state().session.tools.brush.hardness, 0.5); + assert!((h.state().session.tools.brush.opacity - 0.99).abs() < 0.0001); +} diff --git a/crates/ui-egui/src/outline.rs b/crates/ui-egui/src/outline.rs index bdb5c41501..9e06a1ae68 100644 --- a/crates/ui-egui/src/outline.rs +++ b/crates/ui-egui/src/outline.rs @@ -10,6 +10,21 @@ use photocraft_raster::Surface; /// A boundary segment in document pixel coordinates (axis-aligned). pub type Segment = ([i32; 2], [i32; 2]); +/// Bound a whole-selection trace, including offscreen pixels carried into view by a transform. +/// Include grid alignment and the two padding cells in the budget; ordinary traces keep their step. +pub(crate) fn bounded_step(bounds: Rect, mut step: u32) -> u32 { + step = step.max(1); + let cells = |step: u32| { + let s = i64::from(step); + let axis = |lo: i32, hi: i32| ((i64::from(hi) + s - 1).div_euclid(s) - i64::from(lo).div_euclid(s) + 2).max(0) as u64; + axis(bounds.x0, bounds.x1).saturating_mul(axis(bounds.y0, bounds.y1)) + }; + while cells(step) > 1_048_576 { + step = step.saturating_mul(2); + } + step +} + /// Extract the boundary of `mask` (> 0.5 = selected) as merged horizontal and vertical segments. pub fn outline(mask: &Surface, bounds: Rect) -> Vec { outline_scaled(mask, bounds, 1) @@ -40,15 +55,22 @@ fn outline_scaled_impl(mask: &Surface, bounds: Rect, step: u32, parallel: bool) let mut grid = vec![false; w * h]; let half = step / 2; let sample_row = |gy: usize, row: &mut [bool]| { - let y = (gy0 + gy as i32) * step + half; - if y < bounds.y0 || y >= bounds.y1 { + let y0 = (gy0 + gy as i32) * step; + if y0 >= bounds.y1 || y0 + step <= bounds.y0 { return; } + // A coarse cell can overlap a one-pixel-wide selection even when its centre misses it. + // Sample the overlapping edge; padding cells still stay outside the mask. + let y = (y0 + half).clamp(bounds.y0, bounds.y1 - 1); let mut vals = vec![0.0f32; row.len()]; mask.sample_row_strided(y, gx0 * step + half, step, 0, &mut vals); for (gx, (cell, v)) in row.iter_mut().zip(vals).enumerate() { let x = (gx0 + gx as i32) * step + half; - *cell = x >= bounds.x0 && x < bounds.x1 && v > 0.5; + *cell = if x >= bounds.x0 && x < bounds.x1 { + v > 0.5 + } else { + x - half < bounds.x1 && x - half + step > bounds.x0 && mask.sample_channel(x.clamp(bounds.x0, bounds.x1 - 1), y, 0) > 0.5 + }; } }; #[cfg(not(target_arch = "wasm32"))] @@ -108,6 +130,33 @@ mod tests { use super::*; use photocraft_color::PixelFormat; + #[test] + fn whole_transform_trace_has_a_bounded_grid() { + assert_eq!(bounded_step(Rect::new(0, 0, 200, 150), 1), 1); + assert_eq!(bounded_step(Rect::new(0, 0, 6000, 6000), 1), 8); + for bounds in [Rect::new(-50_001, -50_003, 50_000, 50_000), Rect::new(-1_000_000, 0, 1_000_000, 1)] { + let step = i64::from(bounded_step(bounds, 1)); + let w = (i64::from(bounds.x1) + step - 1).div_euclid(step) - i64::from(bounds.x0).div_euclid(step) + 2; + let h = (i64::from(bounds.y1) + step - 1).div_euclid(step) - i64::from(bounds.y0).div_euclid(step) + 2; + assert!(w * h <= 1_048_576, "{bounds:?}: {w} × {h}"); + } + // Both distant pieces survive the coarse trace: it covers the whole selection, not the viewport. + let mut mask = Surface::new(PixelFormat::GRAY8); + mask.fill_rect(Rect::new(-50_000, -50_000, -49_488, -49_488), &[1.0]); + mask.fill_rect(Rect::new(49_488, 49_488, 50_000, 50_000), &[1.0]); + let bounds = mask.content_bounds(); + let lines = outline_scaled_serial(&mask, bounds, bounded_step(bounds, 1)); + assert!(lines.iter().any(|(a, _)| a[0] < -49_000)); + assert!(lines.iter().any(|(a, _)| a[0] > 49_000)); + let mut thin = Surface::new(PixelFormat::GRAY8); + thin.fill_rect(Rect::new(-1_000_000, 0, -999_488, 1), &[1.0]); + thin.fill_rect(Rect::new(999_488, 0, 1_000_000, 1), &[1.0]); + let bounds = thin.content_bounds(); + let lines = outline_scaled_serial(&thin, bounds, bounded_step(bounds, 1)); + assert!(lines.iter().any(|(a, _)| a[0] < -999_000), "thin left piece remains visible"); + assert!(lines.iter().any(|(a, _)| a[0] > 999_000), "thin right piece remains visible"); + } + #[test] fn rectangle_has_four_edges() { let mut m = Surface::new(PixelFormat::GRAY8); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 1eb1284923..698bd68474 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -90,18 +90,16 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { .exact_size(w) .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(m, 8))) .show(ui, |ui| { - if t.pro { - // Photoshop's toolbar header chevrons switch between one and two columns. - let (cr, resp) = ui.allocate_exact_size(vec2(bx, 14.0), Sense::click()); - // The chevrons point the way the toolbar grows or shrinks: towards the canvas. - let (shrink, grow) = if crate::direction::is_rtl() { ("chevrons-right", "chevrons-left") } else { ("chevrons-left", "chevrons-right") }; - icons::paint(ui, cr, if double { shrink } else { grow }, 11.0, if resp.hovered() { t.text } else { t.text_faint }); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Toolbar"))); - if resp.clicked() { - app.ui.panels.toolbar_double = !app.ui.panels.toolbar_double; - } - ui.add_space(4.0); + // Toolbar width is a user preference, not a Pro-only feature. + // Keep the same hit target in every theme (including RTL layouts). + let (cr, resp) = ui.allocate_exact_size(vec2(bx, 14.0), Sense::click()); + let (shrink, grow) = if crate::direction::is_rtl() { ("chevrons-right", "chevrons-left") } else { ("chevrons-left", "chevrons-right") }; + icons::paint(ui, cr, if double { shrink } else { grow }, 11.0, if resp.hovered() { t.text } else { t.text_faint }); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Toolbar"))); + if resp.clicked() { + app.ui.panels.toolbar_double = !double; } + ui.add_space(4.0); // Subtle violet wash at the bottom of the toolbar. let full = ui.max_rect(); if !t.bevel && !t.pro && t.dark() { @@ -548,10 +546,18 @@ fn color_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui, two_columns: bool) { } s.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Swap colours (X)"))); if s.on_hover_text(tl!("Swap colours (X)")).clicked() { - let _ = app.run("tools.swapColors", json!({})); + chip_command(app, "tools.swapColors"); } if d.is_some_and(|d| d.on_hover_text(tl!("Default colours (D)")).clicked()) { - let _ = app.run("tools.defaultColors", json!({})); + chip_command(app, "tools.defaultColors"); + } +} + +/// Run Switch Colors or Default Colors from the chips. As in Photoshop, characters highlighted +/// in the type layer being edited take the new foreground colour, like picking one (#5690). +fn chip_command(app: &mut PhotocraftApp, id: &str) { + if app.run(id, json!({})).is_ok() { + crate::type_tool::foreground_changed(app); } } @@ -1044,7 +1050,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let block = crate::eraser_ui::block_mode(app, app.ui.tool); ui.add_enabled_ui(!block, |ui| { opt_label(ui, tl!("Size")); - widgets::value_field(ui, &mut b.size, 1.0..=2500.0, "px", 76.0); + widgets::value_field(ui, &mut b.size, 1.0..=photocraft_engine::paint::MAX_BRUSH_SIZE, "px", 76.0); }); widgets::vline(ui, 22.0); if app.ui.tool == Tool::Eraser { @@ -1366,6 +1372,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { Tool::Move => { // The Studio themes keep Photoshop's Auto-Select toggle too (#1275). widgets::checkbox(ui, &mut app.ui.tool_options.move_auto_select, tl!("Auto-Select:")); + // And Show Transform Controls (#5740): without it the box could not be + // turned on outside the Pro themes. + widgets::checkbox(ui, &mut app.ui.tool_options.move_show_transform, tl!("Show Transform Controls")); hint(ui, tl!("Drag to move the active layer")); } Tool::Eyedropper => crate::eyedropper_ui::options(app, ui), @@ -1536,8 +1545,8 @@ fn body_text_width(ui: &egui::Ui, s: &str) -> f32 { 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() }; - let label = ui.label(RichText::new(tl!(&text)).color(t.text_dim)); - widgets::scrub_label(ui, &label); + let label = ui.add(egui::Label::new(RichText::new(tl!(&text)).color(t.text_dim)).selectable(false)); + widgets::scrub_option_label(ui, &label); } fn percent_field(ui: &mut egui::Ui, label: &str, value: &mut f32, range: std::ops::RangeInclusive, width: f32) { @@ -1761,6 +1770,13 @@ fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { .hover_doc .map(|p| (p[0].floor() as i32, p[1].floor() as i32)) .filter(|(x, y)| *x >= 0 && *y >= 0 && *x < size.width as i32 && *y < size.height as i32); + // While a guide is dragged (#5728), the Info panel shows its position in one axis only, even + // when the pointer is outside the canvas (ruler drags do not update `hover_doc` there). + let (guide_x, guide_y) = match app.guide_drag { + Some(d) if d.vertical => (Some(d.pos), None), + Some(d) => (None, Some(d.pos)), + None => (None, None), + }; // The readout averages the Eyedropper's Sample Size, as in Photoshop (#1649). let sample_size = app.ui.tool_options.eyedropper_size; let rgba = pos.and_then(|(x, y)| { @@ -1799,8 +1815,8 @@ fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.columns(2, |cols| { let dash = || "—".to_string(); // Measured from the rulers' zero point (#2955). - row(&mut cols[0], "X:", pos.map_or_else(dash, |p| fx(p.0 as f64 - origin[0]))); - row(&mut cols[0], "Y:", pos.map_or_else(dash, |p| fy(p.1 as f64 - origin[1]))); + row(&mut cols[0], "X:", guide_x.map(|p| fx(p - origin[0])).or_else(|| pos.map(|p| fx(p.0 as f64 - origin[0]))).unwrap_or_else(dash)); + row(&mut cols[0], "Y:", guide_y.map(|p| fy(p - origin[1])).or_else(|| pos.map(|p| fy(p.1 as f64 - origin[1]))).unwrap_or_else(dash)); row(&mut cols[1], "W:", sel.map_or_else(dash, |r| fx(r.width() as f64))); row(&mut cols[1], "H:", sel.map_or_else(dash, |r| fy(r.height() as f64))); }); @@ -2138,7 +2154,7 @@ fn blend_options(groups: bool, current: BlendMode) -> Vec<(BlendMode, &'static s /// list downward (reveals rows above) and negative upward (reveals rows below). /// The speed ramps with proximity to the edge and clamps to the maximum speed when /// dragged outside, using elapsed time instead of assuming a particular refresh rate. -fn layer_drag_edge_scroll(pointer: Option, viewport: Rect, dragging: bool, dt: f32) -> f32 { +pub(crate) fn layer_drag_edge_scroll(pointer: Option, viewport: Rect, dragging: bool, dt: f32) -> f32 { if !dragging || viewport.width() <= 0.0 || viewport.height() <= 0.0 || dt.is_nan() || dt <= 0.0 { return 0.0; } @@ -2284,7 +2300,8 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let fill = 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") + // Keep each document's position independent, even when tab indices change (#5522). + .id_salt(("layer-rows", doc.id.0)) .max_height(rows_h) .min_scrolled_height(if fill { rows_h } else { 0.0 }) .auto_shrink([false, !fill]) @@ -2514,6 +2531,11 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } }); for (id, p) in actions { + // A row click commits even when it selects the layer already being edited (#5584). + // Finish before selection changes so the existing type session keeps its own target. + if id == "layer.select" && app.ui.text_edit.is_some() { + crate::type_tool::commit(app); + } if id == "ui.maskTarget" { app.ui.mask_target = p.as_bool().unwrap_or(false); app.ui.vector_mask_target = false; @@ -4279,6 +4301,64 @@ mod color_tests { } } + /// While characters are highlighted in a type layer being edited, the Switch Colors and + /// Default Colors buttons recolour them with the new foreground (#5690); without a selection + /// they only change the tool colours, as before. + #[test] + fn swap_and_default_colors_recolour_selected_type() { + use egui_kittest::kittest::Queryable; + use photocraft_doc::LayerId; + let rgb = |app: &PhotocraftApp, id: LayerId, ci: usize| { + let doc = &app.session.active().unwrap().doc; + let photocraft_doc::LayerContent::Text(t) = &doc.layer(id).unwrap().content else { panic!("not a type layer") }; + let b = t.text.char_indices().nth(ci).map_or(t.text.len(), |(b, _)| b); + let mut at = 0; + for r in t.char_runs() { + if b < at + r.len { + return r.style.color.to_rgba8(); + } + at += r.len; + } + [0; 4] + }; + for selected in [true, false] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let made = app.run("type.create", json!({"text": "Hello world", "size": 40, "x": 50, "y": 150, "color": "#000000"})).unwrap(); + let id = LayerId(made["layer"].as_u64().unwrap()); + app.run("tools.setColors", json!({"foreground": "#ff0000", "background": "#0000ff"})).unwrap(); + let anchor = if selected { 6 } else { 11 }; + app.ui.text_edit = Some(crate::state::TextEdit { + layer: id.0, + caret: 11, + anchor, + session: "s".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); + let mut h = + egui_kittest::Harness::builder().with_size(vec2(80.0, 120.0)).build_ui_state(|ui, app: &mut PhotocraftApp| color_chips(app, ui, false), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(2); + h.get_by_label("Swap colours (X)").click(); + h.run_steps(2); + assert_eq!(h.state().session.tools.foreground, [0.0, 0.0, 1.0, 1.0]); + assert_eq!(h.state().session.tools.background, [1.0, 0.0, 0.0, 1.0]); + let blue = if selected { [0, 0, 255, 255] } else { [0, 0, 0, 255] }; + assert_eq!(rgb(h.state(), id, 0), [0, 0, 0, 255], "unselected characters keep their colour"); + assert_eq!((rgb(h.state(), id, 6), rgb(h.state(), id, 10)), (blue, blue), "selected: {selected}"); + // Default Colors: the selection takes black, the new foreground. + h.state_mut().run("tools.setColors", json!({"foreground": "#00ff00"})).unwrap(); + h.get_by_label("Default colours (D)").click(); + h.run_steps(2); + assert_eq!(h.state().session.tools.foreground, [0.0, 0.0, 0.0, 1.0]); + assert_eq!(rgb(h.state(), id, 6), [0, 0, 0, 255], "selected: {selected}"); + } + } + /// The Studio layout (chips stacked, icons below) fits the height the toolbar reserves for /// the chips, so the two-column check does not change with the theme. #[test] @@ -5410,35 +5490,45 @@ mod toolbar_tests { use super::*; use egui_kittest::kittest::Queryable; - /// #1197: the header chevron switches the toolbar between one and two columns. + /// #1197 / #3469: every theme can toggle the toolbar without changing its selection. #[test] - fn header_chevron_toggles_two_columns() { - let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); - let mut h = egui_kittest::Harness::builder().with_size(vec2(800.0, 1400.0)).build_ui_state( - |ui, app: &mut PhotocraftApp| { - if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { - toolbar(app, ui); - let left = ui.available_rect_before_wrap().left(); - ui.data_mut(|d| d.insert_temp(egui::Id::new("toolbar-test-left"), left)); - } - }, - app, - ); - PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Pro); - h.run_steps(2); - let left = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.ctx.data(|d| d.get_temp::(egui::Id::new("toolbar-test-left"))).unwrap_or(0.0); - let single = left(&h); - assert!(!h.state().ui.panels.toolbar_double); + fn header_chevron_toggles_two_columns_in_every_layout() { + for theme in [ + crate::theme::ThemeKind::Pro, + crate::theme::ThemeKind::Studio, + crate::theme::ThemeKind::StudioLight, + crate::theme::ThemeKind::Classic, + crate::theme::ThemeKind::AdwaitaDark, + crate::theme::ThemeKind::SolarizedDark, + crate::theme::ThemeKind::BreezeDark, + ] { + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut h = egui_kittest::Harness::builder().with_size(vec2(800.0, 1400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + toolbar(app, ui); + let left = ui.available_rect_before_wrap().left(); + ui.data_mut(|d| d.insert_temp(egui::Id::new("toolbar-test-left"), left)); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, theme); + h.run_steps(2); + let left = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.ctx.data(|d| d.get_temp::(egui::Id::new("toolbar-test-left"))).unwrap_or(0.0); + let single = left(&h); + assert!(!h.state().ui.panels.toolbar_double, "{theme:?}"); - h.get_by_label("Toolbar").click(); - h.run_steps(2); - assert!(h.state().ui.panels.toolbar_double); - assert!(left(&h) > single + 20.0, "the toolbar did not widen: {single} -> {}", left(&h)); + h.get_by_label("Toolbar").click(); + h.run_steps(2); + assert!(h.state().ui.panels.toolbar_double, "{theme:?}"); + assert!(left(&h) > single + 20.0, "{theme:?}: the toolbar did not widen: {single} -> {}", left(&h)); - h.get_by_label("Toolbar").click(); - h.run_steps(2); - assert!(!h.state().ui.panels.toolbar_double); - assert_eq!(left(&h), single); + h.get_by_label("Toolbar").click(); + h.run_steps(2); + assert!(!h.state().ui.panels.toolbar_double, "{theme:?}"); + assert_eq!(left(&h), single, "{theme:?}"); + } } fn click(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, p: egui::Pos2) { @@ -6206,6 +6296,114 @@ mod properties_target_regressions { #[cfg(test)] #[path = "history_panel_select_tests.rs"] mod history_panel_select_tests; +#[cfg(test)] +mod layer_click_type_commit_tests { + use super::*; + use egui_kittest::{Harness, kittest::Queryable}; + + fn harness(kind: theme::ThemeKind) -> (Harness<'static, PhotocraftApp>, LayerId, LayerId) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let text = LayerId(app.run("type.create", json!({"text": "Before", "size": 24, "x": 20, "y": 80})).unwrap()["layer"].as_u64().unwrap()); + app.run("layer.setProps", json!({"layer": text.0, "name": "Edited type"})).unwrap(); + let other = LayerId(app.run("layer.new.layer", json!({"name": "Other pixels"})).unwrap()["layer"].as_u64().unwrap()); + app.run("layer.select", json!({"layer": text.0})).unwrap(); + crate::type_tool::edit_active(&mut app).unwrap(); + let session = app.ui.text_edit.as_ref().unwrap().session.clone(); + app.run("type.edit", json!({"layer": text.0, "text": "Preserved edit", "coalesce": session})).unwrap(); + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(360.0, 650.0)).with_step_dt(1.0 / 60.0).build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if !ready { + PhotocraftApp::setup_context(ui.ctx(), kind); + ready = true; + return; + } + layers(app, ui); + }, + app, + ); + h.run_steps(4); + (h, text, other) + } + + fn click(h: &mut Harness<'_, PhotocraftApp>, at: Pos2, modifiers: egui::Modifiers) { + h.event(egui::Event::ModifiersChanged(modifiers)); + h.hover_at(at); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers }); + h.run_steps(1); + } + } + + fn preserved(app: &PhotocraftApp, id: LayerId) { + let layer = app.session.active().unwrap().doc.layer(id).unwrap(); + let LayerContent::Text(text) = &layer.content else { panic!("type must remain editable") }; + assert_eq!(text.text, "Preserved edit"); + } + + #[test] + fn layer_name_and_thumbnail_clicks_commit_same_or_other_type_selection() { + for kind in theme::ThemeKind::ALL { + for same in [true, false] { + for thumbnail in [true, false] { + let (mut h, text, other) = harness(kind); + let label = if same { "Edited type" } else { "Other pixels" }; + let row = h.get_by_label(label).rect(); + let at = pos2(row.left() + if thumbnail { 43.0 } else { 120.0 }, row.center().y); + click(&mut h, at, egui::Modifiers::NONE); + assert!(h.state().ui.text_edit.is_none(), "{kind:?}, same={same}, thumbnail={thumbnail}"); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(if same { text } else { other })); + preserved(h.state(), text); + } + } + } + } + + #[test] + fn layer_click_commits_without_breaking_modifier_selection_or_double_click_editing() { + for kind in theme::ThemeKind::ALL { + let (mut h, text, other) = harness(kind); + let row = h.get_by_label("Other pixels").rect(); + click(&mut h, pos2(row.left() + 120.0, row.center().y), egui::Modifiers { command: true, ..Default::default() }); + assert!(h.state().ui.text_edit.is_none()); + let selected = h.state().session.active().unwrap().selected_layers(); + assert!(selected.contains(&text) && selected.contains(&other)); + preserved(h.state(), text); + + let (mut h, text, _) = harness(kind); + let row = h.get_by_label("Edited type").rect(); + let thumb = pos2(row.left() + 43.0, row.center().y); + click(&mut h, thumb, egui::Modifiers::NONE); + assert!(h.state().ui.text_edit.is_none()); + click(&mut h, thumb, egui::Modifiers::NONE); + assert_eq!(h.state().ui.text_edit.as_ref().map(|ed| ed.layer), Some(text.0)); + preserved(h.state(), text); + } + } + + #[test] + #[ignore = "offscreen visual evidence for #5584"] + fn layer_click_synthetic_visual_evidence() { + let (h, text, _) = harness(theme::ThemeKind::ProMedium); + let mut app = h.into_state(); + app.ui.tool = Tool::Type; + let mut h = Harness::builder().with_size(vec2(1280.0, 900.0)).with_step_dt(1.0 / 60.0).with_max_steps(64).wgpu().build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, theme::ThemeKind::ProMedium); + app + }); + h.run_steps(4); + assert!(h.state().ui.text_edit.is_some()); + h.render().unwrap().save("5584-before.png").unwrap(); + let row = h.get_by_label("Other pixels").rect(); + click(&mut h, pos2(row.left() + 120.0, row.center().y), egui::Modifiers::NONE); + assert!(h.state().ui.text_edit.is_none()); + preserved(h.state(), text); + h.run_steps(4); + h.render().unwrap().save("5584-after.png").unwrap(); + } +} #[cfg(test)] mod sampler_readout_tests { diff --git a/crates/ui-egui/src/path_selection_tests.rs b/crates/ui-egui/src/path_selection_tests.rs index 8f82e2ad68..b4aaa9e503 100644 --- a/crates/ui-egui/src/path_selection_tests.rs +++ b/crates/ui-egui/src/path_selection_tests.rs @@ -247,3 +247,87 @@ fn clicking_shape_with_path_selection_selects_its_layer() { crate::vector_ui::path_selection_finish(h.state_mut(), [150.0, 90.0], [150.0, 90.0]); assert_eq!(h.state().session.active().unwrap().active_layer.unwrap().0, rect_layer); } + +/// Work paths remain visible (outline) regardless of the active tool. Before the fix, +/// `draw_overlay` was gated on `vector_tool` so paths disappeared when switching to Brush, Move, +/// etc. This test renders the canvas with a Brush tool active and a work path present, then +/// confirms the path's outline pixels are painted in the accent colour, proving the path is drawn. +#[test] +fn path_overlay_drawn_for_non_vector_tools() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.sync_views(); + // Place a work path whose top edge runs exactly along y=10 in document space. + app.run("path.set", json!({"name": "work", "path": rect_path(50.0, 10.0, 100.0, 80.0)})).unwrap(); + // Switch to a non-vector tool — before the fix this hid the path outline. + app.ui.tool = Tool::Brush; + + let mut h = Harness::builder().with_size(vec2(900.0, 700.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(6); + // Fit the view so document coordinates map predictably to screen pixels. + { + let v = &mut h.state_mut().ui.views[0]; + v.zoom = 2.0; // 2 px/doc-px → the 200×200 doc fills 400×400 screen pixels + v.center = [100.0, 100.0]; + v.fit_pending = false; + } + h.run_steps(3); + + // Get the accent colour so we know what to look for. + let accent = crate::theme::Tokens::get(&h.ctx).accent; + + let xf = crate::canvas::ViewXform::active(h.state()).expect("active document view"); + let ppp = h.ctx.pixels_per_point(); + let img = h.render().unwrap(); + + // Sample the rendered outline at points derived from the same transform the canvas used. The + // top edge is doc-y=10; x=70..130 stays away from the rectangle's anchor squares. A 1 px + // egui stroke is anti-aliased over the white canvas, so accept visible blends of the accent. + let accent_r = accent.r(); + let accent_g = accent.g(); + let accent_b = accent.b(); + let is_accent_stroke = |pixel: [u8; 4]| { + let denom = f32::from(255u8.saturating_sub(accent_r)).max(1.0); + let coverage = f32::from(255u8.saturating_sub(pixel[0])) / denom; + if !(0.2..=1.05).contains(&coverage) { + return false; + } + let blended = |channel: u8| 255.0 + coverage * (f32::from(channel) - 255.0); + (f32::from(pixel[1]) - blended(accent_g)).abs() <= 12.0 && (f32::from(pixel[2]) - blended(accent_b)).abs() <= 12.0 + }; + + let has_accent_near = |at: egui::Pos2| { + let cx = (at.x * ppp).round() as i32; + let cy = (at.y * ppp).round() as i32; + let radius = (2.0 * ppp).ceil() as i32; + for y in (cy - radius)..=(cy + radius) { + for x in (cx - radius)..=(cx + radius) { + if x >= 0 && y >= 0 && (x as u32) < img.width() && (y as u32) < img.height() { + let pixel = img.get_pixel(x as u32, y as u32); + if is_accent_stroke([pixel[0], pixel[1], pixel[2], pixel[3]]) { + return true; + } + } + } + } + false + }; + let hits = (70..=130).step_by(5).filter(|x| has_accent_near(xf.to_screen(*x as f32, 10.0))).count(); + assert!( + hits >= 10, + "expected the work path outline to be visible with Brush tool active, \ + but found accent-coloured pixels at only {hits} sample point(s) along the transformed top edge \ + (accent = #{accent_r:02x}{accent_g:02x}{accent_b:02x}); \ + the path must be drawn regardless of the active tool", + ); +} diff --git a/crates/ui-egui/src/pen_draw_tests.rs b/crates/ui-egui/src/pen_draw_tests.rs index 74689a1070..155450eac6 100644 --- a/crates/ui-egui/src/pen_draw_tests.rs +++ b/crates/ui-egui/src/pen_draw_tests.rs @@ -122,6 +122,23 @@ fn alt_breaks_the_handle_being_dragged_and_grabs_the_last_outgoing_handle() { assert!(near([k.out_ctrl.x, k.out_ctrl.y], [140.0, 20.0])); } +#[test] +fn opening_a_new_document_finishes_the_previous_pen_path() { + let mut h = harness(); + click(&mut h, [20.0, 20.0]); + click(&mut h, [120.0, 20.0]); + assert_eq!(h.state().ui.pen.as_ref().map(|p| p.knots.len()), Some(2)); + let original = h.state().session.active().unwrap().doc.id; + + h.state_mut().run("file.new", json!({"width": 300, "height": 200})).unwrap(); + assert!(h.state().ui.pen.is_none(), "the Pen draft must not overlay the new document"); + let docs = h.state().session.documents(); + assert_eq!(docs.len(), 2); + assert_eq!(docs[0].doc.id, original); + assert_eq!(docs[0].doc.work_path.as_ref().unwrap().subpaths[0].knots.len(), 2); + assert!(docs[1].doc.work_path.is_none(), "the new document starts with no path"); +} + #[test] fn dragging_on_the_first_anchor_closes_with_its_handles_in_one_step() { let mut h = harness(); @@ -154,3 +171,51 @@ fn dragging_on_the_first_anchor_closes_with_its_handles_in_one_step() { assert!(sp.closed && sp.knots.len() == 3); assert!(near([k.in_ctrl.x, k.in_ctrl.y], [0.0, 20.0]) && near([k.out_ctrl.x, k.out_ctrl.y], [40.0, 20.0]), "{k:?}"); } + +/// #5702: a finished work path showed again whenever the Pen came back, and ⌫ / Delete reached +/// Edit › Clear (the pixels under it) instead, so the path looked impossible to delete. With the +/// Pen the key deletes the path, in one undoable step, and leaves the pixels; other tools keep +/// Edit › Clear. +#[test] +fn delete_with_the_pen_deletes_the_finished_work_path_not_the_pixels() { + let mut h = harness(); + h.state_mut().run("layer.new.layer", json!({})).unwrap(); + h.state_mut() + .session + .edit("paint", |doc, a| { + doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 200, 150), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + for p in [[20.0, 20.0], [120.0, 20.0], [120.0, 100.0]] { + click(&mut h, p); + } + h.key_press(Key::Enter); + h.run_steps(2); + let work = |h: &Harness<'static, PhotocraftApp>| h.state().session.active().unwrap().doc.work_path.is_some(); + let red = |h: &Harness<'static, PhotocraftApp>| { + let st = h.state().session.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().rgba(60, 60) == [1.0, 0.0, 0.0, 1.0] + }; + assert!(work(&h) && h.state().ui.pen.is_none(), "a finished work path"); + let steps = h.state().session.active().unwrap().history.past_len(); + + h.key_press(Key::Delete); + h.run_steps(2); + assert!(!work(&h), "Delete deletes the work path"); + assert!(red(&h), "and leaves the pixels under it"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1, "one History step"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert!(work(&h), "Undo brings it back"); + + h.state_mut().ui.tool = Tool::PathSelection; + h.key_press(Key::Backspace); + h.run_steps(2); + assert!(!work(&h), "Backspace with Path Selection deletes it too"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + + h.state_mut().ui.tool = Tool::Brush; + h.key_press(Key::Delete); + h.run_steps(2); + assert!(work(&h), "another tool's Delete is Edit › Clear, not Delete Path"); +} diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 808a181478..d194803020 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1006,6 +1006,8 @@ pub fn open_shortcuts(app: &mut PhotocraftApp, tab: u64) -> u64 { "filter": "", "overrides": p.shortcuts, "alternates": p.alternate_shortcuts, + "sets": p.shortcut_sets, + "activeSet": p.shortcut_set, "hidden": p.menus.hidden, "colors": p.menus.colors, "toolbarHidden": p.toolbar.hidden, @@ -1675,12 +1677,6 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map>(v.clone())); @@ -1698,6 +1694,17 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map (Vec, String) { let lang = crate::i18n::current(); @@ -1933,6 +1940,167 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, alternates: &BTreeMap>, id: &str, default: Option<&str>) -> Vec { + let main = match overrides.get(id) { + Some(s) if s.is_empty() => None, + Some(s) => Some(s.as_str()), + None => default, + }; + let mut p = prefs::Preferences::default(); + if let Some(list) = alternates.get(id) { + p.alternate_shortcuts.insert(id.to_string(), list.clone()); + } + let mut keys: Vec = main.and_then(prefs::normalize_shortcut).into_iter().collect(); + keys.extend(p.alternates(id, main)); + keys +} + +/// Edit › Keyboard Shortcuts › Set, as in Photoshop: choose a saved set (its bindings replace +/// the dialog's), save the bindings as a new set or over the one in use, delete it, export it as +/// a `.kys` file or summarize it as an HTML page. Everything but Export and Summarize waits for +/// OK, like the rest of the dialog. +#[allow(clippy::too_many_arguments)] +fn set_row( + app: &mut PhotocraftApp, + ui: &mut egui::Ui, + f: &mut Map, + overrides: &mut BTreeMap, + alternates: &mut BTreeMap>, + items: &[(String, String, Vec, Option)], + message: &mut String, + warning: &mut bool, +) { + use photocraft_engine::shortcut_sets::{self, ShortcutSet}; + let mut sets: BTreeMap = f.get("sets").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + let mut active = f.get("activeSet").and_then(Value::as_str).unwrap_or("").to_string(); + if !active.is_empty() && !sets.contains_key(&active) { + active.clear(); + } + let now = ShortcutSet { shortcuts: overrides.clone(), alternate_shortcuts: alternates.clone() }; + let modified = match sets.get(&active) { + Some(s) => *s != now, + None => now != ShortcutSet::default(), + }; + let defaults = tl!("PhotoCraft Defaults").to_string(); + let shown = |name: &str| -> String { + let base = if name.is_empty() { defaults.clone() } else { name.to_string() }; + if name == active && modified { crate::i18n::fmt(tl!("{set} (modified)"), &[("set", &base)]) } else { base } + }; + let names: Vec<(String, String)> = std::iter::once(String::new()).chain(sets.keys().cloned()).map(|n| (n.clone(), shown(&n))).collect(); + let mut new_name: Option = f.get("newSetName").and_then(Value::as_str).map(str::to_string); + let set_label = if active.is_empty() { defaults.clone() } else { active.clone() }; + let mut export = false; + let mut summarize = false; + ui.horizontal(|ui| { + ui.label(tl!("Set:")); + let mut chosen = active.clone(); + let pairs: Vec<(String, &str)> = names.iter().map(|(n, l)| (n.clone(), l.as_str())).collect(); + if crate::widgets::dropdown(ui, "shortcut-set", &mut chosen, &pairs, 220.0) && chosen != active { + let set = sets.get(&chosen).cloned().unwrap_or_default(); + *overrides = set.shortcuts; + *alternates = set.alternate_shortcuts; + active = chosen; + message.clear(); + } + if ui.button(tl!("New Set…")).on_hover_text(tl!("Save these shortcuts as a new set")).clicked() { + new_name = Some(String::new()); + } + if ui.add_enabled(!active.is_empty() && modified, egui::Button::new(tl!("Save"))).on_hover_text(tl!("Save the changes to this set")).clicked() { + sets.insert(active.clone(), now.clone()); + *message = tl!("Set saved.").into(); + *warning = false; + } + if ui.add_enabled(!active.is_empty(), egui::Button::new(tl!("Delete"))).on_hover_text(tl!("Delete this set")).clicked() { + sets.remove(&active); + active.clear(); + *message = tl!("Set deleted.").into(); + *warning = false; + } + export = ui.button(tl!("Export…")).on_hover_text(tl!("Save these shortcuts as a .kys file")).clicked(); + summarize = ui.button(tl!("Summarize…")).on_hover_text(tl!("Save a list of these shortcuts as a web page")).clicked(); + }); + if let Some(name) = new_name.as_mut() { + let mut create = false; + let mut cancel = false; + ui.horizontal(|ui| { + ui.label(tl!("Name:")); + let r = ui.add(egui::TextEdit::singleline(name).desired_width(220.0).hint_text(tl!("set name"))); + create = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + create |= ui.button(tl!("Create")).clicked(); + cancel = ui.button(tl!("Cancel")).clicked(); + }); + if create { + match shortcut_sets::set_name(name) { + Ok(n) if !sets.contains_key(&n) && sets.len() >= shortcut_sets::MAX_SETS => { + *message = tl!("There are too many sets: delete one first.").into(); + *warning = true; + } + Ok(n) => { + sets.insert(n.clone(), now.clone()); + active = n; + new_name = None; + *message = tl!("Set saved.").into(); + *warning = false; + } + Err(e) => { + *message = e; + *warning = true; + } + } + } else if cancel { + new_name = None; + } + } + if export || summarize { + let result = if export { + let entries: Vec = items + .iter() + .map(|(id, label, _, def)| photocraft_engine::kys::Entry { + id: id.clone(), + label: label.clone(), + tool: id.starts_with("tools.select."), + keys: dialog_keys(overrides, alternates, id, def.as_deref()), + }) + .collect(); + let text = photocraft_engine::kys::write(&set_label, &entries); + save_text(app, &format!("{set_label}.kys"), text) + } else { + let mut sections: Vec = Vec::new(); + for (id, label, path, def) in items { + let section = path.first().cloned().unwrap_or_else(|| tl!("Other").to_string()); + let name = path.iter().skip(1).chain(std::iter::once(label)).cloned().collect::>().join(" › "); + let row = (name, dialog_keys(overrides, alternates, id, def.as_deref())); + match sections.iter_mut().find(|(s, _)| *s == section) { + Some((_, rows)) => rows.push(row), + None => sections.push((section, vec![row])), + } + } + let html = shortcut_sets::summarize(&set_label, §ions, cfg!(target_os = "macos")); + save_text(app, &format!("{set_label}.html"), html) + }; + if let Err(e) = result { + *message = e; + *warning = true; + } + } + f.insert("sets".into(), json!(sets)); + f.insert("activeSet".into(), json!(active)); + f.insert("newSetName".into(), new_name.map_or(Value::Null, Value::String)); +} + +/// Save `text` through the save dialog under the suggested file name. +fn save_text(app: &mut PhotocraftApp, suggested: &str, text: String) -> Result { + // A set name can hold characters a file name can't. + let name: String = suggested.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }).collect(); + app.pick_save(&name, move |app, path| { + let write = app.services.write.as_mut().ok_or("no writer configured")?; + write(&path, text.as_bytes())?; + Ok(json!({"path": path})) + }) +} + fn toolbar_tab(ui: &mut egui::Ui, f: &mut Map) { let mut hidden: Vec = f.get("toolbarHidden").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); egui::ScrollArea::vertical().max_height(380.0).id_salt("toolbar-scroll").show(ui, |ui| { @@ -2131,7 +2299,12 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result
"), "{kys}"); + assert!(kys.contains("pc-id=\"tools.select.pencil\">F6
"), "tools as rows: {kys}"); + assert!(kys.contains("KeyboardCustomization/PhotoCraft Defaults="), "{kys}"); + // The file reads back with the same bindings. + let set = photocraft_engine::kys::parse(&kys).unwrap(); + assert!(set.commands.iter().any(|c| c.id.as_deref() == Some("edit.copy") && c.shortcuts == ["F9"])); + let html = String::from_utf8(written[1].1.clone()).unwrap(); + assert!(html.contains("CopyF9") && html.contains("

Edit

"), "{html}"); + // Nothing changed the preferences. + assert!(h.state().session.prefs().shortcuts.is_empty()); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index a2f3f17c1b..e8645a1511 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -477,6 +477,19 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { crate::vector_ui::pen_commit(app, false); return; } + // A finished work path shown by the Pen or Path Selection: ⌫/Delete deletes it, as in + // Photoshop (#5702), never Edit › Clear on the pixels under it. + if focus != Focus::DeletingWidget && crate::vector_ui::delete_key_deletes_work_path(app) { + let mods = ctx.input(|i| i.modifiers); + if !mods.command && !mods.ctrl && !mods.shift && !mods.alt && ctx.input_mut(|i| i.consume_key(mods, Key::Backspace) || i.consume_key(mods, Key::Delete)) + { + if let Err(e) = app.run("path.delete", json!({"name": "work"})) { + app.ui.status = e; + app.ui.status_error = true; + } + return; + } + } // Magnetic Lasso: ↩ closes, Esc cancels, ⌫ removes a fastening point (not Edit › Clear), // [ ] change the detection width (not the brush size). if crate::magnetic_lasso_ui::keys(app, ctx) { diff --git a/crates/ui-egui/src/sizing.rs b/crates/ui-egui/src/sizing.rs index 6cce7e0a5c..3c48e041f4 100644 --- a/crates/ui-egui/src/sizing.rs +++ b/crates/ui-egui/src/sizing.rs @@ -71,6 +71,7 @@ pub fn reset(f: &mut Map) { pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { let unit = pref_unit(app.session.prefs().units_and_rulers.rulers); let resample = pref_resample(app.session.prefs().general.image_interpolation); + let pt_per_in = app.session.prefs().units_and_rulers.point_size.per_inch(); let d = &app.session.active()?.doc; let (w, h, res) = (d.size.width as f64, d.size.height as f64, d.resolution_dpi as f64); let label = photocraft_engine::commands::find(command).map(|c| c.label).unwrap_or("Image Size…"); @@ -82,6 +83,7 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { f.insert("__origH".into(), json!(h)); f.insert("__origRes".into(), json!(res)); f.insert("__unit".into(), json!(unit)); + f.insert("__ptPerIn".into(), json!(pt_per_in)); f.insert("__bytesPerPixel".into(), json!(d.layers.first().and_then(|l| l.surface()).map_or(4, |s| s.format().bytes_per_pixel()))); f.insert("width".into(), json!(w)); f.insert("height".into(), json!(h)); @@ -102,27 +104,33 @@ fn num(f: &Map, k: &str) -> f64 { f.get(k).and_then(Value::as_f64).unwrap_or(0.0) } -/// Pixels → display unit. -fn to_unit(px: f64, unit: &str, orig: f64, res: f64) -> f64 { +/// Points per inch from Preferences › Units & Rulers › Point/Pica Size, stored when the dialog +/// opens: 72 (PostScript) when missing or not a positive finite number. +fn pt_per_in(f: &Map) -> f64 { + f.get("__ptPerIn").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0).unwrap_or(72.0) +} + +/// Pixels → display unit; `ppi` is points per inch (a pica is 12 points). +fn to_unit(px: f64, unit: &str, orig: f64, res: f64, ppi: f64) -> f64 { match unit { "percent" => px / orig.max(1.0) * 100.0, "in" => px / res.max(1.0), "cm" => px / res.max(1.0) * 2.54, "mm" => px / res.max(1.0) * 25.4, - "pt" => px / res.max(1.0) * 72.0, - "pica" => px / res.max(1.0) * 6.0, + "pt" => px / res.max(1.0) * ppi, + "pica" => px / res.max(1.0) * ppi / 12.0, _ => px, } } -fn from_unit(v: f64, unit: &str, orig: f64, res: f64) -> f64 { +fn from_unit(v: f64, unit: &str, orig: f64, res: f64, ppi: f64) -> f64 { match unit { "percent" => v * orig / 100.0, "in" => v * res, "cm" => v / 2.54 * res, "mm" => v / 25.4 * res, - "pt" => v / 72.0 * res, - "pica" => v / 6.0 * res, + "pt" => v / ppi * res, + "pica" => v / ppi * 12.0 * res, _ => v, } } @@ -165,18 +173,19 @@ fn dim_field(ui: &mut egui::Ui, f: &mut Map, key: &str, orig_key: let res = f.get("resolution").and_then(Value::as_f64).unwrap_or(num(f, "__origRes")); let orig = num(f, orig_key); let px = num(f, key); - let mut v = to_unit(px, &unit, orig, res) as f32; + let ppi = pt_per_in(f); + let mut v = to_unit(px, &unit, orig, res, ppi) as f32; // The engine's 300000 px limit in this unit (never below 30000), so a large document's size // isn't clamped when the field is drawn: egui clamps the shown value to the range, and the // field reports that as an edit (#4162). - let lim = if unit == "px" { 300000.0 } else { to_unit(300000.0, &unit, orig, res).max(30000.0) as f32 }; + let lim = if unit == "px" { 300000.0 } else { to_unit(300000.0, &unit, orig, res, ppi).max(30000.0) as f32 }; let range = -lim..=lim; // Each unit keeps its own precision: whole pixels, 3 places for inches, 2 for cm/mm/picas (#2434). // A typed unit (`2in` while in pixels) converts at the dialog's resolution. - let units = crate::widgets::FieldUnits { unit: crate::widgets::unit_of_key(&unit), dpi: res, ppi: 72.0 }; + let units = crate::widgets::FieldUnits { unit: crate::widgets::unit_of_key(&unit), dpi: res, ppi }; let changed = crate::widgets::value_field_units(ui, &mut v, range, 90.0, crate::widgets::unit_decimals(&unit), units).changed(); if changed { - let px = from_unit(v as f64, &unit, orig, res); + let px = from_unit(v as f64, &unit, orig, res, ppi); f.insert(key.into(), json!(if unit == "px" { px.round() } else { px })); } let _ = id; @@ -442,15 +451,36 @@ mod tests { #[test] fn unit_conversions_round_trip() { - for unit in ["px", "percent", "in", "cm", "mm", "pt", "pica"] { - let v = to_unit(1500.0, unit, 3000.0, 300.0); - assert!((from_unit(v, unit, 3000.0, 300.0) - 1500.0).abs() < 1e-9, "{unit}"); + for ppi in [72.0, 72.27] { + for unit in ["px", "percent", "in", "cm", "mm", "pt", "pica"] { + let v = to_unit(1500.0, unit, 3000.0, 300.0, ppi); + assert!((from_unit(v, unit, 3000.0, 300.0, ppi) - 1500.0).abs() < 1e-9, "{unit} at {ppi} pt/in"); + } } - assert_eq!(to_unit(1500.0, "percent", 3000.0, 300.0), 50.0); - assert_eq!(to_unit(600.0, "in", 3000.0, 300.0), 2.0); + assert_eq!(to_unit(1500.0, "percent", 3000.0, 300.0, 72.0), 50.0); + assert_eq!(to_unit(600.0, "in", 3000.0, 300.0, 72.0), 2.0); assert_eq!(human_bytes(6016.0 * 6016.0 * 4.0), "138.1M"); } + /// #5756: points and picas use the Point/Pica Size preference (Traditional is 72.27 pt/in), + /// and a dialog without the field (or with a bad one) falls back to PostScript's 72. + #[test] + fn points_and_picas_use_the_points_per_inch() { + assert!((to_unit(72.0, "pt", 72.0, 72.0, 72.27) - 72.27).abs() < 1e-9); + assert!((to_unit(72.0, "pica", 72.0, 72.0, 72.27) - 72.27 / 12.0).abs() < 1e-9); + assert_eq!(to_unit(72.0, "pt", 72.0, 72.0, 72.0), 72.0); + assert_eq!(to_unit(72.0, "pica", 72.0, 72.0, 72.0), 6.0); + assert!((from_unit(72.27, "pt", 72.0, 72.0, 72.27) - 72.0).abs() < 1e-9); + let mut f = Map::new(); + assert_eq!(pt_per_in(&f), 72.0); + for bad in [json!(0.0), json!(-3.0), json!("x")] { + f.insert("__ptPerIn".into(), bad); + assert_eq!(pt_per_in(&f), 72.0); + } + f.insert("__ptPerIn".into(), json!(72.27)); + assert_eq!(pt_per_in(&f), 72.27); + } + #[test] fn dialog_maps_to_engine_command() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); @@ -547,6 +577,33 @@ mod tests { (h, id) } + /// #5756: points and picas follow Preferences › Units & Rulers › Point/Pica Size. With + /// Traditional (72.27 pt/in) and Resample off, 100 px typed as 72.27 pt wide is 100 ppi. + #[test] + fn traditional_point_size_drives_points_and_picas() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 100, "height": 50, "resolution": 72})).unwrap(); + app.session.execute("prefs.set", json!({"path": "unitsAndRulers.pointSize", "value": "traditional"})).unwrap(); + for command in ["image.imageSize", "image.canvasSize"] { + let id = open(&mut app, command).unwrap(); + assert_eq!(app.ui.dialog_mut(id).unwrap().fields.get("__ptPerIn"), Some(&json!(72.27)), "{command}"); + app.ui.close_dialog(id); + } + let mut h = egui_kittest::Harness::builder() + .with_size(egui::vec2(1400.0, 900.0)) + .build_ui_state(|ui, app: &mut PhotocraftApp| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + let id = open(h.state_mut(), "image.imageSize").unwrap(); + let d = h.state_mut().ui.dialog_mut(id).unwrap(); + d.fields.insert("resample".into(), json!("none")); + d.fields.insert("__unit".into(), json!("pt")); + h.run_steps(3); + type_width(&mut h, "72.27"); + let f = fields(&h); + assert_eq!((num(&f, "width"), num(&f, "height")), (100.0, 50.0), "{f:?}"); + assert!((num(&f, "resolution") - 100.0).abs() < 1e-2, "{f:?}"); + } + /// Types `text` into the Width field and leaves it with Tab. fn type_width(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, text: &str) { use egui::accesskit::Role; diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs index deee9a7d17..7f49843c53 100644 --- a/crates/ui-egui/src/smart_ui.rs +++ b/crates/ui-egui/src/smart_ui.rs @@ -295,15 +295,29 @@ pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: } return; } + // An adjustment recorded on the smart object (Brightness/Contrast, Levels…) re-opens its own + // Image › Adjustments editor, like the filters above. + if crate::adjust_dialog::has_dialog(&f.command) { + if let Some(id) = crate::adjust_dialog::open(app, &f.command) { + edit_filter(app, id, layer, index, &f.params); + } + return; + } if !crate::filter_dialog::has_dialog(&f.command) { return; } let Some(id) = crate::filter_dialog::open(app, &f.command) else { return }; + edit_filter(app, id, layer, index, &f.params); +} + +/// Hands the open dialog `id` the smart filter's recorded `params` and makes OK update that +/// filter (`layer.smartFilter.setParams`, see [`confirm_command`]) instead of adding another. +fn edit_filter(app: &mut PhotocraftApp, id: u64, layer: u64, index: usize, params: &Value) { if let Some(d) = app.ui.dialog_mut(id) { // The live preview would stack a second copy of the filter on the smart object. d.fields.remove("__preview"); d.fields.insert("__smartFilter".into(), json!({"layer": layer, "index": index})); - if let Value::Object(p) = &f.params { + if let Value::Object(p) = params { for (k, v) in p { if d.fields.contains_key(k) { d.fields.insert(k.clone(), v.clone()); @@ -405,4 +419,37 @@ mod tests { assert!(drawn.contains(&(frame, t.field))); assert!(drawn.iter().all(|(_, c)| *c != t.icon)); } + + /// #5689: an Image › Adjustments recorded on a smart object (Brightness/Contrast…) opens its + /// own editor from the Smart Filters row, with the recorded settings, and OK updates that + /// filter instead of stacking another. + #[test] + fn an_adjustment_smart_filter_opens_its_editor_and_updates_it() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"contents": "black"})).unwrap(); + let id = s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("image.adjustments.brightnessContrast", json!({"brightness": 40, "contrast": -10})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + let sm = match &app.session.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().content { + LayerContent::Smart(sm) => sm.clone(), + other => panic!("expected a smart object, got {other:?}"), + }; + assert_eq!(sm.smart_filters.len(), 1); + open_editor(&mut app, &egui::Context::default(), id, &sm, 0); + let d = app.ui.dialogs.last().expect("the adjustment editor opened"); + assert_eq!(d.fields.get("__adjust").and_then(Value::as_str), Some("brightnessContrast")); + assert_eq!(d.fields.get("brightness").and_then(Value::as_f64), Some(40.0), "the recorded settings"); + assert_eq!(d.fields.get("contrast").and_then(Value::as_f64), Some(-10.0)); + let dialog = d.id; + // OK updates the recorded filter (one history step), it does not add a second filter. + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let LayerContent::Smart(sm) = &app.session.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().content else { + panic!("the layer stays a smart object"); + }; + assert_eq!(sm.smart_filters.len(), 1, "no filter was stacked"); + assert_eq!(sm.smart_filters[0].params.get("brightness").and_then(Value::as_f64), Some(40.0)); + assert_eq!(sm.smart_filters[0].params.get("contrast").and_then(Value::as_f64), Some(-10.0)); + } } diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index b41383c028..e0b122c373 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -31,6 +31,9 @@ pub enum Gesture { /// A selection outline (or its floating piece) dragged with a selection tool: its bounds at /// the start of the drag. SelectionMove { rect: [f64; 4] }, + /// A marquee, Object Selection box or shape being drawn, repositioned with Space held + /// (`hold_keys`): its bounds when Space went down. + DrawMove { rect: [f64; 4] }, /// A guide being moved with the Move tool. Guide, } @@ -292,7 +295,7 @@ fn apply(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) -> [f64; 2 } Gesture::TransformCorner { .. } => snap.targets.snap_point(p, tol), Gesture::Guide => snap.targets.snap_point(p, tol), - Gesture::Move { rect } | Gesture::TransformMove { rect } | Gesture::SelectionMove { rect } => { + Gesture::Move { rect } | Gesture::TransformMove { rect } | Gesture::SelectionMove { rect } | Gesture::DrawMove { rect } => { let d = [p[0] - snap.start[0], p[1] - snap.start[1]]; let moved = [rect[0] + d[0], rect[1] + d[1], rect[2] + d[0], rect[3] + d[1]]; let set = if snap.targets.is_empty() { &snap.smart } else { &snap.targets }; @@ -388,6 +391,40 @@ fn crop_handle(app: &PhotocraftApp, p: [f64; 2], mods: egui::Modifiers, targets: Some((view.map_or(q, |view| crate::crop_mode::from_press(app, view, q)), lines)) } +/// The bounds `[x0, y0, x1, y1]` of what drag `d` draws so far, for the tools whose drag Space +/// repositions and that snap (`None` for the rest, or a shape still too small to draw). +fn drawn_rect(app: &PhotocraftApp, d: &crate::canvas::Drag) -> Option<[f64; 4]> { + let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]); + let (a, b) = match d.tool { + Tool::RectMarquee | Tool::EllipseMarquee => crate::canvas::marquee_corners(&app.ui.tool_options, d, last), + Tool::ObjectSelection => (d.start, last), + t if crate::vector_ui::is_shape_tool(t) => return crate::vector_ui::shape_bounds(&app.ui.tool_options, t, d.start, last, d.live), + _ => return None, + }; + Some([a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]) +} + +/// Space held while a marquee, Object Selection box or shape is drawn moves the whole of it +/// (`hold_keys`): its edges and centre snap, as a moved selection's do, not just the corner under +/// the pointer. Releasing Space goes back to snapping that corner. +fn follow_reposition(app: &mut PhotocraftApp) { + let Some(d) = app.drag.as_ref() else { return }; + let Some(snap) = app.prefs_rt.snap.as_ref() else { return }; + let gesture = match (&snap.gesture, d.reposition) { + (Gesture::Point, true) => match drawn_rect(app, d) { + Some(rect) => Gesture::DrawMove { rect }, + None => return, + }, + (Gesture::DrawMove { .. }, false) => Gesture::Point, + _ => return, + }; + let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]); + if let Some(snap) = app.prefs_rt.snap.as_mut() { + snap.gesture = gesture; + snap.start = last; + } +} + /// Snap a tool event's coordinates (called by `canvas::tool_event` before the tools see it). pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> ToolEvent { match ev { @@ -415,6 +452,7 @@ pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifier if app.prefs_rt.snap.is_none() { return ev; } + follow_reposition(app); let disabled = override_for_gesture(app, mods); if let Some(s) = app.prefs_rt.snap.as_mut() { s.disabled = disabled; @@ -427,6 +465,7 @@ pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifier if app.prefs_rt.snap.is_none() { return ev; } + follow_reposition(app); let disabled = override_for_gesture(app, mods); if let Some(s) = app.prefs_rt.snap.as_mut() { s.disabled = disabled; @@ -661,6 +700,37 @@ mod tests { assert_eq!(sel.x1, 116); } + /// Space while drawing a marquee, Object Selection box or shape moves it: its edges snap, not + /// only the corner under the pointer; with Space released the corner snaps again. + #[test] + fn drawing_repositioned_with_space_snaps_its_edges() { + for tool in [Tool::RectMarquee, Tool::EllipseMarquee, Tool::ObjectSelection, Tool::Rectangle, Tool::Line] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.sync_views(); + app.ui.views[0].zoom = 1.0; + app.ui.views[0].fit_pending = false; + app.run("view.newGuide", json!({"orientation": "vertical", "position": 220})).unwrap(); + app.ui.tool = tool; + let m = egui::Modifiers::NONE; + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 100.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 150.0, y: 140.0, pressure: 1.0 }, m); + // Space down: the 50 × 40 box moves so its left edge lands 4 px short of the guide; + // the pointer corner (x = 266) is near nothing. + app.drag.as_mut().unwrap().reposition = true; + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 266.0, y: 140.0, pressure: 1.0 }, m); + assert_eq!(app.drag.as_ref().unwrap().start, [220.0, 100.0], "{tool:?}"); + if tool != Tool::RectMarquee { + continue; + } + // Space up: sizing resumes and the corner snaps to the document's right edge. + app.drag.as_mut().unwrap().reposition = false; + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 397.0, y: 140.0 }, m); + let sel = app.session.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(); + assert_eq!((sel.x0, sel.y0, sel.x1, sel.y1), (220, 100, 400, 140)); + } + } + /// Dragging inside a selection with a marquee or lasso moves the outline (⌘ cuts the pixels /// into a floating piece): its bounds snap, not the pointer. #[test] @@ -1019,6 +1089,52 @@ mod tests { assert_eq!(mover_bounds(&app).x0, 300); } + /// #5751: a Rectangle drawn on a 1080 px canvas, then resized with Free Transform under the + /// Move tool. With Snap Vector Tools and Transforms to Pixel Grid on (the default) the box's + /// edges stay on whole pixels: the grab offset and a proportional scale used to leave them at + /// 20.6 and 1040.4. Off, the box follows the pointer exactly. + #[test] + fn free_transform_of_a_shape_keeps_whole_pixels() { + for grid in [true, false] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 1080, "height": 1080, "resolution": 72})).unwrap(); + app.run("prefs.set", json!({"path": "tools.snapVectorToolsAndTransformsToPixelGrid", "value": grid})).unwrap(); + app.sync_views(); + app.ui.views[0].zoom = 0.6; + app.ui.views[0].fit_pending = false; + app.ui.tool = Tool::Rectangle; + let m = egui::Modifiers::NONE; + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 40.0, y: 31.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 300.0, y: 700.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 481.0, y: 1000.0 }, m); + app.ui.tool = Tool::Move; + crate::menus::invoke(&mut app, &egui::Context::default(), "edit.freeTransform", json!({})).unwrap(); + let whole = |q: [[f64; 2]; 4]| q.iter().flatten().all(|v| v.fract() == 0.0); + // The left edge handle, grabbed 1.3 px inside, dropped away from any snap target. + let q = app.ui.transform.as_ref().unwrap().quad; + let mid_y = (q[0][1] + q[3][1]) / 2.0; + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: q[0][0] + 1.3, y: mid_y, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 60.0, y: mid_y, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 80.6, y: mid_y }, m); + let q = app.ui.transform.as_ref().unwrap().quad; + assert_eq!(whole(q), grid, "grid {grid}: left edge {q:?}"); + // The bottom-right corner scaled proportionally (the default), off any snap target. + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: q[2][0], y: q[2][1], pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 450.4, y: 900.1 }, m); + let q = app.ui.transform.as_ref().unwrap().quad; + assert_eq!(whole(q), grid, "grid {grid}: corner {q:?}"); + crate::transform_tool::commit(&mut app); + let st = app.session.active().unwrap(); + let photocraft_doc::LayerContent::Shape(sh) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("a shape layer") }; + let (x0, y0, x1, y1) = sh.path.control_bounds().unwrap(); + if grid { + for v in [x0, y0, x1, y1] { + assert!((v - v.round()).abs() < 1e-6, "committed shape {:?}", (x0, y0, x1, y1)); + } + } + } + } + #[test] fn guides_snap_to_layer_edges() { let mut app = app_with_box(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index aeefb3ecbd..5cbda434e7 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -1073,6 +1073,9 @@ pub struct UiState { /// Inline type editing session, if any. #[serde(default)] pub text_edit: Option, + /// Tool to restore when an inline type edit session commits or cancels (e.g. Move tool, #1132). + #[serde(skip)] + pub text_edit_return_tool: Option, /// Ctrl/Cmd Type-tool gesture, exposed to automation but never restored as a held pointer. #[serde(default, skip_deserializing)] pub type_transform: Option, @@ -1304,6 +1307,7 @@ impl Default for UiState { spring: None, recent_files: Vec::new(), text_edit: None, + text_edit_return_tool: None, type_transform: None, type_transform_pivot: None, cam_transform: None, diff --git a/crates/ui-egui/src/titlebar.rs b/crates/ui-egui/src/titlebar.rs index de10ca30f3..25b23ee0f6 100644 --- a/crates/ui-egui/src/titlebar.rs +++ b/crates/ui-egui/src/titlebar.rs @@ -538,6 +538,23 @@ mod tests { use egui::{Event, Pos2, RawInput, ViewportId}; use photocraft_engine::Session; + /// #5826: the drag overlay under a dialog doesn't depend on the custom title bar. On macOS + /// (OS traffic lights over the app's own title strip) a dialog left the window undraggable. + #[test] + fn a_dialog_keeps_the_title_strip_draggable_with_either_title_bar() { + for custom in [false, true] { + let mut app = PhotocraftApp::new(Session::new(), crate::Services::default()); + app.custom_titlebar = custom; + app.run("file.new", serde_json::json!({"width": 8, "height": 8})).unwrap(); + assert!(!app.titlebar_modal_drag(true), "no dialog, no overlay"); + crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + assert!(app.titlebar_modal_drag(true), "custom title bar: {custom}"); + assert!(!app.titlebar_modal_drag(false), "chrome hidden (full screen): no title strip"); + app.ui.panels.menu_bar = false; + assert!(!app.titlebar_modal_drag(true), "no menu bar, no title strip"); + } + } + /// A headless window `width` pt wide showing the title bar and the resize zones. struct Win { ctx: egui::Context, diff --git a/crates/ui-egui/src/tone.rs b/crates/ui-egui/src/tone.rs index 0eacc731ae..ba17755bd9 100644 --- a/crates/ui-egui/src/tone.rs +++ b/crates/ui-egui/src/tone.rs @@ -40,12 +40,20 @@ pub enum HistSource { BelowLayer(LayerId), /// One layer on its own (the target of a destructive Image › Adjustments command). Layer(LayerId), + /// The gray plane a Curves target edits: a layer mask, a saved channel or the Quick Mask. + /// The dialog shows this plane's own levels, not the colour layers beneath (#5335). + Channel { layer: LayerId, surface: photocraft_engine::channel_cmds::GraySurface }, } fn tag(source: HistSource, space: ToneSpace) -> u64 { let (k, id) = match source { HistSource::BelowLayer(l) => (1u64, l.0), HistSource::Layer(l) => (2, l.0), + HistSource::Channel { layer, surface } => match surface { + photocraft_engine::channel_cmds::GraySurface::LayerMask => (3, layer.0), + photocraft_engine::channel_cmds::GraySurface::Alpha(i) => (4, i as u64), + photocraft_engine::channel_cmds::GraySurface::QuickMask => (5, layer.0), + }, }; let s = match space { ToneSpace::Rgb => 0u64, @@ -68,6 +76,38 @@ fn isolate(layers: &mut [Layer], id: LayerId) -> bool { /// Histograms of `doc` as seen by `source`, in the channels of `space`. pub fn compute_histograms(doc: &Document, source: HistSource, space: ToneSpace) -> Histograms { + let mut h = vec![ + [0u32; 256]; + match space { + ToneSpace::Rgb => 4, + ToneSpace::Cmyk => 5, + ToneSpace::Lab => 3, + } + ]; + match source { + // The gray plane itself: its levels are what the curve edits, whatever the document's + // colour mode (#5335). + HistSource::Channel { layer, surface } => { + let surf = match surface { + photocraft_engine::channel_cmds::GraySurface::LayerMask => doc.layer(layer).and_then(|l| l.mask.as_ref()).map(|m| &m.surface), + photocraft_engine::channel_cmds::GraySurface::Alpha(i) => doc.channels.get(i).map(|c| &c.surface), + photocraft_engine::channel_cmds::GraySurface::QuickMask => doc.quick_mask.as_ref().map(|c| &c.surface), + }; + if let Some(s) = surf + && let Some(bin) = h.first_mut() + { + *bin = gray_bins(s, doc.bounds()); + } + } + HistSource::BelowLayer(_) | HistSource::Layer(_) => { + composite_bins(doc, source, space, &mut h); + } + } + Histograms { tag: tag(source, space), ch: h } +} + +/// Bins of the composited image below (`BelowLayer`) or of one layer alone (`Layer`), in `space`. +fn composite_bins(doc: &Document, source: HistSource, space: ToneSpace, h: &mut [[u32; 256]]) { let mut d = doc.clone(); match source { HistSource::BelowLayer(hide) => { @@ -78,14 +118,9 @@ pub fn compute_histograms(doc: &Document, source: HistSource, space: ToneSpace) HistSource::Layer(id) => { isolate(&mut d.layers, id); } + HistSource::Channel { .. } => {} } let img = photocraft_compose::thumbnail(&d, 384); - let n = match space { - ToneSpace::Rgb => 4, - ToneSpace::Cmyk => 5, - ToneSpace::Lab => 3, - }; - let mut h = vec![[0u32; 256]; n]; let bin = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as usize; for p in img.pixels.as_chunks::<4>().0 { if p[3] == 0 { @@ -115,7 +150,26 @@ pub fn compute_histograms(doc: &Document, source: HistSource, space: ToneSpace) } } } - Histograms { tag: tag(source, space), ch: h } +} + +/// 256-bin histogram of a gray plane: its levels in 0–255, sampled like the engine's auto-tone +/// histograms so a big canvas costs no more than about a megapixel here (#5335). +fn gray_bins(surf: &photocraft_raster::Surface, area: photocraft_geom::Rect) -> [u32; 256] { + let mut h = [0u32; 256]; + let step = ((u64::from(area.width()) * u64::from(area.height())) / 1_000_000).max(1).isqrt().max(1) as i32; + let mut y = area.y0; + while y < area.y1 { + let mut x = area.x0; + while x < area.x1 { + let v = (surf.sample_channel(x, y, 0).clamp(0.0, 1.0) * 255.0).round() as usize; + if let Some(bin) = h.get_mut(v) { + *bin += 1; + } + x += step; + } + y += step; + } + h } /// Cached histograms for a tone editor; recomputed only when something other than the editor's @@ -126,6 +180,7 @@ pub fn histograms(app: &mut PhotocraftApp, source: HistSource, space: ToneSpace) let want = tag(source, space); let layer = match source { HistSource::BelowLayer(l) | HistSource::Layer(l) => l, + HistSource::Channel { layer, .. } => layer, }; let pixels_kept = view_only(st); if let Some((d, l, r, h)) = app.tone_hist.as_mut() @@ -362,6 +417,41 @@ mod tests { assert_eq!(compute_histograms(&st.doc, HistSource::Layer(bg), ToneSpace::Cmyk).ch.len(), 5); assert_eq!(compute_histograms(&st.doc, HistSource::Layer(bg), ToneSpace::Lab).ch.len(), 3); } + + /// #5335: the Curves histogram of a targeted gray plane describes that plane, not the + /// colour layers beneath it: a channel filled with 25% grey over a black layer shows a + /// spike at 25% grey, not at black. + #[test] + fn channel_histogram_describes_the_gray_plane() { + use photocraft_engine::channel_cmds::GraySurface; + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32})).unwrap(); // white background + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 32, "height": 32})).unwrap(); + s.execute("edit.fill", json!({"color": "#000000"})).unwrap(); + s.execute("channel.new", json!({"name": "Sky sel", "fill": "white"})).unwrap(); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + let b = photocraft_geom::Rect::from_xywh(0, 0, 32, 32); + s.edit("fill-planes", |doc, active| { + doc.channels[0].surface.fill_rect(b, &[0.25]); + doc.layer_mut(active.unwrap()).unwrap().mask.as_mut().unwrap().surface.fill_rect(b, &[0.0]); + Ok(()) + }) + .unwrap(); + let st = s.active().unwrap(); + let layer = st.active_layer.unwrap(); + let h = compute_histograms(&st.doc, HistSource::Channel { layer, surface: GraySurface::Alpha(0) }, ToneSpace::Rgb); + assert!(h[0][64] > 0, "a spike at 25% grey, got {:?}", &h[0][0..80]); + assert_eq!(h[0][0], 0, "not the black layer beneath"); + let m = compute_histograms(&st.doc, HistSource::Channel { layer, surface: GraySurface::LayerMask }, ToneSpace::Rgb); + assert!(m[0][0] > 0, "the hidden mask is black"); + // Each plane keeps its own cache entry: switching targets recomputes. + assert_ne!( + tag(HistSource::Channel { layer, surface: GraySurface::Alpha(0) }, ToneSpace::Rgb), + tag(HistSource::Channel { layer, surface: GraySurface::LayerMask }, ToneSpace::Rgb) + ); + assert_ne!(tag(HistSource::Channel { layer, surface: GraySurface::LayerMask }, ToneSpace::Rgb), tag(HistSource::Layer(layer), ToneSpace::Rgb)); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/tool_cursor.rs b/crates/ui-egui/src/tool_cursor.rs index 32f6aed82e..8b67b96d60 100644 --- a/crates/ui-egui/src/tool_cursor.rs +++ b/crates/ui-egui/src/tool_cursor.rs @@ -93,9 +93,25 @@ fn close_ring() -> Vec { } /// Can this pass show an OS cursor bitmap? eframe's immediate viewports and its web integration -/// don't upload cursor images; every desktop root viewport does (winit custom cursors). +/// don't upload cursor images; every desktop root viewport does (winit custom cursors). Wayland at +/// any scale but 1 doesn't either: winit sizes the bitmap there with a `wp_viewport` destination +/// that some compositors ignore on cursor surfaces (Hyprland 0.56; Mutter before GNOME 48). They +/// show it one bitmap pixel per logical pixel, so at 1.25 a brush outline was 1.25× too large and +/// its centre sat 0.2 × its half-width right of and below the pointer, which is where the brush +/// painted. Nothing tells the app which compositor does what, so the outline is painted instead. fn native(ctx: &egui::Context) -> bool { - cfg!(not(target_arch = "wasm32")) && ctx.viewport_id() == egui::ViewportId::ROOT + cfg!(not(target_arch = "wasm32")) && ctx.viewport_id() == egui::ViewportId::ROOT && !scaled_wayland(ctx) +} + +/// Mark this window as a Wayland surface (or not), for [`native`]; the shell calls it every pass. +pub(crate) fn set_wayland(ctx: &egui::Context, wayland: bool) { + ctx.data_mut(|d| d.insert_temp(egui::Id::new("photocraft-cursor-wayland"), wayland)); +} + +/// A Wayland window whose scale is not 1, where winit's cursor bitmaps are unreliable. +fn scaled_wayland(ctx: &egui::Context) -> bool { + let wayland = cfg!(target_os = "linux") && ctx.data(|d| d.get_temp::(egui::Id::new("photocraft-cursor-wayland"))).unwrap_or(false); + wayland && ctx.native_pixels_per_point().unwrap_or_else(|| ctx.pixels_per_point() / ctx.zoom_factor()) != 1.0 } /// Bitmap pixels per egui point. winit takes an `NSCursor` image's dimensions as logical points on diff --git a/crates/ui-egui/src/tool_cursor/tests.rs b/crates/ui-egui/src/tool_cursor/tests.rs index 409d528b95..d68be241f5 100644 --- a/crates/ui-egui/src/tool_cursor/tests.rs +++ b/crates/ui-egui/src/tool_cursor/tests.rs @@ -272,6 +272,40 @@ fn ordinary_brush_cursor_is_not_part_of_the_canvas_draw_list() { assert!(output.shapes.iter().any(|s| matches!(s.shape, egui::Shape::Circle(_))), "the full tip is not silently shrunk"); } +#[test] +#[cfg(target_os = "linux")] +fn scaled_wayland_paints_the_brush_outline_instead_of_an_os_bitmap() { + // Hyprland ignores the viewport winit sizes cursor bitmaps with: at 1.25 the outline was + // 1.25× too large and its centre 0.2 × its half-width right of and below where the brush + // painted. Off scale 1 on Wayland the outline is painted at the pointer, at its true size. + let run = |ctx: &egui::Context, native_ppp: f32| { + let mut input = egui::RawInput::default(); + input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(native_ppp); + let mut output = ctx.run_ui(input, |ui| { + let icon = circle(ui.painter(), pos2(100.0, 100.0), 20.0, false); + ui.ctx().set_cursor_icon(icon); + }); + output.textures_delta.clear(); + let circles: Vec<_> = + output.shapes.iter().filter_map(|s| if let egui::Shape::Circle(c) = &s.shape { Some((c.center, c.radius)) } else { None }).collect(); + (output.platform_output.cursor_image.is_some(), output.platform_output.cursor_icon, circles) + }; + let ctx = egui::Context::default(); + ctx.add_plugin(CursorLifecycle); + set_wayland(&ctx, true); + for ppp in [1.25, 1.5, 2.0] { + let (bitmap, icon, circles) = run(&ctx, ppp); + assert!(!bitmap, "{ppp}: no OS bitmap"); + assert_eq!(icon, CursorIcon::None, "{ppp}: the painted outline is the pointer"); + assert!(!circles.is_empty() && circles.iter().all(|&(c, r)| c == pos2(100.0, 100.0) && r == 20.0), "{ppp}: {circles:?}"); + } + let (bitmap, _, circles) = run(&ctx, 1.0); + assert!(bitmap && circles.is_empty(), "Wayland at scale 1 keeps the low-latency bitmap"); + set_wayland(&ctx, false); + let (bitmap, _, circles) = run(&ctx, 1.25); + assert!(bitmap && circles.is_empty(), "X11 at 1.25 keeps the bitmap"); +} + /// Run explicitly for visual QA: native cursors are absent from GPU screenshots. #[test] #[ignore = "writes target/tool-cursor-preview.png for visual inspection"] diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index d35aac9040..d46ef189bc 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -1435,6 +1435,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> } else { apply_mode_drag(s, g, [x, y], mode, type_layer, mods); } + round_to_pixel_grid(app); } } } @@ -1442,6 +1443,40 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> true } +/// Preferences › Tools › Snap Vector Tools and Transforms to Pixel Grid (#5751): while a shape +/// layer or a path is moved or scaled (the box still an upright rectangle), its edges stay on +/// whole pixels, whatever the tool. Rounding the pointer alone left the edges off by the grab +/// offset, and a proportional scale off by its ratio: a shape dragged to 540 px came out 539.6. +/// Rotated, skewed and distorted boxes are left as dragged. +fn round_to_pixel_grid(app: &mut PhotocraftApp) { + if !app.session.prefs().tools.snap_vector_tools_and_transforms_to_pixel_grid { + return; + } + let Some(t) = app.ui.transform.as_ref() else { return }; + if t.selection || t.warp.is_some() || t.content_aware.is_some() || t.target.is_some() { + return; + } + let vector = + t.path.is_some() || app.session.active().and_then(|st| st.doc.layer(LayerId(t.layer))).is_some_and(|l| matches!(l.content, LayerContent::Shape(_))); + if !vector || !upright(&t.quad) { + return; + } + let q = t.quad.map(|p| p.map(f64::round)); + // A box narrower than a pixel keeps its size rather than collapsing. + if (q[1][0] - q[0][0]).abs() < 1.0 || (q[3][1] - q[0][1]).abs() < 1.0 || q.iter().flatten().any(|v| !v.is_finite()) { + return; + } + if let Some(s) = app.ui.transform.as_mut() { + s.quad = q; + } +} + +/// Whether corners clockwise from top-left form an axis-aligned rectangle (flips included). +fn upright(q: &[[f64; 2]; 4]) -> bool { + let near = |a: f64, b: f64| (a - b).abs() < 1e-6; + near(q[0][1], q[1][1]) && near(q[2][1], q[3][1]) && near(q[0][0], q[3][0]) && near(q[1][0], q[2][0]) +} + /// The same effective modifiers drive the geometry and decide whether Ctrl is a distortion /// gesture rather than the ordinary snap override. Keep type and Content-Aware Scale refusals. fn drag_mods(app: &PhotocraftApp, t: &TransformSession, hit: Hit, mods: egui::Modifiers) -> (TransformMode, egui::Modifiers) { @@ -1717,8 +1752,7 @@ pub(crate) fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods let Some(inv) = h.inverse() else { return }; let (u, v) = inv.apply(p[0], p[1]); let (pu, pv) = inv.apply(g.pivot0[0], g.pivot0[1]); - // Moving edges/corner in unit space: [u0, v0, u1, v1] starting as [0, 0, 1, 1]. - let mut r = [0.0f64, 0.0, 1.0, 1.0]; + // Which sides of the unit box move: (u = 0, u = 1) and (v = 0, v = 1). let (mu, mv) = match g.hit { Hit::Corner(0) => ((true, false), (true, false)), Hit::Corner(1) => ((false, true), (true, false)), @@ -1729,33 +1763,21 @@ pub(crate) fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods Hit::Edge(2) => ((false, false), (false, true)), _ => ((true, false), (false, false)), }; - if mu.0 { - r[0] = u; - } - if mu.1 { - r[2] = u; - } - if mv.0 { - r[1] = v; - } - if mv.1 { - r[3] = v; - } - // ⌥: symmetric about the reference point. - if mods.alt { - if mu.0 { - r[2] = 2.0 * pu - r[0]; + // Each axis scales by a factor `k` about an anchor: the opposite side, or with ⌥ the + // reference point, wherever it was set (#1329). The dragged handle follows the cursor. + let axis = |moving: (bool, bool), at: f64, pivot: f64| -> (f64, f64) { + if !moving.0 && !moving.1 { + return (0.5, 1.0); } - if mu.1 { - r[0] = 2.0 * pu - r[2]; - } - if mv.0 { - r[3] = 2.0 * pv - r[1]; - } - if mv.1 { - r[1] = 2.0 * pv - r[3]; - } - } + let handle = if moving.0 { 0.0 } else { 1.0 }; + let anchor = if mods.alt { pivot } else { 1.0 - handle }; + let d = handle - anchor; + // A reference point on the dragged side leaves nothing to scale against. + let k = if d.abs() > 1e-9 { (at - anchor) / d } else { 1.0 }; + (anchor, if k.is_finite() { k } else { 1.0 }) + }; + let (mut au, mut ku) = axis(mu, u, pu); + let (mut av, mut kv) = axis(mv, v, pv); // Corners and edges scale proportionally by default; ⇧ frees them (the legacy // preference swaps the two for corners and always stretches edges, `legacy_mods`). let corner = matches!(g.hit, Hit::Corner(_)); @@ -1765,41 +1787,16 @@ pub(crate) fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods // corners), so the opposite edge's handle stays put. Past the opposite edge only // the dragged axis flips. if mu.0 || mu.1 { - let k = (r[2] - r[0]).abs(); - let c = if mods.alt { pv } else { 0.5 }; - r[1] = c - k / 2.0; - r[3] = c + k / 2.0; + (av, kv) = (if mods.alt { pv } else { 0.5 }, ku.abs()); } else { - let k = (r[3] - r[1]).abs(); - let c = if mods.alt { pu } else { 0.5 }; - r[0] = c - k / 2.0; - r[2] = c + k / 2.0; + (au, ku) = (if mods.alt { pu } else { 0.5 }, kv.abs()); } } if corner && !mods.shift { - let (sx, sy) = (r[2] - r[0], r[3] - r[1]); - let k = if sx.abs() > sy.abs() { sx.abs() } else { sy.abs() }; - let (nx, ny) = (k * sx.signum(), k * sy.signum()); - let (ax, ay) = if mods.alt { (pu, pv) } else { (if mu.0 { r[2] } else { r[0] }, if mv.0 { r[3] } else { r[1] }) }; - let fx = if mods.alt { - 0.5 - } else if mu.0 { - 1.0 - } else { - 0.0 - }; - let fy = if mods.alt { - 0.5 - } else if mv.0 { - 1.0 - } else { - 0.0 - }; - r[0] = ax - nx * fx; - r[2] = r[0] + nx; - r[1] = ay - ny * fy; - r[3] = r[1] + ny; + let k = ku.abs().max(kv.abs()); + (ku, kv) = (k * ku.signum(), k * kv.signum()); } + let r = [au - au * ku, av - av * kv, au + (1.0 - au) * ku, av + (1.0 - av) * kv]; let map = |a: f64, b: f64| { let (x, y) = h.apply(a, b); [x, y] @@ -3755,6 +3752,37 @@ mod tests { assert!(close(s.quad, corners([-50.0, 0.0, 150.0, 50.0])), "{:?}", s.quad); } + /// A reference point moved off the centre (⌥-click) is the centre of ⌥-scaling too, not just + /// of rotation: it stays put and the box scales away from it. + #[test] + fn alt_scales_about_a_moved_reference_point() { + let alt = egui::Modifiers::ALT; + // Reference point on the top-left corner: the bottom-right corner to (200, 100) doubles + // the box, top-left fixed (not centred on the reference point). + let mut s = session(); + s.pivot = [0.0, 0.0]; + drag(&mut s, [100.0, 50.0], [200.0, 100.0], alt); + assert!(close(s.quad, corners([0.0, 0.0, 200.0, 100.0])), "{:?}", s.quad); + assert!(near(s.pivot, [0.0, 0.0]), "{:?}", s.pivot); + // Reference point at (25, 25): the right edge to x = 175 is 2× about x = 25, and + // proportionally 2× about y = 25 too. + let mut s = session(); + s.pivot = [25.0, 25.0]; + drag(&mut s, [100.0, 25.0], [175.0, 25.0], alt); + assert!(close(s.quad, corners([-25.0, -25.0, 175.0, 75.0])), "{:?}", s.quad); + // ⌥⇧: that edge's axis only. + let mut s = session(); + s.pivot = [25.0, 25.0]; + drag(&mut s, [100.0, 25.0], [175.0, 25.0], egui::Modifiers { alt: true, shift: true, ..Default::default() }); + assert!(close(s.quad, corners([-25.0, 0.0, 175.0, 50.0])), "{:?}", s.quad); + // Reference point in line with the dragged corner horizontally: that axis has nothing to + // scale against and stays, the other doubles about it. + let mut s = session(); + s.pivot = [100.0, 50.0]; + drag(&mut s, [100.0, 0.0], [150.0, -50.0], egui::Modifiers { alt: true, shift: true, ..Default::default() }); + assert!(close(s.quad, corners([0.0, -50.0, 100.0, 50.0])), "{:?}", s.quad); + } + fn near(a: [f64; 2], b: [f64; 2]) -> bool { (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6 } @@ -4924,6 +4952,32 @@ mod tests { assert!(app.ui.transform.as_ref().unwrap().warp.is_none()); } + #[test] + fn a_thin_layer_scaled_then_warped_commits_the_curve() { + // #5793: a 3 px column stretched wide, switched to Warp and pulled down in the middle. + // The commit came out straight: the warp's grid was one cell across the 3 source pixels. + let mut app = app_with_square(128, photocraft_geom::Rect::new(40, 20, 43, 60)); + let ctx = egui::Context::default(); + begin(&mut app, &ctx).unwrap(); + scale_about_pivot(&mut app, 30.0, 1.0); + enter_warp(&mut app, &ctx); + warp_pointer(&mut app, ToolEvent::Down { x: 41.5, y: 40.0, pressure: 1.0 }, 2.0, egui::Modifiers::NONE); + warp_pointer(&mut app, ToolEvent::Move { x: 41.5, y: 70.0, pressure: 1.0 }, 2.0, egui::Modifiers::NONE); + warp_pointer(&mut app, ToolEvent::Up { x: 41.5, y: 70.0 }, 2.0, egui::Modifiers::NONE); + commit(&mut app); + assert!(app.ui.transform.is_none(), "{}", app.ui.status); + let st = app.session.active().unwrap(); + let s = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + let alpha = |x: i32, y: i32| { + let mut p = [0.0f32; 4]; + s.read_pixel(x, y, &mut p); + p[3] + }; + // The middle sags below the band's old bottom edge (y 60); the ends stay put. + assert!(alpha(41, 70) > 0.99, "middle {}", alpha(41, 70)); + assert!(alpha(15, 70) < 0.01 && alpha(15, 40) > 0.99, "end {} {}", alpha(15, 70), alpha(15, 40)); + } + fn warping_square() -> PhotocraftApp { let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 32, 32)); begin_warp(&mut app, &egui::Context::default()).unwrap(); diff --git a/crates/ui-egui/src/type_panels_ui.rs b/crates/ui-egui/src/type_panels_ui.rs index 499e8eac44..d97a649fba 100644 --- a/crates/ui-egui/src/type_panels_ui.rs +++ b/crates/ui-egui/src/type_panels_ui.rs @@ -768,7 +768,17 @@ fn spelling_window(app: &mut PhotocraftApp, ctx: &egui::Context) { .anchor(Align2::CENTER_CENTER, vec2(0.0, -40.0)) .show(ctx, |ui| { ui.set_width(380.0); - ui.label(RichText::new(tl!("Check Spelling")).color(t.text).size(13.0).strong()); + ui.horizontal(|ui| { + ui.label(RichText::new(tl!("Check Spelling")).color(t.text).size(13.0).strong()); + // The close box (#5496) is Done, as in Photoshop: every change is already made. + if crate::dialogs::CLOSE_BOX { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::icons::button(ui, "x", 20.0, false, tl!("Close")).clicked() { + act = Some("done"); + } + }); + } + }); ui.add_space(6.0); ui.horizontal_top(|ui| { ui.vertical(|ui| { @@ -930,4 +940,33 @@ mod tests { // Headless calls with an action go to the engine. assert!(menu(&mut app, "edit.checkSpelling", &json!({"action": "list"})).is_none()); } + + /// #5496: Check Spelling's close box is Done, as in Photoshop on Windows: the window closes + /// and the text keeps what it had. None on macOS. + #[test] + fn check_spellings_close_box_is_done() { + use egui_kittest::kittest::Queryable; + let mut app = app_with_text("Teh cat"); + menu(&mut app, "edit.checkSpelling", &json!({})).unwrap().unwrap(); + let revision = app.session.active().unwrap().revision; + // The fonts are set up after the harness's first frame: the window appears after it. + let spell = app.ui.type_panels.spell.take(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state(|ui, app| windows(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.state_mut().ui.type_panels.spell = spell; + h.run_steps(3); + if !crate::dialogs::CLOSE_BOX { + assert_eq!(h.query_all_by_label("Close").count(), 0, "no close box on macOS"); + return; + } + let x = h.get_by_label("Close").rect(); + let done = h.get_by_label("Done").rect(); + assert!(x.bottom() <= done.top() && x.right() >= done.right(), "the × ends the title row: {x:?} over {done:?}"); + h.hover_at(x.center()); + h.drag_at(x.center()); + h.drop_at(x.center()); + h.run_steps(2); + assert!(h.state().ui.type_panels.spell.is_none(), "the × closed Check Spelling"); + assert_eq!(h.state().session.active().unwrap().revision, revision, "and changed nothing"); + } } diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index bee49293b5..d73bb2e2c4 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -121,6 +121,7 @@ pub fn edit_active(app: &mut PhotocraftApp) -> Result<(), String> { ed.caret = n; } let vertical = app.session.active().and_then(|st| text_layer(&st.doc, id)).is_some_and(|t| t.orientation == photocraft_doc::text::Orientation::Vertical); + app.ui.text_edit_return_tool = None; app.ui.tool = if vertical { crate::state::Tool::VerticalType } else { crate::state::Tool::Type }; app.ui.mask_target = false; app.ui.vector_mask_target = false; @@ -132,6 +133,7 @@ pub fn edit_active(app: &mut PhotocraftApp) -> Result<(), String> { /// type is there. pub fn edit_at(app: &mut PhotocraftApp, x: f64, y: f64) -> bool { let Some(id) = hit_layer(app, x, y) else { return false }; + let previous_tool = app.ui.tool; commit(app); if app.session.select_layer(id).is_err() { return false; @@ -144,6 +146,9 @@ pub fn edit_at(app: &mut PhotocraftApp, x: f64, y: f64) -> bool { app.ui.text_edit = Some(TextEdit { layer: id.0, caret: off, anchor: off, session: key, created: false, mask: false, dragging: false, resize: None, preedit: None }); let vertical = app.session.active().and_then(|st| text_layer(&st.doc, id)).is_some_and(|t| t.orientation == photocraft_doc::text::Orientation::Vertical); + if !previous_tool.is_type() { + app.ui.text_edit_return_tool = Some(previous_tool); + } app.ui.tool = if vertical { crate::state::Tool::VerticalType } else { crate::state::Tool::Type }; app.ui.mask_target = false; app.ui.vector_mask_target = false; @@ -271,6 +276,7 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifie let off = hit_offset(app, id, x, y); app.ui.text_edit = Some(TextEdit { layer: id.0, caret: off, anchor: off, session: key, created: false, mask: false, dragging: true, resize: None, preedit: None }); + app.ui.text_edit_return_tool = None; return true; } false @@ -335,6 +341,7 @@ pub fn pointer_up(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) { // Like Photoshop: the placeholder is selected, so typing replaces it. let n = text.chars().count(); app.ui.text_edit = Some(TextEdit { layer: id, caret: n, anchor: 0, session: key, created: true, mask, dragging: false, resize: None, preedit: None }); + app.ui.text_edit_return_tool = None; } } @@ -773,6 +780,7 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { let id = LayerId(ed.layer); let Some(text) = current_text(app, id) else { app.ui.text_edit = None; + app.ui.text_edit_return_tool = None; return false; }; // Undo/redo can shorten the text under us. @@ -960,6 +968,11 @@ pub fn commit_with(app: &mut PhotocraftApp, mods: egui::Modifiers) { } crate::type_transform::reset(app); let Some(ed) = app.ui.text_edit.take() else { return }; + if let Some(tool) = app.ui.text_edit_return_tool.take() + && app.ui.tool.is_type() + { + app.ui.tool = tool; + } let Some(text) = current_text(app, LayerId(ed.layer)) else { return }; if ed.mask { // Type Mask: the text's glyphs become (or combine with) the selection and the working @@ -1014,20 +1027,20 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX } // Frame: paragraph text shows its box with handles; point text an underline per line. let mods = crate::workspace_ui::sticky_mods(app, painter.ctx().input(|i| i.modifiers)); - if crate::type_transform::draw(app, painter, xf, mods) { - return; - } + let has_transform = crate::type_transform::draw(app, painter, xf, mods); let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape.clone())); let frame = Stroke::new(1.0, t.accent); match shape { Some(photocraft_doc::text::TextShape::Box { x, y, width, height }) => { - let c = [scr_t(x, y), scr_t(x + width, y), scr_t(x + width, y + height), scr_t(x, y + height)]; - painter.add(egui::Shape::closed_line(c.to_vec(), frame)); - let mids = [c[0].lerp(c[1], 0.5), c[1].lerp(c[2], 0.5), c[2].lerp(c[3], 0.5), c[3].lerp(c[0], 0.5)]; - for p in c.iter().chain(mids.iter()) { - let r = egui::Rect::from_center_size(*p, egui::vec2(7.0, 7.0)); - painter.rect_filled(r, 0.0, Color32::WHITE); - painter.rect_stroke(r, 0.0, frame, egui::StrokeKind::Inside); + if !has_transform { + let c = [scr_t(x, y), scr_t(x + width, y), scr_t(x + width, y + height), scr_t(x, y + height)]; + painter.add(egui::Shape::closed_line(c.to_vec(), frame)); + let mids = [c[0].lerp(c[1], 0.5), c[1].lerp(c[2], 0.5), c[2].lerp(c[3], 0.5), c[3].lerp(c[0], 0.5)]; + for p in c.iter().chain(mids.iter()) { + let r = egui::Rect::from_center_size(*p, egui::vec2(7.0, 7.0)); + painter.rect_filled(r, 0.0, Color32::WHITE); + painter.rect_stroke(r, 0.0, frame, egui::StrokeKind::Inside); + } } } _ => { @@ -2392,6 +2405,11 @@ fn type_options(app: &mut PhotocraftApp, ui: &mut egui::Ui) { pub fn cancel(app: &mut PhotocraftApp) { crate::type_transform::reset(app); let Some(ed) = app.ui.text_edit.take() else { return }; + if let Some(tool) = app.ui.text_edit_return_tool.take() + && app.ui.tool.is_type() + { + app.ui.tool = tool; + } undo_session(app, &ed); } @@ -2682,4 +2700,41 @@ mod tests { assert_eq!(word_boundary("hello big world", 7, false), 6); assert_eq!(word_boundary("hello big world", 15, false), 10); } + #[test] + fn double_click_from_move_tool_restores_move_tool_on_commit_and_cancel() { + let (mut app, _) = variants(); + app.ui.tool = crate::state::Tool::Move; + assert!(edit_at(&mut app, ON_TEXT.0, ON_TEXT.1)); + assert_eq!(app.ui.tool, crate::state::Tool::Type); + assert_eq!(app.ui.text_edit_return_tool, Some(crate::state::Tool::Move)); + commit(&mut app); + assert_eq!(app.ui.tool, crate::state::Tool::Move, "commit restores the Move tool"); + assert_eq!(app.ui.text_edit_return_tool, None); + + // Also test cancel + app.ui.tool = crate::state::Tool::Move; + assert!(edit_at(&mut app, ON_TEXT.0, ON_TEXT.1)); + assert_eq!(app.ui.tool, crate::state::Tool::Type); + cancel(&mut app); + assert_eq!(app.ui.tool, crate::state::Tool::Move, "cancel restores the Move tool"); + assert_eq!(app.ui.text_edit_return_tool, None); + + // Switching tools mid-edit commits without overriding the newly chosen tool + app.ui.tool = crate::state::Tool::Move; + assert!(edit_at(&mut app, ON_TEXT.0, ON_TEXT.1)); + assert_eq!(app.ui.tool, crate::state::Tool::Type); + assert_eq!(app.ui.text_edit_return_tool, Some(crate::state::Tool::Move)); + app.ui.tool = crate::state::Tool::Brush; + commit(&mut app); + assert_eq!(app.ui.tool, crate::state::Tool::Brush, "switching tools keeps the chosen tool"); + assert_eq!(app.ui.text_edit_return_tool, None); + + // Layers-thumbnail edit stays on Type tool (matches Photoshop) + app.ui.tool = crate::state::Tool::Move; + edit_active(&mut app).unwrap(); + assert_eq!(app.ui.tool, crate::state::Tool::Type); + assert_eq!(app.ui.text_edit_return_tool, None); + commit(&mut app); + assert_eq!(app.ui.tool, crate::state::Tool::Type, "Layers-thumbnail edit stays on Type tool"); + } } diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 0f5be9e2fa..f56829c6d1 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -779,6 +779,23 @@ fn temporary_type_transform_visibility_and_cursors() { assert!(crate::type_transform::visible(h.state(), sticky)); } +#[test] +fn type_selection_highlight_visible_with_command_held() { + let (app, _id) = transform_app(false, false, Affine::IDENTITY); + let mut h = harness(1.0, app); + if let Some(ed) = h.state_mut().ui.text_edit.as_mut() { + ed.anchor = 0; + ed.caret = 5; + } + hold(&mut h, type_command()); + h.run(); + let accent = crate::theme::Tokens::get(&h.ctx).accent; + let expected_fill = egui::Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 110); + let has_selection_highlight = + h.output().shapes.iter().any(|clipped| if let egui::Shape::Path(path) = &clipped.shape { path.fill == expected_fill && path.closed } else { false }); + assert!(has_selection_highlight, "selection highlight must remain visible while Ctrl/Cmd is held"); +} + /// Real canvas input: layer-local scaling stays correct across orientation, zoom, HiDPI and flip. #[test] fn temporary_type_transform_scales_without_reflow_or_leaving_type() { @@ -1528,6 +1545,26 @@ mod multi_layer_formatting { assert_eq!(text(h.state(), ids[1]).char_runs(), peer); } + /// #5717: a layer scaled so its shown size or leading is past a field's range (2400 pt, or + /// 0.08 pt) keeps it while the Character panel and the options bar only show it. + #[test] + fn showing_a_scaled_layer_past_the_size_range_does_not_change_it() { + for (layer, scale) in [(0, 200.0), (1, 0.005)] { + for properties in [true, false] { + let (mut app, ids) = fixture(); + let id = ids[layer]; + app.run("type.edit", json!({"layer": id.0, "transform": [scale, 0.0, 0.0, scale, 0.0, 0.0]})).unwrap(); + selected(&mut app, &[id]); + let before = text(&app, id).char_runs(); + let steps = app.session.active().unwrap().history.entries().len(); + let mut h = controls(app, properties); + h.run_steps(3); + assert_eq!(text(h.state(), id).char_runs(), before, "scale {scale}, properties {properties}"); + assert_eq!(h.state().session.active().unwrap().history.entries().len(), steps, "no history step"); + } + } + } + #[test] fn formatting_menus_isolate_the_edit_layer_and_range_but_honor_explicit_targets() { let (mut app, ids) = fixture(); diff --git a/crates/ui-egui/src/type_transform.rs b/crates/ui-egui/src/type_transform.rs index b724cfe321..0c1b4837ed 100644 --- a/crates/ui-egui/src/type_transform.rs +++ b/crates/ui-egui/src/type_transform.rs @@ -52,6 +52,7 @@ pub(crate) fn cancel_stale(app: &mut PhotocraftApp) { cancel_drag(app); if lost_layer { app.ui.text_edit = None; + app.ui.text_edit_return_tool = None; } } } diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index fe6db74079..8b5e914af2 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -104,6 +104,19 @@ fn shape_geometry(o: &ToolOptions, tool: Tool, start: [f64; 2], end: [f64; 2], m }) } +/// The box `[x0, y0, x1, y1]` a Shape-tool drag draws (a line's: its ends'), which snaps while +/// Space repositions it (`snap_ui`). None when too small to draw. +pub fn shape_bounds(o: &ToolOptions, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) -> Option<[f64; 4]> { + let g = shape_geometry(o, tool, start, end, mods)?; + if tool == Tool::Line { + let a: [f64; 2] = serde_json::from_value(g["from"].clone()).ok()?; + let b: [f64; 2] = serde_json::from_value(g["to"].clone()).ok()?; + return Some([a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]); + } + let [x, y, w, h]: [f64; 4] = serde_json::from_value(g["rect"].clone()).ok()?; + Some([x, y, x + w, y + h]) +} + /// Finish a Shape-tool drag: ⇧ constrains proportions, ⌥ draws from the centre. pub fn finish_shape(app: &mut PhotocraftApp, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) { let Some(mut p) = shape_geometry(&app.ui.tool_options, tool, start, end, mods) else { return }; @@ -725,6 +738,19 @@ pub(crate) fn active_shape_path(app: &PhotocraftApp) -> Option<(u64, Path)> { } } +/// Does ⌫ / Delete delete the work path? With the Pen, Freeform Pen or Path Selection tool, no +/// path being drawn and the work path the one these tools show and edit (no shape layer or +/// targeted vector mask in front of it), as in Photoshop (#5702). It was drawn whenever one of +/// these tools was active, but the key reached Edit › Clear, so it looked impossible to delete. +/// Direct Selection and the Curvature Pen delete selected anchors in Photoshop, not the path. +pub(crate) fn delete_key_deletes_work_path(app: &PhotocraftApp) -> bool { + matches!(app.ui.tool, Tool::Pen | Tool::FreeformPen | Tool::PathSelection) + && app.ui.pen.is_none() + && app.ui.transform.is_none() + && app.ui.view.shows(app.ui.view.show.target_path) + && matches!(target_path(app), Some((PathTarget::Work, _))) +} + /// The path Path Selection edits: the targeted vector mask, the active shape layer's path, else /// the work path. fn target_path(app: &PhotocraftApp) -> Option<(PathTarget, Path)> { @@ -955,7 +981,6 @@ pub(crate) fn draw_handle(painter: &egui::Painter, anchor: Pos2, handle: Pos2, a /// Work path / active shape path outlines, anchors, and the pen path in progress. pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, doc: &Document) { let tool = app.ui.tool; - let vector_tool = matches!(tool, Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen | Tool::PathSelection) || is_shape_tool(tool); let accent = Tokens::get(painter.ctx()).accent; let to_scr = |q: [f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32); let draw_path = |p: &Path, anchors: bool| { @@ -976,23 +1001,21 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform crate::anchor_tools::draw_hover(app, painter, &to_scr, accent, painter.ctx().input(|i| i.modifiers)); } } else if paths { - if vector_tool { - if let Some(wp) = &doc.work_path { - draw_path(wp, tool == Tool::PathSelection); - } - let st = app.session.active(); - if let Some(l) = st.and_then(|s| s.active_layer.and_then(|id| s.doc.layer(id))) - && let LayerContent::Shape(sh) = &l.content - { - draw_path(&sh.path, tool == Tool::PathSelection); - } + // Paths remain visible as outlines with any tool. Path Selection adds anchor squares; Direct + // Selection has its own overlay above. + if let Some(wp) = &doc.work_path { + draw_path(wp, tool == Tool::PathSelection); + } + let st = app.session.active(); + if let Some(l) = st.and_then(|s| s.active_layer.and_then(|id| s.doc.layer(id))) + && let LayerContent::Shape(sh) = &l.content + { + draw_path(&sh.path, tool == Tool::PathSelection); } // A targeted vector mask shows its path with any tool (#196). if let Some((_, p)) = targeted_vector_mask(app) { draw_path(&p, tool == Tool::PathSelection); } - // The segment the Pen's Auto Add/Delete would add an anchor to, or the anchor it would delete. - crate::anchor_tools::draw_pen_hover(app, painter, &to_scr, accent, painter.ctx().input(|i| i.modifiers)); crate::corner_radius::draw(app, painter, &to_scr, accent); } // Pen path in progress, with handles of the last knot and a rubber band to the pointer. While @@ -1013,9 +1036,12 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform pts.extend(curve_points(w[0][0], w[0][2], w[1][1], w[1][0], &to_scr)); } painter.add(egui::Shape::line(pts, Stroke::new(1.5, accent))); + // No rubber band where a click adds or deletes an anchor of this path instead (#6140). + let auto = app.hover_doc.is_some_and(|h| crate::anchor_tools::pen_auto(app, tool, painter.ctx().input(|i| i.modifiers), h).is_some()); if let (Some(last), Some(h)) = (pen.knots.last(), app.hover_doc) && !pen.dragging && pen.moving.is_none() + && !auto { if pen.curvature && pen.knots.len() >= 2 { // The Curvature Pen previews the curve the next click makes through the pointer. @@ -1054,6 +1080,11 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform painter.circle_stroke(c + vec2(11.0, 11.0), 3.5, Stroke::new(1.5, accent)); } } + // The segment the Pen's Auto Add/Delete would add an anchor to, or the anchor it would delete, + // over the path being drawn. While drawing, the other paths can be hidden: it edits none of them. + if paths || app.ui.pen.is_some() { + crate::anchor_tools::draw_pen_hover(app, painter, &to_scr, accent, painter.ctx().input(|i| i.modifiers)); + } } // --------------------------------------------------------------------------------------------- diff --git a/crates/ui-egui/src/wheel_nav.rs b/crates/ui-egui/src/wheel_nav.rs index 8917f9123c..bde5e71ae3 100644 --- a/crates/ui-egui/src/wheel_nav.rs +++ b/crates/ui-egui/src/wheel_nav.rs @@ -198,6 +198,13 @@ const SNAP_IDLE_TIMEOUT: f64 = 0.5; /// accumulator per viewport. `hovered` gates accumulation: only the viewport receiving /// navigation may add raw input to the snap residual. pub fn read(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: Id, hovered: bool) -> Option { + read_with(ctx, zoom_with_wheel, pinch_zooms, owner, hovered, true) +} + +/// [`read`], choosing how a plain wheel scroll pans: `smooth` glides it over a few frames (egui's +/// smoothed delta); off, as with Interface › Enable Animations off, the view moves the whole notch +/// in the frame the wheel turns and stops there (#5721), as scroll areas already do. +pub fn read_with(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: Id, hovered: bool, smooth: bool) -> Option { let notch = ctx.options(|o| o.input_options.line_scroll_speed); // --- Modifier tracking (always runs for all callers) --- @@ -316,6 +323,8 @@ pub fn read(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: Id, d.remove::(snap_time_id(owner)); }); } + // Unsmoothed, a plain scroll pans by this frame's own wheel events (none: no tail). + let scroll = if smooth || alt || shift { scroll } else { raw }; classify(Input { scroll, raw, zoom_delta, alt, shift, zoom_with_wheel, pinch_zooms, notch }) } } @@ -370,6 +379,42 @@ mod tests { (a - b).length() < 0.01 } + /// #5721: with smoothing off a wheel notch pans the whole notch in the frame it arrives and + /// nothing after; smoothed (the default) it glides, reaching the same total over later frames. + #[test] + fn unsmoothed_wheel_pans_at_once_without_a_glide() { + let pans = |smooth: bool| -> Vec { + let owner = Id::new("test-unsmoothed"); + let ctx = Context::default(); + configure(&ctx); + (0..30) + .map(|n| { + let events = if n == 0 { vec![wheel(Vec2::new(0.0, -1.0), Modifiers::NONE)] } else { Vec::new() }; + let raw = RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() }; + let mut pan = Vec2::ZERO; + let mut out = ctx.run_ui(raw, |ui| { + if let Some(Wheel::Pan(d)) = read_with(ui.ctx(), false, true, owner, true, smooth) { + pan = d; + } + }); + out.textures_delta.clear(); + pan + }) + .collect() + }; + let notch = { + let ctx = Context::default(); + configure(&ctx); + ctx.options(|o| o.input_options.line_scroll_speed) + }; + let direct = pans(false); + assert!(near(direct[0], Vec2::new(0.0, -notch)), "the whole notch at once: {:?}", direct[0]); + assert!(direct[1..].iter().all(|p| *p == Vec2::ZERO), "no glide after it"); + let smooth = pans(true); + assert!(smooth[0].y.abs() < notch - 0.5, "smoothed, the first frame moves only part of it: {:?}", smooth[0]); + assert!(smooth[1..].iter().any(|p| p.y != 0.0), "and the rest follows"); + } + #[test] fn plain_scroll_pans() { assert_eq!(classify(input(Vec2::new(3.0, -40.0), false)), Some(Wheel::Pan(Vec2::new(3.0, -40.0)))); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 49bcf0978a..6e841292e7 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -384,6 +384,8 @@ struct LabelScrub { rect: Rect, /// How far right of the label the field may start (the whole row for a slider row). max_gap: f32, + /// Options captions defer interaction until the adjacent field bounds are known. + option_area: bool, /// Whole steps to move the value (left negative; Shift ×10, Alt a tenth as fast). steps: f32, started: bool, @@ -402,9 +404,35 @@ pub fn scrub_label(ui: &Ui, label: &Response) -> Response { scrub_label_within(ui, label, 24.0) } +/// A tool-options caption whose scrub target includes the adjacent field's outer box. +/// Keep the original label bounds for linking; register the hit area before the numeric editor. +pub fn scrub_option_label(ui: &Ui, label: &Response) { + let scrub = LabelScrub { + pass: ui.ctx().cumulative_pass_nr(), + label: label.id.with("pc-option-scrub"), + rect: label.rect, + max_gap: 24.0, + option_area: true, + steps: 0.0, + started: false, + dragging: false, + stopped: false, + }; + ui.data_mut(|d| d.insert_temp(label_scrub_id(), scrub)); +} + fn scrub_label_within(ui: &Ui, label: &Response, max_gap: f32) -> Response { // Drag only, not focusable: Tab goes to the value field, not to its label as well. let resp = label.interact(Sense::DRAG); + let scrub = label_scrub_response(ui, &resp, label.rect, max_gap); + ui.data_mut(|d| { + d.insert_temp(label_scrub_id(), scrub); + d.insert_temp(resp.id.with("pc-scrub-linked"), false); + }); + resp +} + +fn label_scrub_response(ui: &Ui, resp: &Response, original_rect: Rect, max_gap: f32) -> LabelScrub { // Whether a field took this label's drags last frame: only then is it scrubby. let linked = ui.data(|d| d.get_temp::(resp.id.with("pc-scrub-linked"))).unwrap_or(false); if linked && resp.enabled() && (resp.hovered() || resp.dragged()) { @@ -434,21 +462,17 @@ fn scrub_label_within(ui: &Ui, label: &Response, max_gap: f32) -> Response { steps = acc.trunc(); ui.data_mut(|d| d.insert_temp(acc_id, acc - steps)); } - let scrub = LabelScrub { + LabelScrub { pass: ui.ctx().cumulative_pass_nr(), label: resp.id, - rect: resp.rect, + rect: original_rect, max_gap, + option_area: false, steps, started: resp.drag_started(), dragging: resp.dragged(), stopped: resp.drag_stopped(), - }; - ui.data_mut(|d| { - d.insert_temp(label_scrub_id(), scrub); - d.insert_temp(resp.id.with("pc-scrub-linked"), false); - }); - resp + } } /// The label drag left for the field at `rect` this frame, if its label is just left of it on @@ -505,7 +529,17 @@ fn value_field_box( let FieldOpts { decimals, units, wrap_drag } = opts; let t = Tokens::get(ui.ctx()); let (rect, slot) = ui.allocate_exact_size(size, Sense::hover()); - let scrub = take_label_scrub(ui, rect); + let scrub = take_label_scrub(ui, rect).map(|s| { + if !s.option_area { + return s; + } + // A direct click+drag target avoids egui snapping the padding/suffix to the nearby + // numeric button. It is not focusable; the editor drawn next wins inside its bounds. + // Preserve a popup field's trailing arrow, and never extend outside the UI clip. + let hit = Rect::from_min_max(pos2(s.rect.left(), rect.top()), pos2(rect.right() - trailing, rect.bottom())).intersect(ui.clip_rect()); + let resp = ui.interact(hit, s.label, Sense::CLICK | Sense::DRAG); + label_scrub_response(ui, &resp, s.rect, s.max_gap) + }); let (dpi, ppi) = ui.data(|d| d.get_temp::<(f64, f64)>(field_resolution_id())).unwrap_or((72.0, 72.0)); // Typed text loses its spaces before parsing, so a " pt" suffix reads as "pt". let typed_suffix = suffix.trim(); @@ -589,7 +623,11 @@ fn value_field_box( let v = (v * 1e4).round() / 1e4; // `f32::clamp` panics on a reversed or NaN range, which `DragValue::range` accepts. *value = if lo <= hi { v.clamp(lo, hi) } else { v }; - ui.memory_mut(|m| m.request_focus(resp.id)); + // Only take focus back after a typed sum gave it up: re-requesting it while held resets + // the field's arrow-key filter next frame, and a held arrow then moves focus away (#5474). + if !ui.memory(|m| m.has_focus(resp.id)) { + ui.memory_mut(|m| m.request_focus(resp.id)); + } // The text stays selected, as in Photoshop: typing after a step replaces the value. crate::field_tab::select_all(ui.ctx(), resp.id, &if fine { fmt_num2(f64::from(*value)) } else { fmt_num(f64::from(*value)) }); resp.mark_changed(); @@ -721,14 +759,20 @@ pub fn text_field_ex(ui: &mut Ui, id: egui::Id, text: &mut String, hint: &str, w ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), StrokeKind::Inside); } let font = if mono { theme::mono(12.0) } else { egui::FontId::proportional(12.5) }; - let mut edit = egui::TextEdit::singleline(text).id(id).hint_text(hint).frame(egui::Frame::NONE).font(font); + let inner = rect.shrink2(TEXT_FIELD_PAD); + // TextEdit draws its text at the top of its rect; fill the box and centre the line in it, so + // the text sits on the field's middle like a dropdown's or value field's. + let mut edit = + egui::TextEdit::singleline(text).id(id).hint_text(hint).frame(egui::Frame::NONE).font(font).min_size(inner.size()).vertical_align(egui::Align::Center); if let Some(limit) = char_limit { edit = edit.char_limit(limit); } // A child Ui keeps the field's selection colour local: `Visuals::selection` also fills selected // list rows and slider trails, which must keep the theme's. let layout = egui::Layout::centered_and_justified(egui::Direction::TopDown); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(TEXT_FIELD_PAD)).layout(layout)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(layout)); + // The theme's 24 pt interact height would make the edit taller than the box's inside. + child.spacing_mut().interact_size.y = inner.height(); child.style_mut().visuals.selection.bg_fill = t.field_selection(); child.add(edit) } @@ -780,6 +824,9 @@ fn number_edit( let mut resp = ui.add( egui::DragValue::new(value) .range(range.clone()) + // Only edits are clamped (below): showing a value past the range, such as a type layer + // scaled to 2400 pt in a 1296 pt field, must not change it (#5717). + .clamp_existing_to_range(false) // A warp is an absolute-position jump. The scoped scrub below removes that jump. .speed(if wrapped_drag { 0.0 } else { speed }) .custom_formatter(move |v, _| match decimals { @@ -800,7 +847,12 @@ fn number_edit( }), ); if wrap_drag { - crate::scrub_wrap::update(ui, &resp, value, before, range, speed, wrapping); + crate::scrub_wrap::update(ui, &resp, value, before, range.clone(), speed, wrapping); + } + let (lo, hi) = (*range.start(), *range.end()); + // `f32::clamp` panics on a reversed or NaN range, which `DragValue::range` accepts. + if *value != before && lo <= hi { + *value = value.clamp(lo, hi); } resp.flags.set(egui::response::Flags::CHANGED, *value != before); resp @@ -1296,9 +1348,16 @@ pub fn dropdown(ui: &mut Ui, id: &str, current: &mut T, op dropdown_hovered(ui, id, current, options, width).0 } +/// [`dropdown`] whose option labels are shown exactly as given: names the user typed (a channel +/// or layer name) must not be translated, unlike the built-in labels [`dropdown`] translates (#5335). +pub fn dropdown_literal(ui: &mut Ui, id: &str, current: &mut T, options: &[(T, &str)], width: f32) -> bool { + let (chosen, _) = dropdown_with(ui, id, current, options, width, false, false); + !chosen.is_empty() +} + /// [`dropdown`], also returning the option under the pointer in its open list (live previews). pub fn dropdown_hovered(ui: &mut Ui, id: &str, current: &mut T, options: &[(T, &str)], width: f32) -> (bool, Option) { - let (chosen, hovered) = dropdown_with(ui, id, current, options, width, false); + let (chosen, hovered) = dropdown_with(ui, id, current, options, width, false, true); (!chosen.is_empty(), hovered) } @@ -1309,11 +1368,21 @@ pub fn dropdown_hovered(ui: &mut Ui, id: &str, current: &m /// in order (one per notch; the last is `current`) and the option under the pointer in the open /// list, none while the pointer rests where the wheel last stepped. pub fn dropdown_wheel_hovered(ui: &mut Ui, id: &str, current: &mut T, options: &[(T, &str)], width: f32) -> (Vec, Option) { - dropdown_with(ui, id, current, options, width, true) + dropdown_with(ui, id, current, options, width, true, true) } -fn dropdown_with(ui: &mut Ui, id: &str, current: &mut T, options: &[(T, &str)], width: f32, wheel: bool) -> (Vec, Option) { - let label = options.iter().find(|(v, _)| v == current).map(|(_, l)| tl!(l)).unwrap_or("—"); +#[allow(clippy::too_many_arguments)] +fn dropdown_with( + ui: &mut Ui, + id: &str, + current: &mut T, + options: &[(T, &str)], + width: f32, + wheel: bool, + translate: bool, +) -> (Vec, Option) { + let shown = |l: &str| if translate { tl!(l).to_string() } else { l.to_string() }; + let label = options.iter().find(|(v, _)| v == current).map(|(_, l)| shown(l)).unwrap_or_else(|| "—".into()); let (mut chosen, mut hovered) = (Vec::new(), None); // The wheel's notches add up over the button and the open list alike. let wheel_id = ui.make_persistent_id(id).with("wheel"); @@ -1325,11 +1394,12 @@ fn dropdown_with(ui: &mut Ui, id: &str, current: &mut T, o let mut reveal = wheel && ui.data_mut(|d| d.remove_temp::(reveal_id)).unwrap_or(false); // The list opens on the press: press, drag onto an option and release to choose it (#2735). let (press, close) = crate::press_menu::PressCombo::before(ui, id); + let texts: Vec = options.iter().map(|(_, l)| shown(l)).collect(); let response = egui::ComboBox::from_id_salt(id).selected_text(label).width(width).height(420.0).icon(chevron_icon).close_behavior(close).show_ui(ui, |ui| { - fit_widest_option(ui, options.iter().map(|(_, l)| tl!(l))); + fit_widest_option(ui, texts.iter().map(|l| l.as_str())); // A typed letter jumps to the next option starting with it, ↩ closes the list (#1481). - let labels: Vec<&str> = options.iter().map(|(_, l)| tl!(l)).collect(); + let labels: Vec<&str> = texts.iter().map(|l| l.as_str()).collect(); let shown = options.iter().position(|(v, _)| v == current); if let Some((v, _)) = type_ahead::keys(ui, ui.make_persistent_id(id), shown, &labels).and_then(|i| options.get(i)) { *current = v.clone(); @@ -1348,8 +1418,8 @@ fn dropdown_with(ui: &mut Ui, id: &str, current: &mut T, o // The row nearest the pointer, by its distance to each row. let pointer = ui.ctx().pointer_hover_pos().filter(|_| ui.rect_contains_pointer(ui.clip_rect())); let mut nearest: Option<(f32, &T)> = None; - for (v, l) in options { - let item = ui.selectable_label(v == current, tl!(l)); + for ((v, _), text) in options.iter().zip(texts.iter()) { + let item = ui.selectable_label(v == current, text.as_str()); if reveal && v == current { item.scroll_to_me(None); } @@ -1996,6 +2066,30 @@ mod tests { assert_eq!(press(&mut h, Modifiers::NONE, Key::ArrowDown), 10.0, "the field keeps focus"); } + /// #5474: a held arrow repeats on consecutive frames; every repeat steps the focused field + /// and none of them moves focus to the field above. + #[test] + fn held_arrow_keeps_stepping_the_focused_field() { + let mut h = Harness::new_ui_state( + |ui, s: &mut (f32, f32)| { + super::value_field(ui, &mut s.0, 0.0..=1000.0, "", 80.0); + super::value_field(ui, &mut s.1, 0.0..=1000.0, "", 80.0); + }, + (10.0, 59.0), + ); + h.run(); + h.get_all_by_role(egui::accesskit::Role::SpinButton).nth(1).expect("second field").click(); + h.run(); + // Held down: key-down repeats on consecutive frames, no key-up in between. + for repeat in [false, true, true, true, true, true] { + h.event(egui::Event::Key { key: Key::ArrowUp, physical_key: None, pressed: true, repeat, modifiers: Modifiers::NONE }); + h.step(); + } + h.event(egui::Event::Key { key: Key::ArrowUp, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run(); + assert_eq!(*h.state(), (10.0, 65.0)); + } + #[test] fn arrow_keys_leave_an_unfocused_field_alone() { let mut h = field(100.0, 0.0..=1000.0); @@ -2194,6 +2288,29 @@ mod tests { assert_eq!(*h.state(), (25.0, 3)); } + /// #5717: a value past the range shows as it is without reporting a change; an edit is clamped. + #[test] + fn value_field_keeps_a_shown_value_past_its_range_and_clamps_edits() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut h = Harness::builder().with_size(egui::vec2(300.0, 100.0)).build_ui_state( + |ui, (v, changes): &mut (f32, u32)| { + if super::value_field(ui, v, 0.1..=1296.0, "pt", 90.0).changed() { + *changes += 1; + } + }, + (2400.0, 0), + ); + h.run_steps(3); + assert_eq!(*h.state(), (2400.0, 0), "showing it changes nothing"); + h.get_by_role(egui::accesskit::Role::SpinButton).click(); + h.run(); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("5000".into())); + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(h.state().0, 1296.0, "a typed value is clamped"); + } + #[test] fn value_field_applies_typed_arithmetic() { use egui::Key::{Enter, Tab}; diff --git a/crates/ui-egui/tests/full_res_preview.rs b/crates/ui-egui/tests/full_res_preview.rs index 6ac2a513c5..191a071b9d 100644 --- a/crates/ui-egui/tests/full_res_preview.rs +++ b/crates/ui-egui/tests/full_res_preview.rs @@ -109,6 +109,10 @@ fn mean(r: &[i32]) -> f32 { r.iter().sum::() as f32 / r.len() as f32 } +/// Dragging an adjustment layer's control (Properties panel) previews through the reduced copy +/// only as coarse as the screen (#5764): at 100 % every column shows even with low-resolution +/// previews on; zoomed out to 50 % the default drag runs on the reduced copy (only the black columns, inverted), and with +/// them off it matches the committed view (the columns' mipmapped grey). #[test] fn adjustment_layer_drag_previews_at_full_resolution_when_low_res_is_off() { let _gpu = photocraft_testkit::gpu_lock(); @@ -126,12 +130,19 @@ fn adjustment_layer_drag_previews_at_full_resolution_when_low_res_is_off() { app.live_adjust = Some((id, json!({}))); id }; - let reduced = row(&mut h); - assert!(columns(&reduced) < 0.1, "default: the reduced copy loses the columns ({})", columns(&reduced)); + let at_100 = row(&mut h); + assert!(columns(&at_100) > 0.9, "default at 100 %: the drag is never coarser than the screen ({})", columns(&at_100)); + control(&mut h, "ui.set", json!({"zoom": 0.5, "center": [W / 2, H / 2]})); + h.state_mut().live_adjust = None; + let committed = mean(&row(&mut h)); + assert!(committed > 60.0, "zoomed out the inverted columns average to grey ({committed})"); + h.state_mut().live_adjust = Some((layer, json!({}))); + let reduced = mean(&row(&mut h)); + assert!(reduced > 225.0, "default at 50 %: the reduced copy keeps only the (inverted) black columns ({reduced})"); low_res(&mut h, false); h.state_mut().live_adjust = Some((layer, json!({}))); - let full = row(&mut h); - assert!(columns(&full) > 0.9, "low-res previews off: every column shows ({})", columns(&full)); + let full = mean(&row(&mut h)); + assert!((full - committed).abs() < 8.0, "low-res previews off: the drag matches the committed view ({full} vs {committed})"); } #[test] diff --git a/crates/vector/src/edit.rs b/crates/vector/src/edit.rs index d08d762121..7f1f4d5823 100644 --- a/crates/vector/src/edit.rs +++ b/crates/vector/src/edit.rs @@ -80,6 +80,12 @@ fn handle(k: &Knot, h: Handle) -> Point { } } +/// Whether the segment from knot `a` to knot `b` is straight: both of its handles retracted. +fn straight(a: &Knot, b: &Knot) -> bool { + let retracted = |k: &Knot, h: Handle| dist(handle(k, h), k.anchor) < 1e-9; + retracted(a, Handle::Out) && retracted(b, Handle::In) +} + fn other(h: Handle) -> Handle { match h { Handle::In => Handle::Out, @@ -178,10 +184,9 @@ pub fn convert_point(path: &mut Path, knot: [usize; 2], out: Option) -> R /// the pointer exactly, with the nearer handle moving more. Smooth end knots stay smooth. pub fn bend_segment(path: &mut Path, knot: [usize; 2], t: f64, d: [f64; 2]) -> Result<(), String> { let end = segment_end(path, knot)?; - let retracted = |k: &Knot, h: Handle| dist(handle(k, h), k.anchor) < 1e-9; let a = *knot_mut(path, knot)?; let b = *knot_mut(path, end)?; - if retracted(&a, Handle::Out) && retracted(&b, Handle::In) { + if straight(&a, &b) { return move_anchors(path, &[knot, end], d); } // B(t) moves by 3(1−t)²t·Δc1 + 3(1−t)t²·Δc2. With Δc1 = (1−t)·s·d and Δc2 = t·s·d that is @@ -213,10 +218,9 @@ pub fn add_anchor(path: &mut Path, knot: [usize; 2], t: f64) -> Result<[usize; 2 return Err("`t` must be strictly between 0 and 1".into()); } let end = segment_end(path, knot)?; - let retracted = |k: &Knot, h: Handle| dist(handle(k, h), k.anchor) < 1e-9; let a = *knot_mut(path, knot)?; let b = *knot_mut(path, end)?; - let new = if retracted(&a, Handle::Out) && retracted(&b, Handle::In) { + let new = if straight(&a, &b) { // Hit testing and hover feedback use the cubic parameter, which is not linear even // when both handles are retracted. Keep the inserted corner at that same point. let p = eval(&[a.anchor, a.out_ctrl, b.in_ctrl, b.anchor], t); diff --git a/crates/vector/src/flatten.rs b/crates/vector/src/flatten.rs index b3fd0a90f6..a586dfa1bf 100644 --- a/crates/vector/src/flatten.rs +++ b/crates/vector/src/flatten.rs @@ -109,8 +109,38 @@ pub fn validate_shape(path: &Path, stroke: Option<&photocraft_doc::ShapeStroke>) Ok(()) } -/// Flattens one subpath. Closed subpaths do not repeat the first point at the end. +/// Points one flattened path may hold. Paths that never pass [`validate_shape`] (vector masks +/// from files, imported SVG, effect outlines) are flattened more coarsely past it, so hostile +/// curves cannot exhaust memory. +pub(crate) const MAX_PATH_POINTS: usize = 1 << 20; + +/// How much to coarsen the curves of `subpaths` at `tol` so the whole path stays within +/// [`MAX_PATH_POINTS`]: the share of each curve's pieces to keep (1 = all of them). +fn piece_scale<'a>(subpaths: impl IntoIterator, tol: f64) -> f64 { + // Subpath starts and straight segments take one point each and cannot be coarsened. + let (mut fixed, mut curves) = (0usize, 0usize); + for s in subpaths { + fixed = fixed.saturating_add(1); + for seg in s.segments() { + if is_line(&seg) { + fixed = fixed.saturating_add(1); + } else { + curves = curves.saturating_add(pieces(&seg, tol)); + } + } + } + let total = fixed.saturating_add(curves); + if total <= MAX_PATH_POINTS { 1.0 } else { MAX_PATH_POINTS as f64 / total as f64 } +} + +/// Flattens one subpath. Closed subpaths do not repeat the first point at the end. A hostile +/// subpath is flattened more coarsely so it stays within a bounded number of points. pub fn flatten_subpath(s: &Subpath, tol: f64) -> Polyline { + flatten_scaled(s, tol, piece_scale([s], tol)) +} + +/// [`flatten_subpath`] keeping the share `scale` of each curve's pieces (at least one). +fn flatten_scaled(s: &Subpath, tol: f64, scale: f64) -> Polyline { let mut out = Polyline { closed: s.closed, ..Default::default() }; if let Some(k) = s.knots.first() { out.push((k.anchor.x, k.anchor.y), true); @@ -121,6 +151,7 @@ pub fn flatten_subpath(s: &Subpath, tol: f64) -> Polyline { continue; } let n = pieces(seg, tol); + let n = if scale < 1.0 { ((n as f64 * scale) as usize).max(1) } else { n }; for j in 1..=n { out.push(eval(seg, j as f64 / n as f64), j == n); } @@ -136,15 +167,18 @@ pub fn flatten_subpath(s: &Subpath, tol: f64) -> Polyline { out } -/// Flattens every subpath (see [`flatten_subpath`]). +/// Flattens every subpath (see [`flatten_subpath`]); a hostile path is flattened more coarsely +/// so the whole path stays within a bounded number of points. pub fn flatten_path(p: &Path, tol: f64) -> Vec { - p.subpaths.iter().map(|s| flatten_subpath(s, tol)).collect() + let scale = piece_scale(&p.subpaths, tol); + p.subpaths.iter().map(|s| flatten_scaled(s, tol, scale)).collect() } #[cfg(test)] mod tests { use super::*; use crate::shapes; + use photocraft_doc::Knot; #[test] fn lines_are_not_subdivided() { @@ -168,4 +202,26 @@ mod tests { assert!(p.pts.len() < 4 * (1 + (0.75 * 100.0 / tol.min(0.15)).sqrt() as usize + 1)); } } + + #[test] + fn hostile_curves_flatten_within_the_point_budget() { + // Unvalidated paths (a vector mask from a file, an imported SVG) can carry handles far + // outside the canvas: each of these segments alone asks for the 65536-piece cap, so the + // path would flatten to ~1.3M points (#5195). + let segments = 20; + let knots = (0..=segments) + .map(|i| { + let a = Point::new(f64::from(i) * 50.0, f64::from(i % 2) * 1000.0); + Knot::smooth(a, Point::new(a.x - 3e7, a.y + 3e7), Point::new(a.x + 3e7, a.y - 3e7)) + }) + .collect(); + let path = Path::new(vec![Subpath { closed: false, knots, op: Default::default() }]); + let lines = flatten_path(&path, 0.01); + let points: usize = lines.iter().map(|l| l.pts.len()).sum(); + assert!(points > 1000 && points <= MAX_PATH_POINTS + segments as usize + 1, "{points} points"); + assert_eq!(flatten_subpath(&path.subpaths[0], 0.01).pts.len(), points); + // Ordinary paths are flattened exactly as before. + let circle = shapes::ellipse(0.0, 0.0, 200.0, 200.0); + assert_eq!(flatten_path(&circle, 0.01)[0], flatten_scaled(&circle.subpaths[0], 0.01, 1.0)); + } } diff --git a/crates/vector/src/lib.rs b/crates/vector/src/lib.rs index 44e70ce760..d336435492 100644 --- a/crates/vector/src/lib.rs +++ b/crates/vector/src/lib.rs @@ -43,10 +43,12 @@ pub const DEFAULT_TOLERANCE: f64 = 0.01; /// folded with the subpath operations; open subpaths are closed implicitly. pub fn fill_rasterizer(path: &Path, tol: f64) -> Rasterizer { let mut r = Rasterizer::new(path.inverted); + // One point budget for the whole path, then one edge sort for all components. + let polys: Vec> = flatten_path(path, tol).into_iter().map(|l| l.pts).collect(); for c in path.components() { - let polys: Vec> = path.subpaths[c.clone()].iter().map(|s| flatten_subpath(s, tol).pts).collect(); - r.add_component(&polys, path.effective_op(c.start), path.fill_rule); + r.push_component(polys.get(c.clone()).unwrap_or_default(), path.effective_op(c.start), path.fill_rule); } + r.sort_edges(); r } @@ -105,26 +107,26 @@ impl CompiledVectorMask { /// Effective mask values over `rect`, with invocation-local rasterization state. pub fn render(&self, rect: Rect) -> Vec { - let mut v = match &self.geometry { - MaskGeometry::Constant(value) => vec![*value; rect.width() as usize * rect.height() as usize], - MaskGeometry::Path(rasterizer) => rasterizer.render(rect), - }; - self.apply_density(&mut v); - v + self.render_with(rect, Rasterizer::render) } /// Effective mask values without entering a thread pool. Use this while /// initializing a shared result that other Rayon workers may wait for. pub fn render_sequential(&self, rect: Rect) -> Vec { + self.render_with(rect, |rasterizer, rect| { + let mut v = vec![0.0; rect.width() as usize * rect.height() as usize]; + if !v.is_empty() { + rasterizer.render_into(rect, &mut v); + } + v + }) + } + + /// Mask values over `rect`, with a path's coverage from `coverage`. + fn render_with(&self, rect: Rect, coverage: impl FnOnce(&Rasterizer, Rect) -> Vec) -> Vec { let mut v = match &self.geometry { MaskGeometry::Constant(value) => vec![*value; rect.width() as usize * rect.height() as usize], - MaskGeometry::Path(rasterizer) => { - let mut v = vec![0.0; rect.width() as usize * rect.height() as usize]; - if !v.is_empty() { - rasterizer.render_into(rect, &mut v); - } - v - } + MaskGeometry::Path(rasterizer) => coverage(rasterizer, rect), }; self.apply_density(&mut v); v diff --git a/crates/vector/src/map.rs b/crates/vector/src/map.rs index ab2e62e1e8..6078e604f8 100644 --- a/crates/vector/src/map.rs +++ b/crates/vector/src/map.rs @@ -15,13 +15,18 @@ use photocraft_geom::Point; pub fn map_points(path: &Path, f: impl Fn(Point) -> Point) -> Path { let mut out = path.clone(); for s in &mut out.subpaths { - for k in &mut s.knots { - *k = Knot { anchor: f(k.anchor), in_ctrl: f(k.in_ctrl), out_ctrl: f(k.out_ctrl), smooth: k.smooth }; - } + map_knots(s, &f); } out } +/// Sends every anchor and control point of `s` through `f`, in place. +fn map_knots(s: &mut Subpath, f: &impl Fn(Point) -> Point) { + for k in &mut s.knots { + *k = Knot { anchor: f(k.anchor), in_ctrl: f(k.in_ctrl), out_ctrl: f(k.out_ctrl), smooth: k.smooth }; + } +} + /// Pieces a path may be split into, in total: bounds the work and memory on hostile input. const MAX_PIECES: usize = 1 << 20; /// Deepest split of one segment (2^6 = 64 pieces). @@ -47,7 +52,9 @@ fn map_subpath(s: &Subpath, f: &impl Fn(Point) -> Point, tol: f64, depth: u32) - let n = s.knots.len(); let segs = s.segments(); if segs.is_empty() { - return map_points(&Path::new(vec![s.clone()]), f).subpaths.pop().unwrap_or_default(); + let mut out = s.clone(); + map_knots(&mut out, f); + return out; } // The mapped pieces of each segment (segment i runs from knot i to knot i + 1). let pieces: Vec> = segs.iter().map(|seg| map_segment(seg, f, tol, depth)).collect(); diff --git a/crates/vector/src/raster.rs b/crates/vector/src/raster.rs index ba2cdd21ff..eb6c4a943b 100644 --- a/crates/vector/src/raster.rs +++ b/crates/vector/src/raster.rs @@ -37,6 +37,10 @@ impl Edge { } } +/// Polygon coordinates are clamped to `±COORD_LIMIT`: far outside any `i32` pixel rectangle, +/// and small enough that edge slopes and positions never overflow to infinity or NaN. +const COORD_LIMIT: f64 = 1e15; + /// A combination step: a component (set of polygons filled with `rule`) and how it folds into /// the result so far. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -66,6 +70,19 @@ impl Rasterizer { /// Adds a component made of closed polygons (open input is closed implicitly). pub fn add_component(&mut self, polys: &[Vec<(f64, f64)>], op: PathOp, rule: FillRule) { + self.push_component(polys, op, rule); + self.sort_edges(); + } + + /// Restores the `y0` order of the edges after [`Self::push_component`]. The sort is stable, + /// so sorting once after many components orders the edges exactly like sorting after each. + pub(crate) fn sort_edges(&mut self) { + self.edges.sort_by(|a, b| a.y0.total_cmp(&b.y0)); + } + + /// [`Self::add_component`] without restoring the edge order: callers adding many components + /// sort once at the end with [`Self::sort_edges`]. + pub(crate) fn push_component(&mut self, polys: &[Vec<(f64, f64)>], op: PathOp, rule: FillRule) { let comp = self.comps.len() as u32; self.comps.push(Component { op, rule }); if op != PathOp::Combine && comp != 0 { @@ -77,11 +94,13 @@ impl Rasterizer { continue; } for i in 0..n { - let a = poly[i]; - let b = poly[(i + 1) % n]; + let (a, b) = (poly[i], poly[(i + 1) % n]); if !(a.0.is_finite() && a.1.is_finite() && b.0.is_finite() && b.1.is_finite()) { continue; } + // Far beyond any pixel rectangle; keeps edge arithmetic from overflowing. + let clamp = |p: (f64, f64)| (p.0.clamp(-COORD_LIMIT, COORD_LIMIT), p.1.clamp(-COORD_LIMIT, COORD_LIMIT)); + let (a, b) = (clamp(a), clamp(b)); self.bounds = Some(match self.bounds { None => (a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)), Some(bb) => (bb.0.min(a.0.min(b.0)), bb.1.min(a.1.min(b.1)), bb.2.max(a.0.max(b.0)), bb.3.max(a.1.max(b.1))), @@ -90,10 +109,15 @@ impl Rasterizer { continue; } let (p, q, dir) = if a.1 < b.1 { (a, b, 1) } else { (b, a, -1) }; - self.edges.push(Edge { x0: p.0, y0: p.1, y1: q.1, dir, comp, dxdy: (q.0 - p.0) / (q.1 - p.1) }); + let dxdy = (q.0 - p.0) / (q.1 - p.1); + // Only a vanishingly short edge has an infinite slope once clamped: like a + // horizontal edge it covers no area, and it would turn coverage into NaN. + if !dxdy.is_finite() { + continue; + } + self.edges.push(Edge { x0: p.0, y0: p.1, y1: q.1, dir, comp, dxdy }); } } - self.edges.sort_by(|a, b| a.y0.total_cmp(&b.y0)); } /// Pixel bounds of everything that may be covered (`None` = nothing; the whole plane @@ -428,6 +452,43 @@ mod tests { assert!(r.render(Rect::new(0, 0, 4, 4)).iter().all(|v| *v == 1.0)); } + #[test] + fn huge_finite_coordinates_keep_edges_finite() { + // The diagonal runs from far left of row 3 to far right of row 4, crossing x = 0 at + // y = 4: row 3 is outside the triangle on the canvas, row 4 inside. Its slope used to + // overflow to infinity, and the NaN it gave at the edge's top dropped that boundary, + // filling row 3 (#5195). + let mut r = Rasterizer::new(false); + r.add_component(&[vec![(-1e308, 3.0), (1e308, 5.0), (-1e308, 5.0)]], PathOp::Combine, FillRule::NonZero); + let c = r.render(Rect::new(0, 0, 10, 8)); + assert!(c.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))); + for (y, row) in c.chunks(10).enumerate() { + let expect = if y == 4 { 1.0 } else { 0.0 }; + assert!(row.iter().all(|v| (v - expect).abs() < 1e-3), "row {y}: {row:?}"); + } + } + + #[test] + fn sorting_edges_once_matches_sorting_per_component() { + // Components sharing top rows (tied `y0`s) and a subtraction: one stable sort at the end + // gives the exact edge order (and so the exact bits) of a sort after every component. + let comps = [ + (vec![square(1.0, 1.0, 6.0), vec![(2.5, 1.0), (9.0, 4.0), (3.0, 8.5)]], PathOp::Combine, FillRule::NonZero), + (vec![square(3.0, 1.0, 2.5)], PathOp::Subtract, FillRule::EvenOdd), + (vec![vec![(0.5, 1.0), (8.0, 1.0), (4.2, 7.7)]], PathOp::Exclude, FillRule::NonZero), + ]; + let (mut each, mut once) = (Rasterizer::new(false), Rasterizer::new(false)); + for (polys, op, rule) in &comps { + each.add_component(polys, *op, *rule); + once.push_component(polys, *op, *rule); + } + once.sort_edges(); + let rect = Rect::new(0, 0, 10, 10); + let (a, b) = (each.render(rect), once.render(rect)); + assert!(a.iter().zip(&b).all(|(p, q)| p.to_bits() == q.to_bits())); + assert!(sum(&a) > 1.0); + } + #[test] fn pixel_bounds_clip_upper_edge_at_i32_max() { let mut r = Rasterizer::new(false); diff --git a/crates/vector/src/stroke.rs b/crates/vector/src/stroke.rs index dba9473e8f..2e08ab68ba 100644 --- a/crates/vector/src/stroke.rs +++ b/crates/vector/src/stroke.rs @@ -3,6 +3,8 @@ //! together with the non-zero rule yields their exact union without computing offset curves. //! Dashing splits the flattened polylines by arc length first. +use std::borrow::Cow; + use photocraft_doc::{LineCap, LineJoin}; use crate::flatten::Polyline; @@ -86,8 +88,14 @@ fn area_radius(r: f64, step: f64) -> f64 { if step < 1e-9 { r } else { r * (step / step.sin()).sqrt() } } -fn circle(c: P, r: f64, tol: f64) -> Vec

{ - let n = ((std::f64::consts::TAU / arc_step(r, tol)).ceil() as usize).max(8); +/// Points a full circle needs at `tol` (at least 8). +fn circle_points(r: f64, tol: f64) -> usize { + ((std::f64::consts::TAU / arc_step(r, tol)).ceil() as usize).max(8) +} + +/// A circle of at most `arc_cap` points (`usize::MAX` = no limit, at least 4). +fn circle(c: P, r: f64, tol: f64, arc_cap: usize) -> Vec

{ + let n = circle_points(r, tol).min(arc_cap).max(4); let rr = area_radius(r, std::f64::consts::TAU / n as f64); (0..n) .map(|i| { @@ -97,28 +105,78 @@ fn circle(c: P, r: f64, tol: f64) -> Vec

{ .collect() } +/// Dash steps one stroke may spend, shared by all of its lines: it bounds the number of dashes +/// (and so of caps) for file-provided patterns. Past it, the rest is drawn solid. +const DASH_STEP_BUDGET: usize = 100_000; + +/// Outline points a stroke may spend before its round caps and joins get coarser (#5211). +const OUTLINE_POINT_BUDGET: usize = 1 << 20; + /// Stroke outline pieces for `lines`. Fill them with the non-zero rule. +/// +/// The outline is bounded: when full-resolution round caps and joins would exceed +/// [`OUTLINE_POINT_BUDGET`] points, every round cap and join gets the same, smaller number of +/// segments (down to 4 per circle and 1 per join; areas stay exact). Strokes under the budget +/// are unchanged and no piece is dropped, so the total is at most the larger of the budget and +/// that minimal tessellation, which is linear in the (budgeted) flattened points and dashes. pub fn stroke_polygons(lines: &[Polyline], style: &StrokeStyle, tol: f64) -> Vec> { let mut out = Vec::new(); if style.width <= 0.0 || !style.width.is_finite() || !style.miter_limit.is_finite() || style.miter_limit < 1.0 { return out; } + let mut steps_left = DASH_STEP_BUDGET; + let mut pieces: Vec> = Vec::new(); for pl in lines { if pl.pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) { continue; } if !style.dashes.is_empty() { - for dash in dash_polyline(pl, &style.dashes, style.dash_offset) { - stroke_one(&dash, style, tol, &mut out); - } + pieces.extend(dash_polyline_budgeted(pl, &style.dashes, style.dash_offset, &mut steps_left).into_iter().map(Cow::Owned)); } else { - stroke_one(pl, style, tol, &mut out); + pieces.push(Cow::Borrowed(pl)); } } + let arc_cap = round_arc_cap(&pieces, style, tol); + for pl in &pieces { + stroke_one(pl, style, tol, arc_cap, &mut out); + } out } -fn stroke_one(pl: &Polyline, style: &StrokeStyle, tol: f64, out: &mut Vec>) { +/// Points per full circle allowed for round caps and joins so the outline of `pieces` stays +/// within [`OUTLINE_POINT_BUDGET`]; `usize::MAX` when full resolution fits. +fn round_arc_cap(pieces: &[Cow], style: &StrokeStyle, tol: f64) -> usize { + let (round_cap, round_join) = (style.cap == LineCap::Round, style.join == LineJoin::Round); + if !round_cap && !round_join { + return usize::MAX; + } + // Upper bounds: ribbons and miter/bevel pieces, full circles (caps and dots), round joins. + let (mut base, mut circles, mut joins) = (0usize, 0usize, 0usize); + for pl in pieces { + let m = pl.pts.len(); + if m == 0 { + continue; + } + base = base.saturating_add(m.saturating_mul(6)).saturating_add(8); + if round_cap && !(pl.closed && m > 2) { + circles = circles.saturating_add(if m == 1 { 1 } else { 2 }); + } + if round_join { + joins = joins.saturating_add(m); + } + } + // A join sweeps at most half a circle, plus its centre and two end points. + let cost = |n: usize| base.saturating_add(circles.saturating_mul(n)).saturating_add(joins.saturating_mul(n / 2 + 3)); + let full = circle_points(style.width * 0.5, tol); + if cost(full) <= OUTLINE_POINT_BUDGET { + return usize::MAX; + } + let left = OUTLINE_POINT_BUDGET.saturating_sub(base).saturating_sub(joins.saturating_mul(3)) as f64; + let per_circle = circles as f64 + joins as f64 * 0.5; + ((left / per_circle.max(1.0)) as usize).clamp(4, full) +} + +fn stroke_one(pl: &Polyline, style: &StrokeStyle, tol: f64, arc_cap: usize, out: &mut Vec>) { let hw = style.width * 0.5; // Drop repeated points. let mut pts: Vec

= Vec::with_capacity(pl.pts.len()); @@ -145,7 +203,7 @@ fn stroke_one(pl: &Polyline, style: &StrokeStyle, tol: f64, out: &mut Vec // A dot: only round and square caps draw something. let c = pts[0]; match style.cap { - LineCap::Round => emit(out, circle(c, hw, tol)), + LineCap::Round => emit(out, circle(c, hw, tol, arc_cap)), LineCap::Square => emit(out, vec![(c.0 - hw, c.1 - hw), (c.0 + hw, c.1 - hw), (c.0 + hw, c.1 + hw), (c.0 - hw, c.1 + hw)]), LineCap::Butt => {} } @@ -213,19 +271,19 @@ fn stroke_one(pl: &Polyline, style: &StrokeStyle, tol: f64, out: &mut Vec (jn, true) => jn, (_, false) => LineJoin::Miter, }; - join_piece(v, d1, d2, hw, join, if knot[j] { style.miter_limit.min(500.0) } else { f64::INFINITY }, tol, out); + join_piece(v, d1, d2, hw, join, if knot[j] { style.miter_limit.min(500.0) } else { f64::INFINITY }, tol, arc_cap, out); } } if !closed { let d0 = norm(sub(pts[1], pts[0])); let d1 = norm(sub(pts[n - 1], pts[n - 2])); - cap_piece(pts[0], mul(d0, -1.0), hw, style.cap, tol, out); - cap_piece(pts[n - 1], d1, hw, style.cap, tol, out); + cap_piece(pts[0], mul(d0, -1.0), hw, style.cap, tol, arc_cap, out); + cap_piece(pts[n - 1], d1, hw, style.cap, tol, arc_cap, out); } } #[allow(clippy::too_many_arguments)] -fn join_piece(v: P, d1: P, d2: P, hw: f64, join: LineJoin, miter_limit: f64, tol: f64, out: &mut Vec>) { +fn join_piece(v: P, d1: P, d2: P, hw: f64, join: LineJoin, miter_limit: f64, tol: f64, arc_cap: usize, out: &mut Vec>) { let c = cross(d1, d2); let d = dot(d1, d2); if c.abs() < 1e-12 && d > 0.0 { @@ -239,15 +297,14 @@ fn join_piece(v: P, d1: P, d2: P, hw: f64, join: LineJoin, miter_limit: f64, tol match join { LineJoin::Bevel => emit(out, vec![v, a, b]), LineJoin::Miter => { - // Miter length / width = 1 / sin(θ/2) where θ is the angle between the segments. - let half = ((1.0 - d) * 0.5).max(0.0).sqrt(); // sin of half the turning angle + // Miter length / width = 1 / sin(θ/2) where θ is the angle between the segments, + // i.e. 1 / cos of half the turning angle. let cos_half = ((1.0 + d) * 0.5).max(0.0).sqrt(); if cos_half < 1e-9 || 1.0 / cos_half > miter_limit { emit(out, vec![v, a, b]); } else { let bis = norm(add(n1, n2)); let m = add(v, mul(bis, hw / cos_half)); - let _ = half; emit(out, vec![v, a, m, b]); } } @@ -267,7 +324,12 @@ fn join_piece(v: P, d1: P, d2: P, hw: f64, join: LineJoin, miter_limit: f64, tol delta = std::f64::consts::PI * if cross(n1, d1) > 0.0 { 1.0 } else { -1.0 }; } a1 = a0 + delta; - let steps = ((delta.abs() / arc_step(hw, tol)).ceil() as usize).max(1); + let mut steps = (delta.abs() / arc_step(hw, tol)).ceil() as usize; + if arc_cap != usize::MAX { + // Over the outline budget: the join's share of an `arc_cap`-point circle. + steps = steps.min((arc_cap as f64 * delta.abs() / std::f64::consts::TAU).ceil() as usize); + } + let steps = steps.max(1); let rr = area_radius(hw, delta.abs() / steps as f64); let mut poly = vec![v, a]; for k in 1..steps { @@ -280,10 +342,10 @@ fn join_piece(v: P, d1: P, d2: P, hw: f64, join: LineJoin, miter_limit: f64, tol } } -fn cap_piece(p: P, dir: P, hw: f64, cap: LineCap, tol: f64, out: &mut Vec>) { +fn cap_piece(p: P, dir: P, hw: f64, cap: LineCap, tol: f64, arc_cap: usize, out: &mut Vec>) { match cap { LineCap::Butt => {} - LineCap::Round => emit(out, circle(p, hw, tol)), + LineCap::Round => emit(out, circle(p, hw, tol, arc_cap)), LineCap::Square => { let n = mul(perp(dir), hw); let e = mul(dir, hw); @@ -294,6 +356,12 @@ fn cap_piece(p: P, dir: P, hw: f64, cap: LineCap, tol: f64, out: &mut Vec /// Splits a polyline into dashes (open polylines). `pattern` alternates on/off lengths in px. pub fn dash_polyline(pl: &Polyline, pattern: &[f64], offset: f64) -> Vec { + dash_polyline_budgeted(pl, pattern, offset, &mut DASH_STEP_BUDGET.clone()) +} + +/// [`dash_polyline`] spending at most `steps_left` dash steps (decremented); once they are +/// spent, the rest of the line is drawn solid. +fn dash_polyline_budgeted(pl: &Polyline, pattern: &[f64], offset: f64, steps_left: &mut usize) -> Vec { if pattern.is_empty() { return vec![pl.clone()]; } @@ -313,13 +381,16 @@ pub fn dash_polyline(pl: &Polyline, pattern: &[f64], offset: f64) -> Vec = if pl.closed { + let mut pts = pl.pts.clone(); pts.push(pl.pts[0]); - knots.push(true); - } + pts.into() + } else { + pl.pts.as_slice().into() + }; + // Knot flags past the source's (and the repeated closing point) count as knots. + let knot_at = |i: usize| i >= pl.pts.len() || pl.knot.get(i).copied().unwrap_or(true); // Position in the pattern. let mut idx = 0usize; let mut left = pat[0]; @@ -336,23 +407,27 @@ pub fn dash_polyline(pl: &Polyline, pattern: &[f64], offset: f64) -> Vec = if idx.is_multiple_of(2) { Some(Polyline { pts: vec![pts[0]], knot: vec![true], closed: false }) } else { None }; - let mut guard = 0usize; - for (wi, w) in pts.windows(2).enumerate() { + 'walk: for (wi, w) in pts.windows(2).enumerate() { let (a, b) = (w[0], w[1]); - let b_knot = knots[wi + 1]; + let b_knot = knot_at(wi + 1); let seg = len(sub(b, a)); let mut pos = 0.0; while seg - pos > 1e-12 { - guard += 1; - if guard > 100_000 { - // Bound allocations and time, including file-provided patterns. Commands - // reject patterns that exceed this budget before document mutation. - return Vec::new(); - } let step = left.min(seg - pos); - if step > 0.0 && pos + step == pos { - return Vec::new(); + if *steps_left == 0 || (step > 0.0 && pos + step == pos) { + // Past the step budget (which bounds allocations and time for file-provided + // patterns; commands reject such patterns before mutating the document) or + // dashes below the coordinates' precision: keep the dashes made so far and draw + // the rest of the line solid rather than dropping the whole stroke (#5195). + let here = if pos > 0.0 { add(a, mul(sub(b, a), pos / seg)) } else { a }; + let rest = cur.get_or_insert_with(|| Polyline { pts: vec![here], knot: vec![true], closed: false }); + for (j, p) in pts.iter().enumerate().skip(wi + 1) { + rest.pts.push(*p); + rest.knot.push(knot_at(j)); + } + break 'walk; } + *steps_left -= 1; pos += step; left -= step; let at_end = seg - pos <= 1e-12; @@ -451,11 +526,57 @@ mod tests { #[test] fn invalid_and_too_dense_dashes_finish_with_bounded_work() { let pl = line(&[(0.0, 0.0), (100.0, 0.0)], false); - for pattern in [vec![0.0, 0.0], vec![f64::INFINITY, 1.0], vec![f64::NAN, 1.0], vec![-1.0, 2.0], vec![1.0; 33], vec![1e-8, 1e-8]] { + for pattern in [vec![0.0, 0.0], vec![f64::INFINITY, 1.0], vec![f64::NAN, 1.0], vec![-1.0, 2.0], vec![1.0; 33]] { assert!(dash_polyline(&pl, &pattern, 0.0).is_empty()); } assert!(dash_polyline(&pl, &[2.0, 1.0], f64::INFINITY).is_empty()); assert!(dash_polyline(&line(&[(f64::NAN, 0.0), (1.0, 0.0)], false), &[2.0, 1.0], 0.0).is_empty()); + // Too dense for the step budget: bounded work, and the line is still drawn. + let d = dash_polyline(&pl, &[1e-8, 1e-8], 0.0); + assert!(!d.is_empty() && d.len() <= 50_001); + assert_eq!(d.last().and_then(|p| p.pts.last()), Some(&(100.0, 0.0))); + } + + #[test] + fn dashes_past_the_step_budget_keep_the_rest_of_the_line_solid() { + // An SVG `stroke-dasharray="0.5 0.5"` along 60000 px needs more steps than the budget: + // the stroke used to vanish entirely (#5195). The dashes made so far stay, the rest of + // the line (through its later vertices) is drawn solid. + let pl = line(&[(0.0, 0.0), (60_000.0, 0.0), (60_000.0, 1000.0)], false); + let d = dash_polyline(&pl, &[0.5, 0.5], 0.0); + assert!(d.len() > 40_000, "{} dashes", d.len()); + assert_eq!(d[0].pts, [(0.0, 0.0), (0.5, 0.0)]); + let tail = d.last().unwrap(); + assert_eq!(tail.pts.last(), Some(&(60_000.0, 1000.0))); + assert!(tail.pts.contains(&(60_000.0, 0.0)) && tail.pts.len() == tail.knot.len()); + // The solid tail starts where the dashes stopped (100000 steps of 0.5 px). + assert!((tail.pts[0].0 - 50_000.0).abs() < 1.0 && tail.pts[0].1 == 0.0, "{:?}", tail.pts[0]); + // Closed outlines finish the same way, back to their first point, where the solid tail + // joins the first dash (one piece with a join at the seam, no caps). + let outline = line(&[(0.0, 0.0), (60_000.0, 0.0), (60_000.0, 1000.0), (0.0, 1000.0)], true); + let d = dash_polyline(&outline, &[0.5, 0.5], 0.0); + let joined = &d[0]; + assert!(joined.pts.contains(&(60_000.0, 1000.0)) && joined.pts.contains(&(0.0, 1000.0)) && joined.pts.contains(&(0.0, 0.0))); + assert_eq!(joined.pts.last(), Some(&(0.5, 0.0))); + } + + #[test] + fn wide_round_capped_dashes_keep_the_outline_within_its_point_budget() { + let points = |polys: &[Vec

]| polys.iter().map(Vec::len).sum::(); + // 5000 dashes, each with two round caps of ~629 points at full resolution (#5211). + let st = StrokeStyle { width: 4000.0, cap: LineCap::Round, join: LineJoin::Round, dashes: vec![1.0, 9.0], ..Default::default() }; + let pl = line(&[(0.0, 0.0), (25_000.0, 0.0), (25_000.0, 25_000.0)], false); + let polys = stroke_polygons(std::slice::from_ref(&pl), &st, 0.01); + assert!(points(&polys) <= OUTLINE_POINT_BUDGET, "{} points", points(&polys)); + // No dash is dropped: a ribbon and two caps each. + assert!(polys.len() >= 3 * 5000, "{} pieces", polys.len()); + // Many tiny undashed subpaths: bounded too, every one still drawn. + let dots: Vec = (0..3000).map(|i| line(&[(f64::from(i), 0.0), (f64::from(i) + 0.5, 0.0)], false)).collect(); + let polys = stroke_polygons(&dots, &StrokeStyle { dashes: Vec::new(), ..st.clone() }, 0.01); + assert!(points(&polys) <= OUTLINE_POINT_BUDGET && polys.len() >= 3 * 3000, "{} points", points(&polys)); + // Under the budget, round caps keep their full resolution. + let one = stroke_polygons(&dots[..1], &StrokeStyle { dashes: Vec::new(), ..st }, 0.01); + assert!(one.iter().any(|p| p.len() == circle_points(2000.0, 0.01)) && circle_points(2000.0, 0.01) > 600); } #[test] diff --git a/crates/vector/src/trace.rs b/crates/vector/src/trace.rs index 5aa5c03722..1a547bfb0b 100644 --- a/crates/vector/src/trace.rs +++ b/crates/vector/src/trace.rs @@ -25,10 +25,8 @@ type P = (f64, f64); pub fn trace_mask(values: &[f32], rect: Rect, outside: f32, tolerance: f64) -> Path { let loops = contours(values, rect, outside); let mut subpaths = Vec::new(); + // `contours` only returns loops of three or more points. for (i, lp) in loops.iter().enumerate() { - if lp.len() < 3 { - continue; - } // Fit to half the tolerance: the worst point may deviate by the full amount only rarely. let mut s = fit_closed(lp, (tolerance * 0.5).max(0.05), 1.5); s.op = if i == 0 { PathOp::Combine } else { PathOp::Exclude }; diff --git a/docs/architecture.md b/docs/architecture.md index 67eebe9cce..614cabfb05 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,8 +7,8 @@ What exists on `main` today. The numbered sections below are the original design plan of 2026-09-30; where the code differs, this summary wins. -- **Workspace:** 25 library crates under `crates/`, 3 apps (`photocraft` desktop, `photocraft-cli`, - `photocraft-web`), `xtask`, an example plug-in and 5 fuzz crates; ~539,000 lines of Rust in +- **Workspace:** 25 library crates under `crates/`, 4 apps (`photocraft` desktop, `photocraft-cli`, + `photocraft-web`, `photocraft-web-boot`), `xtask`, an example plug-in and 5 fuzz crates; ~539,000 lines of Rust in ~1,310 files; ~8,040 `#[test]` functions (ui-egui 2,809, engine 1,796, algo 572, io 539, codecs 426, psd 341), counted 2026-10-13. Crate map and layers: [`AGENTS.md`](../AGENTS.md) §2 and `xtask/src/layers.rs` (enforced by `cargo xtask layers`). @@ -146,7 +146,8 @@ photocraft/ ├─ 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 +│ ├─ photocraft-web/ wasm32 binary (ui-egui + platform-web), demo +│ └─ photocraft-web-boot/ wasm32 binary that starts the packaged web app from parts of its wasm ├─ shaders/ (or inside gpu/) WGSL, with generated struct headers ├─ assets/ icons, bundled fonts, default brushes/swatches/presets ├─ fuzz/ cargo-fuzz targets (psd, codecs, format) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3ad3892001..8536507104 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -227,7 +227,7 @@ the same commands work in the app, the CLI and headless MCP. Paths are dotted ca | `prefs.set` | `{"path":"cursors.painting","value":"precise"}` or `{"values":{"unitsAndRulers.rulers":"cm","guidesGridAndSlices.gridColor":"#ff8800"}}`. Values are validated (choices, ranges, `#rrggbb` colours, shortcut syntax); a batch applies all or nothing. A section path takes an object and merges it key by key | | `prefs.reset` | `{"path":"performance"}` (a section or key); no path resets everything. An individual `shortcuts.` or `menus.colors.` reset removes the override and returns `null`, including when it is already absent | | `edit.preferences.

` | the section's values; in the app (no params) it opens the Preferences dialog on that section | -| `edit.keyboardShortcuts` | `{"set":{"edit.fill":"Cmd+Shift+F"},"reset":true\|["id",…],"removeConflicts":true,"filter":"blur","list":false,"importKys":"<.kys XML>"}`: returns overrides, matching commands and conflicts. A shortcut moved to another command is removed from its old owner unless `removeConflicts` is false. `""` removes a shortcut, `null` restores the default. The held temporary tools are bindable too: `tools.temporary.hand` (Space; also repositions a selection being drawn), `tools.temporary.zoomIn` (Cmd+Space), `tools.temporary.zoomOut` (Alt+Space; Alt added to a held Zoom In, Cmd+Alt+Space, zooms out too), `tools.temporary.erase` (`` ` ``; with the Brush or Pencil, a stroke started while it is held is in Clear mode, `paint.stroke`/`paint.pencil` `"mode": "clear"`, erasing with that tool's brush); they list with `"hold": true`. ⌘ (Ctrl off the Mac) held alone is the Move tool with the painting, retouching and other non-selection tools, as in Photoshop; it is a modifier, not a binding. `importKys` applies a Photoshop `.kys` set (the file's XML text, at most 4 MiB): rows are matched to commands by menu label, a key equal to the default restores it, keys also given in `set` win, tool keys are not imported, and the result gains `import` (`name`, `imported`, `set`, `unknown`, `unreadable`, `alternates`, `toolKeys`). In the app it opens Keyboard Shortcuts and Menus, whose Import Shortcuts… button loads a `.kys` set into the dialog (opening or dropping a `.kys`, or Photoshop's live `Keyboard Shortcuts.psp`, does the same). On a desktop with Photoshop installed, the first launch that has no shortcut overrides yet asks whether to import the newest install's live set (Import / Don't Import; `ui.inspect` shows it as `kysOffer`); the answer is recorded in the preferences as `dialogs.photoshopShortcuts`, so it is asked once | +| `edit.keyboardShortcuts` | `{"set":{"edit.fill":"Cmd+Shift+F"},"reset":true\|["id",…],"removeConflicts":true,"filter":"blur","list":false,"importKys":"<.kys XML>"}`: returns overrides, matching commands and conflicts. A shortcut moved to another command is removed from its old owner unless `removeConflicts` is false. `""` removes a shortcut, `null` restores the default. The held temporary tools are bindable too: `tools.temporary.hand` (Space; also repositions a selection being drawn), `tools.temporary.zoomIn` (Cmd+Space), `tools.temporary.zoomOut` (Alt+Space; Alt added to a held Zoom In, Cmd+Alt+Space, zooms out too), `tools.temporary.erase` (`` ` ``; with the Brush or Pencil, a stroke started while it is held is in Clear mode, `paint.stroke`/`paint.pencil` `"mode": "clear"`, erasing with that tool's brush); they list with `"hold": true`. ⌘ (Ctrl off the Mac) held alone is the Move tool with the painting, retouching and other non-selection tools, as in Photoshop; it is a modifier, not a binding. `importKys` applies a Photoshop `.kys` set (the file's XML text, at most 4 MiB): rows are matched to commands by our id (`pc-id`, in sets PhotoCraft exported) or else by menu label, a key equal to the default restores it, keys also given in `set` win, and the result gains `import` (`name`, `imported`, `set`, `unknown`, `unreadable`, `alternates`, `toolKeys`, `windows`). A set saved by Photoshop on Windows (no ⌘ or Opt anywhere, Ctrl used) reads Ctrl as the primary modifier, so `Ctrl+L` is the `Cmd+L` default (`windows: true`). Tool keys are matched to tools by name in the app's dialog import (a tool's own letter keeps its Shift cycling); this command doesn't know the tools and leaves them. In the app it opens Keyboard Shortcuts and Menus, whose Import Shortcuts… button loads a `.kys` set into the dialog (opening or dropping a `.kys`, or Photoshop's live `Keyboard Shortcuts.psp`, does the same). On a desktop with Photoshop installed, the first launch that has no shortcut overrides yet asks whether to import the newest install's live set (Import / Don't Import; `ui.inspect` shows it as `kysOffer`); the answer is recorded in the preferences as `dialogs.photoshopShortcuts`, so it is asked once. **Sets** (Edit › Keyboard Shortcuts › Set): `useSet` loads a saved set's bindings (`""` or `"PhotoCraft Defaults"`: the defaults) before the call's other changes; then `saveSet` saves the bindings as they end up under a name and uses it, `deleteSet` removes one (the bindings stay), `sets` replaces every saved set (`{"": {"shortcuts": {…}, "alternateShortcuts": {…}}}`, the dialog's OK) and `activeSet` names the set in use without loading it. Names are trimmed, 1–64 characters, at most 64 sets. Every reply has `sets`, `activeSet` (`""` for the defaults), `defaultsName` and `modified` (the bindings differ from the set in use). `exportKys: true` adds `kys`, the bindings of the engine's commands as a `.kys` file (Photoshop's macOS key spelling; each row carries our id as `pc-id`, so the file imports back exactly); `summarize: true` (or `{"mac": false}`) adds `html`, Photoshop's Summarize page. The app's dialog exports and summarizes the shell's commands and tools as well. A bad parameter anywhere in the call changes nothing | | `edit.menus` / `edit.toolbar` | `{"hide":["edit.fade"],"show":[…],"color":{"edit.fill":"red"},"reset":false}` / `{"hidden":["Sponge"],"order":[…]}` | | `edit.colorSettings` | `{"workingRgb":"display-p3","workingCmyk":"coated-cmyk","workingGray":"sgray","policyRgb":"preserve\|convert\|off",…,"askOnMismatch":true,"intent":"perceptual","bpc":true}`; honoured when opening files (`file.openAs`, the app's File › Open) and by Image › Mode and "working" profile specs. `color.profileMismatch {"action":"preserve\|convert\|discard\|assignWorking"}` answers the mismatch prompt | diff --git a/docs/development.md b/docs/development.md index 8bfc53df81..ceb679f5f7 100644 --- a/docs/development.md +++ b/docs/development.md @@ -377,7 +377,7 @@ trunk build --release # writes ../../dist/web (index.html, .js glue 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. `trunk build --release` uses the `wasm-release` Cargo profile (`data-cargo-profile` in `index.html`: fat LTO, opt-level "z" for the UI, engine, I/O and web shells and for large crates off the hot paths (document model, file parsers, JSON, SVG parsing, shader translation), "s" for other crates except pixel processing, which keeps level 3). The UI override reduces browser code size while retaining the speed-oriented settings for filters and compositing. Serve the `.wasm` with compression; its raw size varies with the source and compiler. Keep it under 24 MiB (`packaging/web/package.sh` enforces this; Cloudflare's per-file cap is 25 MiB). Pull-request CI also builds the actual browser app with the same pinned Trunk version as the release job and runs `packaging/web/package.sh`, including the relative-URL and 24 MiB checks. The `web build and wasm size gate` check runs on main and on PRs that change something the web build compiles or packages (`apps/photocraft-web`, `packaging/web`, crate sources and manifests, `Cargo.toml`/`Cargo.lock`, the CI workflow), and reports the resulting wasm size in the Actions job summary. The web build never embeds craft-fonts (see Fonts above). To see where the bytes go, run `twiggy top -n 40` on `target/wasm32-unknown-unknown/wasm-release/photocraft-web.wasm` (before wasm-opt strips the names). +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. `trunk build --release` uses the `wasm-release` Cargo profile (`data-cargo-profile` in `index.html`: fat LTO, opt-level "z" for the UI, engine, I/O and web shells and for large crates off the hot paths (document model, file parsers, JSON, SVG parsing, shader translation), "s" for other crates except pixel processing, which keeps level 3). The UI override reduces browser code size while retaining the speed-oriented settings for filters and compositing. Serve the `.wasm` with compression; its raw size varies with the source and compiler. Some static hosts refuse a file that large (Cloudflare's per-file cap is 25 MiB), so the release site doesn't hold it as one file: `packaging/web/package.sh` gzips it, cuts it into parts of at most 20 MiB under `app/`, and ships the page of `apps/photocraft-web-boot`, a small module that fetches the parts, has the browser inflate them (or inflates them itself where the browser can't) and passes the stream to the app's own loader (issue #5302). The script fails if any file in the site is over 24 MiB; the wasm's own size is no longer limited, but it is still what every visitor downloads, so watch it. Pull-request CI also builds the actual browser app with the same pinned Trunk version as the release job and runs `packaging/web/package.sh`, including the relative-URL and file-size checks. The `web build and wasm size gate` check runs on main and on PRs that change something the web build compiles or packages (`apps/photocraft-web`, `apps/photocraft-web-boot`, `packaging/web`, crate sources and manifests, `Cargo.toml`/`Cargo.lock`, the CI workflow), and reports the wasm's size and the site's largest file in the Actions job summary. The web build never embeds craft-fonts (see Fonts above). To see where the bytes go, run `twiggy top -n 40` on `target/wasm32-unknown-unknown/wasm-release/photocraft-web.wasm` (before wasm-opt strips the names). URL flags: `?webgl` forces the WebGL2 backend, and `?cpu` forces the CPU canvas path. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2651f4c4d1..ba74623709 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -21,7 +21,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Format | Photoshop | PhotoCraft read | PhotoCraft write | Fidelity and tests | |---|---|---|---|---| -| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 185/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | +| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 188/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | @@ -38,12 +38,12 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Portable Bit Map (PBM/PGM/PPM/PNM/PFM/PAM) | R/W | yes | yes | 8/16/32f | | FITS | R/W (FITS Liberator plug-in) | yes | yes | 8/16/32f gray or RGB, header cards kept; no fpack, no debayer | | Targa | R/W | yes | yes | 8-bit | -| Photoshop PDF | R/W | no (#2541) | raster only (print, artboards to PDF) | No vector/text PDF, no multi-page save, no PDF presentation (FILE-215-10) | +| Photoshop PDF | R/W | no (#2541) | yes, one flattened page (Save As / Save a Copy / CLI, #3401; also print and artboards-to-PDF) | No vector/text PDF, no multi-page save, no PDF presentation, layers are not preserved (FILE-215-10); read still missing (#2541) | | Photoshop EPS, DCS 1.0/2.0, EPS TIFF/PICT preview | R/W | no | no | | | Generic EPS / AI | R (rasterize) | no | no | | | SVG | R (rasterize) | yes, as editable shape layers or a vector smart object | no | Better than Photoshop for editing; gradients approximated (FILE-215-9) | | JPEG 2000 | R/W | no (#1047) | no | | -| Cineon / DPX | R (DPX), R/W (CIN) | DPX: luma, RGB, RGBA at 8/10/12/16 bits, both byte orders (#1049); Cineon no | DPX: 8/10/12/16 bits (`dpxBits`, `--dpx-bits`); Cineon no | Writes DPX, which Photoshop only reads; header fields (creator, project, copyright, transfer, frame rate, time code…) round-trip; no Cineon, no RLE | +| Cineon / DPX | R (DPX), R/W (CIN) | DPX: luma, RGB, RGBA at 8/10/12/16 bits, both byte orders (#1049); Cineon: 10-bit gray/RGB, read-only | DPX: 8/10/12/16 bits (`dpxBits`, `--dpx-bits`); Cineon no | Writes DPX, which Photoshop only reads; header fields (creator, project, copyright, transfer, frame rate, time code…) round-trip; Cineon read-only, no RLE | | DICOM | R/W | no | no | | | PCX | R/W | no | no | | | Pixar | R/W | no | no | | @@ -63,10 +63,10 @@ supports many more, including CR3). | Camera format | PhotoCraft | Evidence | |---|---|---| -| DNG (incl. LJPEG and JPEG XL tiles) | yes; lossy DNG no | FILE-215-11 | +| DNG (incl. LJPEG, lossy JPEG and JPEG XL tiles) | yes | 2026-10-13: lossy DNG (DNG 1.4 compression 34892: 8-bit baseline JPEG tiles or strips, CFA and LinearRaw, partial edge tiles) decodes to the stored samples, which go through LinearizationTable / BlackLevel / WhiteLevel (default 255) like every other depth; `crates/raw/tests/dng.rs` `lossy_jpeg_*` (FILE-215-11, lossy DNG part; no lossy file in the raw corpus yet) | | Canon CR2 | yes | | | Canon CR3 | detected, unsupported | `crates/raw/src/lib.rs:219` | -| Nikon NEF/NRW (lossless, lossy) | yes; "lossy after split" no | #50; D5100 image area measured on three 14-bit lossy NEFs; [validation](nikon-nef-development.md) | +| Nikon NEF/NRW (lossless, lossy) | yes; "lossy after split" no | #50; D5100 and D90 image areas measured (14-bit NEFs); [validation](nikon-nef-development.md) | | Sony ARW/SR2/SRF (incl. compressed) | yes | | | Panasonic RW2, Pentax PEF | yes | | | Olympus ORF | uncompressed only | | @@ -129,7 +129,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Patterns `.pat` | R/W | R/W | | | Swatches `.aco`, `.ase` | R/W | R/W | FILE-215-14 | | LUTs `.cube` / `.3dl` / `.look` | R/W (Export Color Lookup) | R/W / R / R | `cms/src/lutfile.rs` | -| Keyboard shortcuts `.kys` | R/W | R | `engine/src/kys.rs` | +| Keyboard shortcuts `.kys` | R/W | R/W | `engine/src/kys.rs` (`write`: Edit › Keyboard Shortcuts › Export…, rows carry our id; Photoshop reading our files is unverified) | | ICC profiles | R | R | Own CMS | | Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (112 Photoshop events ↔ commands; other steps kept verbatim). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | | Styles `.asl` | R/W | no | #1693 | @@ -142,6 +142,8 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 | +| 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | | 2026-10-13 | minor | CMYK Hue/Saturation (not Colorize) and Color Balance work on C, M and Y as inverted RGB with black kept, on the inks for display, export and destructive edits; cmyk8/16 hue-saturation 100% → 0.4–0.7% bad pixels, color-balance 74% → 25% | | 2026-10-13 | minor | CMYK Posterize works on the stored values (1 − ink), as Photoshop does: psd-tools posterize_cmyk passes (13.8 % → 0 of pixels off on the ink path); psd-tools floor → 242 of 309 | @@ -170,4 +172,5 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | 2026-10-12 | minor | Save writes a JPEG at its own estimated quality and a WebP lossy or lossless as opened (#2399) | | 2026-10-11 | minor | PSD smart filters: Filter Gallery (`GEfc`) maps to `filter.filterGallery` when every effect is modelled (Chrome so far), editable from the Layers panel | | 2026-10-11 | minor | D5100 image area and fitted-look preservation in Camera Raw tuning | +| 2026-10-11 | minor | PDF write now covers Save As / Save a Copy / CLI (`io::pdf::export`, #3401): one flattened RGB/Gray/CMYK page at the document's print size with the embedded profile. Read (#2541), multi-page save, PDF presentation, vector/text output still open | 2026-10-10 | major | First version: Photoshop 2026 27.11.0's formats from its bundle, against our codecs; Affinity notes moved from `roadmap.md` | diff --git a/docs/gaps.md b/docs/gaps.md index 52b467b2dd..faf0884c89 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -43,7 +43,7 @@ what an agent can't finish alone. - **Missing:** PSDs we write are not always accepted by Photoshop, and Photoshop-authored files don't always render as Photoshop renders them. - **Evidence:** corpus floors (`crates/io/tests/corpus.rs`, scorecard): Photoshop-authored - oracles 185/258 (72%; CMYK and Lab adjustment layers, 32-bit unclipped adjustments, Levels, Threshold, Posterize and mask feathers 2026-10-13; Gradient Map luminosity 2026-10-13; CMYK Invert, Posterize and Threshold on the inks 2026-10-12), io corpus 148/170 (87%), psd-tools 249/309 (81%); round trips ~100% + oracles 188/258 (73%; smart filter masks 2026-10-14; CMYK and Lab adjustment layers, 32-bit unclipped adjustments, Levels, Threshold, Posterize and mask feathers 2026-10-13; Gradient Map luminosity 2026-10-13; CMYK Invert, Posterize and Threshold on the inks 2026-10-12), io corpus 148/170 (87%), psd-tools 249/309 (81%); round trips ~100% within the corpus. Smart-filter re-render matches 5/30. User reports: a PSD with text layers re-saved unmodified would not reopen (#2374, closed 2026-10-10), Photoshop 26.6 cannot reopen our files (#1281), "PSD not opening in Photoshop" (#2469), a CS2-era smart object unavailable (#2465, fixed 2026-10-12), PSB smart object @@ -129,6 +129,10 @@ what an agent can't finish alone. tuning retains the fitted look and applies user gains afterwards. Three D5100 NEFs establish a guarded 4928 × 3264 image area. This is an estimated rendering, not a measured D5100 colour calibration; [validation](nikon-nef-development.md). +- **Progress (2026-10-13):** the D90's image area (4288 × 2848 at (10, 10) in a + 4352 × 2868 plane) is measured by aligning the decoded plane with the camera's own + JPEG, so a D90 NEF opens on the camera's frame instead of the uncropped sensor plane; + `crates/raw/src/cameras.rs` (`IMAGE_AREAS`). - **Estimate:** 120–200 h. ### G15. AI and generative features @@ -218,12 +222,15 @@ what an agent can't finish alone. ## File-format gaps ### G10. Missing and partial file formats -- **Missing:** JPEG Options dialog (FILE-215-19), Photoshop PDF read and write and multi-page PDF - (FILE-215-10, #2541), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047), - Cineon (DPX landed, #1049), DICOM, EPS/DCS, PCX, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop +- **Missing:** JPEG Options dialog (FILE-215-19), Photoshop PDF read and its vector/text/multi-page + write (FILE-215-10, #2541; a single flattened page now saves via Save As / Save a Copy / CLI, + 2026-10-11, #3401), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047), + Cineon write (read landed), DICOM, EPS/DCS, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop Raw; layered TIFF extra alpha channels (FILE-215-7); spot plates (FILE-215-17); .atn, .asl, .shc, .csh presets (FILE-215-15); C2PA (#1050). - **Detail:** [`file-format-parity.md`](file-format-parity.md). +- **Progress (2026-10-13):** Cineon (`.cin`) 10-bit gray/RGB frames open read-only, sharing the + DPX header/unpacking (`crates/codecs/src/codecs/dpx.rs`); writing is still open. - **Estimate:** 120–220 h. ## Hardware gaps @@ -263,6 +270,8 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | +| 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | | 2026-10-13 | minor | G1: CMYK Hue/Saturation and Color Balance work on C, M and Y as inverted RGB with black kept, as Photoshop does; cmyk hue-saturation 100% → 0.4–0.7% bad pixels, color-balance 74% → 25%; floor unchanged | | 2026-10-13 | minor | G1: CMYK Posterize works on the stored values (1 − ink); psd-tools posterize_cmyk passes; psd-tools floor → 242 of 309 | @@ -287,6 +296,7 @@ what an agent can't finish alone. | 2026-10-12 | minor | G1: #2465 (CS2 `PlLd`-only smart objects) marked fixed | | 2026-10-12 | minor | G1: CMYK Invert, Posterize and Threshold work on the inks, 16-bit Lab Invert keeps greys neutral; Photoshop-oracle floor 133 → 145 of 258 (145 already passed on main) | | 2026-10-12 | minor | G5: spring-loaded tool letters done (#2672) | +| 2026-10-13 | minor | G10: Cineon read landed (10-bit gray/RGB, shares the DPX path); Cineon write remains open | | 2026-10-12 | minor | G10: DPX read and write (#1049); Cineon remains open | | 2026-10-12 | minor | G4: checkbox-only Brush Settings rows preserve the open controls and Smoothing settings (#4283); no readiness percentage or estimate changed | | 2026-10-12 | minor | G11: Load/Save Actions `.atn` landed (AUTO-219-2 partial); remaining panel work listed | @@ -298,6 +308,7 @@ what an agent can't finish alone. | 2026-10-11 | minor | G13: Czech dynamic UI text localized; engine messages and native review remain open | | 2026-10-11 | minor | G8: triple/quadruple/five-click type selection done (TYPE-218-8, #3493); Type evidence recounted | | 2026-10-11 | minor | G8: Character panel OpenType buttons landed (TYPE-218-5, #3752); Numerals and Stylistic Sets submenus still missing | -| 2026-10-10 | minor | G2: recorded Tilt-Shift computation/filtered-proxy measurements and limits; gap and estimate remain open | +| 2026-10-11 | minor | G10: one-page flattened PDF write shipped (io::pdf, #3401); FILE-215-10 now partial | +| 2026-10-10 | minor | G2: recorded Tilt-Shift computation/filtered-proxy measurements and limits; gap and estimate remain open | 2026-10-10 | minor | Checked against the alpha gate (no alpha blockers); #2374 marked closed | | 2026-10-10 | major | First version, from the full re-measure against Photoshop 2026 27.11.0 and the open issues | diff --git a/docs/releasing.md b/docs/releasing.md index 6d35b6379e..def1911aa8 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -284,11 +284,14 @@ and lists the tarball, which checks the layout without a FreeBSD machine. ### Web -`packaging/web/package.sh` runs `trunk build --release` (see `apps/photocraft-web/Trunk.toml`) -and zips `dist/web` together with sample `_headers` and `.htaccess` files and the hosting guide. -The site only uses relative URLs, so it works under any path and in an iframe. The wasm builds -with the size-optimized `wasm-release` Cargo profile (set in `apps/photocraft-web/index.html`), -and the script fails if any `.wasm` exceeds 24 MiB, below Cloudflare's 25 MiB per-file limit. +`packaging/web/package.sh` runs `trunk build --release` for the app (`apps/photocraft-web`, into +`dist/web`) and for the module that starts it (`apps/photocraft-web-boot`, into `dist/web-boot`). +It gzips the app's wasm, cuts it into parts of at most 20 MiB and zips the boot module's page with +the parts, sample `_headers` and `.htaccess` files and the hosting guide. The site only uses +relative URLs, so it works under any path and in an iframe. The wasm builds with the +size-optimized `wasm-release` Cargo profile (set in `apps/photocraft-web/index.html`), and the +script fails if any file in the site exceeds 24 MiB, below Cloudflare's 25 MiB per-file limit; +the app's wasm itself may be larger, since it ships as parts. [`packaging/web/README.md`](../packaging/web/README.md) covers MIME types, compression, caching, the iframe snippet and the `?webgl` / `?cpu` flags. diff --git a/docs/scorecard.md b/docs/scorecard.md index fe9c144ba8..1e0576ba02 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -7,12 +7,12 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | -| [File compatibility](#file-compatibility) | 7 | 13 | 7 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 185/256, round trip ≥ 258/256 | +| [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | | [Tools](#tools) | 33 | 11 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 164 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 166 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | -| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | +| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 29 of 29 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 4 | 2 | | | [Open bugs](#open-bugs) | 29 | 2 | 1 | | @@ -94,7 +94,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t |---|---|---:|---:|---:| | io corpus | `corpus/psd` | 170 | 148 | 169 | | psd-tools corpus | `corpus/psd-tools` | 309 | 249 | 308 | -| photoshop oracles | `corpus/photoshop` | 256 | 185 | 258 | +| photoshop oracles | `corpus/photoshop` | 256 | 188 | 258 | | Id | Target | Status | Issue | Note | |---|---|---|---|---| @@ -107,7 +107,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | FILE-215-7 | Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref | partial | [#215](https://github.com/storytold/photocraft/issues/215) | psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha channels and a Quick Mask are not written (the TIFF codec holds one transparency sample) | | FILE-215-8 | Large and BigTIFF import with banded decode across all IFDs | done | [#215](https://github.com/storytold/photocraft/issues/215) | codecs tiff_ifd.rs (bounds-checked IFD chain + SubIFD walk, classic and BigTIFF, cycle-safe; tiff_info / decode_tiff_page), tiff.rs banded parallel strip/tile decode, planar 1 and 2, BigTIFF write (tiff_bigtiff or > 4 GiB); io import_tiff_page; 30 MP RGB8 Deflate 345 -> 32 ms; no page picker in the UI yet | | FILE-215-9 | SVG opens and places as shape layers or a vector Smart Object | done | [#215](https://github.com/storytold/photocraft/issues/215) | io svg.rs: paths, groups, text outlines as shape layers (gradients approximated; clip/mask/filter/pattern/image subtrees rasterised); smart_cmds.rs renders an SVG source at its placement scale | -| FILE-215-10 | Multi-page PDF save, PDF presentation, layer comps to PDF, vector/text output, PDF import | missing | [#215](https://github.com/storytold/photocraft/issues/215) | Only print_cmds.rs:133 print_pdf (raster print page) | +| FILE-215-10 | Multi-page PDF save, PDF presentation, layer comps to PDF, vector/text output, PDF import | partial | [#215](https://github.com/storytold/photocraft/issues/215) | io pdf.rs: Save As / Save a Copy / CLI write one flattened RGB/Gray/CMYK page with ICC (#3401); print PDFs in print_cmds. Multi-page save, presentation, comps, vector/text output and PDF import (#2541) remain | | FILE-215-11 | RAW: lossy DNG, highlight modes, .xmp sidecars, RAW prefs wired, per-camera corpus | missing | [#215](https://github.com/storytold/photocraft/issues/215) | raw sensor.rs:210 lossy DNG unsupported; raw_defaults unread; no xmp | | FILE-215-12 | Camera Raw dialog shows a histogram; settings persist as a PSD Smart Filter | partial | [#215](https://github.com/storytold/photocraft/issues/215) | Interactive RGB/Lab scopes. PSD WB/Light/Presence, curves, HSL, grading, sharpening/noise detail, grain and numeric vignette controls mapped with descriptor preservation. Active manual Optics and unverified vignette style keep the updated ACR 18.4 fixture opaque; PSD/.pcraft saves preserve it. HDR, remaining settings and Photoshop acceptance still open | | FILE-215-13 | Video layer frames persist in .pcraft; PSD video layers mapped | partial | [#215](https://github.com/storytold/photocraft/issues/215) | format convert.rs:89 + tests/roundtrip.rs:282 persist frames; no PSD video mapping | @@ -177,7 +177,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 26 of 164** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 26 of 166** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. @@ -241,7 +241,7 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T ## Reliability -Never-crash attribute on 28 of 28 crate roots. +Never-crash attribute on 29 of 29 crate roots. | Id | Target | Status | Issue | Note | |---|---|---|---|---| diff --git a/docs/ui-design.md b/docs/ui-design.md index c6135ff744..d4ac18d297 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -58,6 +58,7 @@ curl -sfL -o assets/icons/.svg https://raw.githubusercontent.com/lucide-ic | Quick layer pick | `quick_pick.rs` | macOS: ⌘⌥⌃-click with any tool selects the topmost visible layer with pixels under the pointer (`layer.pickAt`) without switching tools; the drag and release are swallowed. Windows/Linux have no third modifier distinct from the ⌃⌥ brush resize, so the gesture is a no-op there (use the Move tool's Auto-Select). A failed pick is a status-bar error. | | Layers rows | `panels.rs` | Double-click: on the name renames in place; on the Background makes it a normal layer; on an adjustment or fill thumbnail opens its Properties; on a Smart Object thumbnail opens its contents (Edit Contents); anywhere else on the row opens Layer Style. | | Layer masks | `panels.rs` | Clicking the mask thumbnail targets the mask (corner-bracket frame; the tab reads "Layer, Layer Mask/8"). Brush, eraser (paints background colour), gradient, bucket and moving the selected pixels (⌘-drag, `select.float`; the hole takes the background colour) then send `"target": "mask"`. Adjustment and fill layers target their mask automatically. | +| Channels selection | `channels_panel.rs` | Load channel as selection in the footer follows the highlighted channel row. When the active layer mask is targeted for painting while the underlying channel view remains Composite, it loads that layer mask; otherwise it loads the selected composite, color, alpha or vector mask channel. | | Path thumbnails | `vector_ui.rs` | Ctrl/Cmd-click the thumbnail of a Work Path, saved path or layer path to load it as a selection; Shift adds, Alt subtracts, Shift+Alt intersects. Clicking the row name only selects the path. | | Levels / Curves | `tone.rs` | Histogram of the image *below* the adjustment. Curves: click to add a point, drag out to delete. Every change is a coalesced `layer.setAdjustment`, so one drag = one undo step and the canvas updates at full resolution on the GPU. | | Shape Stroke Options | `shape_stroke_ui.rs`, `vector_ui.rs` | Direct editing: valid changes apply immediately, with one numeric drag per undo step in Properties. Escape/outside click closes the popup and retains changes; a child dropdown keeps the parent open. The options bar sets new-shape defaults; Properties edits the selected shape. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7fbe8932cc..793f09840c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -96,6 +96,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | ⌥-drag duplicates, ⇧ constrains to 45° | done | `move_mods.rs` | | Auto-Select with hover outline, box-select layers, double-click fit | missing | TOOL-214-4 | | Auto-Select bounding box and snapping follow the picked layer | partial | follow the previous layer (#2666) | +| Several layers selected (⇧-click adds, box-select): Show Transform Controls frames them all, with every selected shape's outline | partial: the shared frame has no handles, since Free Transform works on one layer (`canvas.rs` `transform_controls_layers`, `several_selected_layers_share_one_frame_without_handles`; `move_auto_select_tests.rs`) | | | ⌘-drag temporary Move shows the box and snaps | partial | #2681 | | ⌘-right-click layer list under the pointer | done | `layer_pick_ui.rs` | | Double-click type to edit it (caret at the pointer, Type tool) | done | #1132, `type_tool::edit_at` | @@ -120,7 +121,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme |---|---|---| | Click corner, drag smooth, ⌥ breaks a handle, ⌘ direct-selects, ⇧ 45° | done | TOOL-214-10; `vector_ui.rs` (#3751) | | Handle on the last point, close-drag handle | partial | #1482 | -| Pen Auto Add/Delete: click a segment of the active path to add, an anchor to delete; ⇧ suspends it | done | #4144 | +| Pen Auto Add/Delete: click a segment of the active path, or of the path being drawn, to add, an anchor to delete; ⇧ suspends it | done | #4144, #6140 | | Curvature Pen edits the active path with no modifier: drag an anchor (the curve reflows), click a segment to add an anchor, double-click smooth ↔ corner; cursor badge + on segments, four arrows on anchors | done: `direct_select.rs` (`curvature_press`, `curvature_hover`), `path.moveAnchors`/`path.convertPoint` with `curvature`, `direct_select_tests.rs` | | | Path operations in the options bar (combine, subtract, intersect, exclude) | partial | `layer.combineShapes.*`; Pen options #1397 | | On-canvas corner-radius handles on live shapes | done: every selected live rectangle shows a widget at each arc centre with Path Selection, the Rectangle tool and Move with Show Transform Controls; a drag rounds every corner of every selected rectangle to the same radius, ⌥ that corner on each, with the corner radius cursor and an `R:` readout, previewed and committed as one history step (`corner_radius.rs`, `corner_radius_tests.rs`) | TOOL-214-12 | @@ -186,6 +187,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | PhotoCraft | ~199 shortcut assignments, 113 distinct modifier/F-key combos, 20 tool letters, 3 temporary-tool bindings | measured (source) | | Shortcut audit | 214 → 0 failures (2026-10-07) | measured then; not re-run here | | `.kys` import | done (`engine/src/kys.rs`) | | +| Shortcut sets: Set menu, New Set…, Save, Delete, `.kys` Export…, Summarize… | done (`engine/src/shortcut_sets.rs`, `prefs_ui::set_row`); sets live in the preferences. Photoshop reading our exported `.kys` is unverified | `shortcut_sets` tests, `shortcut_set_tests.rs` | | Shortcut glyphs per platform | done (UI-217-19) | | | Toolbar tool shortcut editing | done (#4086): each tool has a row under Tools; assignments persist, removed tools leave the default key group, and unchanged tools keep Shift cycling. Toolbar and flyout hints follow the binding | `tool_shortcuts.rs`, `shortcut_capture_tests.rs` | | Brush size/hardness keys on non-US layouts | done (#3109): `[` `]` need AltGr on many layouts, so Photoshop uses other keys there (German: Ö / #, ⇧ for hardness). `layout_keys.rs` has a data table of such layouts (`LAYOUTS`, add rows to support one more) and a fallback for all others: the keys at the US `[` / `]` positions act as `[` / `]` when they don't type brackets. Off once a real bracket is typed; Edit › Keyboard Shortcuts overrides still apply | | @@ -241,6 +243,7 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-11 | minor | Spring-loaded tool letters done (#2672): holding any tool letter (Z included) past 300 ms, or using the canvas while it is down, makes the tool temporary until release | | 2026-10-11 | minor | Type formatting keys done: ⇧⌘K, U, B, I and /, ⌥↑/↓, ⇧⌥↑/↓, ⌥←/→ over a selection and ⌥⇧⌘H format the selected type while editing, and no longer reach Color Settings, Desaturate, Auto Color or Inverse | | 2026-10-12 | minor | Pen Auto Add/Delete on the active path, with its options-bar checkbox, hover feedback and + / − cursor badge (#4144) | +| 2026-10-15 | minor | Pen Auto Add/Delete works on the path being drawn: a click on its segment adds an anchor, on a middle anchor deletes it, and drawing carries on (#6140) | | 2026-10-12 | minor | Toolbar tool shortcuts can be reassigned, removed and reset in Keyboard Shortcuts (#4086); capture, restart, group cycling, conflict and text-focus regressions | | 2026-10-12 | minor | Smart filter rows can be selected, and dragged (⌥: copied) onto another smart object or onto Delete (#4271) | | 2026-10-12 | minor | Free Transform options bar: H and V skew fields; a sheared box reads as skew instead of a taller H | @@ -253,3 +256,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-11 | minor | Pen ⇧ 45° done: ⇧-click puts the next anchor on the nearest 45° ray, ⇧-drag turns its handles in 45° steps (TOOL-214-10 done, #3751) | | 2026-10-14 | minor | Last selected tool restored across launches; temporary overrides excluded, explicit Eraser choices retained, and final selection flushed on normal exit | | 2026-10-13 | minor | Free Transform with a selection: the box is the selection's rectangle, and the marching ants follow it while it is transformed (#5297) | +| 2026-10-14 | minor | Move › Show Transform Controls frames every selected layer and outlines the selected shapes | diff --git a/packaging/linux/AppRun b/packaging/linux/AppRun index f04c5f4eab..43d0eeef7d 100755 --- a/packaging/linux/AppRun +++ b/packaging/linux/AppRun @@ -35,25 +35,24 @@ integrate() { src_desktop="$APPDIR/usr/share/applications/$DESK_ID.desktop" [ -f "$src_desktop" ] || return 0 - # If an external AppImage integrator (AppImageLauncher, appimaged, Gear Lever) is - # already managing this AppImage, do not write a second launcher (#3623); remove our - # own if left over from an earlier run, and still install the hicolor icons below. + # If another integrator already registered a .desktop whose Exec points at this + # AppImage, do not write a second launcher (#3623); remove our own if one is left + # over, and still install the hicolor icons below. $DESKTOPINTEGRATION is not that + # signal: PhotoCraft sets X-AppImage-Integrate=false, so AppImageLauncher skips the + # AppImage and still exports the variable on its binfmt bypass. Treating it as + # "already integrated" left the menu with no entry (#5534). apps="$data/applications" mkdir -p "$apps" 2>/dev/null || return 0 external=0 - if [ -n "${DESKTOPINTEGRATION:-}" ]; then - external=1 - else - for f in "$apps"/*.desktop; do - if [ ! -f "$f" ] || [ "$f" = "$apps/$DESK_ID.desktop" ]; then - continue - fi - if grep '^Exec=' "$f" 2>/dev/null | grep -Fq "$appimage"; then - external=1 - break - fi - done - fi + for f in "$apps"/*.desktop; do + if [ ! -f "$f" ] || [ "$f" = "$apps/$DESK_ID.desktop" ]; then + continue + fi + if grep '^Exec=' "$f" 2>/dev/null | grep -Fq "$appimage"; then + external=1 + break + fi + done if [ "$external" = 1 ]; then if [ -f "$apps/$DESK_ID.desktop" ]; then rm -f "$apps/$DESK_ID.desktop" 2>/dev/null || true diff --git a/packaging/linux/apprun-test.sh b/packaging/linux/apprun-test.sh index 475fb5ee5e..8c9e33e6bd 100755 --- a/packaging/linux/apprun-test.sh +++ b/packaging/linux/apprun-test.sh @@ -83,11 +83,27 @@ printf '[Desktop Entry]\nType=Application\nName=PhotoCraft\nExec="%s" %%F\nIcon= [ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ] || fail "icons missing with external integrator" rm -f "$ext_desktop" -# 7. DESKTOPINTEGRATION set by an external integrator skips creating a second .desktop entry. +# 7. AppImageLauncher exports DESKTOPINTEGRATION even after X-AppImage-Integrate=false +# made it skip the AppImage. That is not an existing launcher, so AppRun still +# writes its own entry and a later launch must not delete it (#5534). rm -rf "$XDG_DATA_HOME" DESKTOPINTEGRATION=AppImageLauncher "$APPDIR/AppRun" >/dev/null || fail "DESKTOPINTEGRATION launch exited non-zero" -[ ! -e "$DESKTOP" ] || fail "DESKTOPINTEGRATION still wrote duplicate desktop entry" +[ -f "$DESKTOP" ] || fail "DESKTOPINTEGRATION suppressed the desktop entry (#5534)" +grep -Fxq "Exec=\"$(readlink -f "$APPIMAGE")\" %F" "$DESKTOP" || fail "Exec missing under DESKTOPINTEGRATION" +grep -Fxq "X-AppImage-Integrate=false" "$DESKTOP" || fail "X-AppImage-Integrate=false missing under DESKTOPINTEGRATION" [ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ] || fail "icons not installed under DESKTOPINTEGRATION" +before="$(stat -c %i "$DESKTOP")" +DESKTOPINTEGRATION=AppImageLauncher "$APPDIR/AppRun" >/dev/null || fail "second DESKTOPINTEGRATION launch exited non-zero" +[ -f "$DESKTOP" ] || fail "DESKTOPINTEGRATION removed the desktop entry on a later launch" +[ "$before" = "$(stat -c %i "$DESKTOP")" ] || fail "desktop entry rewritten under DESKTOPINTEGRATION although unchanged" +# A real integrator entry still wins, including when DESKTOPINTEGRATION is also set (#3623). +ext_desktop="$XDG_DATA_HOME/applications/appimagekit-photocraft.desktop" +printf '[Desktop Entry]\nType=Application\nName=PhotoCraft\nExec="%s" %%F\nIcon=%s\n' "$(readlink -f "$APPIMAGE")" "$APP_ID" >"$ext_desktop" +DESKTOPINTEGRATION=AppImageLauncher "$APPDIR/AppRun" >/dev/null || fail "launch with integrator entry and DESKTOPINTEGRATION exited non-zero" +[ ! -e "$DESKTOP" ] || fail "duplicate desktop entry kept when an integrator entry exists" +[ -f "$ext_desktop" ] || fail "external integrator desktop entry removed" +[ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ] || fail "icons missing when DESKTOPINTEGRATION and an integrator entry coexist" +rm -f "$ext_desktop" # 8. Opt-out installs nothing. rm -rf "$XDG_DATA_HOME" diff --git a/packaging/web/.htaccess b/packaging/web/.htaccess index 7b7e6e185f..4aca774cc8 100644 --- a/packaging/web/.htaccess +++ b/packaging/web/.htaccess @@ -13,4 +13,12 @@ AddType text/javascript .js Header set Cache-Control "no-cache" + # app/: the app's loader and its list of parts keep their names from release to release; the + # parts (the app's wasm, gzipped and cut up) carry a content hash. + + Header set Cache-Control "no-cache" + + + Header set Cache-Control "public, max-age=31536000, immutable" + diff --git a/packaging/web/README.md b/packaging/web/README.md index 5ca375f6cc..e96dc38257 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -6,8 +6,10 @@ static site in `photocraft-web-/`: | File | What it is | |---|---| | `index.html` | The page. It loads everything through relative URLs. | -| `photocraft-web-.js` | wasm-bindgen glue (generated, ES module) | -| `photocraft-web-_bg.wasm` | The app: about 23 MiB raw, 10.5 MiB with gzip (see [Sizes](#sizes)) | +| `photocraft-web-boot-.js`, `photocraft-web-boot-_bg.wasm` | A small module (about 120 KB) that starts the app from its parts, with its wasm-bindgen glue | +| `app/photocraft-web-.part-aa`, `-ab`, … | The app's WebAssembly, gzipped and cut into parts of at most 20 MiB (see [Sizes](#sizes)) | +| `app/parts.txt` | The parts, in order | +| `app/photocraft-web.js` | The app's wasm-bindgen glue (generated, ES module) | | `_headers`, `.htaccess` | Sample header rules for Netlify/Cloudflare Pages and Apache | | `fonts/` (optional) | Fonts loaded on demand, listed in `fonts/manifest.txt` (see [Fonts](#fonts)) | @@ -68,44 +70,63 @@ The editor and image processing run on the visitor's device. Open uses the brows picker; Save and Export download files. Preferences use browser localStorage. There is no server-side document storage, desktop TCP control server, or web autosave/crash recovery; save/download work before closing or reloading the page. A browser with WebGPU or WebGL2 is -required; `?webgl` forces the fallback when troubleshooting. +required; `?webgl` forces the fallback when troubleshooting. In a browser that can't +inflate a stream itself (no `DecompressionStream`: before Safari 16.4 and Firefox 113), the +release zip's site inflates the app's parts in its own code, a little slower. To verify the browser deployment, open the page, open an image and save/export it. ## Sizes -Measured on 2026-10-12 (0.8.0, `packaging/web/package.sh`; gzip `-9`): +Measured on 2026-10-13 (0.8.2, `packaging/web/package.sh`): -| File | Raw | gzip | -|---|---|---| -| `photocraft-web-_bg.wasm` | 24,167,186 bytes (23.0 MiB) | 10,998,053 (10.5 MiB) | -| `photocraft-web-.js` | about 170 KB | about 26 KB | +| File | Size | +|---|---| +| `app/photocraft-web-.part-aa` (the only part today) | 11,401,527 bytes (10.9 MiB) | +| `app/photocraft-web.js` | about 170 KB (26 KB with gzip) | +| `photocraft-web-boot-_bg.wasm` and `.js` | about 120 KB together | + +The app's WebAssembly is 25,019,043 bytes (23.9 MiB) before gzip. Per-file upload limits: Cloudflare Pages and Workers static assets reject any file over -**25 MiB** (26,214,400 bytes). The wasm fits, and `packaging/web/package.sh` fails the build if -it ever grows past 24 MiB, so a release can't ship a file a common host refuses. (Releases up to -0.2.0 shipped a 25.8 MiB wasm, which Cloudflare rejected; issue #198.) The size comes from the -`wasm-release` Cargo profile (fat LTO, size-optimized code with the pixel and file-decoding -crates kept at speed) plus `wasm-opt -Oz`, and from storing the bundled fonts (PhotoCraft's and -egui's) and the translation catalogs compressed, unpacked on first use. +**25 MiB** (26,214,400 bytes), and the app's WebAssembly is at that size. (Releases up to 0.2.0 +shipped a 25.8 MiB wasm, which Cloudflare rejected; issue #198.) So the site doesn't hold it as +one file: packaging gzips it and cuts the result into parts of at most 20 MiB, and the page puts +them back together while it downloads them. `packaging/web/package.sh` fails if any file in the +site is over 24 MiB, so a release can't ship a file a common host refuses, and the app can grow +without reaching that limit (issue #5302). A build straight from Trunk (`trunk build --release` +in `apps/photocraft-web`, or the Docker image above) still has the app in one `.wasm`, for hosts +without such a cap. + +The app is built with the `wasm-release` Cargo profile (fat LTO, size-optimized code with the +pixel and file-decoding crates kept at speed) plus `wasm-opt -Oz`, and stores the bundled fonts +(PhotoCraft's and egui's) and the translation catalogs compressed, unpacked on first use. ## Any path works All URLs in `index.html` are relative (`public_url = "./"` in `apps/photocraft-web/Trunk.toml`), so the site works at a domain root (`https://example.com/`), under a prefix (`https://example.com/tools/photocraft/`) and from a CDN bucket. The asset names carry a content -hash, so they can be cached forever. Only `index.html` needs revalidation. +hash, so they can be cached forever. `index.html`, `app/photocraft-web.js` and `app/parts.txt` keep +their names and need revalidation. ## Required server settings - **MIME type:** serve `.wasm` as `application/wasm`. Browsers refuse to stream-compile it under any other type, and the app then loads slowly or not at all. Serve `.js` as `text/javascript`. Most hosts already do both. For nginx, check that `mime.types` has `application/wasm wasm;`. -- **Compression:** turn on gzip or Brotli for `.wasm`, `.js` and `.html`. That takes the - download from about 19 MiB to about 8 MiB (gzip) or 5.6 MiB (Brotli). You can also - precompress (`brotli -k *.wasm`) and let the server send `Content-Encoding: br`. -- **Caching:** `Cache-Control: public, max-age=31536000, immutable` on the hashed `.wasm` and - `.js` files, and `no-cache` on `index.html`. +- **The parts as they are:** serve `app/*.part-*` byte for byte, with no `Content-Encoding` (they + are gzip already, and the page inflates them). Their extension is one servers don't + know, so the usual result, `application/octet-stream` and no compression, is right. +- **Missing files are 404s:** a host that answers every unknown path with `index.html` (a + single-page-app fallback) hides a failed upload. PhotoCraft notices and says the download + failed, but a 404 is clearer in the server's logs. +- **Compression:** turn on gzip or Brotli for `.wasm`, `.js` and `.html`. It matters little for + the download, since the parts are compressed already; it shrinks the two `.js` files and the + small boot module. You can also precompress and let the server send `Content-Encoding: br`. +- **Caching:** `Cache-Control: public, max-age=31536000, immutable` on the files whose names + carry a hash (the parts and `photocraft-web-boot-.*`), and `no-cache` on `index.html`, + `app/photocraft-web.js` and `app/parts.txt`. The sample `_headers` and `.htaccess` do this. - **HTTPS:** WebGPU (and the clipboard) only work in a secure context, which means `https://` or `http://localhost`. Over plain HTTP elsewhere, the app falls back to WebGL2. - **No special isolation headers:** PhotoCraft doesn't use `SharedArrayBuffer`, so it doesn't @@ -120,8 +141,9 @@ location /photocraft/ { types { application/wasm wasm; text/javascript js; text/html html; } gzip on; gzip_types application/wasm text/javascript text/html; - location ~* \.(wasm|js)$ { add_header Cache-Control "public, max-age=31536000, immutable"; } - location ~* index\.html$ { add_header Cache-Control "no-cache"; } + # The first matching line wins, so the files that keep their names come first. + location ~* (index\.html|app/photocraft-web\.js|app/parts\.txt)$ { add_header Cache-Control "no-cache"; } + location ~* (\.(wasm|js)|\.part-[a-z]+)$ { add_header Cache-Control "public, max-age=31536000, immutable"; } } ``` diff --git a/packaging/web/_headers b/packaging/web/_headers index fac0fbe1fd..f8a7e7ca45 100644 --- a/packaging/web/_headers +++ b/packaging/web/_headers @@ -11,3 +11,16 @@ /index.html Cache-Control: no-cache + +# app/ holds the app's wasm, gzipped and cut into parts whose names carry a content hash... +/app/* + Cache-Control: public, max-age=31536000, immutable + +# ...and the app's loader and its list of parts, which keep their names from release to release. +/app/photocraft-web.js + ! Cache-Control + Cache-Control: no-cache + +/app/parts.txt + ! Cache-Control + Cache-Control: no-cache diff --git a/packaging/web/package.sh b/packaging/web/package.sh index 8c6c1f8e2c..58b9bccd07 100755 --- a/packaging/web/package.sh +++ b/packaging/web/package.sh @@ -6,6 +6,10 @@ # Needs: trunk (brew install trunk / cargo install trunk --locked) and the wasm32-unknown-unknown # target. The zip holds a self-contained static site in photocraft-web-/ that works # from any URL path and inside an