diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31b8d856..f74eddbf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,9 +30,10 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - # Which OSes the test job runs on. main, manual runs and PRs labelled `full-ci` get all three. - # Other PRs get Linux, plus macOS and Windows when they touch code whose behaviour differs by + # Which OSes the test job runs on. main, manual runs and PRs labelled `full-ci` get Linux and + # Windows. Other PRs get Linux, plus Windows when they touch code whose behaviour differs by # platform: the app shell, UI, text and fonts, GPU, pen input, build scripts or the lockfile. + # macOS is tested only by release.yml. platforms: name: select platforms runs-on: ubuntu-latest @@ -48,7 +49,9 @@ jobs: BASE: ${{ github.event.pull_request.base.sha }} FULL: ${{ contains(github.event.pull_request.labels.*.name, 'full-ci') }} run: | - all='["ubuntu-latest","macos-latest","windows-latest"]' + # No macOS on PRs or main: the org's 5 macOS slots are kept for release builds, and + # release.yml builds and tests macOS (a failing release is fixed and rebuilt). + all='["ubuntu-latest","windows-latest"]' if [ "$EVENT" != pull_request ] || [ "$FULL" = true ]; then echo "os=$all" >> "$GITHUB_OUTPUT" exit 0 @@ -206,6 +209,8 @@ jobs: - { crate: codecs, name: decode_tiff } - { crate: codecs, name: decode_exr } - { crate: codecs, name: decode_heif } + - { crate: affinity, name: preview } + - { crate: affinity, name: import_preview, args: "--features io" } - { crate: format, name: load } steps: - uses: actions/checkout@v4 @@ -217,7 +222,7 @@ jobs: run: cargo install cargo-fuzz --locked - name: Fuzz working-directory: crates/${{ matrix.target.crate }} - run: cargo fuzz run ${{ matrix.target.name }} -- -max_total_time=${{ inputs.fuzz_seconds }} + run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time=${{ inputs.fuzz_seconds }} - name: Upload crash artifacts if: failure() uses: actions/upload-artifact@v4 diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 9989a6a1..de62e87a 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -18,9 +18,10 @@ next to them. | `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | -| `assets/icons/*.svg` except `slice-knife.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg` and `direct-select.svg` (110 files) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/*.svg` except `slice-knife.svg`, `clip-below.svg`, `clip-release.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg` and `direct-select.svg` (109 files) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/{check,chevron-down,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,lock,minus,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | +| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/lasso-magnetic.svg` | Magnetic Lasso tool glyph, adapted from "Magnetic Lasso Tool" (redrawn on the 24 px toolbar grid) | Lil' Seal (Noun Project); adapted by PhotoCraft contributors | | CC BY 3.0, [`assets/icons/LICENSE-noun-magnetic-lasso.txt`](assets/icons/LICENSE-noun-magnetic-lasso.txt) | @@ -83,6 +84,8 @@ next to the files: | `corpus/psd/` (170 files) | Small selection of the psd-tools and ag-psd test files | Kota Yamaguchi; Agamnentzar | psd-tools (above) and [ag-psd `test/`](https://github.com/Agamnentzar/ag-psd/tree/master/test) | MIT (both) | | `corpus/heif/` (9 files, 0.1 MB) | HEIC/HEIF test files: `heic-rs/` checkerboards, RGB strips and a grid-tiled photo with EXIF and XMP (synthetic pixels encoded by macOS `sips`, each `.ref.png` Apple's decode); `pillow-heif/` the 10-bit RGBA `RGBA_10__29x100.heif` and its source `RGBA_16__29x100.png` | Thomas Braun (heic-rs); Pillow-Heif contributors | [heic-rs `tests/fixtures`](https://github.com/tbraun96/heic-rs/tree/main/tests/fixtures), [pillow-heif `tests/images`](https://github.com/bigcat88/pillow_heif/tree/master/tests/images) | MIT OR Apache-2.0 (heic-rs); BSD-3-Clause (pillow-heif) | | `corpus/exr/` (5 files, 2.3 MB) | The deep OpenEXR test images `11`, `42`, `64`, `multivariate` and `objectid.deep.exr` | Contributors to the OpenEXR Project | [openexr `src/test/bin/test_images`](https://github.com/AcademySoftwareFoundation/openexr/tree/main/src/test/bin/test_images) | BSD-3-Clause | +| `corpus/affinity/` (21 files, 2.4 MB) | Public Affinity documents, unmodified: `vector-art/` (9), `afdesignload/` (9), `jac21/` (2) and `asset-store-template/` (1), each with its upstream licence next to it | samuel-etver; Nick Beeuwsaert; Jeremy Cantu; DAIKI (eviltwo) | [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) | CC0-1.0 (vector-art); 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 | Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0. diff --git a/Cargo.lock b/Cargo.lock index 86dae603..9dd34b93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3975,6 +3975,7 @@ dependencies = [ "arboard", "async-io", "core-graphics2", + "dispatch2", "eframe", "egui", "fmv-macos-events", @@ -4008,6 +4009,16 @@ dependencies = [ "zbus", ] +[[package]] +name = "photocraft-affinity" +version = "0.5.0" +dependencies = [ + "crc32fast", + "flate2", + "proptest", + "ruzstd", +] + [[package]] name = "photocraft-algo" version = "0.5.0" @@ -4125,6 +4136,7 @@ version = "0.5.0" dependencies = [ "blake3", "flate2", + "photocraft-affinity", "photocraft-algo", "photocraft-cms", "photocraft-codecs", @@ -4203,6 +4215,9 @@ name = "photocraft-io" version = "0.5.0" dependencies = [ "blake3", + "crc32fast", + "flate2", + "photocraft-affinity", "photocraft-algo", "photocraft-cms", "photocraft-codecs", diff --git a/Cargo.toml b/Cargo.toml index 24b00462..b7adf410 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ photocraft-ops = { path = "crates/ops" } photocraft-psd = { path = "crates/psd" } photocraft-codecs = { path = "crates/codecs" } photocraft-raw = { path = "crates/raw" } +photocraft-affinity = { path = "crates/affinity" } photocraft-compose = { path = "crates/compose" } photocraft-gpu = { path = "crates/gpu" } photocraft-paint = { path = "crates/paint" } diff --git a/README.md b/README.md index 026256bb..3dd367c3 100644 --- a/README.md +++ b/README.md @@ -178,8 +178,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
-

🧰 34 tools

- Move · Rectangular and Elliptical Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Magic Wand · Quick Selection · Object Selection · Crop · Eyedropper · Brush · Pencil · Mixer Brush · Color Replacement · Eraser · Clone Stamp · Healing Brush · Spot Healing · History Brush · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Path Selection · Type · five Shape tools · Hand · Zoom +

🧰 52 tools

+ Move · Rectangular and Elliptical Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Slice · Slice Select · Eyedropper · Ruler · Note · Count · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Horizontal and Vertical Type · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom

🖌️ A real brush engine

@@ -199,7 +199,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off

🗂️ Formats

- PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and AVIF, with symmetric read and write at 8, 16 and 32 bits, HEIC photos from iPhone and Mac (read; in official builds, an optional --features heif build feature), and the native .pcraft format. + PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and AVIF, with symmetric read and write at 8, 16 and 32 bits, HEIC photos from iPhone and Mac (read; in official builds, an optional --features heif build feature), and the native .pcraft format. Affinity documents (.af, .afdesign, .afphoto, .afpub) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.

🪄 The everyday essentials

diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index 43f71fb7..fcccb2b3 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -340,7 +340,10 @@ fn same_dir(a: &Path, b: &Path) -> bool { } fn is_input(p: &Path) -> bool { - let known = |e: &str| matches!(e, "pcraft" | "psd" | "psb") || photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read); + let known = |e: &str| { + matches!(e, "pcraft" | "psd" | "psb" | "af" | "afdesign" | "afphoto" | "afpub") + || photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read) + }; p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase())) } diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 90c8f64d..df49842f 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -12,11 +12,12 @@ use wasm_bindgen::JsCast as _; type Inbox = Arc)>>>; -/// Everything File › Open reads: PhotoCraft and Photoshop documents, flat images, and Photoshop -/// brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries. +/// Everything File › Open reads: PhotoCraft, Photoshop and Affinity documents, flat images, and +/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries. const OPEN_EXTS: &[&str] = &[ "pcraft", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", - "pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", + "pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", + "afdesign", "afphoto", "afpub", ]; const CANVAS_ID: &str = "photocraft_canvas"; diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index a79bfe96..b1293f6a 100644 --- a/apps/photocraft/Cargo.toml +++ b/apps/photocraft/Cargo.toml @@ -54,9 +54,12 @@ fmv-macos-events = "=0.1.0" objc2 = "=0.6.4" objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSGeometry"] } objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSResponder", "NSView", "NSWindow"] } -# The macOS menu bar (mac_menu.rs): Tauri's menu crate, AppKit menus behind a safe API (no GTK -# with default features off). Pinned exactly: review the diff before bumping. +# The macOS menu bar (mac_menu.rs): a standalone native menu crate (from the tauri-apps org, but +# not Tauri: no webview, no JS), AppKit menus behind a safe API (no GTK with default features off). Pinned exactly: review the diff before bumping. muda = { version = "=0.21.1", default-features = false } +# Quit with `terminate:` from the main queue, outside winit's event handler (mac_window.rs). +# Already in Cargo.lock (winit, objc2-app-kit); pinned exactly to the same version. +dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] } # Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly. [target.'cfg(target_os = "linux")'.dependencies] diff --git a/apps/photocraft/src/gpu_startup.rs b/apps/photocraft/src/gpu_startup.rs index 03d8b11b..963a3fa3 100644 --- a/apps/photocraft/src/gpu_startup.rs +++ b/apps/photocraft/src/gpu_startup.rs @@ -408,6 +408,17 @@ impl Sentinel { } } +/// The Linux display-server preference (`performance.linuxDisplayServer`) from the preferences +/// file, read before the window opens; `Auto` when the file or value is missing or unknown. +#[cfg(any(target_os = "linux", test))] +pub fn read_display_server(path: Option<&Path>) -> photocraft_engine::prefs::LinuxDisplayServer { + let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null); + v.get("performance") + .and_then(|p| serde_json::from_value::(p.clone()).ok()) + .map(|performance| performance.linux_display_server) + .unwrap_or_default() +} + /// Read the policy leniently at startup, including old settings without renderingMode. pub fn read_rendering_prefs(path: Option<&Path>) -> (GpuBackend, RenderingMode) { let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null); @@ -428,6 +439,27 @@ mod tests { Marker { backend: backend.into(), adapter: "Intel(R) UHD Graphics".into(), adapter_backend: adapter_backend.into(), ..Default::default() } } + #[test] + fn display_server_preference_is_read_before_the_window_opens() { + use photocraft_engine::prefs::LinuxDisplayServer; + let dir = std::env::temp_dir().join(format!("photocraft-display-server-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("preferences.json"); + let read = |json: &str| { + std::fs::write(&file, json).unwrap(); + read_display_server(Some(&file)) + }; + assert_eq!(read(r#"{"performance":{"linuxDisplayServer":"x11"}}"#), LinuxDisplayServer::X11); + assert_eq!(read(r#"{"performance":{"linuxDisplayServer":"auto","gpuBackend":"vulkan"}}"#), LinuxDisplayServer::Auto); + // Unknown values, other types, broken JSON and a missing file all start as Auto. + assert_eq!(read(r#"{"performance":{"linuxDisplayServer":"wayland"}}"#), LinuxDisplayServer::Auto); + assert_eq!(read(r#"{"performance":{"linuxDisplayServer":11}}"#), LinuxDisplayServer::Auto); + assert_eq!(read("{not json"), LinuxDisplayServer::Auto); + assert_eq!(read_display_server(Some(&dir.join("missing.json"))), LinuxDisplayServer::Auto); + assert_eq!(read_display_server(None), LinuxDisplayServer::Auto); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn cpu_policy_ignores_hardware_backend_override() { let p = plan_with_mode(Vulkan, RenderingMode::Cpu, None, Some("vulkan"), false, Os::Windows); diff --git a/apps/photocraft/src/mac_menu.rs b/apps/photocraft/src/mac_menu.rs index 871a9d03..648595ad 100644 --- a/apps/photocraft/src/mac_menu.rs +++ b/apps/photocraft/src/mac_menu.rs @@ -1,5 +1,5 @@ -//! The macOS menu bar, through muda (Tauri's menu crate: AppKit menus behind a safe API, no GTK on -//! macOS). The menus themselves, their Mac layout and how their keys reach PhotoCraft are in +//! The macOS menu bar, through muda (a standalone native menu crate, not Tauri: no webview, just +//! AppKit menus behind a safe API, no GTK on macOS). The menus themselves, their Mac layout and how their keys reach PhotoCraft are in //! `photocraft_ui_egui::native_menu`; this file only builds and updates the `NSMenu`s. //! //! - Item ids are command ids; a command in two menus (Keyboard Shortcuts is in Edit and Window) diff --git a/apps/photocraft/src/mac_window.rs b/apps/photocraft/src/mac_window.rs index 073ec159..491c7b28 100644 --- a/apps/photocraft/src/mac_window.rs +++ b/apps/photocraft/src/mac_window.rs @@ -1,9 +1,13 @@ -//! macOS title bar dragging. The window draws under a transparent title bar (`main.rs`), so AppKit -//! would otherwise drag the window from anywhere in that strip, menus included: pressing a menu -//! title and dragging down to an item moved the window instead. Turning AppKit's own dragging off -//! leaves only the app's drag region (the free gap between the menus and the controls, +//! macOS window lifecycle: title bar dragging and quitting. +//! +//! Dragging: the window draws under a transparent title bar (`main.rs`), so AppKit would +//! otherwise drag the window from anywhere in that strip, menus included: pressing a menu title +//! and dragging down to an item moved the window instead. Turning AppKit's own dragging off leaves +//! only the app's drag region (the free gap between the menus and the controls, //! `panels::title_bar`), which starts a drag explicitly (`ViewportCommand::StartDrag`, AppKit's -//! `performWindowDragWithEvent:`, which works on an unmovable window). All safe objc2 calls. +//! `performWindowDragWithEvent:`, which works on an unmovable window). +//! +//! Quitting: see [`terminate_later`]. All safe objc2 calls. use objc2::MainThreadMarker; use objc2_app_kit::NSApplication; @@ -18,3 +22,20 @@ pub fn disable_native_title_drag() { window.setMovable(false); } } + +/// Quit the way a Mac app does, with `-[NSApplication terminate:]` (`Services::quit`), instead of +/// letting eframe close the window. eframe drops the window while AppKit's run loop is still +/// running, and on Macs with a Touch Bar AppKit's next display cycle then tries to stop observing +/// a view that is gone: an uncaught exception that crashes (macOS 27) or leaves a windowless app +/// that only Force Quit ends (macOS 26) (#1458, #1575). `terminate:` ends the process before +/// another display cycle runs; winit's `applicationWillTerminate:` closes the windows and has +/// eframe save the window layout first, as the old ⌘Q (winit's default menu) did. +/// +/// Scheduled on the main queue: winit has to be out of its event handler, or eframe's exit +/// handling (and the save) is dropped. +pub fn terminate_later() { + dispatch2::DispatchQueue::main().exec_async(|| match MainThreadMarker::new() { + Some(mtm) => NSApplication::sharedApplication(mtm).terminate(None), + None => log::warn!("quit: the main queue ran off the main thread; not terminating"), + }); +} diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 1f98f601..c426910b 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -209,6 +209,20 @@ fn main() -> eframe::Result { // app no pen input (#639). #[cfg(target_os = "linux")] let session = tablet::display_session(); + // Preferences › Performance › Linux display server = X11: Xwayland on a Wayland session too, + // where native file drops work (winit 0.30 has none on Wayland, #386). Only when Xwayland can + // run the window ($DISPLAY set, X11 libraries installed); otherwise the session's own. + #[cfg(target_os = "linux")] + let session = if session == linux_libs::DisplaySession::Wayland + && gpu_startup::read_display_server(services::prefs_file().as_deref()) == photocraft_engine::prefs::LinuxDisplayServer::X11 + && std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()) + && linux_libs::available(linux_libs::DisplaySession::X11) + { + eprintln!("photocraft: opening the window through Xwayland (Preferences › Performance › Linux display server)"); + linux_libs::DisplaySession::X11 + } else { + session + }; // winit and wgpu dlopen the windowing and GPU libraries, and some of those crates panic when // one is missing (issue #201). Name the package to install and exit instead. @@ -419,6 +433,8 @@ fn main() -> eframe::Result { #[cfg(target_os = "macos")] { app.services.os_events = Some(apple_events.connect(&cc.egui_ctx)); + // Quit through AppKit, not by closing the window (Touch Bar Macs, #1575, #1458). + app.services.quit = Some(Box::new(mac_window::terminate_later)); // The macOS menu bar, installed now so winit's default menu doesn't stay up. if !in_window_menus { app.services.native_menu = mac_menu::install(&cc.egui_ctx, &app); diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 363059ad..b6ab8aff 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -13,13 +13,24 @@ use std::pin::Pin; use std::rc::Rc; use std::sync::Arc; -/// Everything File › Open reads: PhotoCraft and Photoshop documents, flat images, and Photoshop -/// brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries. +/// Everything File › Open reads: PhotoCraft, Photoshop and Affinity documents, flat images, and +/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries. const OPEN_EXTS: &[&str] = &[ "pcraft", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", - "pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", + "pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", + "afdesign", "afphoto", "afpub", ]; +/// Open dialog extensions that also match uppercase and mixed-case names (`IMG_0001.JPG`). +/// On Linux and the BSDs rfd turns each extension into a case-sensitive `*.ext` glob for the XDG +/// portal or zenity, so every letter becomes a character class, as the portal spec suggests +/// (`*.[iI][cC][oO]`). Windows and macOS take literal extensions and ignore case already. +fn open_filter_extensions(extensions: &[&str]) -> Vec { + let case_sensitive_globs = cfg!(all(unix, not(target_os = "macos"))); + let class = |c: char| if c.is_ascii_alphabetic() { format!("[{}{}]", c.to_ascii_lowercase(), c.to_ascii_uppercase()) } else { c.to_string() }; + extensions.iter().map(|ext| if case_sensitive_globs { ext.chars().map(class).collect() } else { ext.to_string() }).collect() +} + /// File › Save As formats: (filter name, extensions). The filter matching the suggested name's /// extension comes first, so a .pcraft document saves as .pcraft by default and everything else /// keeps defaulting to Photoshop. @@ -66,7 +77,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, } let answer: Pin> + Send>> = match request { FileDialogRequest::Open { multiple } => { - let dialog = dialog.add_filter("All Formats", OPEN_EXTS).add_filter("PhotoCraft", &["pcraft"]); + let dialog = dialog.add_filter("All Formats", &open_filter_extensions(OPEN_EXTS)).add_filter("PhotoCraft", &open_filter_extensions(&["pcraft"])); if multiple { let picked = dialog.pick_files(); Box::pin(async move { picked.await.map(|files| FileDialogAnswer::Paths(files.iter().map(path_of).collect())) }) @@ -382,6 +393,44 @@ mod tests { use photocraft_ui_egui::{PhotocraftApp, prefs_ui}; use serde_json::{Value, json}; + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn open_filters_cover_uppercase_and_mixed_case_extensions() { + // Matches `text` against a glob of literals and `[..]` classes, as the portal would after `*.`. + fn matches(glob: &str, text: &str) -> bool { + let mut text = text.chars(); + let mut glob = glob.chars(); + while let Some(g) = glob.next() { + let class: String = if g == '[' { glob.by_ref().take_while(|&c| c != ']').collect() } else { g.to_string() }; + if !text.next().is_some_and(|t| class.contains(t)) { + return false; + } + } + text.next().is_none() + } + assert_eq!(open_filter_extensions(&["cr2", "pcraft"]), ["[cC][rR]2", "[pP][cC][rR][aA][fF][tT]"]); + let globs = open_filter_extensions(OPEN_EXTS); + assert_eq!(globs.len(), OPEN_EXTS.len()); + for (ext, glob) in OPEN_EXTS.iter().zip(&globs) { + // Every casing: lower, UPPER, and alternating both ways (JpG, jPg). + let alternating = |upper_first: bool| -> String { + ext.chars().enumerate().map(|(i, c)| if (i % 2 == 0) == upper_first { c.to_ascii_uppercase() } else { c }).collect() + }; + for name in [ext.to_string(), ext.to_ascii_uppercase(), alternating(true), alternating(false)] { + assert!(matches(glob, &name), "{glob} should match .{name}"); + } + assert!(!matches(glob, &format!("{ext}x")) && !matches(glob, &ext[..ext.len() - 1]), "{glob} matches only .{ext}"); + } + assert!(!globs.iter().any(|glob| matches(glob, "txt"))); + } + + #[cfg(not(all(unix, not(target_os = "macos"))))] + #[test] + fn open_filters_keep_literal_extensions_on_windows_and_macos() { + assert_eq!(open_filter_extensions(OPEN_EXTS), OPEN_EXTS); + assert_eq!(open_filter_extensions(&["pcraft"]), ["pcraft"]); + } + #[test] fn xwayland_command_matches_how_photocraft_was_installed() { // Flatpak: WAYLAND_DISPLAY doesn't reach the sandbox's socket choice; flatpak's flags do. diff --git a/assets/icons/clip-below.svg b/assets/icons/clip-below.svg new file mode 100644 index 00000000..70ec3d65 --- /dev/null +++ b/assets/icons/clip-below.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/assets/icons/clip-release.svg b/assets/icons/clip-release.svg new file mode 100644 index 00000000..90e5b414 --- /dev/null +++ b/assets/icons/clip-release.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/assets/icons/corner-right-down.svg b/assets/icons/corner-right-down.svg deleted file mode 100644 index 61c0e477..00000000 --- a/assets/icons/corner-right-down.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index a141aded..c85cfdf1 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -22,6 +22,7 @@ - [PSD and PSB](formats/psd-psb.md) - [Raster formats](formats/raster-formats.md) - [SVG](formats/svg.md) +- [Affinity documents](formats/affinity.md) - [The `.pcraft` format](formats/pcraft.md) # Automation diff --git a/book/src/architecture/crates.md b/book/src/architecture/crates.md index c423cf2e..34df8b60 100644 --- a/book/src/architecture/crates.md +++ b/book/src/architecture/crates.md @@ -5,7 +5,7 @@ The layer numbers below describe allowed dependency direction, not runtime privi | Layer | Crates | Responsibility | |---|---|---| | L0 | `geom`, `cms`, `color`, `raster` | Geometry, ICC processing, color/blend math, pixel formats, and copy-on-write tiles | -| L0 standalone | `psd`, `codecs` | PSD/PSB and flat raster format parsing/encoding | +| L0 standalone | `psd`, `codecs`, `affinity` | PSD/PSB and flat raster format parsing/encoding; bounded Affinity preview extraction | | L1 | `doc` | Pure-data documents, layers, masks, effects, adjustments, text, vectors, and smart objects | | L2 | `ops`, `paint`, `algo`, `text`, `vector` | History operations, painting, imaging algorithms, type, paths, and shapes | | L3 | `compose`, `gpu`, `format` | CPU composition, wgpu composition, and `.pcraft` persistence | diff --git a/book/src/formats/affinity.md b/book/src/formats/affinity.md new file mode 100644 index 00000000..36544c7f --- /dev/null +++ b/book/src/formats/affinity.md @@ -0,0 +1,31 @@ +# Affinity documents + +PhotoCraft opens native Affinity documents: `.af` files from Affinity 3 and `.afdesign`, +`.afphoto` and `.afpub` files from Affinity 1 and 2. Layers come in as editable PhotoCraft layers: + +- pages and artboards become artboards (Publisher spreads sit side by side); +- layers and groups become groups; +- curves and shapes (rectangles, ellipses, polygons, stars and the other geometric shapes) become + shape layers with their fill, gradient and stroke; +- artistic and frame text becomes type layers, drawn with the fonts installed on your computer; +- placed JPEG and PNG images become embedded smart objects, and pixel layers become pixel layers; +- vector and pixel masks, opacity, visibility, locks, names and blend modes are kept. + +The document is 8-bit RGB at the Affinity document's resolution. What PhotoCraft can't reproduce is +listed in a warning when the file opens, for example layer effects, adjustment layers and live +filters, brush strokes (drawn as plain strokes), special shapes such as clouds and hearts (drawn as +ellipses), master pages, and CMYK or Lab colour (converted to RGB without the document's profile). +Nothing is dropped without a warning. + +PhotoCraft can't write Affinity files. An opened Affinity document has no save path: Save asks for +a new file (`.pcraft`, PSD, PNG…) and never writes over the Affinity file. + +If the native data can't be read (a damaged file, or a layout PhotoCraft doesn't know), PhotoCraft +opens the small PNG preview Affinity stores in the file instead, as a single pixel layer named +“Affinity preview”, and the warning says why. That preview can't be placed or used by File ▸ Place, +stacks, batch processing or other imports that can't show the warning. Export PSD or PNG from +Affinity in that case. + +See [the crate's notes](https://github.com/storytold/photocraft/blob/main/crates/affinity/README.md) +for the full list of what is imported, how the reader was written and validated, and its safety +limits. diff --git a/book/src/formats/overview.md b/book/src/formats/overview.md index bb8108c0..0f066983 100644 --- a/book/src/formats/overview.md +++ b/book/src/formats/overview.md @@ -1,13 +1,14 @@ # Format overview -External bytes enter PhotoCraft through three main paths: +External bytes enter PhotoCraft through four main paths: - `photocraft-psd` parses and writes PSD and PSB structures. - `photocraft-codecs` decodes and encodes flat raster formats. - `photocraft-format` loads and saves the native `.pcraft` bundle. +- `photocraft-affinity` reads native Affinity documents (`.af`, `.afdesign`, `.afphoto`, `.afpub`): layers, artboards, vectors, text, images and masks; it does not write them. `photocraft-io` maps PSD and flat images into the document model and exports documents back to those formats. It is the integration layer; parsing limits remain the responsibility of the underlying format crates. Formats are a primary security boundary. File extension is not a trust signal, metadata can be attacker-controlled, and small compressed inputs can declare very large decoded outputs. Callers should use the bounded default APIs and preserve errors rather than retrying with unlimited limits. -See [PSD and PSB](psd-psb.md), [Raster formats](raster-formats.md), [SVG](svg.md), and [The `.pcraft` format](pcraft.md). Cross-format hardening guidance is in [Parser hardening](../security/parser-hardening.md). +See [PSD and PSB](psd-psb.md), [Raster formats](raster-formats.md), [SVG](svg.md), [Affinity documents](affinity.md), and [The `.pcraft` format](pcraft.md). Cross-format hardening guidance is in [Parser hardening](../security/parser-hardening.md). diff --git a/contributors/people.toml b/contributors/people.toml index 41d7b4a3..afe2ac1b 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -17,6 +17,9 @@ # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. +[people.AlanRockefeller] +sync_github_name = true + [people.echelon] real_name = "Brandon Thomas" display_name = "Brandon Thomas" diff --git a/crates/affinity/Cargo.toml b/crates/affinity/Cargo.toml new file mode 100644 index 00000000..e686e737 --- /dev/null +++ b/crates/affinity/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "photocraft-affinity" +description = "Bounded reader for native Affinity documents: archive, object stream and document model" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[features] +# Synthetic-container builders for tests and fuzz seeds; not an Affinity exporter. +synth = [] + +[dependencies] +crc32fast = "1.5" +flate2 = { workspace = true } +ruzstd = "0.9" + +[dev-dependencies] +proptest = { workspace = true } diff --git a/crates/affinity/README.md b/crates/affinity/README.md new file mode 100644 index 00000000..3672eb3a --- /dev/null +++ b/crates/affinity/README.md @@ -0,0 +1,149 @@ +# photocraft-affinity + +An independent, bounded Rust reader for native Affinity documents: `.af` from Affinity 3 and +`.afdesign`, `.afphoto` and `.afpub` from Affinity 1 and 2 (container versions 8 to 12). It reads +the archive, the tagged object stream in `doc.dat` and the part of the document model listed below, +and hands `photocraft-io` a document in pixels with every transform composed. It does not write +Affinity files. + +## What is imported + +`photocraft-io` maps the model to an 8-bit RGB document at the Affinity document's resolution: + +| Affinity | PhotoCraft | +|---|---| +| Pages, Publisher spreads | one artboard per spread, spreads side by side 64 px apart | +| Artboards (`ShpN` with `ABEn`), with name, solid background, any transform | artboards (nested ones as groups with a vector mask) | +| Layers (`Scop`), groups (`Grup`), pass-through or isolated | groups (pass-through or Normal) | +| Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | shape layers (multi-subpath curves fill even-odd) | +| Rectangles with corner radii (relative or absolute), ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids | shape layers | +| Compound shapes (`Comp`, add and subtract) | one even-odd shape | +| A shape or curve with children clips them | group with the outline as vector mask, the shape at its bottom | +| Vector masks (`AdCh` curves and shapes) | vector masks | +| Pixel masks (`MRst`, attached or as a layer in a group) | layer masks | +| Solid fills: RGBA, HSLA, CMYK, grey, Lab (D50) | shape fill (converted to RGB) | +| Linear, elliptical and radial gradients with Affinity's midpoint bias | gradient fills | +| Strokes: weight, scale with object, dash pattern, alignment, caps, joins, miter limit | shape strokes | +| Fill layers (`FRst`) | filled rectangles | +| Artistic and frame text: characters, font family/weight/style, size, tracking, fixed leading, colour, paragraph alignment, first baseline | type layers (rendered with the fonts installed) | +| Placed images (`ImgN`): the original JPEG/PNG embedded in the file | embedded smart objects | +| Pixel layers (`Rstr`): RGBA 8/16-bit and CMYK 8-bit tiles, cropped to their content | pixel layers | +| Embedded documents and symbols (`EmbN`) | the picture Affinity cached of them | +| Opacity, visibility, lock, names, blend modes | the same (Add as Linear Dodge) | + +Everything else is reported in the import warnings, one line per kind with a count, never +dropped silently: layer effects, adjustment layers and live filters, brush and pressure strokes, +transparency gradients, fill opacity, bitmap fills, master pages, conical gradients, special shapes +(cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner types other than +round, stars with rounded points, several fills or strokes on one object (one of each is used), +strokes behind the fill, outlined or scaled text, text fields such as page numbers, frames with +columns or a curved outline, grey/Lab/32-bit pixels, CMYK and Lab colour (converted to RGB without +the document's profile), facing pages and the Average, Negation, Reflect, Glow and Erase blend +modes (as Normal). + +Opened Affinity documents start without a save path: Save asks for a new copy and never writes +back over the Affinity file. When the native document can't be read at all, `photocraft-io` opens +the embedded PNG preview instead, as one pixel layer named “Affinity preview”, with a warning +that names the reason. That preview can't be placed or used by imports that can't show the +warning (smart-object decoding, stacks, batch, displacement maps, variables). + +## Embedded preview + +`preview(bytes)` reads only the thumbnail record the header's offset 24 points to (the same in +container versions 8 to 12); it never scans for PNG signatures. All integers are little-endian. + +| Record offset | Size | Observed value | +|---|---:|---| +| 0 | 8 | `ffffffff` followed by `Thmb` | +| 8 | 4 | Record version 1 | +| 12 | 4 | PNG byte length + 13 | +| 16 | 4 | PNG start offset 29 | +| 20 | 4 | Zero | +| 24 | 4 | PNG byte length | +| 28 | 1 | PNG encoding 1 | +| 29 | declared length | Exactly one PNG | + +The PNG is limited to 16 MiB and 4096 pixels a side. IHDR must come first and be unique, IDAT +chunks contiguous, an optional PLTE unique and before IDAT, every chunk CRC must match and IEND must +end the file; compressed metadata (`zTXt`, `iTXt`, `iCCP`), APNG chunks and unknown critical chunks +are rejected. The I/O adapter decodes it with a 128 MiB allocation limit. + +## Provenance and clean-room + +Affinity has no published file-format specification, so this reader is built only from a public +description of the format and from public files, never from Affinity itself. This is how +PhotoCraft already treats formats that are only partly documented: `photocraft-psd` fills the gaps +in Adobe's specification from the MIT-licensed psd-tools and ag-psd, and `photocraft-raw` recovered +Nikon's compression tables by black-box analysis of CC0 sample files (see their READMEs). + +* **No Affinity software.** Affinity was never downloaded, installed, run, scripted, screenshotted or + disassembled for this work, and nothing from an Affinity installation (program code, resources, + presets, fonts, colour profiles) was read. No file was made with Affinity for it: every sample + is a document its author published. +* **A public, permissively licensed description.** The container and object-stream layout was + learned from [VMDevCpp/afread](https://github.com/VMDevCpp/afread) (MIT, at + `04b672334a43e3e37ded6b5ffc57af231d589774`, written for container versions 7–11) and re-described + in our own words before this Rust code was written; no code was translated. No GPL/AGPL code (such + as Inkscape's Affinity extension) was read. +* **Every structure checked against public files.** afread doesn't say how its author learned the + format, so nothing here rests on it alone: each structure the reader relies on is confirmed by + the public documents. Every archive entry carries a CRC-32 that must match, and every field in + the object stream names its own type, so a misread layout fails loudly instead of producing + plausible wrong data. +* **Meaning fitted to Affinity's own pictures.** What shapes, paints, text and pixel data mean was + worked out by comparing our render with the thumbnail every Affinity document embeds (Affinity's + render of itself), on 176 public documents saved by Affinity 1.x, 2.x and 3.0/3.1 on Windows, + macOS and iPad. The collection has since grown to 189 distinct documents, and the reader opens + all of them. Most are published under CC0, MIT, BSD, Apache-2.0 or CC BY (-SA); the others + (no licence stated, or GPL or non-commercial terms) were only opened locally to compare + pictures, never committed or redistributed. The corpus test keeps the comparison for the pinned + files, which are CC0, MIT or Apache-2.0 only. +* **Nothing of Affinity's is in this repository**: no program code, assets or documents. The test + files are fetched at pinned commits and sha256-verified, never committed. +* **Read-only.** Nothing is written in Affinity's format; export waits until someone can check + written files in Affinity itself. + +The same reader is shared, as an independent copy, with VectorCraft's `vectorcraft-affinity`. + +## Validation + +* Unit tests on synthetic containers built by the `synth` feature (stored, zlib and zstd entries, + checksums, budgets, cycles, hostile streams, every truncation) and property tests of random + mutations, here and in `photocraft-io`'s `tests/affinity.rs`. +* `cargo xtask corpus --affinity` fetches 21 public CC0/MIT documents, including the four + Affinity 3 `.af` files of [samuel-etver/vector-art](https://github.com/samuel-etver/vector-art) + (CC0), at pinned commits, each checked against `xtask/affinity-corpus.sha256`. `tests/real_files.rs` + parses them; `photocraft-io`'s `tests/affinity_corpus.rs` imports and flattens each one and + compares it with the thumbnail Affinity saved in it (mean difference 0–4.6 of 255, with a + ceiling per file). +* `fuzz/` has `cargo-fuzz` targets for the whole reader (`preview`), the object stream (`stream`) + and the I/O import (`import_preview`); CI's fuzz job runs `preview` and `import_preview`: + + ```sh + cd crates/affinity/fuzz + cargo +nightly fuzz run stream -- -max_total_time=600 + cargo +nightly fuzz run import_preview --features io -- -max_total_time=600 + ``` + +What has not been verified: files from Affinity builds or platforms outside the public set, files +written by other applications, rotated or skewed images against Affinity's render, mask polarity +against a render with and without the mask, and anything listed as a warning above. The `synth` +builders write only what this reader accepts; Affinity has never opened their output, so they are +test fixtures, not an exporter. + +## Safety limits + +Every size is checked before it is allocated: 256 MiB per archive entry and 1 GiB per import by +default (`Limits`), a 64 MiB zstd window, 4096 saved revisions, array lengths no longer than the +bytes left, 16 777 216 decoded values per document stream (fields and array elements together, counted before +anything is allocated: a value takes about 40 bytes in memory however few it took in the file; the largest of +those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve +nodes per curve, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient +stops; `photocraft-io` adds 300 000 pixels a side per document and 64 megapixels per image on the +canvas. Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the +crate has no panics outside tests. + +## Privacy + +Affinity documents can hold the folder they were saved in, user names, original image paths and +XMP metadata. This reader never imports those fields. diff --git a/crates/affinity/fuzz/.gitignore b/crates/affinity/fuzz/.gitignore new file mode 100644 index 00000000..ab0eaa1a --- /dev/null +++ b/crates/affinity/fuzz/.gitignore @@ -0,0 +1,5 @@ +target/ +corpus/ +artifacts/ +coverage/ +Cargo.lock diff --git a/crates/affinity/fuzz/Cargo.toml b/crates/affinity/fuzz/Cargo.toml new file mode 100644 index 00000000..6c61c87b --- /dev/null +++ b/crates/affinity/fuzz/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "photocraft-affinity-fuzz" +version = "0.0.0" +publish = false +edition = "2024" +license = "MIT OR Apache-2.0" + +[package.metadata] +cargo-fuzz = true + +[workspace] + +[features] +io = ["dep:photocraft-io"] + +[dependencies] +libfuzzer-sys = "0.4" +photocraft-affinity = { path = "..", features = ["synth"] } +photocraft-io = { path = "../../io", optional = true } + +[[bin]] +name = "preview" +path = "fuzz_targets/preview.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "import_preview" +path = "fuzz_targets/import_preview.rs" +required-features = ["io"] +test = false +doc = false +bench = false + +[[bin]] +name = "stream" +path = "fuzz_targets/stream.rs" +test = false +doc = false +bench = false diff --git a/crates/affinity/fuzz/fuzz_targets/import_preview.rs b/crates/affinity/fuzz/fuzz_targets/import_preview.rs new file mode 100644 index 00000000..50177197 --- /dev/null +++ b/crates/affinity/fuzz/fuzz_targets/import_preview.rs @@ -0,0 +1,10 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + // The complete import: native document mapping, or the preview fallback. + if photocraft_affinity::is_affinity(data) { + let _ = photocraft_io::import("fuzz.af", data); + } +}); diff --git a/crates/affinity/fuzz/fuzz_targets/preview.rs b/crates/affinity/fuzz/fuzz_targets/preview.rs new file mode 100644 index 00000000..31e2bf97 --- /dev/null +++ b/crates/affinity/fuzz/fuzz_targets/preview.rs @@ -0,0 +1,10 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +// The whole native reader (archive, object stream, model) and the preview, on arbitrary bytes. +fuzz_target!(|data: &[u8]| { + let _ = photocraft_affinity::container::header(data); + let _ = photocraft_affinity::preview(data); + let _ = photocraft_affinity::read(data, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 }); +}); diff --git a/crates/affinity/fuzz/fuzz_targets/stream.rs b/crates/affinity/fuzz/fuzz_targets/stream.rs new file mode 100644 index 00000000..fd6128a6 --- /dev/null +++ b/crates/affinity/fuzz/fuzz_targets/stream.rs @@ -0,0 +1,11 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +// The doc.dat object stream and the model built from it, wrapped in a stored archive entry so +// mutations reach the tagged grammar instead of the archive's checksum. +fuzz_target!(|stream: &[u8]| { + let _ = photocraft_affinity::stream::parse(stream); + let file = photocraft_affinity::synth::container(&[("doc.dat", stream, photocraft_affinity::synth::Method::Stored)], None); + let _ = photocraft_affinity::read(&file, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 }); +}); diff --git a/crates/affinity/src/access.rs b/crates/affinity/src/access.rs new file mode 100644 index 00000000..0b60ed7d --- /dev/null +++ b/crates/affinity/src/access.rs @@ -0,0 +1,105 @@ +//! Typed reads of [`Stream`] objects. Every accessor returns `None` when a field is missing or has +//! another type, so the document mapping treats odd data as absent rather than failing. + +use crate::stream::{ObjId, Object, Stream, Tag, Value}; + +impl Stream { + pub fn object(&self, id: ObjId) -> Option<&Object> { + self.objects.get(id) + } + + pub fn field(&self, id: ObjId, tag: &[u8; 4]) -> Option<&Value> { + self.object(id)?.get(Tag::of(tag)) + } + + pub fn class(&self, id: ObjId) -> Option { + self.object(id).map(|o| o.class) + } + + pub fn is(&self, id: ObjId, class: &[u8; 4]) -> bool { + self.class(id) == Some(Tag::of(class)) + } + + /// A child object (shared, linked or inline); `None` for null or missing. + pub fn obj(&self, id: ObjId, tag: &[u8; 4]) -> Option { + match self.field(id, tag)? { + Value::Obj(o) => *o, + _ => None, + } + } + + /// An array of objects (nulls skipped). + pub fn objs(&self, id: ObjId, tag: &[u8; 4]) -> Vec { + match self.field(id, tag) { + Some(Value::Array(a)) => a.iter().filter_map(|v| if let Value::Obj(Some(o)) = v { Some(*o) } else { None }).collect(), + _ => Vec::new(), + } + } + + pub fn f64(&self, id: ObjId, tag: &[u8; 4]) -> Option { + match self.field(id, tag)? { + Value::Float(f) if f.is_finite() => Some(*f), + _ => None, + } + } + + pub fn int(&self, id: ObjId, tag: &[u8; 4]) -> Option { + match self.field(id, tag)? { + Value::Int(i) => Some(*i), + Value::UInt(u) => i64::try_from(*u).ok(), + _ => None, + } + } + + pub fn bool(&self, id: ObjId, tag: &[u8; 4]) -> Option { + match self.field(id, tag)? { + Value::Bool(b) => Some(*b), + _ => None, + } + } + + pub fn str(&self, id: ObjId, tag: &[u8; 4]) -> Option<&str> { + match self.field(id, tag)? { + Value::Str(s) => Some(s), + _ => None, + } + } + + pub fn enumeration(&self, id: ObjId, tag: &[u8; 4]) -> Option<(u16, u16)> { + match self.field(id, tag)? { + Value::Enum { id, version } => Some((*id, *version)), + _ => None, + } + } + + /// A fixed vector of `N` finite floats (points, rectangles, transforms). + pub fn floats(&self, id: ObjId, tag: &[u8; 4]) -> Option<[f64; N]> { + match self.field(id, tag)? { + Value::Floats(v) if v.len() == N && v.iter().all(|f| f.is_finite()) => { + let mut a = [0.0; N]; + a.copy_from_slice(v); + Some(a) + } + _ => None, + } + } + + pub fn bytes(&self, id: ObjId, tag: &[u8; 4]) -> Option<&[u8]> { + match self.field(id, tag)? { + Value::Bytes(b) | Value::Blob(b) => Some(b), + _ => None, + } + } + + pub fn entry(&self, id: ObjId, tag: &[u8; 4]) -> Option<&str> { + match self.field(id, tag)? { + Value::Entry(s) => Some(s), + _ => None, + } + } + + /// Untagged fields of a positional object, in order. + pub fn positional(&self, id: ObjId) -> impl Iterator { + self.object(id).into_iter().flat_map(|o| o.fields.iter().map(|(_, v)| v)) + } +} diff --git a/crates/affinity/src/container.rs b/crates/affinity/src/container.rs new file mode 100644 index 00000000..b1a60ed4 --- /dev/null +++ b/crates/affinity/src/container.rs @@ -0,0 +1,371 @@ +//! The Affinity archive: header, the chain of file allocation tables (one per save) and +//! bounded, CRC-checked extraction of named entries such as `doc.dat` and raster tiles. + +use std::collections::{HashMap, HashSet}; +use std::io::Read; + +use crate::Error; + +pub const MAGIC: &[u8; 4] = b"\x00\xffKA"; +/// Oldest and newest container versions seen in real documents (Affinity 1.x writes 8, +/// Affinity 3 writes 12). Version 7 is described by afread but no sample exists. +pub const VERSIONS: std::ops::RangeInclusive = 8..=12; +/// `Prsn` (stored byte-reversed): documents. Asset, brush and style libraries share the magic. +const DOCUMENT_KIND: &[u8; 4] = b"nsrP"; + +/// A save appends one table; real files have one to a few hundred. +const MAX_TABLES: usize = 4096; +/// zstd window ceiling: real documents use a few MiB; this stops a header from reserving gigabytes. +const MAX_ZSTD_WINDOW: u64 = 64 << 20; + +/// Decompression budget for one import. Every entry declares its size, which is checked against +/// these limits before anything is allocated, and the decoded length must match it exactly. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Limits { + /// Largest single entry (`doc.dat` or one tile). + pub max_entry: usize, + /// Total bytes extracted over the whole import. + pub max_total: usize, +} + +impl Default for Limits { + fn default() -> Self { + Self { max_entry: 256 << 20, max_total: 1 << 30 } + } +} + +/// The container header, all integers little-endian. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Header { + pub version: u16, + /// Seen as 0, 4 or 8 in real files; bits 0 and 1 mark container variants that are rejected. + pub flags: u16, + pub(crate) fat_offset: u64, + pub(crate) thumbnail_offset: u64, + /// Save time in Unix seconds, as written by the application. + pub saved: u32, +} + +/// One FAT record, as of the newest table that mentions its id. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Entry { + pub id: u32, + pub name: String, + offset: u64, + size: u64, + compressed: u64, + crc: u32, + compression: u8, +} + +impl Entry { + /// Decompressed size declared by the table. + pub fn size(&self) -> u64 { + self.size + } +} + +#[derive(Debug)] +pub struct Archive<'a> { + bytes: &'a [u8], + pub header: Header, + /// Head revision of every named, non-deleted entry. + entries: HashMap, + /// Number of tables (saves) in the chain. + pub revisions: usize, + extracted: usize, + limits: Limits, +} + +pub(crate) struct Cursor<'a> { + bytes: &'a [u8], + pub(crate) pos: usize, +} + +impl<'a> Cursor<'a> { + pub(crate) fn new(bytes: &'a [u8], pos: usize) -> Self { + Self { bytes, pos } + } + pub(crate) fn remaining(&self) -> usize { + self.bytes.len().saturating_sub(self.pos) + } + pub(crate) fn take(&mut self, n: usize) -> Result<&'a [u8], Error> { + let end = self.pos.checked_add(n).ok_or(Error::Malformed("length overflow"))?; + let s = self.bytes.get(self.pos..end).ok_or(Error::Malformed("truncated data"))?; + self.pos = end; + Ok(s) + } + pub(crate) fn array(&mut self) -> Result<[u8; N], Error> { + let s = self.take(N)?; + let mut a = [0u8; N]; + a.copy_from_slice(s); + Ok(a) + } + pub(crate) fn u8(&mut self) -> Result { + Ok(self.array::<1>()?[0]) + } + pub(crate) fn u16(&mut self) -> Result { + Ok(u16::from_le_bytes(self.array()?)) + } + pub(crate) fn u32(&mut self) -> Result { + Ok(u32::from_le_bytes(self.array()?)) + } + pub(crate) fn u64(&mut self) -> Result { + Ok(u64::from_le_bytes(self.array()?)) + } +} + +fn offset(v: u64) -> Result { + usize::try_from(v).map_err(|_| Error::Malformed("offset beyond addressable memory")) +} + +/// True when `bytes` start with the Affinity container signature (documents and libraries). +pub fn is_affinity(bytes: &[u8]) -> bool { + bytes.starts_with(MAGIC) +} + +/// Check the signature, version and document kind. +pub fn header(bytes: &[u8]) -> Result { + if !is_affinity(bytes) { + return Err(Error::Malformed("missing container signature")); + } + let mut c = Cursor::new(bytes, 4); + let version = c.u16()?; + let flags = c.u16()?; + if !VERSIONS.contains(&version) { + return Err(Error::Unsupported("unknown container version")); + } + if flags & 3 != 0 { + return Err(Error::Unsupported("container variant")); + } + if c.take(4)? != DOCUMENT_KIND { + return Err(Error::Unsupported("this Affinity file is a library (assets, brushes or styles), not a document")); + } + if c.take(4)? != b"#Inf" { + return Err(Error::Malformed("missing information header")); + } + let fat_offset = c.u64()?; + let thumbnail_offset = c.u64()?; + c.take(16)?; + let saved = u32::try_from(c.u64()?).unwrap_or(0); + c.take(8)?; + if c.take(4)? != b"Prot" { + return Err(Error::Malformed("missing protocol header")); + } + Ok(Header { version, flags, fat_offset, thumbnail_offset, saved }) +} + +impl<'a> Archive<'a> { + pub fn open(bytes: &'a [u8], limits: Limits) -> Result { + let header = header(bytes)?; + // Newest record per id: (table date, table index, record). + let mut latest: HashMap)> = HashMap::new(); + let mut names: HashMap = HashMap::new(); + let mut seen = HashSet::new(); + let mut next = header.fat_offset; + let mut tables = 0usize; + while next != 0 { + if !seen.insert(next) { + return Err(Error::Malformed("allocation tables form a cycle")); + } + tables += 1; + if tables > MAX_TABLES { + return Err(Error::Limit("too many saved revisions")); + } + let mut c = Cursor::new(bytes, offset(next)?); + let tag = c.array::<4>()?; + let level = match &tag { + b"#FAT" => 1, + b"#FT2" => 2, + b"#FT3" => 3, + b"#FT4" => 4, + _ => return Err(Error::Malformed("missing allocation table")), + }; + next = c.u64()?; + let date = c.u64()?; + c.take(24)?; + let files = c.u32()?; + c.take(8)?; + let dirs = c.u16()?; + c.take(1)?; + for _ in 0..files { + let id = c.u32()?; + let flag = c.u8()?; + let mut record = None; + match flag { + 0 | 1 => { + let offset = c.u64()?; + let size = c.u64()?; + let compressed = c.u64()?; + let crc = c.u32()?; + let stored = c.u8()?; + if level >= 2 { + c.take(4)?; + } + if level >= 4 { + c.take(4)?; + } + // Old tables number the methods; newer ones store the method byte itself. + let compression = if level <= 2 { + match stored { + 1 => 0x01, + 2 => 0x41, + 3 => 0x81, + 4 => 0xC1, + _ => 0, + } + } else { + stored + }; + record = Some(Entry { id, name: String::new(), offset, size, compressed, crc, compression }); + } + 2 => {} + _ => return Err(Error::Malformed("unknown allocation record")), + } + if flag == 0 { + let len = usize::from(c.u16()?); + let name = String::from_utf8_lossy(c.take(len)?).into_owned(); + names.entry(id).or_insert(name); + } + // Equal dates: the first table in the chain (the newest) wins, as in afread. + let replace = latest.get(&id).is_none_or(|(d, _, _)| date > *d); + if replace { + latest.insert(id, (date, tables, record)); + } + } + for _ in 0..dirs { + let len = usize::from(c.u16()?); + c.take(10)?; + c.take(len)?; + } + } + let mut entries = HashMap::new(); + for (id, (_, _, record)) in latest { + if let (Some(mut e), Some(name)) = (record, names.get(&id)) { + e.name = name.clone(); + entries.insert(e.name.clone(), e); + } + } + Ok(Self { bytes, header, entries, revisions: tables, extracted: 0, limits }) + } + + pub fn entry(&self, name: &str) -> Option<&Entry> { + self.entries.get(name) + } + + pub fn entry_names(&self) -> impl Iterator { + self.entries.keys().map(String::as_str) + } + + /// Bytes extracted so far, counted against [`Limits::max_total`]. + pub fn extracted(&self) -> usize { + self.extracted + } + + /// Extract an entry by name: decompress, undo the byte predictor and verify the CRC. + pub fn read(&mut self, name: &str) -> Result, Error> { + let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?.clone(); + let size = usize::try_from(e.size).map_err(|_| Error::Limit("archive entry too large"))?; + if size > self.limits.max_entry { + return Err(Error::Limit("archive entry too large")); + } + let total = self.extracted.checked_add(size).ok_or(Error::Limit("document too large"))?; + if total > self.limits.max_total { + return Err(Error::Limit("document too large")); + } + let mut c = Cursor::new(self.bytes, offset(e.offset)?); + if c.take(4)? != b"#Fil" { + return Err(Error::Malformed("archive entry has no data block")); + } + let method = e.compression & 3; + let stored_len = if matches!(method, 1 | 2) { e.compressed } else { e.size }; + let data = c.take(offset(stored_len)?)?; + let mut out = match method { + 1 => inflate(data, size)?, + 2 => unzstd(data, size)?, + _ => data.to_vec(), + }; + if out.len() != size { + return Err(Error::Malformed("archive entry size mismatch")); + } + unpredict(&mut out, e.compression)?; + if crc32fast::hash(&out) != e.crc { + return Err(Error::Malformed("archive entry checksum mismatch")); + } + self.extracted = total; + Ok(out) + } +} + +/// Read at most `size + 1` bytes so a stream that decodes past its declared size is caught +/// without unbounded allocation. +fn read_bounded(mut r: impl Read, size: usize) -> Result, Error> { + let mut out = Vec::with_capacity(size.min(64 << 20)); + let cap = u64::try_from(size).unwrap_or(u64::MAX).saturating_add(1); + r.by_ref().take(cap).read_to_end(&mut out).map_err(|_| Error::Malformed("corrupt compressed entry"))?; + Ok(out) +} + +fn inflate(data: &[u8], size: usize) -> Result, Error> { + read_bounded(flate2::read::ZlibDecoder::new(data), size) +} + +fn unzstd(data: &[u8], size: usize) -> Result, Error> { + let mut src = data; + let mut out = Vec::new(); + // Entries may hold several concatenated frames. + while !src.is_empty() { + let left = size.checked_sub(out.len()).ok_or(Error::Malformed("archive entry size mismatch"))?; + let decoder = ruzstd::decoding::StreamingDecoder::new_with_max_window_size(&mut src, MAX_ZSTD_WINDOW) + .map_err(|_| Error::Malformed("corrupt compressed entry"))?; + let frame = read_bounded(decoder, left)?; + if frame.is_empty() { + return Err(Error::Malformed("corrupt compressed entry")); + } + out.extend_from_slice(&frame); + if out.len() > size { + return Err(Error::Malformed("archive entry size mismatch")); + } + } + Ok(out) +} + +/// Undo the byte predictor selected by the high bits of the compression byte. +fn unpredict(b: &mut [u8], compression: u8) -> Result<(), Error> { + match (compression & 0xC0, compression & 0x20 != 0) { + (0x00, _) => {} + (0x40, _) => byte_delta(b), + (0x80, false) => { + let mut prev = 0u16; + for pair in b.as_chunks_mut::<2>().0.iter_mut() { + let v = u16::from_le_bytes(*pair).wrapping_add(prev); + *pair = v.to_le_bytes(); + prev = v; + } + } + (0x80, true) => { + byte_delta(b); + // A 64 KiB tile of 16-bit samples is stored as all high bytes, then all low bytes. + if b.len() == 0x10000 { + let planes = b.to_vec(); + let (hi, lo) = planes.split_at(0x8000); + for (i, out) in b.as_chunks_mut::<4>().0.iter_mut().enumerate() { + let (Some(h), Some(l)) = (hi.get(2 * i..2 * i + 2), lo.get(2 * i..2 * i + 2)) else { + return Err(Error::Malformed("tile size")); + }; + *out = [l[0], h[0], l[1], h[1]]; + } + } + } + _ => return Err(Error::Unsupported("archive entry predictor")), + } + Ok(()) +} + +fn byte_delta(b: &mut [u8]) { + let mut prev = 0u8; + for v in b { + *v = v.wrapping_add(prev); + prev = *v; + } +} diff --git a/crates/affinity/src/geometry.rs b/crates/affinity/src/geometry.rs new file mode 100644 index 00000000..6a63190c --- /dev/null +++ b/crates/affinity/src/geometry.rs @@ -0,0 +1,175 @@ +//! Outlines of curves (`PCvD` node lists), parametric shapes (`Shpe` objects) and compounds, in +//! node space. + +use crate::model::{Affine, Path, Point, Reader, SubPath}; +use crate::stream::{ObjId, Tag, Value}; + +/// Nodes per curve object. +const MAX_NODES: usize = 4_000_000; + +/// The node-space outline of a curve, shape or compound node, and whether it fills even-odd. +/// Instances (master-page or symbol copies) take the geometry of their master. +pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> { + let s = r.s; + match &class.0.to_be_bytes() { + b"PCrv" | b"TxtC" => { + let crvs = s.obj(id, b"Crvs").or_else(|| s.obj(id, b"MCrM").and_then(|m| s.obj(m, b"Crvs")))?; + let path = curves(r, crvs)?; + let multi = path.subpaths.len() > 1; + Some((path, multi)) + } + b"Comp" => { + // One path of all the operands' contours in the compound's space, filled even-odd: this + // matches add and subtract operands in every public sample; a true union of overlapping + // added operands would differ. + let mut path = Path::default(); + for c in s.objs(id, b"Chld") { + let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); + let class = s.class(c)?; + if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) { + r.warn("compound shapes that intersect or exclude (imported as add)"); + } + if let Some((p, _)) = outline(r, c, class, local.then(world)) { + path.subpaths.extend(p.transformed(local).subpaths); + } + } + Some((path, true)) + } + _ => { + let (shape, box_owner) = match s.obj(id, b"Shpe") { + Some(sh) => (sh, id), + None => { + let master = s.obj(id, b"ShpM")?; + (s.obj(master, b"Shpe")?, master) + } + }; + let b = s.floats::<4>(box_owner, b"ShpB")?; + crate::shapes::shape(r, shape, b, world).map(|p| (p, false)) + } + } +} + +/// `PCvD.Data`: positional `[u8, u32 count, then per subpath: bool closed, 18-byte records]`. +/// A record is (f64 x, f64 y, u8 kind, u8 role): role 0 is an anchor, 1 the out-handle of the +/// anchor before it, 2 the in-handle of the anchor after it. A closed subpath repeats its first +/// anchor at the end. `CnrD` live corners are rounded on the stored polyline. +pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option { + let s = r.s; + let data = s.obj(crvs, b"Data")?; + let mut fields = s.positional(data); + fields.next()?; + let count = match fields.next()? { + Value::UInt(n) => usize::try_from(*n).ok()?, + _ => return None, + }; + let corners = s.obj(crvs, b"CnrD").and_then(|c| corner_radii(s.positional(c).collect())); + let mut path = Path::default(); + let mut seen = 0usize; + for index in 0..count { + let closed = match fields.next()? { + Value::Bool(b) => *b, + _ => return None, + }; + let Value::Array(records) = fields.next()? else { return None }; + seen = seen.checked_add(records.len())?; + if seen > MAX_NODES { + r.warn("curves with more than four million nodes"); + return None; + } + let anchors = anchors(records)?; + let radii = if index == 0 { corners.as_ref() } else { None }; + if let Some(sp) = subpath(&anchors, closed, radii) { + path.subpaths.push(sp); + } + } + if corners.is_some() && count > 1 { + r.warn("rounded corners on curves with several subpaths (only the first is rounded)"); + } + Some(path) +} + +struct Anchor { + p: Point, + inh: Option, + out: Option, + /// Index of the anchor's record (live corners refer to records). + record: usize, +} + +fn record(v: &Value) -> Option<(Point, u8)> { + let Value::Bytes(b) = v else { return None }; + if b.len() != 18 { + return None; + } + let x = f64::from_le_bytes(b.get(0..8)?.try_into().ok()?); + let y = f64::from_le_bytes(b.get(8..16)?.try_into().ok()?); + if !x.is_finite() || !y.is_finite() { + return None; + } + Some((Point { x, y }, *b.get(17)?)) +} + +fn anchors(records: &[Value]) -> Option> { + let mut out: Vec = Vec::with_capacity(records.len() / 3 + 1); + let mut pending_in = None; + for (i, v) in records.iter().enumerate() { + let (p, role) = record(v)?; + match role { + 0 => { + out.push(Anchor { p, inh: pending_in.take(), out: None, record: i }); + } + 1 => { + // A leading out-handle without an anchor is edit state only. + if let Some(a) = out.last_mut() { + a.out = Some(p); + } + } + 2 => pending_in = Some(p), + _ => return None, + } + } + Some(out) +} + +fn subpath(anchors: &[Anchor], closed: bool, radii: Option<&Vec<(usize, f64)>>) -> Option { + let first = anchors.first()?; + if let Some(radii) = radii.filter(|r| !r.is_empty()) { + let corner = |a: &Anchor| radii.iter().filter(|(rec, _)| *rec == a.record).map(|(_, r)| *r).fold(0.0, f64::max); + let polyline = anchors.iter().all(|a| a.inh.is_none_or(|h| h == a.p) && a.out.is_none_or(|h| h == a.p)); + if polyline { + let mut pts: Vec<(Point, f64)> = anchors.iter().map(|a| (a.p, corner(a))).collect(); + // A closed subpath repeats its first anchor; its radius may be on either copy. + if closed + && pts.len() > 1 + && pts.first().map(|p| p.0) == pts.last().map(|p| p.0) + && let Some((_, r)) = pts.pop() + && let Some(f) = pts.first_mut() + { + f.1 = f.1.max(r); + } + return Some(crate::shapes::fillet(&pts, closed)); + } + } + let segments = anchors + .windows(2) + .filter_map(|w| { + let [a, b] = w else { return None }; + Some([a.out.unwrap_or(a.p), b.inh.unwrap_or(b.p), b.p]) + }) + .collect(); + Some(SubPath { start: first.p, segments, closed }) +} + +/// `CnrD`: positional `[u8, i32[] record indices, f64[] radii, u8[] types]`. +fn corner_radii(fields: Vec<&Value>) -> Option> { + let (Some(Value::Array(idx)), Some(Value::Array(rad))) = (fields.get(1), fields.get(2)) else { return None }; + Some( + idx.iter() + .zip(rad) + .filter_map(|(i, r)| match (i, r) { + (Value::Int(i), Value::Float(r)) if r.is_finite() && *r > 0.0 => Some((usize::try_from(*i).ok()?, *r)), + _ => None, + }) + .collect(), + ) +} diff --git a/crates/affinity/src/lib.rs b/crates/affinity/src/lib.rs new file mode 100644 index 00000000..9520fa2a --- /dev/null +++ b/crates/affinity/src/lib.rs @@ -0,0 +1,51 @@ +//! Reader for native Affinity documents (`.af`, and `.afdesign`/`.afphoto`/`.afpub` from +//! Affinity 1 and 2): the archive container, the tagged object stream in `doc.dat`, and the +//! subset of the document model PhotoCraft imports. +//! +//! Layouts follow the MIT-licensed afread reference and were checked against public documents +//! saved by Affinity 1.x to 3.x (container versions 8 to 12); see the crate README for provenance +//! and the exact supported subset. Nothing here writes Affinity files. +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +use std::fmt; + +mod access; +pub mod container; +mod geometry; +pub mod model; +pub mod paint; +pub mod preview; +mod raster; +mod shapes; +pub mod stream; +#[cfg(any(test, feature = "synth"))] +pub mod synth; +mod text; + +pub use container::{Archive, Limits, MAGIC, is_affinity}; +pub use model::{Document, read}; +pub use preview::{MAX_PREVIEW_BYTES, MAX_PREVIEW_DIMENSION, Preview, preview}; + +/// Why a file could not be read. Messages are short reasons; callers add advice. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Error { + Malformed(&'static str), + Unsupported(&'static str), + Limit(&'static str), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Malformed(s) => write!(f, "damaged Affinity file: {s}"), + Self::Unsupported(s) => write!(f, "unsupported Affinity file: {s}"), + Self::Limit(s) => write!(f, "Affinity file exceeds a safety limit: {s}"), + } + } +} + +impl std::error::Error for Error {} + +#[cfg(test)] +mod tests; diff --git a/crates/affinity/src/model.rs b/crates/affinity/src/model.rs new file mode 100644 index 00000000..3579152a --- /dev/null +++ b/crates/affinity/src/model.rs @@ -0,0 +1,587 @@ +//! The document model PhotoCraft imports: spreads, artboards, layers, groups, curves, shapes, +//! text and images, with transforms composed into document space. Anything else is reported +//! by [`Document::warnings`] instead of being dropped silently. + +use std::collections::{BTreeMap, HashSet}; + +use crate::paint::{self, Paint, Stroke}; +use crate::stream::{ObjId, Stream, Tag}; +use crate::{Archive, Error, Limits, stream}; + +/// Affine map `x' = a·x + c·y + e`, `y' = b·x + d·y + f` (kurbo's coefficient order). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Affine(pub [f64; 6]); + +impl Affine { + pub const IDENTITY: Self = Self([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]); + + /// Affinity's `Xfrm`, stored row by row: (a, c, e, b, d, f). + pub(crate) fn from_xfrm(m: [f64; 6]) -> Self { + Self([m[0], m[3], m[1], m[4], m[2], m[5]]) + } + + pub fn then(self, outer: Self) -> Self { + let [a, b, c, d, e, f] = self.0; + let [oa, ob, oc, od, oe, of] = outer.0; + Self([oa * a + oc * b, ob * a + od * b, oa * c + oc * d, ob * c + od * d, oa * e + oc * f + oe, ob * e + od * f + of]) + } + + pub fn apply(&self, p: Point) -> Point { + let [a, b, c, d, e, f] = self.0; + Point { x: a * p.x + c * p.y + e, y: b * p.x + d * p.y + f } + } + + /// Geometric mean of the axis scales: how much a stroke width or font size grows. + pub fn scale(&self) -> f64 { + let [a, b, c, d, ..] = self.0; + (a * d - b * c).abs().sqrt() + } + + pub fn is_finite(&self) -> bool { + self.0.iter().all(|v| v.is_finite()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Point { + pub x: f64, + pub y: f64, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Rect { + pub x0: f64, + pub y0: f64, + pub x1: f64, + pub y1: f64, +} + +/// One cubic Bézier subpath, in document pixels. +#[derive(Debug, Clone, PartialEq)] +pub struct SubPath { + pub start: Point, + /// (control 1, control 2, end) per segment. + pub segments: Vec<[Point; 3]>, + pub closed: bool, +} + +/// An outline in document pixels. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Path { + pub subpaths: Vec, +} + +impl Path { + pub fn transformed(&self, m: Affine) -> Self { + Self { + subpaths: self + .subpaths + .iter() + .map(|s| SubPath { start: m.apply(s.start), segments: s.segments.iter().map(|seg| seg.map(|p| m.apply(p))).collect(), closed: s.closed }) + .collect(), + } + } +} + +/// Affinity's blend modes, numbered by the `Blnd` enum. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Blend { + #[default] + Normal, + PassThrough, + Darken, + DarkerColor, + Multiply, + ColorBurn, + Lighten, + LighterColor, + Screen, + ColorDodge, + Add, + Overlay, + SoftLight, + HardLight, + VividLight, + PinLight, + LinearLight, + HardMix, + Difference, + Exclusion, + Subtract, + Hue, + Saturation, + Luminosity, + Color, + Average, + Negation, + Reflect, + Glow, + Erase, +} + +impl Blend { + /// The enum (id, version) pairs observed in a document whose layers are named after their mode. + fn from_enum(id: u16, version: u16) -> Option { + use Blend::*; + Some(match (id, version) { + (0, _) => Normal, + (1, 0) => Darken, + (2, 1) => DarkerColor, + (2, 0) => Multiply, + (3, 0) => ColorBurn, + (4, 0) => Lighten, + (6, 1) => LighterColor, + (5, 0) => Screen, + (6, 0) => ColorDodge, + (7, 0) => Add, + (8, 0) => Overlay, + (9, 0) => SoftLight, + (10, 0) => HardLight, + (11, 0) => VividLight, + (12, 0) => PinLight, + (15, 1) => LinearLight, + (13, 0) => HardMix, + (14, 0) => Difference, + (15, 0) => Exclusion, + (16, 0) => Subtract, + (17, 0) => Hue, + (18, 0) => Saturation, + (19, 0) => Luminosity, + (20, 0) => Color, + (21, 0) => Average, + (22, 0) => Negation, + (23, 0) => Reflect, + (24, 0) => Glow, + (25, 0) => Erase, + _ => return None, + }) + } +} + +/// A run of text with one set of character attributes. +#[derive(Debug, Clone, PartialEq)] +pub struct TextRun { + pub text: String, + /// PostScript name and family of the chosen font. + pub postscript: String, + pub family: String, + /// CSS weight (400 regular, 700 bold). + pub weight: i64, + pub italic: bool, + /// Font size in document pixels, before the node's transform. + pub size: f64, + /// Tracking in em. + pub tracking: f64, + /// Fixed line pitch in document pixels, when the run overrides automatic leading. + pub leading: Option, + pub fill: Paint, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Align { + Left, + Center, + Right, + Justify, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Text { + pub runs: Vec, + pub align: Align, + /// Artistic text: the first baseline's anchor point in node space (start, centre or end of the + /// line according to `align`). Frame text: the frame box in node space. + pub anchor: Point, + pub frame: Option, + /// Node space to document pixels. + pub transform: Affine, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Pixels { + /// Straight-alpha RGBA, 8 bits per channel, row by row. + Rgba8(Vec), + /// The original file a placed image was made from (PNG, JPEG…), as embedded. + Encoded(Vec), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Image { + pub width: u32, + pub height: u32, + pub pixels: Pixels, + /// Pixel space (0..w, 0..h) to document pixels. + pub transform: Affine, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Kind { + /// Layer container (`Scop`). + Layer, + Group, + /// Artboard: a rectangle that clips its children; `background` paints behind them. + Artboard { + rect: Rect, + background: Vec, + }, + /// Curve, parametric shape or compound shape: an outline with its fills and strokes. + /// Children of a shape are clipped by its outline (painted over its fills, under its strokes). + Shape { + path: Path, + fills: Vec, + strokes: Vec, + even_odd: bool, + }, + Text(Text), + Image(Image), + /// Something this reader does not import; its children are still imported. + Unsupported, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Node { + pub class: Tag, + pub name: String, + pub visible: bool, + pub locked: bool, + pub opacity: f64, + pub blend: Blend, + pub kind: Kind, + /// Vector mask in document pixels: the node shows only inside it (nonzero fill). + pub mask: Option, + /// Pixel mask: grey levels (white shows the node, black hides it); outside it the node is hidden. + pub pixel_mask: Option, + /// Bottom to top. + pub children: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Spread { + /// Spread bounds in document pixels (the canvas, or the union of the artboards). + pub bounds: Rect, + /// Pages of a Publisher-style spread (one, or two facing pages), in document pixels. + pub pages: Vec, + /// Transparent background; otherwise the page is white. + pub transparent: bool, + /// Bottom to top. + pub nodes: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Document { + /// Pixels per inch: all geometry is in document pixels at this resolution. + pub dpi: f64, + pub spreads: Vec, + /// Application that last saved the file, as stored (`Affinity 3.0.2`), when present. + pub saved_by: Option, + /// Content that was not imported or was approximated, one line per kind, deduplicated. + pub warnings: Vec, +} + +/// Deepest layer nesting followed; deeper content is reported, not imported. +const MAX_TREE_DEPTH: usize = 128; +/// Total layer nodes imported. +const MAX_NODES: usize = 500_000; + +/// Read a document: archive, `doc.dat` and the model. +pub fn read(bytes: &[u8], limits: Limits) -> Result { + let mut archive = Archive::open(bytes, limits)?; + let doc = archive.read("doc.dat")?; + let s = stream::parse(&doc)?; + Reader::new(&s, &mut archive).document() +} + +pub(crate) struct Reader<'s, 'a, 'b> { + pub(crate) s: &'s Stream, + pub(crate) archive: &'b mut Archive<'a>, + pub(crate) dpi: f64, + warnings: BTreeMap, + nodes: usize, + active: HashSet, +} + +impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { + fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self { + Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new() } + } + + pub(crate) fn warn(&mut self, what: impl Into) { + *self.warnings.entry(what.into()).or_default() += 1; + } + + fn document(mut self) -> Result { + let s = self.s; + let root = s.root; + self.dpi = s.obj(root, b"UVCn").and_then(|u| s.f64(u, b"UPPI")).filter(|d| *d > 0.0 && *d < 100_000.0).unwrap_or(72.0); + let saved_by = s.obj(root, b"NVer").map(|v| { + let product = s.str(v, b"Prod").unwrap_or("Affinity"); + match (s.int(v, b"Majr"), s.int(v, b"Minr"), s.int(v, b"Bild")) { + (Some(a), Some(b), Some(c)) => format!("{product} {a}.{b}.{c}"), + _ => product.to_string(), + } + }); + let doc = s.obj(root, b"DocR").ok_or(Error::Malformed("document has no root node"))?; + let mut spreads = Vec::new(); + for spread in s.objs(doc, b"Chld") { + if !s.is(spread, b"Sprd") { + self.warn("an unknown kind of page"); + continue; + } + let pages = pages(s, spread); + let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h }); + let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect { + x0: 0.0, + y0: 0.0, + x1: 0.0, + y1: 0.0, + }); + let transparent = s.bool(spread, b"SprT").unwrap_or(false); + let (nodes, mask) = self.children(spread, Affine::IDENTITY, 0)?; + if mask.is_some() { + self.warn("mask layers directly on a page (imported without them)"); + } + spreads.push(Spread { bounds, pages, transparent, nodes }); + } + if spreads.is_empty() { + return Err(Error::Malformed("document has no pages")); + } + if !s.objs(doc, b"MpCh").is_empty() { + self.warn("master pages (their content is not placed on the pages that use them)"); + } + let warnings = self.warnings.into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect(); + Ok(Document { dpi: self.dpi, spreads, saved_by, warnings }) + } + + /// The children of `parent`, and the pixel mask a mask layer among them puts on the parent. + fn children(&mut self, parent: ObjId, world: Affine, depth: usize) -> Result<(Vec, Option), Error> { + let s = self.s; + let mut out = Vec::new(); + let mut mask = None; + for c in s.objs(parent, b"Chld") { + if s.is(c, b"MRst") { + // A mask layer inside a group or layer masks that container. + if s.bool(c, b"Visi") == Some(false) { + continue; + } + let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); + match crate::raster::mask(self, c, local.then(world))? { + Some(m) if mask.is_none() => mask = Some(m), + Some(_) => self.warn("several pixel masks on one layer (only the first is used)"), + None => {} + } + continue; + } + if let Some(n) = self.node(c, world, depth + 1)? { + out.push(n); + } + } + Ok((out, mask)) + } + + fn node(&mut self, id: ObjId, parent: Affine, depth: usize) -> Result, Error> { + let s = self.s; + if depth > MAX_TREE_DEPTH { + self.warn("layers nested deeper than 128 levels"); + return Ok(None); + } + self.nodes += 1; + if self.nodes > MAX_NODES { + return Err(Error::Limit("too many layers")); + } + // A node that contains itself (a shared object linked back into its own subtree). + if !self.active.insert(id) { + self.warn("a layer that contains itself"); + return Ok(None); + } + let local = s.floats::<6>(id, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); + let world = local.then(parent); + let class = s.class(id).unwrap_or(Tag(0)); + let name = s.str(id, b"Desc").unwrap_or_default().to_string(); + let visible = s.bool(id, b"Visi").unwrap_or(true); + let locked = s.bool(id, b"Edtb").is_some_and(|e| !e); + let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0)); + let blend = match s.enumeration(id, b"Blnd") { + None if s.bool(id, b"PasT") != Some(false) && is_container(class) => Blend::PassThrough, + None => Blend::Normal, + Some((i, v)) => Blend::from_enum(i, v).unwrap_or_else(|| { + self.warn("an unknown blend mode (imported as Normal)"); + Blend::Normal + }), + }; + let kind = if !world.is_finite() { + self.warn("a layer with an invalid transform"); + Kind::Unsupported + } else { + self.kind(id, class, world, parent)? + }; + // A compound's children are its operands, already merged into its outline. + let (children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? }; + let (mask, attached_mask) = self.attached_masks(id, world)?; + let pixel_mask = attached_mask.or(child_mask); + self.active.remove(&id); + if s.objs(id, b"FiEf").iter().any(|e| s.bool(*e, b"Enab") != Some(false)) { + self.warn("layer effects (shadows, glows, outlines, bevels…)"); + } + Ok(Some(Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, children })) + } + + fn kind(&mut self, id: ObjId, class: Tag, world: Affine, parent: Affine) -> Result { + let s = self.s; + Ok(match &class.0.to_be_bytes() { + b"Scop" => Kind::Layer, + b"Grup" => Kind::Group, + b"PCrv" | b"ShpN" | b"Comp" | b"SNEN" | b"SNRR" | b"ShRN" => { + if class == Tag::of(b"ShpN") && s.bool(id, b"ABEn") == Some(true) { + return Ok(self.artboard(id, world)); + } + let Some((local, even_odd)) = crate::geometry::outline(self, id, class, world) else { + self.warn(format!("a {} that could not be read", describe(class))); + return Ok(Kind::Unsupported); + }; + let (fills, strokes) = paint::node_paint(self, id, world); + Kind::Shape { path: local.transformed(world), fills, strokes, even_odd } + } + b"TxtA" | b"TxtF" | b"TxtC" => match crate::text::read(self, id, world) { + Some(t) => Kind::Text(t), + None => { + self.warn("text whose story could not be read (master-page text, or an unknown layout)"); + Kind::Unsupported + } + }, + b"ImgN" | b"Rstr" => match crate::raster::node_image(self, id, world)? { + Some(img) => Kind::Image(img), + None => Kind::Unsupported, + }, + b"EmbN" => match crate::raster::embedded(self, id, world)? { + Some(img) => { + self.warn("embedded documents and symbols (imported as pictures of them)"); + Kind::Image(img) + } + None => { + self.warn("embedded documents and symbols without a cached picture"); + Kind::Unsupported + } + }, + b"FRst" => { + // A fill layer paints its whole bitmap area; its gradient lives in page space. + let b = s.obj(id, b"Bitm"); + let size = |t: &[u8; 4]| b.and_then(|b| s.int(b, t)).map(|v| v as f64).filter(|v| *v > 0.0); + match (size(b"BmpW"), size(b"BmpH")) { + (Some(w), Some(h)) => { + let local = Path { subpaths: vec![crate::shapes::rectangle(0.0, 0.0, w, h)] }; + let fills = paint::fills(self, id, parent, b"BFFl"); + Kind::Shape { path: local.transformed(world), fills, strokes: Vec::new(), even_odd: false } + } + _ => { + self.warn("fill layers without an area"); + Kind::Unsupported + } + } + } + _ => { + self.warn(format!("{} layers", describe(class))); + Kind::Unsupported + } + }) + } + + /// Vector shapes attached to a node (`AdCh`) mask it: it shows only inside their outlines; + /// an attached mask layer is a pixel mask. Adjustments attached the same way are reported. + fn attached_masks(&mut self, id: ObjId, world: Affine) -> Result<(Option, Option), Error> { + let s = self.s; + let attached = s.objs(id, b"AdCh"); + let mut mask: Option = None; + let mut pixels: Option = None; + for a in attached { + let class = s.class(a).unwrap_or(Tag(0)); + if s.bool(a, b"Visi") == Some(false) { + continue; + } + if class == Tag::of(b"PCrv") || class == Tag::of(b"ShpN") || class == Tag::of(b"Comp") { + let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world); + if let Some((p, _)) = crate::geometry::outline(self, a, class, local) { + mask.get_or_insert_with(Path::default).subpaths.extend(p.transformed(local).subpaths); + } + } else if class == Tag::of(b"MRst") { + let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world); + match crate::raster::mask(self, a, local)? { + Some(m) if pixels.is_none() => pixels = Some(m), + Some(_) => self.warn("several pixel masks on one layer (only the first is used)"), + None => {} + } + } else { + self.warn(format!("{} layers attached to other layers (imported without them)", describe(class))); + } + } + Ok((mask, pixels)) + } + + fn artboard(&mut self, id: ObjId, world: Affine) -> Kind { + let s = self.s; + let b = s.floats::<4>(id, b"ShpB").map(rect).unwrap_or(Rect { x0: 0.0, y0: 0.0, x1: 0.0, y1: 0.0 }); + let corners = [Point { x: b.x0, y: b.y0 }, Point { x: b.x1, y: b.y0 }, Point { x: b.x1, y: b.y1 }, Point { x: b.x0, y: b.y1 }].map(|p| world.apply(p)); + let [a, b2, c, d, ..] = world.0; + if b2.abs() > 1e-9 || c.abs() > 1e-9 || a <= 0.0 || d <= 0.0 { + self.warn("a rotated or flipped artboard (imported at its bounding box)"); + } + let xs = corners.map(|p| p.x); + let ys = corners.map(|p| p.y); + let rect = Rect { + x0: xs.iter().copied().fold(f64::INFINITY, f64::min), + y0: ys.iter().copied().fold(f64::INFINITY, f64::min), + x1: xs.iter().copied().fold(f64::NEG_INFINITY, f64::max), + y1: ys.iter().copied().fold(f64::NEG_INFINITY, f64::max), + }; + let background = paint::fills(self, id, world, b"BFFl"); + Kind::Artboard { rect, background } + } +} + +/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC` +/// equal pages (Publisher 1 facing spreads). +fn pages(s: &Stream, spread: ObjId) -> Vec { + if let Some(md) = s.obj(spread, b"SpMd") { + let v: Vec = s.objs(md, b"PagR").into_iter().filter_map(|p| s.floats::<4>(p, b"rctp").map(rect)).filter(|r| r.x1 > r.x0 && r.y1 > r.y0).collect(); + if !v.is_empty() { + return v; + } + } + match (s.floats::<4>(spread, b"SprB").map(rect), s.int(spread, b"PagC")) { + (Some(b), Some(n @ 2..=16)) => { + let w = (b.x1 - b.x0) / n as f64; + (0..n).map(|i| Rect { x0: b.x0 + w * i as f64, x1: b.x0 + w * (i + 1) as f64, ..b }).collect() + } + _ => Vec::new(), + } +} + +pub(crate) fn union(a: Rect, b: Rect) -> Rect { + Rect { x0: a.x0.min(b.x0), y0: a.y0.min(b.y0), x1: a.x1.max(b.x1), y1: a.y1.max(b.y1) } +} + +fn is_container(class: Tag) -> bool { + class == Tag::of(b"Scop") || class == Tag::of(b"Grup") +} + +pub(crate) fn rect(v: [f64; 4]) -> Rect { + Rect { x0: v[0], y0: v[1], x1: v[2], y1: v[3] } +} + +/// A readable name for a layer class in warnings. +pub(crate) fn describe(class: Tag) -> &'static str { + match &class.0.to_be_bytes() { + b"PCrv" => "curve", + b"ShpN" | b"SNEN" | b"SNRR" | b"ShRN" => "shape", + b"Comp" => "compound", + b"Rstr" => "pixel", + b"ImgN" => "image", + b"EmbN" => "embedded document", + b"MPIN" => "master page instance", + b"FRst" => "fill", + b"MRst" => "mask", + b"Slic" | b"SlcP" => "slice", + b"3DLA" => "3D", + [_, _, b'R', b'A'] => "adjustment", + _ => "unknown", + } +} diff --git a/crates/affinity/src/paint.rs b/crates/affinity/src/paint.rs new file mode 100644 index 00000000..c4ebff84 --- /dev/null +++ b/crates/affinity/src/paint.rs @@ -0,0 +1,329 @@ +//! Fills, strokes and colours. Two schemas exist: Affinity 1 (`BFil` fill, `PFil` stroke paint, +//! `LSty` stroke style) and Affinity 2/3 (`BFFl`, `LIFl` and `LILn` lists with a current index). + +use crate::model::{Affine, Reader}; +use crate::stream::{ObjId, Value}; + +/// A colour as stored: RGB and grey values are encoded in the document's RGB space (sRGB unless +/// the document embeds another profile), CMYK values go through its CMYK profile. Alpha is straight. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Color { + Rgb { + r: f64, + g: f64, + b: f64, + a: f64, + }, + Cmyk { + c: f64, + m: f64, + y: f64, + k: f64, + a: f64, + }, + Gray { + v: f64, + a: f64, + }, + /// CIE L*a*b* (D50). + Lab { + l: f64, + a: f64, + b: f64, + alpha: f64, + }, +} + +impl Color { + pub fn alpha(&self) -> f64 { + match self { + Color::Rgb { a, .. } | Color::Cmyk { a, .. } | Color::Gray { a, .. } => *a, + Color::Lab { alpha, .. } => *alpha, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GradientKind { + Linear, + /// Elliptical and radial: distance from the centre in unit space. + Radial, + /// Angle around the centre. + Conical, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Stop { + pub offset: f64, + pub color: Color, + /// Affinity's midpoint bias for the segment from this stop to the next (0.5 = even blend). + pub bias: f64, +} + +impl Stop { + /// Where, between this stop (0) and the next (1), the blend reaches half way: the bias curve + /// fitted to Affinity's renders is `t^k` (bias ≥ 0.5) or `1-(1-t)^k`, with `k = 1 + 8·|bias-0.5|`. + pub fn half_point(&self) -> f64 { + let k = 1.0 + 8.0 * (self.bias - 0.5).abs(); + let h = 0.5f64.powf(1.0 / k); + if self.bias >= 0.5 { h } else { 1.0 - h } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Gradient { + pub kind: GradientKind, + pub stops: Vec, + /// Unit space to document pixels: linear runs from unit (0,0) to (1,0); radial is the unit + /// circle; conical starts along unit +x and turns towards unit +y. + pub transform: Affine, +} + +#[derive(Debug, Clone, PartialEq, Default)] +pub enum Paint { + #[default] + None, + Solid(Color), + Gradient(Gradient), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Cap { + Butt, + Round, + Square, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Join { + Miter, + Round, + Bevel, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Align { + Center, + Inside, + Outside, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Stroke { + pub paint: Paint, + /// Width in document pixels. + pub width: f64, + pub cap: Cap, + pub join: Join, + pub miter_limit: f64, + pub align: Align, + /// Dash and gap lengths in document pixels, and the phase. + pub dash: Option<(Vec, f64)>, + /// Painted below the fill instead of above it. + pub behind: bool, +} + +/// Gradient stops per gradient. +const MAX_STOPS: usize = 1024; + +fn floats(v: &Value) -> Option> { + match v { + Value::Floats(f) => Some(f.clone()), + Value::Bytes(b) if b.len() % 4 == 0 => Some(b.as_chunks::<4>().0.iter().map(|c| f64::from(f32::from_le_bytes(*c))).collect()), + _ => None, + } +} + +/// A colour object (`RGBA`, `HSLA`, `CMYK`, `GRAY`, `LABA`, `Pant`). +pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option { + let s = r.s; + let class = s.class(id)?; + let col = || -> Option> { + let v = floats(s.field(id, b"_col")?)?; + v.iter().all(|f| f.is_finite()).then_some(v) + }; + Some(match &class.0.to_be_bytes() { + b"RGBA" => { + let v = col()?; + Color::Rgb { r: *v.first()?, g: *v.get(1)?, b: *v.get(2)?, a: v.get(3)?.clamp(0.0, 1.0) } + } + b"HSLA" => { + let v = col()?; + let (r2, g, b) = hsl(*v.first()?, *v.get(1)?, *v.get(2)?); + Color::Rgb { r: r2, g, b, a: v.get(3)?.clamp(0.0, 1.0) } + } + b"CMYK" => { + let v = col()?; + Color::Cmyk { c: *v.first()?, m: *v.get(1)?, y: *v.get(2)?, k: *v.get(3)?, a: v.get(4)?.clamp(0.0, 1.0) } + } + b"GRAY" => { + let v = col()?; + Color::Gray { v: *v.first()?, a: v.get(1)?.clamp(0.0, 1.0) } + } + b"LABA" => { + let b = s.bytes(id, b"_col")?; + let u = |i: usize| -> Option { Some(f64::from(u16::from_le_bytes([*b.get(2 * i)?, *b.get(2 * i + 1)?])) / 65535.0) }; + Color::Lab { l: 100.0 * u(0)?, a: 255.0 * u(1)? - 128.0, b: 255.0 * u(2)? - 128.0, alpha: u(3)? } + } + b"Pant" => { + r.warn("spot colours (imported as their RGB equivalent)"); + return s.obj(id, b"srgb").and_then(|c| color(r, c)).or_else(|| s.obj(id, b"base").and_then(|c| color(r, c))); + } + b"RegC" => Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0, a: 1.0 }, + _ => { + r.warn("colours in an unknown model"); + return None; + } + }) +} + +fn hsl(h: f64, s: f64, l: f64) -> (f64, f64, f64) { + let c = (1.0 - (2.0 * l - 1.0).abs()) * s; + let hp = (h.rem_euclid(1.0)) * 6.0; + let x = c * (1.0 - (hp % 2.0 - 1.0).abs()); + let (r, g, b) = match hp as u32 { + 0 => (c, x, 0.0), + 1 => (x, c, 0.0), + 2 => (0.0, c, x), + 3 => (0.0, x, c), + 4 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + let m = l - c / 2.0; + (r + m, g + m, b + m) +} + +/// A fill descriptor (`FDsc`) or, in Affinity 1 files, a bare `FilS`; `local` maps the node's +/// space to document pixels. +pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option { + let s = r.s; + let (fill, unit) = if s.is(id, b"FDsc") { + let unit = s.floats::<6>(id, b"FDeX").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); + (s.obj(id, b"FDeF")?, unit) + } else { + (id, Affine::IDENTITY) + }; + match &s.class(fill)?.0.to_be_bytes() { + b"FilN" => Some(Paint::None), + b"FilS" => Some(Paint::Solid(color(r, s.obj(fill, b"Colr")?)?)), + b"FilG" => { + let kind = match s.enumeration(fill, b"Type").map(|(i, _)| i) { + Some(0) | None => GradientKind::Linear, + Some(1 | 2) => GradientKind::Radial, + Some(3) => GradientKind::Conical, + Some(_) => { + r.warn("gradients of an unknown kind (imported as linear)"); + GradientKind::Linear + } + }; + let grad = s.obj(fill, b"Grad")?; + let cols = s.objs(grad, b"Cols"); + let posn: Vec> = match s.field(grad, b"Posn") { + Some(Value::Array(a)) => a.iter().filter_map(floats).collect(), + _ => Vec::new(), + }; + let mut stops = Vec::new(); + for (c, p) in cols.iter().zip(&posn).take(MAX_STOPS) { + let color = color(r, *c)?; + let offset = p.first().copied().filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0); + let bias = p.get(1).copied().filter(|v| v.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0); + stops.push(Stop { offset, color, bias }); + } + if stops.is_empty() { + return Some(Paint::None); + } + Some(Paint::Gradient(Gradient { kind, stops, transform: unit.then(local) })) + } + b"FilB" => { + r.warn("bitmap and pattern fills (left empty)"); + Some(Paint::None) + } + _ => { + r.warn("fills of an unknown kind (left empty)"); + Some(Paint::None) + } + } +} + +fn current(r: &Reader, id: ObjId, list: &[u8; 4], index: &[u8; 4]) -> Option { + let items = r.s.objs(id, list); + let i = r.s.int(id, index).and_then(|i| usize::try_from(i).ok()).unwrap_or(0); + items.get(i).or_else(|| items.first()).copied() +} + +/// The fills of `list` (artboard backgrounds use `BFFl`). +pub(crate) fn fills(r: &mut Reader, id: ObjId, local: Affine, list: &[u8; 4]) -> Vec { + let d = current(r, id, list, b"BFCr").or_else(|| r.s.obj(id, b"BFil")); + d.and_then(|d| descriptor(r, d, local)).filter(|p| *p != Paint::None).into_iter().collect() +} + +/// A node's fill and stroke. `local` maps the node's space to document pixels. +pub(crate) fn node_paint(r: &mut Reader, id: ObjId, local: Affine) -> (Vec, Vec) { + let s = r.s; + let fills = fills(r, id, local, b"BFFl"); + if r.s.objs(id, b"BFFl").len() > 1 || r.s.objs(id, b"LIFl").len() > 1 { + r.warn("several fills or strokes on one object (only the active one is imported)"); + } + if s.f64(id, b"FOpc").is_some_and(|o| o < 0.999) { + r.warn("fill opacity (imported at full fill opacity)"); + } + if s.obj(id, b"Trns").is_some() { + r.warn("transparency gradients (imported without them)"); + } + let (pen, style) = if s.field(id, b"LIFl").is_some() { + (current(r, id, b"LIFl", b"LICr"), current(r, id, b"LILn", b"LICr")) + } else { + (s.obj(id, b"PFil"), s.obj(id, b"LSty")) + }; + let behind = matches!(s.field(id, b"DrwO"), Some(Value::Array(a)) if a.first() == Some(&Value::Enum { id: 1, version: 0 })); + let stroke = match (pen, style) { + (Some(pen), Some(style)) => stroke(r, pen, style, local, behind), + _ => None, + }; + (fills, stroke.into_iter().collect()) +} + +fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) -> Option { + let s = r.s; + let paint = descriptor(r, pen, local)?; + if paint == Paint::None { + return None; + } + let lsty = if s.is(ldsc, b"LDsc") { s.obj(ldsc, b"LDeL")? } else { ldsc }; + let weight = s.f64(lsty, b"Wght").filter(|w| *w > 0.0 && *w < 1e7)?; + // "Scale with object": the weight follows the object's transform; otherwise it is absolute. + let scales = s.bool(ldsc, b"LDSc").unwrap_or(true); + let width = if scales { weight * local.scale() } else { weight }; + let data = s.bytes(lsty, b"Data").unwrap_or_default(); + let miter_limit = data.get(0..8).and_then(|b| b.try_into().ok()).map(f64::from_le_bytes).filter(|m| m.is_finite() && *m >= 1.0).unwrap_or(4.0); + // Cap and join bytes are only partly understood: (0, 0) is butt/miter in every clean sample; + // (2, 2) ends plain lines square and brush lines round; joins import round, which never + // looks wrong. + let (cap, join) = match (data.get(8).copied(), data.get(9).copied(), data.get(10).copied()) { + (Some(0), Some(0), _) => (Cap::Butt, Join::Miter), + (Some(0), Some(_), _) => (Cap::Butt, Join::Round), + (Some(2), Some(2), Some(3)) => (Cap::Round, Join::Round), + (Some(2), Some(2), _) => (Cap::Square, Join::Round), + _ => (Cap::Round, Join::Round), + }; + let align = match s.int(ldsc, b"LDSa") { + Some(1) => Align::Inside, + Some(2) => Align::Outside, + _ => Align::Center, + }; + let dash = match s.field(lsty, b"Patn") { + Some(Value::Array(a)) if data.get(10) == Some(&2) || !a.is_empty() => { + let mut v: Vec = a.iter().filter_map(|x| if let Value::Float(f) = x { Some(*f) } else { None }).collect(); + while v.len() >= 2 && v.iter().rev().take(2).all(|x| *x == 0.0) { + v.truncate(v.len() - 2); + } + let valid = !v.is_empty() && v.iter().all(|x| x.is_finite() && *x >= 0.0) && v.iter().any(|x| *x > 0.0); + valid.then(|| (v.iter().map(|x| x * width).collect(), s.f64(lsty, b"Phse").unwrap_or(0.0) * width)) + } + _ => None, + }; + if s.obj(lsty, b"Brus").is_some() || s.obj(ldsc, b"LDeP").is_some() { + r.warn("brush and pressure strokes (imported as plain strokes)"); + } + Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind }) +} diff --git a/crates/affinity/src/preview.rs b/crates/affinity/src/preview.rs new file mode 100644 index 00000000..f50cb8c3 --- /dev/null +++ b/crates/affinity/src/preview.rs @@ -0,0 +1,127 @@ +//! The indexed PNG thumbnail Affinity stores next to the archive: Affinity's own render of the +//! document, at most 512 pixels on the long side in real files, never the document itself. + +use crate::Error; +use crate::container::header; + +pub const MAX_PREVIEW_BYTES: usize = 16 << 20; +pub const MAX_PREVIEW_DIMENSION: u32 = 4096; + +/// Borrowed embedded thumbnail, at its own dimensions, never the native document dimensions. +#[derive(Debug)] +pub struct Preview<'a> { + pub png: &'a [u8], + pub width: u32, + pub height: u32, +} + +fn bytes_at(bytes: &[u8], offset: usize, length: usize) -> Result<&[u8], Error> { + let end = offset.checked_add(length).ok_or(Error::Malformed("offset overflow"))?; + bytes.get(offset..end).ok_or(Error::Malformed("truncated record")) +} + +fn le32(bytes: &[u8], offset: usize) -> Result { + let b = bytes_at(bytes, offset, 4)?; + Ok(u32::from_le_bytes(b.try_into().map_err(|_| Error::Malformed("integer"))?)) +} + +fn be32(bytes: &[u8], offset: usize) -> Result { + let b = bytes_at(bytes, offset, 4)?; + Ok(u32::from_be_bytes(b.try_into().map_err(|_| Error::Malformed("PNG integer"))?)) +} + +/// Read only the indexed `Thmb` record (identical in container versions 8 to 12), checking every PNG chunk CRC. Compressed +/// pixels must still be validated by the consuming image decoder, with allocation limits. +pub fn preview(bytes: &[u8]) -> Result, Error> { + let h = header(bytes)?; + if h.thumbnail_offset == 0 { + return Err(Error::Unsupported("no embedded preview")); + } + let offset = usize::try_from(h.thumbnail_offset).map_err(|_| Error::Malformed("thumbnail offset overflow"))?; + if offset < 72 { + return Err(Error::Malformed("thumbnail overlaps the file header")); + } + let record = bytes.get(offset..).ok_or(Error::Malformed("thumbnail offset outside file"))?; + if bytes_at(record, 0, 8)? != b"\xff\xff\xff\xffThmb" { + return Err(Error::Malformed("missing indexed thumbnail")); + } + if le32(record, 8)? != 1 || bytes_at(record, 28, 1)? != [1] { + return Err(Error::Unsupported("thumbnail record version or encoding")); + } + let size = usize::try_from(le32(record, 24)?).map_err(|_| Error::Limit("encoded size"))?; + if size > MAX_PREVIEW_BYTES { + return Err(Error::Limit("encoded preview exceeds 16 MiB")); + } + if le32(record, 16)? != 29 || le32(record, 20)? != 0 || u64::from(le32(record, 12)?) != size as u64 + 13 { + return Err(Error::Malformed("inconsistent thumbnail record lengths")); + } + let png = bytes_at(record, 29, size)?; + if bytes_at(png, 0, 8)? != b"\x89PNG\r\n\x1a\n" || be32(png, 8)? != 13 || bytes_at(png, 12, 4)? != b"IHDR" { + return Err(Error::Malformed("thumbnail is not a PNG")); + } + let width = be32(png, 16)?; + let height = be32(png, 20)?; + if width == 0 || height == 0 || width > MAX_PREVIEW_DIMENSION || height > MAX_PREVIEW_DIMENSION { + return Err(Error::Limit("preview dimensions must be 1..4096")); + } + // Bound work by bytes, not a file-supplied chunk count, and require exactly one complete PNG. + let mut pos = 8usize; + let (mut palette, mut image_data, mut image_data_ended) = (false, false, false); + loop { + let length = usize::try_from(be32(png, pos)?).map_err(|_| Error::Limit("PNG chunk length"))?; + let chunk_size = length.checked_add(12).ok_or(Error::Malformed("PNG chunk overflow"))?; + let chunk = bytes_at(png, pos, chunk_size)?; + let checksum_input = bytes_at(chunk, 4, chunk_size - 8)?; + if crc32fast::hash(checksum_input) != be32(chunk, chunk_size - 4)? { + return Err(Error::Malformed("PNG chunk checksum")); + } + let kind = bytes_at(chunk, 4, 4)?; + if !kind.iter().all(u8::is_ascii_alphabetic) { + return Err(Error::Malformed("PNG chunk type")); + } + match kind { + b"IHDR" if pos == 8 => {} + b"IHDR" => return Err(Error::Malformed("duplicate PNG header")), + b"PLTE" => { + if palette || image_data { + return Err(Error::Malformed("PNG palette order")); + } + palette = true; + } + b"IDAT" => { + if image_data_ended { + return Err(Error::Malformed("non-contiguous PNG image data")); + } + image_data = true; + } + b"IEND" => { + if !image_data { + return Err(Error::Malformed("PNG has no image data")); + } + } + _ if kind.first().is_some_and(u8::is_ascii_uppercase) => { + return Err(Error::Unsupported("unknown critical PNG chunk")); + } + _ => {} + } + if image_data && kind != b"IDAT" { + image_data_ended = true; + } + // Native sample previews are plain, single-frame PNGs. Pixel allocation limits do not + // necessarily bound aggregate compressed metadata; leave these layouts unsupported. + if matches!(kind, b"zTXt" | b"iTXt" | b"iCCP") { + return Err(Error::Unsupported("compressed PNG metadata in the preview")); + } + if matches!(kind, b"acTL" | b"fcTL" | b"fdAT") { + return Err(Error::Unsupported("animated PNG preview")); + } + pos = pos.checked_add(chunk_size).ok_or(Error::Malformed("PNG offset overflow"))?; + if kind == b"IEND" { + if length != 0 || pos != png.len() { + return Err(Error::Malformed("PNG end or trailing data")); + } + break; + } + } + Ok(Preview { png, width, height }) +} diff --git a/crates/affinity/src/raster.rs b/crates/affinity/src/raster.rs new file mode 100644 index 00000000..8cdf9b83 --- /dev/null +++ b/crates/affinity/src/raster.rs @@ -0,0 +1,243 @@ +//! Pixel layers (`Rstr`) and placed images (`ImgN`). A placed image keeps its original file +//! (`DyBm.Bckg` → archive entry `c/N`), which is imported as is; other pixels are planar tiles of +//! 256 bytes × 256 rows per channel, each its own archive entry. + +use std::collections::HashMap; + +use crate::Error; +use crate::model::{Affine, Image, Pixels, Reader}; +use crate::stream::{self, ObjId, Tag, Value}; + +/// Pixels decoded per layer (the content's bounding box, not the canvas). +const MAX_PIXELS: u64 = 64 << 20; +const TILE: usize = 256; +const TILE_BYTES: usize = TILE * TILE; + +pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result, Error> { + let s = r.s; + let Some(bitmap) = s.obj(id, b"Bitm") else { + r.warn("pixel layers without pixels"); + return Ok(None); + }; + if !s.is(bitmap, b"DyBm") { + r.warn("pixel layers stored in an unknown way"); + return Ok(None); + } + let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); + let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); + if width == 0 || height == 0 { + return Ok(None); + } + // A placed image: the original file, byte for byte. + let has_tiles = s.field(bitmap, b"Sta1").is_some(); + if let Some(name) = s.entry(bitmap, b"Bckg").filter(|n| !n.is_empty()) + && (s.is(id, b"ImgN") || !has_tiles) + { + match original(r, name) { + Ok(bytes) => return Ok(Some(Image { width, height, pixels: Pixels::Encoded(bytes), transform: world })), + Err(Error::Limit(e)) => return Err(Error::Limit(e)), + Err(_) => r.warn("an embedded image file that could not be read (its cached pixels were used)"), + } + } + // The content's bounding box, so a mostly empty canvas-sized layer stays small. + let crop = s + .field(id, b"BitR") + .and_then(|v| if let Value::Ints(i) = v { Some(i.clone()) } else { None }) + .filter(|v| v.len() == 4 && v[0] > i64::from(i32::MIN) + 1) + .map(|v| { + ( + v[0].clamp(0, i64::from(width)) as u32, + v[1].clamp(0, i64::from(height)) as u32, + v[2].clamp(0, i64::from(width)) as u32, + v[3].clamp(0, i64::from(height)) as u32, + ) + }) + .unwrap_or((0, 0, width, height)); + if s.field(bitmap, b"Bckg").is_some() && has_tiles && !s.is(id, b"ImgN") { + r.warn("edited placed images use their stored pixels"); + } + decode(r, bitmap, crop, world) +} + +/// A mask layer (`MRst`): one channel of coverage as grey pixels (white shows, black hides). +pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine) -> Result, Error> { + let s = r.s; + let Some(bitmap) = s.obj(id, b"Bitm") else { return Ok(None) }; + let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); + let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); + if width == 0 || height == 0 || !matches!(s.enumeration(bitmap, b"Frmt"), Some((6 | 7, _))) { + r.warn("pixel masks in an unknown format (imported without them)"); + return Ok(None); + } + decode(r, bitmap, (0, 0, width, height), world) +} + +/// An embedded document or symbol instance (`EmbN`), as the picture of it Affinity caches. +pub(crate) fn embedded(r: &mut Reader, id: ObjId, world: Affine) -> Result, Error> { + let s = r.s; + let Some(cache) = s.obj(id, b"Bitm").and_then(|e| s.obj(e, b"Cach")) else { return Ok(None) }; + let Some(bbox) = s.obj(id, b"Bitm").and_then(|e| s.floats::<4>(e, b"ChBB")) else { return Ok(None) }; + let width = s.int(cache, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); + let height = s.int(cache, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); + if width == 0 || height == 0 { + return Ok(None); + } + let [x0, y0, x1, y1] = bbox; + // The instance's origin is the centre of the embedded content (checked against the + // thumbnails of documents with embedded documents and SVG files). + let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + let place = Affine([(x1 - x0) / f64::from(width), 0.0, 0.0, (y1 - y0) / f64::from(height), x0 - cx, y0 - cy]).then(world); + decode(r, cache, (0, 0, width, height), place) +} + +/// Decode the tiles of a bitmap inside `crop` (pixels), placed by `world` (bitmap pixels to document). +fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affine) -> Result, Error> { + let s = r.s; + let (x0, y0, x1, y1) = crop; + if x1 <= x0 || y1 <= y0 { + return Ok(None); + } + let (w, h) = (x1 - x0, y1 - y0); + if u64::from(w) * u64::from(h) > MAX_PIXELS { + r.warn("pixel layers larger than 64 megapixels"); + return Ok(None); + } + let format = s.enumeration(bitmap, b"Frmt").map(|(f, _)| f); + let (channels, bps) = match format { + Some(0) => (4, 1), + Some(1) => (4, 2), + Some(6) => (1, 1), + Some(7) => (1, 2), + Some(4) => { + r.warn("CMYK pixel layers (converted to RGB without the document's colour profile)"); + (5, 1) + } + _ => { + r.warn("pixel layers in grey, Lab or 32-bit formats"); + return Ok(None); + } + }; + let mut planes = Vec::with_capacity(channels); + let mut cache = HashMap::new(); + for c in 1..=channels { + planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), &mut cache)?); + } + let n = (w as usize) * (h as usize); + let mut rgba = vec![0u8; n * 4]; + let sample = |p: &Vec, i: usize| -> f64 { + if bps == 2 { + f64::from(u16::from_le_bytes([p.get(2 * i).copied().unwrap_or(0), p.get(2 * i + 1).copied().unwrap_or(0)])) / 65535.0 + } else { + f64::from(p.get(i).copied().unwrap_or(0)) / 255.0 + } + }; + for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() { + let v: Vec = planes.iter().map(|p| sample(p, i)).collect(); + let (r2, g, b, a) = if channels == 1 { + (v[0], v[0], v[0], 1.0) + } else if channels == 5 { + let k = 1.0 - v[3]; + ((1.0 - v[0]) * k, (1.0 - v[1]) * k, (1.0 - v[2]) * k, v[4]) + } else { + (v[0], v[1], v[2], v[3]) + }; + px.copy_from_slice(&[r2, g, b, a].map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8)); + } + let transform = Affine([1.0, 0.0, 0.0, 1.0, f64::from(x0), f64::from(y0)]).then(world); + Ok(Some(Image { width: w, height: h, pixels: Pixels::Rgba8(rgba), transform })) +} + +/// The original image file: entry `c/N` is a stream whose root (`Blck`) holds it as `Data`. +fn original(r: &mut Reader, name: &str) -> Result, Error> { + let bytes = r.archive.read(name)?; + let s = stream::parse(&bytes)?; + let root = s.object(s.root).ok_or(Error::Malformed("embedded image"))?; + match root.get(Tag::of(b"Data")) { + Some(Value::Blob(b)) if !b.is_empty() => Ok(b.clone()), + _ => Err(Error::Malformed("embedded image without data")), + } +} + +fn tag(prefix: &[u8; 3], c: usize) -> [u8; 4] { + [prefix[0], prefix[1], prefix[2], b'0' + c as u8] +} + +/// One channel of level 0, cropped to (x0, y0, w, h) pixels; `bps` bytes per sample. +fn plane( + r: &mut Reader, + bitmap: ObjId, + c: usize, + bps: usize, + (x0, y0, w, h): (u32, u32, u32, u32), + cache: &mut HashMap>, +) -> Result, Error> { + let s = r.s; + let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0); + let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0); + let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| (width * bps).div_ceil(TILE)); + let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE)); + let states: Vec = match s.field(bitmap, &tag(b"Sta", c)) { + Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(), + _ => Vec::new(), + }; + let tiles = s.objs(bitmap, &tag(b"Idx", c)); + let row = (w as usize) * bps; + let mut out = vec![0u8; row * h as usize]; + let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps); + let (py0, py1) = (y0 as usize, (y0 + h) as usize); + let mut next = 0usize; + for (t, state) in states.iter().enumerate().take(tw.saturating_mul(th)) { + let (ty, tx) = (t / tw.max(1), t % tw.max(1)); + let fill = match state { + 0 | 1 => None, + 2 => Some(0xFF), + 4 => { + let Some(blck) = tiles.get(next).copied() else { return Err(Error::Malformed("missing pixel tile")) }; + next += 1; + let (ox, oy) = (tx * TILE, ty * TILE); + if ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0 { + continue; + } + let name = s.entry(blck, b"Data").ok_or(Error::Malformed("pixel tile without data"))?.to_string(); + if !cache.contains_key(&name) { + let mut data = r.archive.read(&name)?; + // Older files wrap a tile in a one-field stream: header, then a 64 KiB blob. + if data.len() == TILE_BYTES + 22 && data.starts_with(b"\x00\xffKS") { + data = data.get(21..21 + TILE_BYTES).ok_or(Error::Malformed("pixel tile"))?.to_vec(); + } + if data.len() != TILE_BYTES { + return Err(Error::Malformed("pixel tile size")); + } + cache.insert(name.clone(), data); + } + let tile = cache.get(&name).ok_or(Error::Malformed("pixel tile"))?; + copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0)); + continue; + } + 3 => { + r.warn("32-bit pixel layers"); + None + } + 5 => { + r.warn("pixel layers drawn from an embedded image (left empty)"); + None + } + _ => return Err(Error::Malformed("unknown pixel tile state")), + }; + if let Some(v) = fill { + copy(&mut out, row, (bx0, py0, bx1, py1), (tx * TILE, ty * TILE), |_, _| v); + } + } + Ok(out) +} + +/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane. +fn copy(out: &mut [u8], row: usize, (bx0, py0, bx1, py1): (usize, usize, usize, usize), (ox, oy): (usize, usize), at: impl Fn(usize, usize) -> u8) { + for y in oy.max(py0)..(oy + TILE).min(py1) { + for x in ox.max(bx0)..(ox + TILE).min(bx1) { + if let Some(b) = out.get_mut((y - py0) * row + (x - bx0)) { + *b = at(x - ox, y - oy); + } + } + } +} diff --git a/crates/affinity/src/shapes.rs b/crates/affinity/src/shapes.rs new file mode 100644 index 00000000..7f845dfe --- /dev/null +++ b/crates/affinity/src/shapes.rs @@ -0,0 +1,259 @@ +//! Parametric shapes: Affinity stores the shape's parameters and its box, never the outline. +//! Formulas were fitted to Affinity's own renders (the document thumbnails) of public files. + +use std::f64::consts::{FRAC_PI_2, PI, TAU}; + +use crate::model::{Affine, Path, Point, Reader, SubPath}; +use crate::stream::{ObjId, Tag, Value}; + +/// Handle length of a quarter circle of radius 1. +const KAPPA: f64 = 0.552_284_749_830_793_4; +/// Vertices of a polygon or star. +const MAX_SIDES: i64 = 10_000; + +/// The last value of a tag: a shape changed from one kind to another keeps a stale first block. +fn last<'a>(r: &'a Reader, id: ObjId, tag: &[u8; 4]) -> Option<&'a Value> { + let t = Tag::of(tag); + r.s.object(id)?.fields.iter().rev().find(|(k, _)| *k == t).map(|(_, v)| v) +} + +fn num(r: &Reader, id: ObjId, tag: &[u8; 4]) -> Option { + match last(r, id, tag)? { + Value::Float(f) if f.is_finite() => Some(*f), + Value::UInt(u) => Some(*u as f64), + Value::Int(i) => Some(*i as f64), + _ => None, + } +} + +fn sides(r: &Reader, id: ObjId, tag: &[u8; 4], default: f64) -> usize { + let n = num(r, id, tag).unwrap_or(default).round(); + n.clamp(3.0, MAX_SIDES as f64) as usize +} + +fn pt(x: f64, y: f64) -> Point { + Point { x, y } +} + +fn polygon(pts: impl IntoIterator) -> Option { + let mut it = pts.into_iter(); + let start = it.next()?; + let segments = it.map(|p| [p, p, p]).collect(); + Some(SubPath { start, segments, closed: true }) +} + +/// Outline of `shape` (a `Shpe` object) in the node box `b` = (x0, y0, x1, y1). +pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) -> Option { + let [x0, y0, x1, y1] = b; + let (w, h) = (x1 - x0, y1 - y0); + let (cx, cy, rx, ry) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, w / 2.0, h / 2.0); + let on_ellipse = |a: f64, f: f64| pt(cx + f * rx * a.cos(), cy + f * ry * a.sin()); + let class = r.s.class(shape)?; + let sub = match &class.0.to_be_bytes() { + b"ShNR" | b"ShRR" => { + let rounded = matches!(last(r, shape, b"CTyp"), Some(Value::Array(a)) if !a.is_empty()); + let radius = match last(r, shape, b"ShCR") { + Some(Value::Floats(v)) => v.first().copied().filter(|f| f.is_finite() && *f > 0.0), + _ => None, + }; + if let (true, Some(f)) = (rounded, radius) { + if let Some(Value::Array(types)) = last(r, shape, b"CTyp") + && types.iter().any(|t| !matches!(t, Value::Enum { id: 0 | 4, .. })) + { + r.warn("rectangles with straight, concave or other corner types (imported with round corners)"); + } + let (crx, cry) = if r.s.bool(shape, b"AbSz") == Some(true) { + // An absolute radius is in document pixels, whatever the node's scale. + let [a, b2, c, d, ..] = world.0; + let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt()); + if sx > 0.0 && sy > 0.0 { (f / sx, f / sy) } else { (0.0, 0.0) } + } else { + (f * w.min(h), f * w.min(h)) + }; + rounded_rect(b, crx.min(w / 2.0), cry.min(h / 2.0)) + } else { + polygon([pt(x0, y0), pt(x1, y0), pt(x1, y1), pt(x0, y1)])? + } + } + b"ShpE" => ellipse(cx, cy, rx, ry), + b"ShPy" => { + let n = sides(r, shape, b"Side", 5.0); + let a = |i: usize| -FRAC_PI_2 + TAU * i as f64 / n as f64; + if r.s.object(shape).is_some_and(|_| matches!(last(r, shape, b"Smth"), Some(Value::Bool(true)))) { + // Smoothed: tangent handles of length curv·4/3·tan(π/2n) along the inscribed ellipse. + let k = num(r, shape, b"Curv").unwrap_or(0.0) * 4.0 / 3.0 * (PI / (2.0 * n as f64)).tan(); + let v = |i: usize| on_ellipse(a(i), 1.0); + let t = |i: usize| pt(-rx * a(i).sin(), ry * a(i).cos()); + let segments = (0..n) + .map(|i| { + let (p, q, tp, tq) = (v(i), v(i + 1), t(i), t(i + 1)); + [pt(p.x + k * tp.x, p.y + k * tp.y), pt(q.x - k * tq.x, q.y - k * tq.y), q] + }) + .collect(); + SubPath { start: v(0), segments, closed: true } + } else { + polygon((0..n).map(|i| on_ellipse(a(i), 1.0)))? + } + } + b"ShSt" => { + let n = sides(r, shape, b"Pnts", 5.0); + let q = num(r, shape, b"IRad").unwrap_or(0.382).clamp(0.0, 1.0); + if [b"CrcI", b"CrcO", b"CrvL", b"CrvR"].iter().any(|t| num(r, shape, t).is_some_and(|v| v.abs() > 1e-9)) { + r.warn("stars with rounded or curved points (imported with sharp points)"); + } + polygon((0..2 * n).map(|i| on_ellipse(-FRAC_PI_2 + PI * i as f64 / n as f64, if i % 2 == 0 { 1.0 } else { q })))? + } + b"ShSS" => { + let n = sides(r, shape, b"Side", 5.0); + let ri = num(r, shape, b"COut").unwrap_or(0.5).clamp(0.0, 1.0); + let step = PI / n as f64; + let (l, hw, inner) = (step.cos(), ri * step.sin(), ri * step.cos()); + polygon((0..n).flat_map(|k| { + let phi = -FRAC_PI_2 + step + TAU * k as f64 / n as f64; + let (c, s) = (phi.cos(), phi.sin()); + [(inner, -hw), (l, -hw), (l, hw), (inner, hw)].map(|(u, v)| pt(cx + rx * (u * c - v * s), cy + ry * (u * s + v * c))) + }))? + } + b"ShPi" => { + let start = num(r, shape, b"AngS").unwrap_or(0.0); + let mut end = num(r, shape, b"AngE").unwrap_or(TAU); + while end < start { + end += TAU; + } + let inner = num(r, shape, b"IRad").unwrap_or(0.0).clamp(0.0, 1.0); + pie(cx, cy, rx, ry, start, end.min(start + TAU), inner) + } + b"ShpT" => { + let p = num(r, shape, b"Pos ").unwrap_or(0.5); + polygon([pt(x0 + p * w, y0), pt(x1, y1), pt(x0, y1)])? + } + b"ShTz" => { + let (l, rr) = (num(r, shape, b"PosL").unwrap_or(0.25), num(r, shape, b"PosR").unwrap_or(0.75)); + polygon([pt(x0 + l * w, y0), pt(x0 + rr * w, y0), pt(x1, y1), pt(x0, y1)])? + } + _ => { + r.warn("cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse)"); + ellipse(cx, cy, rx, ry) + } + }; + Some(Path { subpaths: vec![sub] }) +} + +pub(crate) fn rectangle(x0: f64, y0: f64, x1: f64, y1: f64) -> SubPath { + SubPath { start: pt(x0, y0), segments: [pt(x1, y0), pt(x1, y1), pt(x0, y1)].map(|p| [p, p, p]).to_vec(), closed: true } +} + +fn ellipse(cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath { + let (kx, ky) = (KAPPA * rx, KAPPA * ry); + SubPath { + start: pt(cx + rx, cy), + segments: vec![ + [pt(cx + rx, cy + ky), pt(cx + kx, cy + ry), pt(cx, cy + ry)], + [pt(cx - kx, cy + ry), pt(cx - rx, cy + ky), pt(cx - rx, cy)], + [pt(cx - rx, cy - ky), pt(cx - kx, cy - ry), pt(cx, cy - ry)], + [pt(cx + kx, cy - ry), pt(cx + rx, cy - ky), pt(cx + rx, cy)], + ], + closed: true, + } +} + +fn rounded_rect([x0, y0, x1, y1]: [f64; 4], rx: f64, ry: f64) -> SubPath { + let (kx, ky) = ((1.0 - KAPPA) * rx, (1.0 - KAPPA) * ry); + SubPath { + start: pt(x0 + rx, y0), + segments: vec![ + [pt(x1 - rx, y0), pt(x1 - rx, y0), pt(x1 - rx, y0)], + [pt(x1 - kx, y0), pt(x1, y0 + ky), pt(x1, y0 + ry)], + [pt(x1, y1 - ry), pt(x1, y1 - ry), pt(x1, y1 - ry)], + [pt(x1, y1 - ky), pt(x1 - kx, y1), pt(x1 - rx, y1)], + [pt(x0 + rx, y1), pt(x0 + rx, y1), pt(x0 + rx, y1)], + [pt(x0 + kx, y1), pt(x0, y1 - ky), pt(x0, y1 - ry)], + [pt(x0, y0 + ry), pt(x0, y0 + ry), pt(x0, y0 + ry)], + [pt(x0, y0 + ky), pt(x0 + kx, y0), pt(x0 + rx, y0)], + ], + closed: true, + } +} + +/// Elliptical arc from angle `a0` to `a1` (radians, measured y-up as Affinity's pie angles are), +/// as cubic segments of at most a quarter turn. +fn arc(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, a1: f64, out: &mut Vec<[Point; 3]>) { + let n = ((a1 - a0).abs() / FRAC_PI_2).ceil().clamp(1.0, 8.0) as usize; + let step = (a1 - a0) / n as f64; + let k = 4.0 / 3.0 * (step / 4.0).tan(); + let p = |a: f64| pt(cx + rx * a.cos(), cy - ry * a.sin()); + let d = |a: f64| pt(-rx * a.sin(), -ry * a.cos()); + for i in 0..n { + let (s, e) = (a0 + step * i as f64, a0 + step * (i + 1) as f64); + let (ps, pe, ds, de) = (p(s), p(e), d(s), d(e)); + out.push([pt(ps.x + k * ds.x, ps.y + k * ds.y), pt(pe.x - k * de.x, pe.y - k * de.y), pe]); + } +} + +fn pie(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, a1: f64, inner: f64) -> SubPath { + let start = pt(cx + rx * a0.cos(), cy - ry * a0.sin()); + let mut segments = Vec::new(); + arc(cx, cy, rx, ry, a0, a1, &mut segments); + if inner > 0.0 { + let p = pt(cx + inner * rx * a1.cos(), cy - inner * ry * a1.sin()); + segments.push([p, p, p]); + arc(cx, cy, inner * rx, inner * ry, a1, a0, &mut segments); + } else { + let c = pt(cx, cy); + segments.push([c, c, c]); + } + SubPath { start, segments, closed: true } +} + +/// A polyline with circular corners of the given radii (Affinity's live corners on curves). +pub(crate) fn fillet(pts: &[(Point, f64)], closed: bool) -> SubPath { + let n = pts.len(); + let get = |i: isize| -> Option { + if closed { pts.get(i.rem_euclid(n as isize) as usize).map(|p| p.0) } else { usize::try_from(i).ok().and_then(|i| pts.get(i)).map(|p| p.0) } + }; + let mut corners: Vec<(Point, Point, Point, Point)> = Vec::with_capacity(n); + for (i, (p, radius)) in pts.iter().enumerate() { + let (prev, next) = (get(i as isize - 1), get(i as isize + 1)); + let (Some(a), Some(b)) = (prev, next) else { + corners.push((*p, *p, *p, *p)); + continue; + }; + let (u1, l1) = unit(*p, a); + let (u2, l2) = unit(*p, b); + let cos = (u1.x * u2.x + u1.y * u2.y).clamp(-1.0, 1.0); + let phi = cos.acos(); + if *radius <= 0.0 || phi < 1e-6 || (PI - phi) < 1e-6 { + corners.push((*p, *p, *p, *p)); + continue; + } + let t = (radius / (phi / 2.0).tan()).min(l1 / 2.0).min(l2 / 2.0); + let r = t * (phi / 2.0).tan(); + let k = 4.0 / 3.0 * ((PI - phi) / 4.0).tan() * r; + let s = pt(p.x + u1.x * t, p.y + u1.y * t); + let e = pt(p.x + u2.x * t, p.y + u2.y * t); + corners.push((s, pt(s.x - u1.x * k, s.y - u1.y * k), pt(e.x - u2.x * k, e.y - u2.y * k), e)); + } + let Some(first) = corners.first().copied() else { return SubPath { start: pt(0.0, 0.0), segments: vec![], closed } }; + let mut segments = Vec::with_capacity(2 * n); + let mut cur = first.0; + let start = cur; + for (i, (s, c1, c2, e)) in corners.iter().enumerate() { + if i > 0 { + segments.push([cur, *s, *s]); + } + if s != e { + segments.push([*c1, *c2, *e]); + } + cur = *e; + } + if closed && cur != start { + segments.push([cur, start, start]); + } + SubPath { start, segments, closed } +} + +fn unit(from: Point, to: Point) -> (Point, f64) { + let (dx, dy) = (to.x - from.x, to.y - from.y); + let l = (dx * dx + dy * dy).sqrt(); + if l > 0.0 { (pt(dx / l, dy / l), l) } else { (pt(0.0, 0.0), 0.0) } +} diff --git a/crates/affinity/src/stream.rs b/crates/affinity/src/stream.rs new file mode 100644 index 00000000..a755747c --- /dev/null +++ b/crates/affinity/src/stream.rs @@ -0,0 +1,402 @@ +//! The tagged object stream inside `doc.dat`: a self-describing tree of objects whose fields carry +//! a type code and a four-character tag. Parsing needs no schema; unknown fields are kept, so the +//! document mapping can name what it could not import. + +use crate::Error; +use crate::container::Cursor; + +/// A four-character tag in its readable order (`Desc`, `Chld`). +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Tag(pub u32); + +impl Tag { + pub const fn of(s: &[u8; 4]) -> Self { + Self(u32::from_be_bytes(*s)) + } +} + +impl std::fmt::Debug for Tag { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", String::from_utf8_lossy(&self.0.to_be_bytes())) + } +} + +impl std::fmt::Display for Tag { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Debug::fmt(self, f) + } +} + +/// Index of an object in [`Stream::objects`]. +pub type ObjId = usize; + +#[derive(Debug, Clone, PartialEq)] +pub enum Value { + Int(i64), + UInt(u64), + Float(f64), + Bool(bool), + Enum { + id: u16, + version: u16, + }, + Str(String), + /// Fixed-length vectors of i32/f32/f64 (points, rectangles, transforms). + Ints(Vec), + Floats(Vec), + /// Fixed-size record (curve nodes, ids) or fixed-size struct (colour components). + Bytes(Vec), + Blob(Vec), + /// Name of another archive entry (raster tiles, embedded files). + Entry(String), + Flags(u64), + /// `None` is a null object. + Obj(Option), + Array(Vec), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + Root, + /// Defined once and referenced by id elsewhere. + Shared, + Inline, + /// Untagged fields identified by position (curve data). + Positional, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Object { + pub class: Tag, + pub kind: Kind, + /// Class chain, most derived first (`ShpN`, `VNod`, `Node`). + pub chain: Vec, + /// Fields in stream order; positional fields have tag 0. + pub fields: Vec<(Tag, Value)>, +} + +impl Object { + pub fn get(&self, tag: Tag) -> Option<&Value> { + self.fields.iter().find(|(t, _)| *t == tag).map(|(_, v)| v) + } +} + +#[derive(Debug)] +pub struct Stream { + pub root: ObjId, + pub objects: Vec, +} + +/// Deepest object nesting accepted; real documents stay well below (each layer level adds three). +pub const MAX_DEPTH: usize = 384; + +const MAX_OBJECTS: usize = 8_000_000; + +/// Most values (fields and array elements) one stream may decode. A value takes about 40 bytes +/// in memory however few it took in the file (a bool array packs eight in a byte), so the size +/// of the input alone doesn't bound what parsing allocates: a small compressed entry could +/// otherwise ask for tens of gigabytes. Real documents stay far below. +pub const MAX_VALUES: usize = 1 << 24; + +struct Parser<'a> { + c: Cursor<'a>, + objects: Vec, + /// Shared ids, defined at first use and referenced backwards. + shared: std::collections::HashMap, + depth: usize, + /// Values decoded so far, against [`MAX_VALUES`]. + values: usize, +} + +/// Parse a stream (`00 FF 4B 53` header). Bytes after the root's terminator are ignored. +pub fn parse(bytes: &[u8]) -> Result { + let mut c = Cursor::new(bytes, 0); + if c.take(4)? != b"\x00\xffKS" { + return Err(Error::Malformed("document stream signature")); + } + let version = c.u16()?; + if version > 2 { + return Err(Error::Unsupported("document stream version")); + } + let class = Tag(c.u32()?); + c.u16()?; + if version == 2 { + c.u32()?; + } + let mut p = Parser { c, objects: Vec::new(), shared: std::collections::HashMap::new(), depth: 0, values: 0 }; + let root = p.push(Object { class, kind: Kind::Root, chain: vec![class], fields: Vec::new() })?; + let fields = p.fields(true, class)?; + if let Some(o) = p.objects.get_mut(root) { + o.fields = fields; + } + Ok(Stream { root, objects: p.objects }) +} + +impl Parser<'_> { + fn push(&mut self, o: Object) -> Result { + if self.objects.len() >= MAX_OBJECTS { + return Err(Error::Limit("too many objects")); + } + self.objects.push(o); + Ok(self.objects.len() - 1) + } + + /// Counts `n` more values against [`MAX_VALUES`], before anything is allocated for them. + fn spend(&mut self, n: usize) -> Result<(), Error> { + self.values = self.values.checked_add(n).filter(|&v| v <= MAX_VALUES).ok_or(Error::Limit("too many values in the document stream"))?; + Ok(()) + } + + /// An empty array with room for `n` values, paid for first; an allocation that still fails + /// is an error, not an abort. + fn vec(&mut self, n: usize) -> Result, Error> { + self.spend(n)?; + let mut v = Vec::new(); + v.try_reserve_exact(n).map_err(|_| Error::Limit("not enough memory for the document stream"))?; + Ok(v) + } + + /// Element count of an array: each element takes at least `min` bytes, so a count the + /// remaining input cannot hold is rejected before anything is allocated. + fn count(&mut self, min: usize) -> Result { + let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?; + if n.checked_mul(min.max(1)).is_none_or(|bytes| bytes > self.c.remaining()) { + return Err(Error::Malformed("array longer than its data")); + } + Ok(n) + } + + fn fields(&mut self, tagged: bool, class: Tag) -> Result, Error> { + self.depth += 1; + if self.depth > MAX_DEPTH { + return Err(Error::Limit("objects nested too deeply")); + } + let mut out = Vec::new(); + loop { + let t = self.c.u8()?; + let code = t & 0x7F; + if code == 0 { + break; + } + self.spend(1)?; + let tag = if tagged { Tag(self.c.u32()?) } else { Tag(0) }; + let v = if t & 0x80 != 0 { self.array(code, class)? } else { self.scalar(code, tagged, class)? }; + out.push((tag, v)); + } + self.depth -= 1; + Ok(out) + } + + fn string(&mut self) -> Result { + let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("string length"))?; + Ok(String::from_utf8_lossy(self.c.take(n)?).into_owned()) + } + + fn floats(&mut self, n: usize, wide: bool) -> Result { + let mut v = Vec::with_capacity(n); + for _ in 0..n { + v.push(if wide { f64::from_le_bytes(self.c.array()?) } else { f64::from(f32::from_le_bytes(self.c.array()?)) }); + } + Ok(Value::Floats(v)) + } + + fn scalar(&mut self, code: u8, tagged: bool, class: Tag) -> Result { + let c = &mut self.c; + Ok(match code { + 0x01 => Value::UInt(c.u8()?.into()), + 0x02 => Value::UInt(c.u16()?.into()), + 0x03 | 0x2f | 0x34 => Value::UInt(c.u32()?.into()), + 0x04 => Value::UInt(c.u64()?), + 0x05 => Value::Int(i8::from_le_bytes(c.array()?).into()), + 0x06 => Value::Int(i16::from_le_bytes(c.array()?).into()), + 0x07 => Value::Int(i32::from_le_bytes(c.array()?).into()), + 0x08 => Value::Int(i64::from_le_bytes(c.array()?)), + 0x09 => Value::Float(f32::from_le_bytes(c.array()?).into()), + 0x0a => Value::Float(f64::from_le_bytes(c.array()?)), + 0x15..=0x19 => { + let n = usize::from(code - 0x13); + let mut v = Vec::with_capacity(n); + for _ in 0..n { + v.push(i32::from_le_bytes(c.array()?).into()); + } + Value::Ints(v) + } + 0x1f..=0x23 => return self.floats(usize::from(code - 0x1d), false), + 0x24..=0x28 => return self.floats(usize::from(code - 0x22), true), + 0x29 => Value::Bool(c.u8()? != 0), + 0x2a => Value::Enum { id: c.u16()?, version: c.u16()? }, + 0x2b | 0x2e => Value::Str(self.string()?), + 0x2c => { + let n = usize::from(c.u16()?); + Value::Bytes(c.take(n)?.to_vec()) + } + 0x35..=0x74 => Value::Bytes(c.take(usize::from(code - 0x34))?.to_vec()), + 0x2d => { + let n = usize::try_from(c.u32()?).map_err(|_| Error::Malformed("blob length"))?; + Value::Blob(c.take(n)?.to_vec()) + } + 0x33 => { + c.u32()?; + let n = usize::try_from(c.u32()?).map_err(|_| Error::Malformed("entry name length"))?; + Value::Entry(String::from_utf8_lossy(c.take(n)?).into_owned()) + } + 0x75 => { + c.u16()?; + let n = usize::from(c.u8()?); + if n > 8 { + return Err(Error::Malformed("flags wider than 64 bits")); + } + let mut b = [0u8; 8]; + b.get_mut(..n).ok_or(Error::Malformed("flags"))?.copy_from_slice(c.take(n)?); + Value::Flags(u64::from_le_bytes(b)) + } + 0x30 => { + let fields = self.fields(false, class)?; + let id = self.push(Object { class, kind: Kind::Positional, chain: Vec::new(), fields })?; + Value::Obj(Some(id)) + } + 0x31 | 0x32 if !tagged => return Err(Error::Malformed("object without a field tag")), + 0x31 => self.shared()?, + 0x32 => self.inline(None)?, + _ => return Err(Error::Unsupported("unknown field type in document stream")), + }) + } + + fn array(&mut self, code: u8, class: Tag) -> Result { + let min = match code { + 0x01 | 0x05 | 0x29 => 1, + 0x02 | 0x06 => 2, + 0x03 | 0x07 | 0x09 | 0x2f | 0x34 => 4, + 0x04 | 0x08 | 0x0a => 8, + 0x15..=0x19 => 4 * usize::from(code - 0x13), + 0x1f..=0x23 => 4 * usize::from(code - 0x1d), + 0x24..=0x28 => 8 * usize::from(code - 0x22), + 0x35..=0x74 => usize::from(code - 0x34), + _ => 1, + }; + match code { + 0x29 => { + let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?; + let bits = self.c.take(n.div_ceil(8))?; + let mut v = self.vec(n)?; + v.extend((0..n).map(|i| Value::Bool(bits.get(i / 8).is_some_and(|b| b >> (i % 8) & 1 != 0)))); + Ok(Value::Array(v)) + } + 0x2a => { + let n = self.count(2)?; + let version = self.c.u16()?; + let mut v = self.vec(n)?; + for _ in 0..n { + v.push(Value::Enum { id: self.c.u16()?, version }); + } + Ok(Value::Array(v)) + } + 0x2b | 0x2e => { + self.c.u32()?; + let n = self.count(4)?; + let mut v = self.vec(n)?; + for _ in 0..n { + v.push(Value::Str(self.string()?)); + } + Ok(Value::Array(v)) + } + 0x2c => { + let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?; + let size = usize::from(self.c.u16()?); + if n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) { + return Err(Error::Malformed("array longer than its data")); + } + let mut v = self.vec(n)?; + for _ in 0..n { + v.push(Value::Bytes(self.c.take(size)?.to_vec())); + } + Ok(Value::Array(v)) + } + 0x32 => { + let n = self.count(1)?; + let class = Tag(self.c.u32()?); + self.c.u16()?; + let mut v = self.vec(n)?; + for _ in 0..n { + v.push(self.inline(Some(class))?); + } + Ok(Value::Array(v)) + } + 0x2d | 0x33 | 0x75 => Err(Error::Malformed("array of an unarrayable type")), + _ => { + let n = self.count(min)?; + let mut v = self.vec(n)?; + for _ in 0..n { + v.push(self.scalar(code, true, class)?); + } + Ok(Value::Array(v)) + } + } + } + + /// Inline object; `class` is given once for a whole array of them. + fn inline(&mut self, class: Option) -> Result { + match self.c.u8()? { + 0 => return Ok(Value::Obj(None)), + 1 => {} + _ => return Err(Error::Malformed("object marker")), + } + let class = match class { + Some(c) => c, + None => { + let c = Tag(self.c.u32()?); + self.c.u16()?; + c + } + }; + let fields = self.fields(true, class)?; + let id = self.push(Object { class, kind: Kind::Inline, chain: vec![class], fields })?; + Ok(Value::Obj(Some(id))) + } + + fn shared(&mut self) -> Result { + let flag = self.c.u8()?; + if flag == 0 { + return Ok(Value::Obj(None)); + } + let key = self.c.u32()?; + match flag { + 2 => return self.shared.get(&key).map(|id| Value::Obj(Some(*id))).ok_or(Error::Malformed("reference to an undefined object")), + 1 => {} + _ => return Err(Error::Malformed("object marker")), + } + if self.shared.contains_key(&key) { + return Err(Error::Malformed("object defined twice")); + } + // Register before the fields: an object's own fields may refer back to it. + let id = self.push(Object { class: Tag(0), kind: Kind::Shared, chain: Vec::new(), fields: Vec::new() })?; + self.shared.insert(key, id); + let mut chain = Vec::new(); + let mut fields = Vec::new(); + loop { + match self.c.u8()? { + 0 => { + let tag = Tag(self.c.u32()?); + self.c.u16()?; + chain.push(tag); + fields.extend(self.fields(true, tag)?); + } + 1 => { + chain.push(Tag(self.c.u32()?)); + break; + } + 2 => break, + _ => return Err(Error::Malformed("object class chain")), + } + if chain.len() > 64 { + return Err(Error::Limit("object class chain too long")); + } + } + let class = chain.first().copied().unwrap_or(Tag(0)); + fields.extend(self.fields(true, class)?); + if let Some(o) = self.objects.get_mut(id) { + *o = Object { class, kind: Kind::Shared, chain, fields }; + } + Ok(Value::Obj(Some(id))) + } +} diff --git a/crates/affinity/src/synth.rs b/crates/affinity/src/synth.rs new file mode 100644 index 00000000..5b75ca0b --- /dev/null +++ b/crates/affinity/src/synth.rs @@ -0,0 +1,259 @@ +//! Synthetic containers for tests and fuzz seeds (cargo feature `synth`). +//! +//! These builders write the layout the reader accepts, so tests can describe documents in a few +//! lines. They are **not** an Affinity exporter: nothing written here has been opened in Affinity. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use crate::stream::Tag; + +/// A field value to encode. +#[derive(Debug, Clone)] +pub enum F { + U8(u8), + U32(u32), + I32(i32), + F32(f32), + F64(f64), + Bool(bool), + Enum(u16, u16), + Str(String), + /// f64 vector of 2..6 values (points, rectangles, transforms). + F64s(Vec), + /// Fixed-size struct (colour components), 1..64 bytes. + Struct(Vec), + /// Array of fixed-size records, all `size` bytes. + Records(u16, Vec>), + U8s(Vec), + Entry(String), + /// Shared object: `Def` defines id with class chain and fields, `Ref` links back. + Def(u32, Vec, Vec<(Tag, F)>), + Ref(u32), + Null, + /// Inline object. + Obj(Tag, Vec<(Tag, F)>), + /// Positional (untagged) object. + Pos(Vec), + /// Array of shared objects / links. + Shared(Vec), + /// Array of inline objects of one class. + Objs(Tag, Vec>), + /// Raw bytes appended verbatim (malformed-input tests). + Raw(Vec), +} + +pub fn tag(s: &[u8; 4]) -> Tag { + Tag::of(s) +} + +fn put_tag(out: &mut Vec, t: Tag) { + out.extend(t.0.to_le_bytes()); +} + +fn code(f: &F) -> u8 { + match f { + F::U8(_) => 0x01, + F::U32(_) => 0x03, + F::I32(_) => 0x07, + F::F32(_) => 0x09, + F::F64(_) => 0x0a, + F::Bool(_) => 0x29, + F::Enum(..) => 0x2a, + F::Str(_) => 0x2b, + F::F64s(v) => 0x22 + v.len() as u8, + F::Struct(b) => 0x34 + b.len() as u8, + F::Records(..) => 0x80 | 0x2c, + F::U8s(_) => 0x80 | 0x01, + F::Entry(_) => 0x33, + F::Def(..) | F::Ref(_) => 0x31, + F::Null => 0x31, + F::Obj(..) => 0x32, + F::Pos(_) => 0x30, + F::Shared(_) => 0x80 | 0x31, + F::Objs(..) => 0x80 | 0x32, + F::Raw(_) => 0, + } +} + +fn payload(out: &mut Vec, f: &F) { + match f { + F::U8(v) => out.push(*v), + F::U32(v) => out.extend(v.to_le_bytes()), + F::I32(v) => out.extend(v.to_le_bytes()), + F::F32(v) => out.extend(v.to_le_bytes()), + F::F64(v) => out.extend(v.to_le_bytes()), + F::Bool(v) => out.push(u8::from(*v)), + F::Enum(id, ver) => { + out.extend(id.to_le_bytes()); + out.extend(ver.to_le_bytes()); + } + F::Str(s) => { + out.extend((s.len() as u32).to_le_bytes()); + out.extend(s.as_bytes()); + } + F::F64s(v) => v.iter().for_each(|x| out.extend(x.to_le_bytes())), + F::Struct(b) => out.extend(b), + F::Records(size, recs) => { + out.extend((recs.len() as u32).to_le_bytes()); + out.extend(size.to_le_bytes()); + recs.iter().for_each(|r| out.extend(r)); + } + F::U8s(v) => { + out.extend((v.len() as u32).to_le_bytes()); + out.extend(v); + } + F::Entry(name) => { + put_tag(out, tag(b"Data")); + out.extend((name.len() as u32).to_le_bytes()); + out.extend(name.as_bytes()); + } + F::Def(id, chain, fields) => { + out.push(1); + out.extend(id.to_le_bytes()); + // Class chain: each level but the last carries no fields here; the last ends the chain. + let (last, rest) = chain.split_last().expect("class chain"); + for t in rest { + out.push(0); + put_tag(out, *t); + out.extend(0u16.to_le_bytes()); + out.push(0); + } + out.push(1); + put_tag(out, *last); + fields_into(out, fields, true); + } + F::Ref(id) => { + out.push(2); + out.extend(id.to_le_bytes()); + } + F::Null => out.push(0), + F::Obj(class, fields) => { + out.push(1); + put_tag(out, *class); + out.extend(0u16.to_le_bytes()); + fields_into(out, fields, true); + } + F::Pos(items) => { + for f in items { + out.push(code(f)); + payload(out, f); + } + out.push(0); + } + F::Shared(items) => { + out.extend((items.len() as u32).to_le_bytes()); + items.iter().for_each(|f| payload(out, f)); + } + F::Objs(class, items) => { + out.extend((items.len() as u32).to_le_bytes()); + put_tag(out, *class); + out.extend(0u16.to_le_bytes()); + for fields in items { + out.push(1); + fields_into(out, fields, true); + } + } + F::Raw(b) => out.extend(b), + } +} + +fn fields_into(out: &mut Vec, fields: &[(Tag, F)], tagged: bool) { + for (t, f) in fields { + if let F::Raw(b) = f { + out.extend(b); + continue; + } + out.push(code(f)); + if tagged { + put_tag(out, *t); + } + payload(out, f); + } + out.push(0); +} + +/// A `doc.dat` stream with root class `Pers`. +pub fn stream(root: &[(Tag, F)]) -> Vec { + let mut out = b"\x00\xffKS".to_vec(); + out.extend(2u16.to_le_bytes()); + put_tag(&mut out, tag(b"Pers")); + out.extend(0u16.to_le_bytes()); + out.extend(30u32.to_le_bytes()); + fields_into(&mut out, root, true); + out +} + +/// How an entry is stored. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Method { + Stored, + Zlib, + Zstd, +} + +/// A version-12 container with one `#FT4` table holding `entries`, then an optional thumbnail record. +pub fn container(entries: &[(&str, &[u8], Method)], thumbnail_png: Option<&[u8]>) -> Vec { + let mut out = vec![0u8; 72]; + out[..4].copy_from_slice(crate::container::MAGIC); + out[4..6].copy_from_slice(&12u16.to_le_bytes()); + out[6..8].copy_from_slice(&4u16.to_le_bytes()); + out[8..12].copy_from_slice(b"nsrP"); + out[12..16].copy_from_slice(b"#Inf"); + out[64..68].copy_from_slice(b"Prot"); + let mut records = Vec::new(); + for (i, (name, data, method)) in entries.iter().enumerate() { + let packed = match method { + Method::Stored => data.to_vec(), + Method::Zlib => { + use std::io::Write; + let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + e.write_all(data).unwrap(); + e.finish().unwrap() + } + Method::Zstd => ruzstd::encoding::compress_to_vec(*data, ruzstd::encoding::CompressionLevel::Fastest), + }; + let offset = out.len() as u64; + out.extend(b"#Fil"); + out.extend(&packed); + let mut r = Vec::new(); + r.extend((i as u32 + 1).to_le_bytes()); + r.push(0); + r.extend(offset.to_le_bytes()); + r.extend((data.len() as u64).to_le_bytes()); + r.extend((packed.len() as u64).to_le_bytes()); + r.extend(crc32fast::hash(data).to_le_bytes()); + r.push(match method { + Method::Stored => 0, + Method::Zlib => 1, + Method::Zstd => 2, + }); + r.extend(0u32.to_le_bytes()); + r.extend(0u32.to_le_bytes()); + r.extend((name.len() as u16).to_le_bytes()); + r.extend(name.as_bytes()); + records.push(r); + } + let fat = out.len() as u64; + out.extend(b"#FT4"); + out.extend(0u64.to_le_bytes()); + out.extend(1_700_000_000u64.to_le_bytes()); + out.extend([0u8; 24]); + out.extend((records.len() as u32).to_le_bytes()); + out.extend([0u8; 8]); + out.extend(0u16.to_le_bytes()); + out.push(0); + records.iter().for_each(|r| out.extend(r)); + let thumb = out.len() as u64; + out[16..24].copy_from_slice(&fat.to_le_bytes()); + if let Some(png) = thumbnail_png { + out[24..32].copy_from_slice(&thumb.to_le_bytes()); + out.extend(b"\xff\xff\xff\xffThmb"); + out.extend(1u32.to_le_bytes()); + out.extend((png.len() as u32 + 13).to_le_bytes()); + out.extend(29u32.to_le_bytes()); + out.extend(0u32.to_le_bytes()); + out.extend((png.len() as u32).to_le_bytes()); + out.push(1); + out.extend(png); + } + out +} diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs new file mode 100644 index 00000000..f70648b4 --- /dev/null +++ b/crates/affinity/src/tests.rs @@ -0,0 +1,351 @@ +//! Unit tests on synthetic inputs; `tests/real_files.rs` covers public Affinity documents. + +mod preview { + use crate::container::header; + use crate::*; + use proptest::prelude::*; + + fn fixture() -> Vec { + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + png.extend(13u32.to_be_bytes()); + png.extend(b"IHDR"); + png.extend(2u32.to_be_bytes()); + png.extend(1u32.to_be_bytes()); + png.extend([8, 6, 0, 0, 0]); + png.extend(crc32fast::hash(&png[12..]).to_be_bytes()); + png.extend([0; 4]); + png.extend(b"IDAT"); + png.extend(crc32fast::hash(b"IDAT").to_be_bytes()); + png.extend([0; 4]); + png.extend(b"IEND"); + png.extend(crc32fast::hash(b"IEND").to_be_bytes()); + let mut b = vec![0; 72]; + b[..4].copy_from_slice(MAGIC); + b[4..6].copy_from_slice(&12u16.to_le_bytes()); + b[8..12].copy_from_slice(b"nsrP"); + b[12..16].copy_from_slice(b"#Inf"); + b[24..32].copy_from_slice(&72u64.to_le_bytes()); + b[64..68].copy_from_slice(b"Prot"); + b.extend(b"\xff\xff\xff\xffThmb"); + b.extend(1u32.to_le_bytes()); + b.extend((png.len() as u32 + 13).to_le_bytes()); + b.extend(29u32.to_le_bytes()); + b.extend(0u32.to_le_bytes()); + b.extend((png.len() as u32).to_le_bytes()); + b.push(1); + b.extend(png); + b + } + + #[test] + fn indexed_preview_and_header() { + let b = fixture(); + assert_eq!(header(&b).unwrap().version, 12); + let p = preview(&b).unwrap(); + assert_eq!((p.width, p.height), (2, 1)); + assert_eq!(p.png, &b[101..]); + } + + #[test] + fn every_truncation_is_rejected() { + let b = fixture(); + for end in 0..b.len() { + assert!(preview(&b[..end]).is_err(), "{end}"); + } + } + + #[test] + fn rejects_assets_versions_offsets_lengths_and_bombs() { + let b = fixture(); + for (offset, value) in [ + (4, 13u64.to_le_bytes().to_vec()), + (8, b"urBR".to_vec()), + (24, u64::MAX.to_le_bytes().to_vec()), + (24, 1u64.to_le_bytes().to_vec()), + (24, 0u64.to_le_bytes().to_vec()), + (84, u32::MAX.to_le_bytes().to_vec()), + (88, 30u32.to_le_bytes().to_vec()), + (96, u32::MAX.to_le_bytes().to_vec()), + (117, 5000u32.to_be_bytes().to_vec()), + ] { + let mut broken = b.clone(); + broken[offset..offset + value.len()].copy_from_slice(&value); + assert!(preview(&broken).is_err(), "offset {offset}"); + } + // Versions 8 to 11 store the same record (checked on public Affinity 1 and 2 files). + for version in 8..12 { + let mut legacy = b.clone(); + legacy[4..6].copy_from_slice(&(version as u16).to_le_bytes()); + assert!(preview(&legacy).is_ok()); + } + // Flags 4 and 8 occur in real files; the low two bits mark other container variants. + for (flags, ok) in [(4u16, true), (8, true), (1, false), (2, false)] { + let mut variant = b.clone(); + variant[6..8].copy_from_slice(&flags.to_le_bytes()); + assert_eq!(preview(&variant).is_ok(), ok, "flags {flags}"); + } + let mut old = b.clone(); + old[4..6].copy_from_slice(&7u16.to_le_bytes()); + assert!(matches!(preview(&old), Err(Error::Unsupported(_)))); + } + + #[test] + fn an_unindexed_png_or_resource_is_never_used() { + let mut b = fixture(); + b[24..32].copy_from_slice(&0u64.to_le_bytes()); + assert!(preview(&b).is_err()); + b[24..32].copy_from_slice(&72u64.to_le_bytes()); + b[76..80].copy_from_slice(b"Meta"); + assert!(preview(&b).is_err()); + } + + #[test] + fn every_chunk_checksum_including_the_end_is_checked() { + let b = fixture(); + for at in [130, b.len() - 1] { + let mut damaged = b.clone(); + damaged[at] ^= 1; + assert_eq!(preview(&damaged).unwrap_err(), Error::Malformed("PNG chunk checksum")); + } + let mut ancillary = vec![0; 4]; + ancillary.extend(b"tEXt"); + ancillary.extend(crc32fast::hash(b"tEXt").to_be_bytes()); + let mut with_ancillary = b.clone(); + let at = b.len() - 12; + with_ancillary.splice(at..at, ancillary); + let png_length = (with_ancillary.len() - 101) as u32; + with_ancillary[84..88].copy_from_slice(&(png_length + 13).to_le_bytes()); + with_ancillary[96..100].copy_from_slice(&png_length.to_le_bytes()); + assert!(preview(&with_ancillary).is_ok()); + with_ancillary[at + 8] ^= 1; + assert_eq!(preview(&with_ancillary).unwrap_err(), Error::Malformed("PNG chunk checksum")); + } + + #[test] + fn compressed_metadata_and_animation_are_not_preview_support() { + for kind in [b"zTXt", b"iTXt", b"iCCP", b"acTL", b"fcTL", b"fdAT"] { + let mut extra = vec![0; 4]; + extra.extend(kind); + extra.extend(crc32fast::hash(kind).to_be_bytes()); + let mut b = fixture(); + let at = b.len() - 12; + b.splice(at..at, extra); + let length = (b.len() - 101) as u32; + b[84..88].copy_from_slice(&(length + 13).to_le_bytes()); + b[96..100].copy_from_slice(&length.to_le_bytes()); + assert!(matches!(preview(&b), Err(Error::Unsupported(_)))); + } + } + + #[test] + fn critical_chunks_follow_a_single_complete_png_layout() { + fn with_chunks(kinds: &[&[u8; 4]]) -> Vec { + let original = fixture(); + let mut b = original[..134].to_vec(); // File, record and complete first IHDR. + for kind in kinds { + b.extend([0; 4]); + b.extend(*kind); + b.extend(crc32fast::hash(*kind).to_be_bytes()); + } + let length = (b.len() - 101) as u32; + b[84..88].copy_from_slice(&(length + 13).to_le_bytes()); + b[96..100].copy_from_slice(&length.to_le_bytes()); + b + } + assert!(preview(&with_chunks(&[b"IDAT", b"IDAT", b"IEND"])).is_ok()); + // PNG readers must not reject an otherwise valid unknown ancillary chunk just + // because its reserved (third-letter) bit is set; future versions can define it. + assert!(preview(&with_chunks(&[b"abct", b"IDAT", b"IEND"])).is_ok()); + for kinds in [ + vec![b"IEND"], + vec![b"IDAT", b"IHDR", b"IEND"], + vec![b"PLTE", b"PLTE", b"IDAT", b"IEND"], + vec![b"IDAT", b"PLTE", b"IEND"], + vec![b"IDAT", b"tEXt", b"IDAT", b"IEND"], + vec![b"ABCD", b"IDAT", b"IEND"], + vec![b"IDAT", b"IEND", b"tEXt"], + ] { + assert!(preview(&with_chunks(&kinds)).is_err(), "{kinds:?}"); + } + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + #[test] + fn arbitrary_input_never_panics(b in prop::collection::vec(any::(), 0..2048)) { + let _ = header(&b); + let _ = preview(&b); + } + #[test] + fn mutations_never_panic(edits in prop::collection::vec((0usize..200, any::()), 0..32)) { + let mut b = fixture(); + for (i, value) in edits { if let Some(v) = b.get_mut(i) { *v = value; } } + let _ = preview(&b); + } + } +} + +mod archive { + use crate::Error; + use crate::container::{Archive, Limits}; + use crate::stream::{self, Kind, Tag, Value}; + use crate::synth::{self, F, Method, tag}; + use proptest::prelude::*; + + fn doc() -> Vec { + synth::stream(&[ + (tag(b"Desc"), F::Str("Document".into())), + (tag(b"Visi"), F::Bool(true)), + (tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 5.0, 0.0, 1.0, -2.0])), + (tag(b"Colr"), F::Def(7, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct([0u8; 16].to_vec()))])), + (tag(b"Same"), F::Ref(7)), + (tag(b"Null"), F::Null), + (tag(b"Kids"), F::Shared(vec![F::Def(8, vec![tag(b"Grup"), tag(b"Node")], vec![(tag(b"Desc"), F::Str("g".into()))]), F::Ref(8)])), + (tag(b"Crvs"), F::Obj(tag(b"PCvD"), vec![(tag(b"Data"), F::Pos(vec![F::U8(0), F::U32(1), F::Bool(true), F::Records(18, vec![vec![0; 18]; 4])]))])), + (tag(b"Enum"), F::Enum(2, 1)), + (tag(b"Tile"), F::Entry("d/3".into())), + (tag(b"Stat"), F::U8s(vec![4, 0, 2])), + ]) + } + + #[test] + fn every_method_round_trips_with_checksums() { + let d = doc(); + for method in [Method::Stored, Method::Zlib, Method::Zstd] { + let file = synth::container(&[("doc.dat", &d, method), ("d/3", b"tile", method)], None); + let mut a = Archive::open(&file, Limits::default()).unwrap(); + assert_eq!(a.header.version, 12); + assert_eq!(a.read("doc.dat").unwrap(), d); + assert_eq!(a.read("d/3").unwrap(), b"tile"); + assert_eq!(a.extracted(), d.len() + 4); + assert!(a.read("missing").is_err()); + } + } + + #[test] + fn the_stream_parses_into_shared_objects_and_links() { + let s = stream::parse(&doc()).unwrap(); + let root = &s.objects[s.root]; + assert_eq!(root.class, Tag::of(b"Pers")); + assert_eq!(root.get(Tag::of(b"Desc")), Some(&Value::Str("Document".into()))); + assert_eq!(root.get(Tag::of(b"Xfrm")), Some(&Value::Floats(vec![1.0, 0.0, 5.0, 0.0, 1.0, -2.0]))); + let (Some(Value::Obj(Some(a))), Some(Value::Obj(Some(b)))) = (root.get(Tag::of(b"Colr")), root.get(Tag::of(b"Same"))) else { panic!() }; + assert_eq!(a, b); + assert_eq!(s.objects[*a].class, Tag::of(b"RGBA")); + assert_eq!(s.objects[*a].kind, Kind::Shared); + let Some(Value::Array(kids)) = root.get(Tag::of(b"Kids")) else { panic!() }; + assert_eq!(kids[0], kids[1]); + let Value::Obj(Some(g)) = kids[0] else { panic!() }; + assert_eq!(s.objects[g].chain, vec![Tag::of(b"Grup"), Tag::of(b"Node")]); + assert_eq!(root.get(Tag::of(b"Enum")), Some(&Value::Enum { id: 2, version: 1 })); + assert_eq!(root.get(Tag::of(b"Tile")), Some(&Value::Entry("d/3".into()))); + assert_eq!(root.get(Tag::of(b"Null")), Some(&Value::Obj(None))); + } + + #[test] + fn checksums_sizes_and_budgets_are_enforced() { + let d = doc(); + let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None); + // Flip a byte inside the compressed payload, past the "#Fil" tag at offset 72. + let mut broken = file.clone(); + broken[90] ^= 0x55; + let mut a = Archive::open(&broken, Limits::default()).unwrap(); + assert!(a.read("doc.dat").is_err()); + let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX }).unwrap(); + assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_)))); + let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1 }).unwrap(); + a.read("doc.dat").unwrap(); + assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_)))); + } + + #[test] + fn a_declared_size_smaller_than_the_data_is_rejected_not_truncated() { + let big = vec![7u8; 1 << 20]; + let mut file = synth::container(&[("doc.dat", &big, Method::Zlib)], None); + // Rewrite the declared size in the table to 1 KiB: decoding must stop and fail. + let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()) as usize; + let size_at = fat + 59 + 5 + 8; + file[size_at..size_at + 8].copy_from_slice(&1024u64.to_le_bytes()); + let mut a = Archive::open(&file, Limits::default()).unwrap(); + assert_eq!(a.read("doc.dat").unwrap_err(), Error::Malformed("archive entry size mismatch")); + } + + #[test] + fn libraries_and_cyclic_tables_are_rejected() { + let d = doc(); + let mut file = synth::container(&[("doc.dat", &d, Method::Stored)], None); + let mut library = file.clone(); + library[8..12].copy_from_slice(b"rAsA"); + assert!(matches!(Archive::open(&library, Limits::default()), Err(Error::Unsupported(_)))); + let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()); + let at = fat as usize + 4; + file[at..at + 8].copy_from_slice(&fat.to_le_bytes()); + assert_eq!(Archive::open(&file, Limits::default()).unwrap_err(), Error::Malformed("allocation tables form a cycle")); + } + + #[test] + fn hostile_streams_fail_cleanly() { + let deep = (0..2000).fold(Vec::new(), |inner, _| vec![(tag(b"Chld"), F::Obj(tag(b"Grup"), inner))]); + assert_eq!(stream::parse(&synth::stream(&deep)).unwrap_err(), Error::Limit("objects nested too deeply")); + // An array claiming four billion elements in a few bytes. + let huge = synth::stream(&[(tag(b"Big_"), F::Raw(vec![0x81, b'_', b'g', b'i', b'B', 0xff, 0xff, 0xff, 0xff, 0]))]); + assert_eq!(stream::parse(&huge).unwrap_err(), Error::Malformed("array longer than its data")); + let forward = synth::stream(&[(tag(b"Link"), F::Ref(99))]); + assert!(stream::parse(&forward).is_err()); + let twice = synth::stream(&[(tag(b"A___"), F::Def(1, vec![tag(b"RGBA")], vec![])), (tag(b"B___"), F::Def(1, vec![tag(b"RGBA")], vec![]))]); + assert!(stream::parse(&twice).is_err()); + let unknown = synth::stream(&[(tag(b"X___"), F::Raw(vec![0x0b, 1, 2, 3, 4]))]); + assert!(matches!(stream::parse(&unknown), Err(Error::Unsupported(_)))); + let d = doc(); + for end in 0..d.len() { + assert!(stream::parse(&d[..end]).is_err(), "{end}"); + } + } + + #[test] + fn a_small_compressed_entry_cannot_make_the_stream_allocate_gigabytes() { + // Each decoded value takes about 40 bytes, and a bool array packs eight in a byte: 16 MiB + // of zero bits, a few KiB once compressed, would ask for 2^27 values (about 4 GiB). An + // array of one-byte values just past the budget fails the same way, before allocating. + let field = |code: u8, n: u32, data: usize| { + let mut f = vec![0x80 | code]; + f.extend(tag(b"Big_").0.to_le_bytes()); + f.extend(n.to_le_bytes()); + f.resize(f.len() + data, 0); + F::Raw(f) + }; + let bools = 1u32 << 27; + let bytes = u32::try_from(stream::MAX_VALUES).unwrap() + 1; + for f in [field(0x29, bools, (bools / 8) as usize), field(0x01, bytes, bytes as usize)] { + let d = synth::stream(&[(tag(b"Big_"), f)]); + let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None); + assert!(file.len() < d.len() / 100, "{} of {}", file.len(), d.len()); + let entry = Archive::open(&file, Limits::default()).unwrap().read("doc.dat").unwrap(); + assert_eq!(stream::parse(&entry).unwrap_err(), Error::Limit("too many values in the document stream")); + } + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(512))] + #[test] + fn mutated_containers_and_streams_never_panic(edits in prop::collection::vec((0usize..4096, any::()), 1..24)) { + let d = doc(); + for method in [Method::Stored, Method::Zlib, Method::Zstd] { + let mut file = synth::container(&[("doc.dat", &d, method)], None); + for (i, v) in &edits { + let n = file.len(); + file[i % n] = *v; + } + if let Ok(bytes) = Archive::open(&file, Limits::default()).and_then(|mut a| a.read("doc.dat")) { + let _ = stream::parse(&bytes); + } + } + let mut s = d.clone(); + for (i, v) in &edits { + let n = s.len(); + s[i % n] = *v; + } + let _ = stream::parse(&s); + } + } +} diff --git a/crates/affinity/src/text.rs b/crates/affinity/src/text.rs new file mode 100644 index 00000000..c34e4e43 --- /dev/null +++ b/crates/affinity/src/text.rs @@ -0,0 +1,147 @@ +//! Artistic and frame text: the story's characters with their font, size, colour, tracking and +//! leading, the paragraph alignment and the first baseline. Layout (line breaks in frames, kerning, +//! OpenType features) is left to the importing application. + +use crate::model::{Affine, Align, Point, Reader, Text, TextRun, rect}; +use crate::paint::{self, Paint}; +use crate::stream::{ObjId, Value}; + +/// Characters read per text node (stories are split into blocks; this caps hostile ones). +const MAX_CHARS: usize = 1 << 20; + +pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option { + let s = r.s; + let story = s.obj(id, b"StSt")?; + let frame = s.obj(id, b"TxtH")?; + let artistic = s.is(frame, b"ArFr"); + let bounds = s.floats::<4>(frame, b"FrmB").map(rect)?; + let mut runs = Vec::new(); + let mut align = None; + let mut total = 0usize; + let mut placeholders = false; + for block in s.objs(story, b"Blok") { + let glyphs = s.obj(block, b"Glyp")?; + let chars: Vec = match s.str(glyphs, b"Utf8") { + Some(t) => t.chars().collect(), + None => { + // Inline objects (page numbers, anchors) each take one index before the segment text. + let mut v = Vec::new(); + for seg in s.objs(glyphs, b"Mixd") { + let inline = s.objs(seg, b"Glys").len(); + v.extend(std::iter::repeat_n('\u{FFFC}', inline)); + placeholders |= inline > 0; + v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars()); + } + v + } + }; + total = total.checked_add(chars.len())?; + if total > MAX_CHARS { + r.warn("text longer than a million characters (truncated)"); + break; + } + let mut start = 0usize; + for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() { + let end = usize::try_from(s.int(run, b"Indx")?).ok()?.min(chars.len()); + if end <= start { + continue; + } + let text: String = chars.get(start..end)?.iter().filter(|c| **c != '\0' && **c != '\u{FFFC}').collect(); + start = end; + let Some(attrs) = s.obj(run, b"Item") else { continue }; + if !text.is_empty() { + runs.push(run_attrs(r, attrs, text, world)); + } + } + if align.is_none() { + align = s.obj(block, b"PAtt").and_then(|p| s.objs(p, b"Runs").first().copied()).and_then(|run| s.obj(run, b"Item")).map(|p| paragraph_align(r, p)); + } + } + if placeholders { + r.warn("text fields such as page numbers (left out of the text)"); + } + let align = align.unwrap_or(Align::Left); + let first_baseline = bounds.y0 + s.f64(frame, b"ArtV").unwrap_or(0.0); + let anchor_x = match align { + Align::Center => (bounds.x0 + bounds.x1) / 2.0, + Align::Right => bounds.x1, + _ => bounds.x0, + }; + if !artistic { + if s.objs(frame, b"ColW").len() > 1 || matches!(s.field(frame, b"ColW"), Some(Value::Array(a)) if a.len() > 1) { + r.warn("text frames with several columns (imported as one column)"); + } + if s.class(id) == Some(crate::stream::Tag::of(b"TxtC")) { + r.warn("text frames with a curved outline (imported as rectangular frames)"); + } + } + Some(Text { runs, align, anchor: Point { x: anchor_x, y: first_baseline }, frame: if artistic { None } else { Some(bounds) }, transform: world }) +} + +fn slot(v: Option<&Value>, i: usize, f: impl Fn(&Value) -> Option) -> Option { + match v? { + Value::Array(a) => a.get(i).and_then(f), + _ => None, + } +} + +fn float(v: &Value) -> Option { + match v { + Value::Float(f) if f.is_finite() => Some(*f), + _ => None, + } +} + +fn int(v: &Value) -> Option { + match v { + Value::Int(i) => Some(*i), + Value::UInt(u) => i64::try_from(*u).ok(), + _ => None, + } +} + +fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine) -> TextRun { + let s = r.s; + let doubles = s.field(attrs, b"Doub"); + let ints = s.field(attrs, b"Ints"); + let size = slot(doubles, 0, float).filter(|v| *v > 0.0 && *v < 1e6).unwrap_or(12.0 * r.dpi / 72.0); + let tracking = slot(doubles, 4, float).unwrap_or(0.0); + let leading = (slot(ints, 4, int) == Some(1)).then(|| slot(doubles, 13, float)).flatten().filter(|v| *v > 0.0); + let objects = match s.field(attrs, b"Objs") { + Some(Value::Array(a)) => a.iter().map(|v| if let Value::Obj(o) = v { *o } else { None }).collect(), + _ => Vec::new(), + }; + let fill = objects.first().copied().flatten().and_then(|f| paint::descriptor(r, f, world)).unwrap_or(Paint::None); + if objects.get(1).copied().flatten().and_then(|f| paint::descriptor(r, f, world)).is_some_and(|p| p != Paint::None) { + r.warn("outlined (stroked) text"); + } + if slot(doubles, 10, float).is_some_and(|h| (h - 1.0).abs() > 1e-3) { + r.warn("horizontally scaled text"); + } + let font = s.obj(attrs, b"DFnt"); + let get = |tag: &[u8; 4]| font.and_then(|f| s.str(f, tag)).unwrap_or_default().to_string(); + TextRun { + text, + postscript: get(b"Post"), + family: get(b"Famy"), + weight: font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400), + italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false), + size, + tracking, + leading, + fill, + } +} + +fn paragraph_align(r: &mut Reader, p: ObjId) -> Align { + match slot(r.s.field(p, b"Ints"), 0, int) { + Some(1) => Align::Center, + Some(2) => Align::Right, + Some(3) => Align::Justify, + Some(0) | None => Align::Left, + Some(_) => { + r.warn("an unknown paragraph alignment (imported as left)"); + Align::Left + } + } +} diff --git a/crates/affinity/tests/real_files.rs b/crates/affinity/tests/real_files.rs new file mode 100644 index 00000000..302c75ab --- /dev/null +++ b/crates/affinity/tests/real_files.rs @@ -0,0 +1,83 @@ +//! Public Affinity documents: the pinned set `cargo xtask corpus --affinity` fetches into +//! `corpus/affinity`, or any folder of `.af`/`.afdesign`/`.afphoto`/`.afpub` files named by +//! `AFFINITY_SAMPLES` (searched recursively). Without either, these tests do nothing. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::path::{Path, PathBuf}; + +use photocraft_affinity::{Archive, Limits, stream}; + +fn files(dir: &Path, out: &mut Vec) { + for e in std::fs::read_dir(dir).into_iter().flatten().flatten() { + let p = e.path(); + if p.is_dir() { + files(&p, out); + } else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["af", "afdesign", "afphoto", "afpub", "aftemplate"].contains(&e)) + && std::fs::read(&p).is_ok_and(|b| b.starts_with(photocraft_affinity::MAGIC)) + { + out.push(p); + } + } +} + +fn samples() -> Option { + let pinned = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/affinity"); + std::env::var_os("AFFINITY_SAMPLES").map(PathBuf::from).or_else(|| pinned.join("SOURCES.md").exists().then_some(pinned)) +} + +#[test] +fn public_documents_parse_completely() { + let Some(dir) = samples() else { return }; + let mut paths = Vec::new(); + files(&dir, &mut paths); + assert!(!paths.is_empty(), "no Affinity documents under {dir:?}"); + let mut failures = Vec::new(); + for p in &paths { + let bytes = std::fs::read(p).unwrap(); + let result = Archive::open(&bytes, Limits::default()).and_then(|mut a| { + let doc = a.read("doc.dat")?; + stream::parse(&doc).map(|s| s.objects.len()) + }); + match result { + Ok(n) => assert!(n > 0), + Err(e) => failures.push(format!("{}: {e}", p.display())), + } + } + assert!(failures.is_empty(), "{} of {} failed:\n{}", failures.len(), paths.len(), failures.join("\n")); + eprintln!("{} Affinity documents parsed", paths.len()); +} + +#[test] +fn public_documents_map_to_the_model() { + let Some(dir) = samples() else { return }; + let mut paths = Vec::new(); + files(&dir, &mut paths); + let mut warnings = std::collections::BTreeMap::::new(); + let mut failures = Vec::new(); + let (mut nodes, mut docs) = (0usize, 0usize); + fn count(n: &[photocraft_affinity::model::Node]) -> usize { + n.iter().map(|n| 1 + count(&n.children)).sum() + } + for p in &paths { + let bytes = std::fs::read(p).unwrap(); + if !bytes.starts_with(photocraft_affinity::MAGIC) { + continue; + } + match photocraft_affinity::read(&bytes, Limits::default()) { + Ok(d) => { + docs += 1; + nodes += d.spreads.iter().map(|s| count(&s.nodes)).sum::(); + for w in d.warnings { + let key = w.split(" (").next().unwrap_or(&w).to_string(); + *warnings.entry(key).or_default() += 1; + } + } + Err(e) => failures.push(format!("{}: {e}", p.display())), + } + } + eprintln!("{docs} documents, {nodes} nodes"); + for (w, n) in &warnings { + eprintln!("{n:4} documents: {w}"); + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} diff --git a/crates/algo/src/liquify.rs b/crates/algo/src/liquify.rs index 54e696b0..adda5397 100644 --- a/crates/algo/src/liquify.rs +++ b/crates/algo/src/liquify.rs @@ -320,7 +320,10 @@ impl LiquifyField { let r = (s.size / 2.0).max(0.5); let spacing = (r * 0.2).max(0.5); let steps = (len / spacing).ceil().max(1.0) as usize; - for k in 1..=steps { + // Walk only the steps whose dab can reach a field node: the rest return from `dab` before + // touching anything, and a segment to a far-off point has trillions of them (#937). + let Some((k0, k1)) = self.reachable_steps(a, b, steps, r, s.tool.is_stationary()) else { return dirty }; + for k in k0..=k1 { let t0 = (k - 1) as f64 / steps as f64; let t1 = k as f64 / steps as f64; let c = [a[0] + (b[0] - a[0]) * t1, a[1] + (b[1] - a[1]) * t1]; @@ -333,6 +336,39 @@ impl LiquifyField { dirty } + /// The steps `k0..=k1` of a segment walked in `steps` whose dab can touch a field node, or + /// `None` when none can. Step `k` centres its dab at `a + (b - a)·(k - o)/steps`, with `o` = 1 + /// for the dragging tools and 0 for the stationary ones (see `stroke_segment`). A dab reaches a + /// node only when its centre is within `r` plus a node of the node grid, so the window below + /// pads that by another node and a step: the dabs it leaves out would each change nothing. + fn reachable_steps(&self, a: [f64; 3], b: [f64; 3], steps: usize, r: f64, stationary: bool) -> Option<(usize, usize)> { + if self.w == 0 || self.h == 0 { + return None; + } + let pad = r + 2.0 * self.cell; + let lo = [f64::from(self.bounds.x0) + 0.5 - pad, f64::from(self.bounds.y0) + 0.5 - pad]; + let hi = [lo[0] + (self.w - 1) as f64 * self.cell + 2.0 * pad, lo[1] + (self.h - 1) as f64 * self.cell + 2.0 * pad]; + let (n, o) = (steps as f64, if stationary { 0.0 } else { 1.0 }); + let (mut k0, mut k1) = (1.0f64, n); + for ax in 0..2 { + let d = b[ax] - a[ax]; + if d == 0.0 { + if a[ax].is_nan() || a[ax] < lo[ax] || a[ax] > hi[ax] { + return None; + } + continue; + } + let (u0, u1) = ((lo[ax] - a[ax]) / d, (hi[ax] - a[ax]) / d); + k0 = k0.max((u0.min(u1) * n + o).floor() - 1.0); + k1 = k1.min((u0.max(u1) * n + o).ceil() + 1.0); + } + // NaN: a segment whose length overflowed. + if k0.is_nan() || k1.is_nan() || k0 > k1 { + return None; + } + Some((k0 as usize, k1 as usize)) + } + /// One dab at `c` with brush motion `delta`. Returns the document rect it affected. fn dab(&mut self, s: &LiquifyStroke, c: [f64; 2], delta: [f64; 2], point_pressure: f64) -> Rect { if s.tool == LiquifyTool::LassoMask { @@ -742,6 +778,70 @@ mod tests { use super::*; use photocraft_color::{ColorMode, PixelFormat, SampleType}; + /// The segment walk before #937: every step, whether or not its dab can reach the field. + fn full_walk(f: &mut LiquifyField, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3]) -> Rect { + let mut dirty = Rect::EMPTY; + let (pa, pb) = (a[2].clamp(0.0, 1.0), b[2].clamp(0.0, 1.0)); + let len = (b[0] - a[0]).hypot(b[1] - a[1]); + let r = (s.size / 2.0).max(0.5); + let steps = (len / (r * 0.2).max(0.5)).ceil().max(1.0) as usize; + for k in 1..=steps { + let (t0, t1) = ((k - 1) as f64 / steps as f64, k as f64 / steps as f64); + let c = [a[0] + (b[0] - a[0]) * t1, a[1] + (b[1] - a[1]) * t1]; + let delta = [(b[0] - a[0]) * (t1 - t0), (b[1] - a[1]) * (t1 - t0)]; + let centre = if s.tool.is_stationary() { c } else { [c[0] - delta[0], c[1] - delta[1]] }; + dirty = dirty.union(&f.dab(s, centre, delta, pa + (pb - pa) * t1)); + } + dirty + } + + #[test] + fn a_segment_walks_only_the_dabs_that_can_reach_the_field() { + // #937: a segment to a far-off point walked one dab per spacing step along its whole + // length (~3e14 for 1e15 px). Skipping the dabs that can't reach a field node must leave + // every result bit-identical to the full walk. + use LiquifyTool::*; + let bounds = Rect::new(-7, 3, 89, 67); + let segments: [([f64; 3], [f64; 3]); 9] = [ + ([10.0, 20.0, 1.0], [70.0, 50.0, 0.5]), // inside + ([-900.0, 30.0, 1.0], [40.0, 31.0, 1.0]), // in from far left + ([40.0, 30.0, 1.0], [40.0, 4000.0, 0.2]), // out downwards + ([-500.0, -500.0, 1.0], [600.0, 600.0, 1.0]), // diagonal through + ([-300.0, -9.0, 1.0], [300.0, -9.0, 1.0]), // parallel, just above (the brush reaches) + ([-300.0, -400.0, 1.0], [300.0, -400.0, 1.0]), // parallel, far above (reaches nothing) + ([88.5, -50.0, 1.0], [88.5, 120.0, 1.0]), // vertical, along the right edge + ([200.0, 200.0, 1.0], [201.0, 900.0, 1.0]), // entirely outside + ([0.0, 0.0, 1.0], [0.25, 0.1, 1.0]), // shorter than one step + ]; + for tool in [ForwardWarp, PushLeft, Reconstruct, Smooth, TwirlCw, TwirlCcw, Pucker, Bloat, Freeze, Thaw] { + for size in [6.0, 40.0] { + for (a, b) in segments { + let s = LiquifyStroke::new(tool, size); + let (mut fast, mut full) = (LiquifyField::new(bounds, 2.0), LiquifyField::new(bounds, 2.0)); + // Some displacement first, so Reconstruct and Smooth have something to undo. + for f in [&mut fast, &mut full] { + f.stroke_segment(&LiquifyStroke::new(ForwardWarp, 30.0), [20.0, 20.0, 1.0], [60.0, 40.0, 1.0]); + } + let dirty = fast.stroke_segment(&s, a, b); + assert_eq!(dirty, full_walk(&mut full, &s, a, b), "{tool:?} {size} {a:?} {b:?}"); + assert!(fast.d == full.d && fast.freeze == full.freeze, "{tool:?} {size} {a:?} {b:?}"); + } + } + } + } + + #[test] + fn a_segment_to_a_far_off_point_returns() { + // The issue's request: about 3.3e14 steps before the fix. Also a segment whose length + // overflows f64. + let mut f = LiquifyField::new(Rect::new(0, 0, 24, 16), 2.0); + let s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 30.0); + let dirty = f.stroke_segment(&s, [0.0, 0.0, 1.0], [1e15, 0.0, 1.0]); + assert!(!dirty.is_empty() && dirty.x1 <= 64, "{dirty:?}"); + f.stroke_segment(&s, [-1e308, 5.0, 1.0], [1e308, 5.0, 1.0]); + f.stroke_segment(&LiquifyStroke::new(LiquifyTool::Bloat, 30.0), [5.0, -1e15, 1.0], [5.0, 1e15, 1.0]); + } + fn sample(st: SampleType) -> Surface { let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, st, true)); for y in 0..64 { diff --git a/crates/automation/src/files.rs b/crates/automation/src/files.rs index 09513c23..004b806e 100644 --- a/crates/automation/src/files.rs +++ b/crates/automation/src/files.rs @@ -13,22 +13,23 @@ use crate::AutomationError; pub struct Opened { pub document: Document, pub warnings: Vec, + pub source_read_only: bool, } /// Decode a document that was read through a filesystem capability. pub fn open_bytes(name: &str, bytes: &[u8]) -> Result { if Path::new(name).extension().is_some_and(|extension| extension.eq_ignore_ascii_case(photocraft_format::EXTENSION)) { - return Ok(Opened { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new() }); + return Ok(Opened { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new(), source_read_only: false }); } let r = photocraft_io::import(name, bytes)?; - Ok(Opened { document: r.document, warnings: r.warnings }) + Ok(Opened { document: r.document, warnings: r.warnings, source_read_only: r.source_read_only }) } /// Open a document from disk. Directory bundles and `.pcraft` ZIPs load /// natively; everything else goes through `photocraft-io` (PSD, PNG, …). pub fn open(path: &Path) -> Result { if path.is_dir() { - return Ok(Opened { document: photocraft_format::load_path(path)?, warnings: Vec::new() }); + return Ok(Opened { document: photocraft_format::load_path(path)?, warnings: Vec::new(), source_read_only: false }); } let bytes = photocraft_format::read_file(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); diff --git a/crates/automation/src/headless.rs b/crates/automation/src/headless.rs index 51b0a9bc..4c663086 100644 --- a/crates/automation/src/headless.rs +++ b/crates/automation/src/headless.rs @@ -91,7 +91,10 @@ impl Headless { } None => Some(requested.to_string()), }; - let index = self.session.add_document(o.document, path); + let index = self.session.add_document(o.document, path.filter(|_| !o.source_read_only)); + if let Some(st) = self.session.active_mut() { + st.source_read_only = o.source_read_only; + } let d = &self.session.documents()[index]; Ok(json!({ "index": index, diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index e52343b6..61c439d0 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -670,11 +670,9 @@ impl PhotocraftMcp { async fn save_impl(&self, p: SaveParams) -> Result { if let Some(b) = self.bridge_client() { - let Some(path) = p.path else { - return Ok(fail("bridge mode needs `path`")); - }; - // The bridge forwards to the running app's `app.save`, which takes a path only: - // the extra options are headless-only. Saying so beats saving with defaults + // The bridge forwards to the running app's `app.save`, which takes only an optional + // path (without one it writes back to the document's own layered file, as headless + // does), so the extra options are headless-only. Saying so beats saving with defaults // while the caller believes their quality or format was applied. let unsupported: Vec<&str> = [ p.format.is_some().then_some("format"), @@ -688,7 +686,11 @@ impl PhotocraftMcp { if !unsupported.is_empty() { return Ok(fail(format!("bridge mode saves with the app's current settings; `{}` need headless mode", unsupported.join("`, `")))); } - return to_result(b.call("app.save", json!({"path": path})).await); + let params = match p.path { + Some(path) => json!({"path": path}), + None => json!({}), + }; + return to_result(b.call("app.save", params).await); } let Some(r) = self .headless_op(move |h| { diff --git a/crates/automation/tests/mcp.rs b/crates/automation/tests/mcp.rs index f3effb03..198134ba 100644 --- a/crates/automation/tests/mcp.rs +++ b/crates/automation/tests/mcp.rs @@ -288,6 +288,20 @@ async fn bridge_doc_save_rejects_headless_only_options_instead_of_saving_with_de } } +/// #946: like headless mode, a bridged `doc_save` without `path` reaches the app's `app.save`, +/// which writes back to the document's own PSD, PSB or .pcraft file or refuses with its own error. +#[tokio::test(flavor = "multi_thread")] +async fn bridge_doc_save_without_path_forwards_app_save_for_write_back() { + let (addr, app) = fake_app().await; + let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await; + let saved = json_of(&call(&client, "doc_save", json!({})).await); + assert_eq!(saved, json!({"saved": {}}), "no `path` is sent, not even null"); + let saved = json_of(&call(&client, "doc_save", json!({"path": "out.psd"})).await); + assert_eq!(saved, json!({"saved": {"path": "out.psd"}})); + client.cancel().await.unwrap(); + app.abort(); +} + #[tokio::test(flavor = "multi_thread")] async fn bridge_response_budget_drops_connection_without_retrying_the_operation() { use photocraft_automation::{BridgeClient, budgets::MAX_RESPONSE_BYTES}; @@ -366,6 +380,49 @@ fn write_image(dir: &std::path::Path, name: &str, format: photocraft_codecs::For bytes } +#[tokio::test(flavor = "multi_thread")] +async fn affinity_preview_warns_and_requires_a_new_save_path() { + let dir = tmp("affinity-preview"); + let png = write_image(&dir, "preview.png", photocraft_codecs::Format::Png); + // Synthetic v12 envelope; the native document graph is intentionally absent. + let mut bytes = vec![0; 72]; + bytes[..4].copy_from_slice(b"\x00\xffKA"); + bytes[4..6].copy_from_slice(&12u16.to_le_bytes()); + bytes[8..12].copy_from_slice(b"nsrP"); + bytes[12..16].copy_from_slice(b"#Inf"); + bytes[24..32].copy_from_slice(&72u64.to_le_bytes()); + bytes[64..68].copy_from_slice(b"Prot"); + bytes.extend(b"\xff\xff\xff\xffThmb"); + bytes.extend(1u32.to_le_bytes()); + bytes.extend((png.len() as u32 + 13).to_le_bytes()); + bytes.extend(29u32.to_le_bytes()); + bytes.extend(0u32.to_le_bytes()); + bytes.extend((png.len() as u32).to_le_bytes()); + bytes.push(1); + bytes.extend(png); + std::fs::write(dir.join("source.af"), &bytes).unwrap(); + // A renamed layered extension must not bypass source protection. + std::fs::write(dir.join("renamed.psd"), &bytes).unwrap(); + let client = connect(headless_in(&dir)).await; + for name in ["source.af", "renamed.psd"] { + let opened = json_of(&call(&client, "doc_open", json!({"path": name})).await); + assert!(opened["warnings"].to_string().contains("only its embedded 16×8 PNG preview")); + assert_eq!(opened["width"], 16); + let refused = call(&client, "doc_save", json!({})).await; + assert_eq!(refused.is_error, Some(true), "{}", text(&refused)); + assert!(text(&refused).contains("pass `path`"), "{}", text(&refused)); + let native = call(&client, "doc_save", json!({"path": "source.af"})).await; + assert_eq!(native.is_error, Some(true)); + assert!(text(&native).contains("Affinity export")); + assert_eq!(std::fs::read(dir.join(name)).unwrap(), bytes); + } + json_of(&call(&client, "doc_save", json!({"path": "copy.pcraft"})).await); + assert_eq!(json_of(&call(&client, "doc_save", json!({})).await)["path"], "copy.pcraft"); + assert_eq!(std::fs::read(dir.join("source.af")).unwrap(), bytes); + client.cancel().await.unwrap(); + cleanup(&dir); +} + /// A save without `path` writes back only to a layered file in its own format (#416). #[tokio::test(flavor = "multi_thread")] async fn save_without_path_never_flattens_over_the_opened_file() { @@ -486,6 +543,7 @@ async fn fake_app_with_screenshot(screenshot_png: Option>) -> (String, t "engine.execute" => { json!({"id": id, "ok": true, "result": {"ran": req["params"]["command"], "params": req["params"]["params"], "wait": req["params"]["wait"]}}) } + "app.save" => json!({"id": id, "ok": true, "result": {"saved": req["params"]}}), "engine.commands" => { json!({"id": id, "ok": true, "result": [{"id": "file.new", "label": "New…", "enabled": true}]}) } diff --git a/crates/color/src/blend.rs b/crates/color/src/blend.rs index 8c6a6b48..02fdadef 100644 --- a/crates/color/src/blend.rs +++ b/crates/color/src/blend.rs @@ -151,6 +151,20 @@ impl BlendMode { /// Separable per-channel blend function `B(cb, cs)`. #[inline] pub fn blend_channel(mode: BlendMode, cb: f32, cs: f32) -> f32 { + channel(mode, cb, cs, 1.0) +} + +/// [`blend_channel`] for 32-bit float documents, whose values may exceed 1: Linear Dodge (Add) +/// and Divide don't clip at 1 there (Photoshop's 32-bit Add is how light is added in HDR), only +/// at `f32::MAX`, so they stay finite. Every other mode is [`blend_channel`]. +#[inline] +pub fn blend_channel_hdr(mode: BlendMode, cb: f32, cs: f32) -> f32 { + channel(mode, cb, cs, f32::MAX) +} + +/// `B(cb, cs)` with Linear Dodge and Divide clipped at `hi` (1 at integer depths). +#[inline] +fn channel(mode: BlendMode, cb: f32, cs: f32, hi: f32) -> f32 { match mode { BlendMode::Normal | BlendMode::Dissolve | BlendMode::PassThrough => cs, BlendMode::Darken => cb.min(cs), @@ -160,7 +174,7 @@ pub fn blend_channel(mode: BlendMode, cb: f32, cs: f32) -> f32 { BlendMode::Lighten => cb.max(cs), BlendMode::Screen => cb + cs - cb * cs, BlendMode::ColorDodge => color_dodge(cb, cs), - BlendMode::LinearDodge => (cb + cs).min(1.0), + BlendMode::LinearDodge => (cb + cs).min(hi), BlendMode::Overlay => hard_light(cs, cb), BlendMode::SoftLight => soft_light_ps(cb, cs), BlendMode::HardLight => hard_light(cb, cs), @@ -195,7 +209,7 @@ pub fn blend_channel(mode: BlendMode, cb: f32, cs: f32) -> f32 { if cs <= 0.0 { if cb <= 0.0 { 0.0 } else { 1.0 } } else { - (cb / cs).min(1.0) + (cb / cs).min(hi) } } // Non-separable modes are handled by `blend_rgb`; fall back to source. @@ -337,6 +351,11 @@ pub fn blend_rgb(mode: BlendMode, cb: [f32; 3], cs: [f32; 3]) -> [f32; 3] { } } +/// [`blend_rgb`] for 32-bit float documents: separable modes use [`blend_channel_hdr`]. +pub fn blend_rgb_hdr(mode: BlendMode, cb: [f32; 3], cs: [f32; 3]) -> [f32; 3] { + if mode.is_separable() { std::array::from_fn(|i| blend_channel_hdr(mode, cb[i], cs[i])) } else { blend_rgb(mode, cb, cs) } +} + /// Composite a straight-alpha source over a straight-alpha backdrop with blend mode and opacity. /// /// Uses the W3C/PDF general formula: @@ -476,6 +495,38 @@ mod tests { } } + #[test] + fn add_and_divide_clip_at_integer_depths_but_not_in_float() { + // Integer depths: Photoshop clips Linear Dodge (Add) and Divide at 1. + assert_eq!(blend_channel(BlendMode::LinearDodge, 2.0, 0.5), 1.0); + assert_eq!(blend_channel(BlendMode::LinearDodge, 0.75, 0.5), 1.0); + assert_eq!(blend_channel(BlendMode::Divide, 2.0, 0.5), 1.0); + assert_eq!(blend_channel(BlendMode::Divide, 0.5, 0.25), 1.0); + // 32-bit float: light adds up past 1 (adding light never darkens). + assert!(close(blend_channel_hdr(BlendMode::LinearDodge, 2.0, 0.5), 2.5)); + assert!(close(blend_channel_hdr(BlendMode::LinearDodge, 0.75, 0.5), 1.25)); + assert!(close(blend_channel_hdr(BlendMode::Divide, 2.0, 0.5), 4.0)); + assert!(close(blend_channel_hdr(BlendMode::Divide, 0.5, 0.25), 2.0)); + assert_eq!(blend_rgb_hdr(BlendMode::LinearDodge, [2.0, 0.5, 4.0], [0.5, 0.25, 0.5]), [2.5, 0.75, 4.5]); + assert_eq!(blend_rgb_hdr(BlendMode::Divide, [2.0, 0.5, 4.0], [0.5, 0.25, 0.5]), [4.0, 2.0, 8.0]); + // Divide by zero keeps its guard; overflow stays finite. + assert_eq!(blend_channel_hdr(BlendMode::Divide, 0.0, 0.0), 0.0); + assert_eq!(blend_channel_hdr(BlendMode::Divide, 3.0, 0.0), 1.0); + assert_eq!(blend_channel_hdr(BlendMode::Divide, 3.0, -1.0), 1.0); + assert_eq!(blend_channel_hdr(BlendMode::Divide, f32::MAX, 1e-30), f32::MAX); + assert_eq!(blend_channel_hdr(BlendMode::LinearDodge, f32::MAX, f32::MAX), f32::MAX); + assert!(blend_channel_hdr(BlendMode::LinearDodge, f32::INFINITY, 1.0).is_finite()); + // Every other mode is unchanged, and the in-range results agree. + for m in BlendMode::LAYER_MODES.into_iter().filter(|m| !matches!(m, BlendMode::LinearDodge | BlendMode::Divide)) { + for (cb, cs) in [(0.2, 0.3), (0.9, 0.05), (2.0, 0.5), (0.4, 1.5)] { + assert_eq!(blend_rgb(m, [cb, cs, 0.5], [cs, cb, 0.25]), blend_rgb_hdr(m, [cb, cs, 0.5], [cs, cb, 0.25]), "{m:?} cb={cb} cs={cs}"); + } + } + let (cb, cs) = ([0.2, 0.3, 0.1], [0.3, 0.4, 0.4]); + assert_eq!(blend_rgb_hdr(BlendMode::LinearDodge, cb, cs), blend_rgb(BlendMode::LinearDodge, cb, cs)); + assert_eq!(blend_rgb_hdr(BlendMode::Divide, cb, cs), blend_rgb(BlendMode::Divide, cb, cs)); + } + #[test] fn luminosity_and_color_swap_components() { let cb = [0.8, 0.2, 0.2]; diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index dd589aef..dbab3ca6 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -586,7 +586,9 @@ fn glow_map(shape: &Map, g: &Glow, inner: bool, origin: (i32, i32)) -> Map { }; let mut m = match g.technique { GlowTechnique::Precise => { - let d = if inner && g.source == GlowSource::Edge { dist_inside(shape) } else { dist_outside(&src) }; + // An inner glow, from either source, measures from the edge inwards; a centre glow is + // the inverse below (the distance outside is ≤ 0 everywhere inside, #966). + let d = if inner { dist_inside(shape) } else { dist_outside(&src) }; let solid = g.size * g.spread; let soft = (g.size - solid).max(1e-3); let mut m = Map::new(shape.w, shape.h, 0.0); @@ -1549,9 +1551,9 @@ fn mix_premul(a: [f32; 4], b: [f32; 4], k: f32) -> [f32; 4] { /// `glow_map`, `bevel_maps` and `build_maps`). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum FieldKind { - /// `dist_outside` of the shape: precise outer and centre glows, bevels. + /// `dist_outside` of the shape: precise outer glows, bevels. Outside, - /// `dist_inside` of the shape: precise edge inner glows, bevels. + /// `dist_inside` of the shape: precise inner glows, bevels. Inside, /// Chamfer distance outside the shape: outside strokes, and the spread of drop shadows and /// softer outer glows ([`dilate`]). diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 31817c83..4ab50d37 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -96,8 +96,10 @@ pub fn render_tiled(doc: &Document, rect: Rect, tile: i32) -> Buffer { fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer { let tile = tile.max(1); - // Lab documents mix Normal blending in CIELAB, as Photoshop does (psblend::LAB_MIX). + // Lab documents mix Normal blending in CIELAB, as Photoshop does (psblend::LAB_MIX); 32-bit + // documents don't clip Add / Divide at 1 (psblend::HDR). let lab = doc.mode == photocraft_color::ColorMode::Lab; + let hdr = doc.depth == photocraft_color::SampleType::F32; // CMYK layers are read through the document's own CMYK profile (thread-local scope). let cmyk = cmyk_space(doc); let cmyk = cmyk.as_ref(); @@ -107,26 +109,18 @@ fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer if rect.width() as i32 <= tile && rect.height() as i32 <= tile { return photocraft_color::convert::with_cmyk_space(cmyk, || { let mut buf = multichannel::backdrop(doc, rect); - psblend::LAB_MIX.with(|l| l.set(lab)); - composite_stack_at(&doc.layers, &mut buf, cx, advanced::Scope::ROOT); - psblend::LAB_MIX.with(|l| l.set(false)); + in_blend_space(lab, hdr, || composite_stack_at(&doc.layers, &mut buf, cx, advanced::Scope::ROOT)); buf }); } // Effect maps are built once, here, before any tile needs them (#276). prepare_effects(&doc.layers, rect, cx, |f| { - photocraft_color::convert::with_cmyk_space(cmyk, || { - psblend::LAB_MIX.with(|l| l.set(lab)); - f(); - psblend::LAB_MIX.with(|l| l.set(false)); - }); + photocraft_color::convert::with_cmyk_space(cmyk, || in_blend_space(lab, hdr, f)); }); let run = |t: Rect| { photocraft_color::convert::with_cmyk_space(cmyk, || { let mut b = multichannel::backdrop(doc, t); - psblend::LAB_MIX.with(|l| l.set(lab)); - composite_stack_at(&doc.layers, &mut b, cx, advanced::Scope::ROOT); - psblend::LAB_MIX.with(|l| l.set(false)); + in_blend_space(lab, hdr, || composite_stack_at(&doc.layers, &mut b, cx, advanced::Scope::ROOT)); b }) }; @@ -186,6 +180,17 @@ fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer out } +/// Runs `f` with this thread's document blend flags set ([`psblend::LAB_MIX`], [`psblend::HDR`]), +/// clearing them afterwards. +fn in_blend_space(lab: bool, hdr: bool, f: impl FnOnce() -> R) -> R { + psblend::LAB_MIX.with(|l| l.set(lab)); + psblend::HDR.with(|h| h.set(hdr)); + let r = f(); + psblend::LAB_MIX.with(|l| l.set(false)); + psblend::HDR.with(|h| h.set(false)); + r +} + /// The document's own CMYK profile for reading its CMYK pixels (`None`: not a CMYK document, /// untagged, or the built-in coated CMYK). Enter it with `photocraft_color::convert::with_cmyk_space` /// around code that converts the document's CMYK pixels or colours to RGB. diff --git a/crates/compose/src/masks.rs b/crates/compose/src/masks.rs index c2510dd7..e84c0e6f 100644 --- a/crates/compose/src/masks.rs +++ b/crates/compose/src/masks.rs @@ -16,10 +16,18 @@ use photocraft_raster::Surface; const FORMAT: PixelFormat = PixelFormat { mode: ColorMode::Grayscale, sample: SampleType::F32, alpha: false }; struct Cache { - map: HashMap, + map: HashMap, tick: u64, } +struct CacheEntry { + surface: Surface, + tick: u64, + // The key contains input tile addresses. Keep those tiles alive until eviction: + // edits must copy them, and the allocator cannot reuse their addresses for new tiles. + _pixel_input: Option, +} + fn cache() -> &'static Mutex { static C: OnceLock> = OnceLock::new(); C.get_or_init(|| Mutex::new(Cache { map: HashMap::new(), tick: 0 })) @@ -109,8 +117,8 @@ pub fn combined_mask(layer: &Layer, canvas: Rect) -> Option { c.tick += 1; let tick = c.tick; if let Some(e) = c.map.get_mut(&k) { - e.1 = tick; - return Some(e.0.clone()); + e.tick = tick; + return Some(e.surface.clone()); } } // Far outside the path the vector mask is constant. @@ -164,20 +172,49 @@ pub fn combined_mask(layer: &Layer, canvas: Rect) -> Option { } let mut c = cache().lock().unwrap_or_else(|e| e.into_inner()); if c.map.len() >= CAPACITY - && let Some(old) = c.map.iter().min_by_key(|e| e.1.1).map(|e| *e.0) + && let Some(old) = c.map.iter().min_by_key(|e| e.1.tick).map(|e| *e.0) { c.map.remove(&old); } let tick = c.tick; - c.map.insert(k, (s.clone(), tick)); + c.map.insert(k, CacheEntry { surface: s.clone(), tick, _pixel_input: layer.mask.as_ref().map(|m| m.surface.clone()) }); Some(s) } #[cfg(test)] mod tests { use super::*; + use photocraft_doc::LayerMask; use photocraft_doc::vector::{Knot, Path, Subpath, VectorMask}; + #[test] + fn pixel_edits_refresh_cached_masks_at_every_depth() { + let canvas = Rect::new(0, 0, 64, 64); + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut layer = Layer::raster("edited mask", PixelFormat::RGBA8); + let mut mask = LayerMask::hide_all(); + mask.surface = Surface::new(PixelFormat { sample, ..FORMAT }); + mask.surface.fill_rect(canvas, &[1.0]); + mask.feather = 4.0; + layer.mask = Some(mask); + + let first = combined_mask(&layer, canvas).unwrap(); + assert!((first.sample_channel(32, 32, 0) - 1.0).abs() < 1e-6); + drop(first); + // No document/undo snapshot shares the input. Previously this write could + // reuse the original tile address and return the old cached white mask. + layer.mask.as_mut().unwrap().surface.fill_rect(canvas, &[0.0]); + let edited = combined_mask(&layer, canvas).unwrap(); + assert_eq!(edited.sample_channel(32, 32, 0), 0.0, "{sample:?}"); + + layer.mask.as_mut().unwrap().surface.fill_rect(canvas, &[0.5]); + let repainted = combined_mask(&layer, canvas).unwrap(); + assert!((repainted.sample_channel(32, 32, 0) - 0.5).abs() < 0.005, "{sample:?}"); + let warm = combined_mask(&layer, canvas).unwrap(); + assert!(repainted.tiles().zip(warm.tiles()).all(|((_, a), (_, b))| Arc::ptr_eq(a, b))); + } + } + #[test] fn matches_the_cpu_mask_and_is_cached() { let mut l = Layer::raster("l", PixelFormat::RGBA8); diff --git a/crates/compose/src/psblend.rs b/crates/compose/src/psblend.rs index c3698bb4..5d83068c 100644 --- a/crates/compose/src/psblend.rs +++ b/crates/compose/src/psblend.rs @@ -76,6 +76,9 @@ thread_local! { /// (Photoshop composites Lab documents in Lab; an anti-aliased edge between two colours /// differs by up to 14 / 255 from an sRGB mix: psd-tools stroke-color-descriptors-lab). pub static LAB_MIX: std::cell::Cell = const { std::cell::Cell::new(false) }; + /// Set while rendering a 32-bit float document: Linear Dodge (Add) and Divide don't clip at 1 + /// ([`generic::blend_rgb_hdr`]), as in Photoshop's 32-bit mode; integer depths clip. + pub static HDR: std::cell::Cell = const { std::cell::Cell::new(false) }; } /// `B(Cb, Cs)` with Photoshop's variants. @@ -85,6 +88,7 @@ pub fn blend_rgb(mode: BlendMode, cb: [f32; 3], cs: [f32; 3]) -> [f32; 3] { BlendMode::HardMix => std::array::from_fn(|i| hard_mix_ps(cb[i], cs[i])), BlendMode::ColorBurn => std::array::from_fn(|i| color_burn(cb[i], cs[i])), BlendMode::ColorDodge => std::array::from_fn(|i| color_dodge(cb[i], cs[i])), + m if HDR.with(|h| h.get()) => generic::blend_rgb_hdr(m, cb, cs), m => generic::blend_rgb(m, cb, cs), } } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index faff1659..902202b6 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -76,7 +76,10 @@ fn every_blend_mode_matches_reference_on_opaque_pixels() { top.blend = mode; d.layers = vec![bottom, top]; let got = px(&d, 0, 0); + // A 32-bit document: Add / Divide don't clip at 1 (Divide gives 0.6 / 0.2 = 3 in red). + psblend::HDR.with(|h| h.set(true)); let want = blend::blend_rgb(mode, [0.6, 0.3, 0.2], [0.2, 0.7, 0.5]); + psblend::HDR.with(|h| h.set(false)); for i in 0..3 { assert!((got[i] - want[i]).abs() < 1e-5, "{mode:?}: {got:?} vs {want:?}"); } @@ -373,6 +376,50 @@ fn sixteen_bit_and_float_layers_composite() { } } +/// `top` blended with `mode` over an opaque `bg`, both solid, in a 4×4 RGB document of `depth`. +fn two_solid_layers(depth: SampleType, mode: BlendMode, bg: [f32; 3], top: [f32; 3]) -> Document { + let mut d = Document::new("hdr", Size::new(4, 4), ColorMode::Rgb, depth); + let fmt = d.pixel_format(); + let mut b = Layer::raster("bg", fmt); + b.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 4, 4), &photocraft_raster::from_rgba(&fmt, [bg[0], bg[1], bg[2], 1.0])); + let mut t = Layer::raster("top", fmt); + t.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 4, 4), &photocraft_raster::from_rgba(&fmt, [top[0], top[1], top[2], 1.0])); + t.blend = mode; + d.layers.push(b); + d.layers.push(t); + d +} + +#[test] +fn float_documents_add_and_divide_past_white() { + // 32-bit: Linear Dodge (Add) and Divide keep HDR values (adding light never darkens). + let (bg, top) = ([2.0, 0.5, 4.0], [0.5, 0.25, 0.5]); + for (mode, want) in [(BlendMode::LinearDodge, [2.5, 0.75, 4.5]), (BlendMode::Divide, [4.0, 2.0, 8.0]), (BlendMode::Multiply, [1.0, 0.125, 2.0])] { + let d = two_solid_layers(SampleType::F32, mode, bg, top); + for buf in [flatten(&d), render_tiled(&d, d.bounds(), 2)] { + for p in &buf.px { + assert!(close4(*p, [want[0], want[1], want[2], 1.0]), "{mode:?}: {p:?}"); + } + } + } + // The flag doesn't leak into later renders on this thread. + let d = two_solid_layers(SampleType::U16, BlendMode::LinearDodge, [0.8, 0.5, 0.6], top); + assert!(close4(flatten(&d).px[0], [1.0, 0.75, 1.0, 1.0])); +} + +#[test] +fn integer_documents_clip_add_and_divide_at_white() { + let (bg, top) = ([0.8, 0.5, 0.6], [0.5, 0.25, 0.5]); + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let f32_doc = depth == SampleType::F32; + for (mode, clipped, hdr) in [(BlendMode::LinearDodge, [1.0, 0.75, 1.0], [1.3, 0.75, 1.1]), (BlendMode::Divide, [1.0, 1.0, 1.0], [1.6, 2.0, 1.2])] { + let want = if f32_doc { hdr } else { clipped }; + let p = flatten(&two_solid_layers(depth, mode, bg, top)).px[5]; + assert!(close4(p, [want[0], want[1], want[2], 1.0]), "{mode:?} {depth:?}: {p:?}"); + } + } +} + #[test] fn thumbnail_dimensions() { let d = doc_white(400, 200); @@ -570,6 +617,17 @@ fn outer_and_inner_glow_regions() { assert!(close4(px(&d, 5, 20), [1.0; 4])); } +// A Precise inner glow from the centre is brightest in the middle and fades towards the edge, +// like the Softer one; it used to paint nothing at all (#966). +#[test] +fn precise_inner_glow_from_center_paints_the_middle() { + let d = fx_doc(vec![Effect::InnerGlow(glow(GlowTechnique::Precise, GlowSource::Center))]); + let (center, edge) = (px(&d, 20, 20), px(&d, 10, 20)); + assert!(center[1] > 0.9 && center[0] < 0.1, "green in the middle: {center:?}"); + assert!(edge[0] > center[0] && edge[1] < center[1], "fades towards the edge: {edge:?} vs {center:?}"); + assert!(close4(px(&d, 5, 20), [1.0; 4])); +} + // A glow gradient runs along the glow, not across the canvas: the first stop hugs the shape on // every side and later stops lie further out, opaque until the glow fades (#443). #[test] diff --git a/crates/doc/src/effects.rs b/crates/doc/src/effects.rs index 9fa3302f..06028a40 100644 --- a/crates/doc/src/effects.rs +++ b/crates/doc/src/effects.rs @@ -288,6 +288,18 @@ impl Effect { } } + /// Switches this instance on or off, keeping its settings. + pub fn set_enabled(&mut self, on: bool) { + match self { + Effect::DropShadow(s) | Effect::InnerShadow(s) => s.common.enabled = on, + Effect::OuterGlow(g) | Effect::InnerGlow(g) => g.common.enabled = on, + Effect::Stroke(s) => s.common.enabled = on, + Effect::ColorOverlay { common, .. } | Effect::GradientOverlay { common, .. } | Effect::PatternOverlay { common, .. } => common.enabled = on, + Effect::Satin(s) => s.common.enabled = on, + Effect::BevelEmboss(b) => b.enabled = on, + } + } + /// Short label for UIs. pub fn label(&self) -> &'static str { match self { diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index d85fb05f..a24b7f0f 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -36,6 +36,7 @@ photocraft-codecs = { workspace = true } flate2 = { workspace = true } [dev-dependencies] +photocraft-affinity = { workspace = true, features = ["synth"] } # Synthetic camera raw files for the open tests. photocraft-raw = { workspace = true, features = ["testgen"] } # WebAssembly text -> binary for plug-in test modules (Apache-2.0 WITH LLVM-exception). diff --git a/crates/engine/src/adjust_cmds.rs b/crates/engine/src/adjust_cmds.rs index 000b7e4c..e42ffbaa 100644 --- a/crates/engine/src/adjust_cmds.rs +++ b/crates/engine/src/adjust_cmds.rs @@ -31,42 +31,77 @@ fn num(p: &Value, key: &str, default: f32) -> f32 { p.get(key).and_then(Value::as_f64).map_or(default, |v| v as f32) } +/// `key`'s value; a null counts as missing (as in `adjust_params`). +fn given<'a>(p: &'a Value, key: &str) -> Option<&'a Value> { + p.get(key).filter(|v| !v.is_null()) +} + +fn opt_str<'a>(cmd: &str, p: &'a Value, key: &str) -> Result> { + match given(p, key) { + None => Ok(None), + Some(Value::String(s)) => Ok(Some(s)), + Some(_) => Err(bad(cmd, format!("`{key}` must be a string"))), + } +} + +fn opt_bool(cmd: &str, p: &Value, key: &str) -> Result> { + match given(p, key) { + None => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(_) => Err(bad(cmd, format!("`{key}` must be true or false"))), + } +} + +/// A percentage in -100..100 (clamped); anything but a finite number is an error. +fn percent(cmd: &str, key: &str, v: &Value) -> Result { + let x = v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a number")))?; + Ok((x as f32).clamp(-100.0, 100.0)) +} + /// Selective Color from params. Accepts per-range arrays (`"reds":[c,m,y,k]` in percent) and the /// dialog's single-range form (`"colors":"reds","cyan":…,"magenta":…,"yellow":…,"black":…`); -/// `"method":"relative|absolute"`. Unspecified ranges keep `base`'s values. -pub fn selective_from_params(p: &Value, base: Option<&Adjustment>) -> Adjustment { +/// `"method":"relative|absolute"`. Unspecified ranges keep `base`'s values; a key of the wrong +/// type is an error. +pub fn selective_from_params(p: &Value, base: Option<&Adjustment>) -> Result { + const CMD: &str = "selectiveColor"; let (mut relative, mut adj) = match base { Some(Adjustment::SelectiveColor { relative, adjustments }) => (*relative, *adjustments), _ => (true, [[0.0; 4]; 9]), }; - if let Some(m) = p.get("method").and_then(Value::as_str) { - relative = m != "absolute"; + match opt_str(CMD, p, "method")? { + None => {} + Some("relative") => relative = true, + Some("absolute") => relative = false, + Some(m) => return Err(bad(CMD, format!("`method` must be \"relative\" or \"absolute\", not `{m}`"))), } - if let Some(b) = p.get("relative").and_then(Value::as_bool) { + if let Some(b) = opt_bool(CMD, p, "relative")? { relative = b; } - for (i, key) in RANGES.iter().enumerate() { - if let Some(a) = p.get(*key).and_then(Value::as_array) { - for (k, v) in a.iter().take(4).enumerate() { - adj[i][k] = (v.as_f64().unwrap_or(0.0) as f32).clamp(-100.0, 100.0); + for (row, key) in adj.iter_mut().zip(RANGES) { + let Some(v) = given(p, key) else { continue }; + let a = v.as_array().filter(|a| a.len() == 4).ok_or_else(|| bad(CMD, format!("`{key}` must be an array of 4 numbers [c, m, y, k]")))?; + for (slot, x) in row.iter_mut().zip(a) { + *slot = percent(CMD, key, x)?; + } + } + if let Some(c) = opt_str(CMD, p, "colors")? { + let Some((row, _)) = adj.iter_mut().zip(RANGES).find(|(_, r)| *r == c) else { + return Err(bad(CMD, format!("unknown range `{c}` (ranges: {})", RANGES.join(", ")))); + }; + for (slot, key) in row.iter_mut().zip(["cyan", "magenta", "yellow", "black"]) { + if let Some(v) = given(p, key) { + *slot = percent(CMD, key, v)?; } } } - let range = p.get("colors").and_then(Value::as_str).and_then(|c| RANGES.iter().position(|r| *r == c)); - if let Some(i) = range { - for (k, key) in ["cyan", "magenta", "yellow", "black"].iter().enumerate() { - if let Some(v) = p.get(*key).and_then(Value::as_f64) { - adj[i][k] = (v as f32).clamp(-100.0, 100.0); - } - } - } - Adjustment::SelectiveColor { relative, adjustments: adj } + Ok(Adjustment::SelectiveColor { relative, adjustments: adj }) } /// Color Lookup from params: `"lut"` (a built-in look id or `"none"`), `"file"` (a .cube / .3dl / /// .look path, native only) or `"data"` (the file's text, with `"fileName"` naming its format), /// `"interpolation":"trilinear|tetrahedral"`, `"dither":bool`. The table is embedded in the -/// layer, as Photoshop does. Unspecified fields keep `base`'s values. +/// layer, as Photoshop does. Unspecified fields keep `base`'s values; a key of the wrong type is an +/// error. pub fn lookup_from_params(p: &Value, base: Option<&Adjustment>) -> Result { const CMD: &str = "colorLookup"; let (mut name, mut lut, mut size, mut tetrahedral, mut dither) = match base { @@ -74,7 +109,7 @@ pub fn lookup_from_params(p: &Value, base: Option<&Adjustment>) -> Result (String::new(), None, 0, false, false), }; let mut loaded: Option<(photocraft_cms::lutfile::LutFile, String)> = None; - if let Some(id) = p.get("lut").and_then(Value::as_str) { + if let Some(id) = opt_str(CMD, p, "lut")? { if id == "none" || id.is_empty() { name.clear(); lut = None; @@ -85,12 +120,13 @@ pub fn lookup_from_params(p: &Value, base: Option<&Adjustment>) -> Result) -> Result {} + Some("trilinear") => tetrahedral = false, + Some("tetrahedral") => tetrahedral = true, + Some(i) => return Err(bad(CMD, format!("`interpolation` must be \"trilinear\" or \"tetrahedral\", not `{i}`"))), } - if let Some(b) = p.get("tetrahedral").and_then(Value::as_bool) { + if let Some(b) = opt_bool(CMD, p, "tetrahedral")? { tetrahedral = b; } - if let Some(b) = p.get("dither").and_then(Value::as_bool) { + if let Some(b) = opt_bool(CMD, p, "dither")? { dither = b; } Ok(Adjustment::ColorLookup { name, lut, size, tetrahedral, dither }) @@ -143,7 +182,7 @@ fn read_file(_: &str) -> Option> { pub fn update_adjustment(existing: &Adjustment, p: &Value) -> Option> { let empty = p.as_object().is_none_or(|o| o.keys().all(|k| matches!(k.as_str(), "layer" | "coalesce" | "__kind"))); match existing { - Adjustment::SelectiveColor { .. } => Some(Ok(selective_from_params(p, (!empty).then_some(existing)))), + Adjustment::SelectiveColor { .. } => Some(selective_from_params(p, (!empty).then_some(existing))), Adjustment::ColorLookup { .. } => Some(lookup_from_params(p, (!empty).then_some(existing))), _ => None, } diff --git a/crates/engine/src/adjust_cmds/tests.rs b/crates/engine/src/adjust_cmds/tests.rs index 67719afb..5f781c40 100644 --- a/crates/engine/src/adjust_cmds/tests.rs +++ b/crates/engine/src/adjust_cmds/tests.rs @@ -219,6 +219,87 @@ LUT_3D_SIZE 2 assert_eq!(looks.as_array().unwrap().len(), photocraft_cms::lutfile::BUILTIN.len()); } +/// The kind's parameters, read back from the model (Color Lookup with its table name and size). +fn adjustment_of(s: &Session, id: photocraft_doc::LayerId) -> Adjustment { + match &doc(s).layer(id).unwrap().content { + LayerContent::Adjustment(a) => a.clone(), + other => panic!("{other:?}"), + } +} + +#[test] +fn selective_color_and_color_lookup_reject_wrong_typed_params() { + let cube = "LUT_3D_SIZE 2\n0 0 0\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n"; + // (kind, params, the key the error must name) + let cases: Vec<(&str, Value, &str)> = vec![ + ("selectiveColor", json!({"method": 5}), "method"), + ("selectiveColor", json!({"method": "sideways"}), "method"), + ("selectiveColor", json!({"relative": "yes"}), "relative"), + ("selectiveColor", json!({"reds": "not-an-array"}), "reds"), + ("selectiveColor", json!({"blues": [10, 20]}), "blues"), + ("selectiveColor", json!({"neutrals": [10, "20", 0, 0]}), "neutrals"), + ("selectiveColor", json!({"colors": 3, "cyan": 50}), "colors"), + ("selectiveColor", json!({"colors": "purples", "cyan": 50}), "purples"), + ("selectiveColor", json!({"colors": "reds", "cyan": "50"}), "cyan"), + ("selectiveColor", json!({"colors": "blacks", "black": [1]}), "black"), + ("colorLookup", json!({"lut": 42}), "lut"), + ("colorLookup", json!({"interpolation": 5}), "interpolation"), + ("colorLookup", json!({"interpolation": "bicubic"}), "interpolation"), + ("colorLookup", json!({"tetrahedral": 1}), "tetrahedral"), + ("colorLookup", json!({"dither": "yes"}), "dither"), + ("colorLookup", json!({"data": 42}), "data"), + ("colorLookup", json!({"data": cube, "fileName": 7}), "fileName"), + ("colorLookup", json!({"file": 42}), "file"), + ]; + let named = |r: Result, key: &str, what: &str| match r { + Err(e @ EngineError::BadParams { .. }) => assert!(e.to_string().contains(key), "{what}: {e}"), + other => panic!("{what}: expected BadParams naming `{key}`, got {other:?}"), + }; + for depth in DEPTHS { + for mode in ["rgb", "cmyk", "lab"] { + for (kind, p, key) in &cases { + let what = format!("{kind} {p} {depth} {mode}"); + let mut s = session(depth, mode); + let (layers, steps, px) = (doc(&s).layers.len(), s.active().unwrap().history.past_len(), rgba(&s, 3, 3)); + named(s.execute(&format!("image.adjustments.{kind}"), p.clone()), key, &what); + named(s.execute(&format!("layer.newAdjustmentLayer.{kind}"), p.clone()), key, &what); + assert_eq!(doc(&s).layers.len(), layers, "{what}: no layer added"); + assert_eq!(s.active().unwrap().history.past_len(), steps, "{what}: no history step"); + assert_eq!(rgba(&s, 3, 3), px, "{what}: pixels unchanged"); + } + } + } + // An update with a wrong-typed key fails and keeps every existing value. + let mut s = session(8, "rgb"); + let made = [ + ("selectiveColor", json!({"method": "absolute", "reds": [10, -20, 30, -40], "colors": "blues", "yellow": 40})), + ("colorLookup", json!({"lut": "warm", "interpolation": "tetrahedral", "dither": true})), + ]; + for (kind, p) in made { + let id = photocraft_doc::LayerId(s.execute(&format!("layer.newAdjustmentLayer.{kind}"), p).unwrap()["layer"].as_u64().unwrap()); + let before = adjustment_of(&s, id); + for (k, p, key) in cases.iter().filter(|c| c.0 == kind) { + let mut p = p.clone(); + p["layer"] = json!(id.0); + named(s.execute("layer.setAdjustment", p.clone()), key, &format!("setAdjustment {k} {p}")); + assert_eq!(adjustment_of(&s, id), before, "{p}"); + } + // Null still means "not given": the update keeps everything. + let nulls = json!({"layer": id.0, "method": null, "reds": null, "colors": null, "lut": null, "interpolation": null, "dither": null}); + s.execute("layer.setAdjustment", nulls).unwrap(); + assert_eq!(adjustment_of(&s, id), before, "{kind}: nulls"); + } + // Well-typed values reach the model. + match adjustment_of(&s, doc(&s).layers[1].id) { + Adjustment::SelectiveColor { relative: false, adjustments } => { + assert_eq!(adjustments[0], [10.0, -20.0, 30.0, -40.0]); + assert_eq!(adjustments[4], [0.0, 0.0, 40.0, 0.0]); + } + other => panic!("{other:?}"), + } + assert!(matches!(adjustment_of(&s, doc(&s).layers[2].id), Adjustment::ColorLookup { lut: Some(_), tetrahedral: true, dither: true, .. })); +} + #[test] fn new_kinds_round_trip_through_psd_and_pcraft() { let mut s = session(8, "rgb"); diff --git a/crates/engine/src/adjust_params.rs b/crates/engine/src/adjust_params.rs index ee3b429d..7bad00f4 100644 --- a/crates/engine/src/adjust_params.rs +++ b/crates/engine/src/adjust_params.rs @@ -203,7 +203,7 @@ pub fn from_params(kind: &str, p: &Value, base: Option<&Adjustment>, mode: Color }; Adjustment::GradientMap { stops, reverse: r.boolean("reverse", rev0)?, dither: r.boolean("dither", dither0)? } } - "selectiveColor" => crate::adjust_cmds::selective_from_params(p, base.filter(|b| matches!(b, Adjustment::SelectiveColor { .. }))), + "selectiveColor" => crate::adjust_cmds::selective_from_params(p, base.filter(|b| matches!(b, Adjustment::SelectiveColor { .. })))?, "colorLookup" => crate::adjust_cmds::lookup_from_params(p, base.filter(|b| matches!(b, Adjustment::ColorLookup { .. })))?, other => return Err(bad("adjustment", format!("unknown adjustment kind `{other}`"))), }) @@ -909,6 +909,12 @@ mod tests { ("channelMixer", json!({"red": [1, 2, 3]})), ("gradientMap", json!({"stops": [[0, "#000"]]})), ("gradientMap", json!({"stops": 7})), + ("selectiveColor", json!({"method": 5})), + ("selectiveColor", json!({"reds": "not-an-array"})), + ("selectiveColor", json!({"colors": "reds", "cyan": "50"})), + ("colorLookup", json!({"lut": 42})), + ("colorLookup", json!({"interpolation": 5})), + ("colorLookup", json!({"dither": "yes"})), ("nope", json!({})), ("levels", json!([1, 2])), ] { diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index 665c4b63..32581b69 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -74,11 +74,17 @@ pub fn parse_points(p: &Value, cmd: &str) -> Result> { if pts.is_empty() { return Err(bad(cmd, "`points` is empty")); } - // A stroke runs dab by dab along its length: an absurd coordinate would mean billions of dabs. + check_coords(&pts, cmd)?; + Ok(pts) +} + +/// A stroke runs dab by dab along its length: an absurd coordinate would mean billions of dabs. +/// Shared by the painting and the retouch tools' `points` (#976). +pub(crate) fn check_coords(pts: &[StrokePoint], cmd: &str) -> Result<()> { if pts.iter().any(|q| !(q.x.abs() <= MAX_COORD && q.y.abs() <= MAX_COORD)) { return Err(bad(cmd, format!("point coordinates must be finite and within ±{MAX_COORD}"))); } - Ok(pts) + Ok(()) } /// Deep-merge `patch` into `base` (objects merge key by key; anything else replaces). @@ -324,6 +330,7 @@ pub struct LiveStroke { pub doc: std::sync::Arc, /// Jitter seed to pass to `paint.stroke`. pub seed: u64, + cmd: String, renderer: StrokeRenderer, mirror: Option<(crate::symmetry_cmds::SymmetryAxis, StrokeRenderer)>, mirror_distinct: bool, @@ -365,8 +372,20 @@ impl LiveStroke { let renderer = StrokeRenderer::new(&brush, Some(surf.format()), zoom); let mirror = s.active().and_then(|st| st.symmetry_path.clone()).map(|axis| (axis, StrokeRenderer::new(&brush, Some(surf.format()), zoom))); let pre = surf.clone(); - let mut live = - Self { doc: std::sync::Arc::new(doc), seed, renderer, mirror, mirror_distinct: false, pre, sel, lock, layer, params: p.clone(), tail: Rect::EMPTY }; + let mut live = Self { + doc: std::sync::Arc::new(doc), + seed, + cmd: cmd.into(), + renderer, + mirror, + mirror_distinct: false, + pre, + sel, + lock, + layer, + params: p.clone(), + tail: Rect::EMPTY, + }; live.push(&pts)?; Ok(live) } @@ -381,7 +400,9 @@ impl LiveStroke { /// committing it now would: with smoothing, the brush lags behind the pointer and catches up /// when the stroke ends, so that catch-up tail is drawn too (and redrawn on every step), and /// nothing new appears on release. + /// Invalid coordinates reject the whole batch without changing the preview. pub fn push(&mut self, pts: &[StrokePoint]) -> Result { + check_coords(pts, &self.cmd)?; self.renderer.push(pts); if let Some((axis, mirror)) = &mut self.mirror { let reflected = axis.reflect_points(pts); diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index e963ac70..ed8ccb18 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -336,6 +336,57 @@ fn live_stroke_matches_the_committed_stroke() { assert!(LiveStroke::begin(&Session::new(), &json!({"points": [[1, 1]]})).is_err()); } +#[test] +fn stroke_coordinate_validation_keeps_the_existing_bounds() { + let edges = [StrokePoint::new(-MAX_COORD, MAX_COORD, 1.0), StrokePoint::new(MAX_COORD, -MAX_COORD, 1.0)]; + assert!(check_coords(&edges, "paint.stroke").is_ok()); + assert!(check_coords(&[], "paint.stroke").is_ok(), "an empty live update is allowed"); + for (x, y) in [(MAX_COORD + 1.0, 0.0), (0.0, -MAX_COORD - 1.0), (f64::NAN, 0.0), (0.0, f64::INFINITY)] { + assert!(check_coords(&[StrokePoint::new(x, y, 1.0)], "paint.stroke").is_err()); + } + // The command parser still rejects empty strokes and shares the same inclusive boundary. + assert!(parse_points(&json!({"points": []}), "paint.stroke").is_err()); + assert!(parse_points(&json!({"points": [[MAX_COORD, -MAX_COORD]]}), "paint.stroke").is_ok()); +} + +#[test] +fn live_brush_and_pencil_reject_invalid_batches_without_changing_the_preview() { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32, "depth": depth, "background": "transparent"})).unwrap(); + let st = s.active().unwrap(); + let (doc, revision, history) = (st.doc.clone(), st.revision, st.history.past_len()); + let id = st.active_layer.unwrap(); + for (cmd, erase) in [("paint.stroke", false), ("paint.stroke", true), ("paint.pencil", false)] { + let p = json!({"points": [[8, 16]], "size": 4, "erase": erase, "seed": 7}); + let mut live = LiveStroke::begin_with(&s, cmd, &p).unwrap(); + let mut control = LiveStroke::begin_with(&s, cmd, &p).unwrap(); + // NaN first: on an unguarded renderer this fails without starting the enormous segment. + for (x, y) in [(f64::NAN, 16.0), (16.0, f64::NAN), (f64::INFINITY, 16.0), (16.0, f64::NEG_INFINITY), (1e300, 16.0), (16.0, -MAX_COORD - 1.0)] { + let shown = live.doc.clone(); + let bounds = live.bounds(); + let batch = [StrokePoint::new(12.0, 18.0, 1.0), StrokePoint::new(x, y, 1.0)]; + let err = live.push(&batch).unwrap_err(); + assert!(matches!(err, EngineError::BadParams { cmd: ref actual, ref msg } + if actual == cmd && msg.contains("point coordinates must be finite"))); + assert!(std::sync::Arc::ptr_eq(&shown, &live.doc), "reject the whole batch before touching the preview"); + assert_eq!(live.bounds(), bounds); + } + // A rejected batch must not advance the renderer; resuming is identical to never + // receiving it. Ordinary off-canvas points remain valid too. + let next = [StrokePoint::new(-4.0, 16.0, 1.0), StrokePoint::new(24.0, 16.0, 1.0)]; + assert_eq!(live.push(&next).unwrap(), control.push(&next).unwrap()); + assert_eq!(live.bounds(), control.bounds()); + let (got, want) = (live.doc.layer(id).unwrap().surface().unwrap(), control.doc.layer(id).unwrap().surface().unwrap()); + assert!(same_pixels(got, want, live.bounds())); + assert_eq!(got.content_bounds(), want.content_bounds()); + } + let st = s.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&doc, &st.doc)); + assert_eq!((st.revision, st.history.past_len()), (revision, history)); + } +} + #[test] fn live_pencil_matches_the_committed_pencil_and_auto_erases() { let mut s = session(80, 40); diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index acd12eb0..0cb419f0 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1165,6 +1165,7 @@ fn build() -> Vec { v.extend(crate::plugin_cmds::specs()); v.extend(crate::group_view_cmds::specs()); v.extend(crate::fx_view_cmds::specs()); + v.extend(crate::fx_visibility_cmds::specs()); v.extend(crate::mask_view_cmds::specs()); v.extend(crate::actions_cmds::specs()); v diff --git a/crates/engine/src/distort_cmds/tests.rs b/crates/engine/src/distort_cmds/tests.rs index f3b6fe17..208b2e97 100644 --- a/crates/engine/src/distort_cmds/tests.rs +++ b/crates/engine/src/distort_cmds/tests.rs @@ -266,3 +266,17 @@ fn background_layer_is_floated_by_geometric_warps() { assert!(l.surface().unwrap().format().alpha); assert!(l.surface().unwrap().pixel(2, 15)[3] < 0.01, "exposed area is transparent"); } + +#[test] +fn liquify_with_a_far_off_stroke_point_returns() { + // #937: the issue's request walked ~3.3e14 dab steps along a segment to (1e15, 0). + for depth in [8u64, 16, 32] { + let mut s = session(depth); + let r = s.execute(LIQUIFY, json!({"strokes": [{"tool": "forwardWarp", "size": 30, "points": [[0, 0], [1e15, 0]]}]})).unwrap(); + assert_eq!(r["changed"], true, "{depth}: the start of the stroke is on the canvas"); + let mut s = session(depth); + let before = active_surface(&s); + s.execute(LIQUIFY, json!({"strokes": [{"tool": "bloat", "size": 30, "points": [[5000, 5000], [1e15, 1e15]]}]})).unwrap(); + assert_eq!(active_surface(&s).read_region(Rect::new(0, 0, 80, 60)), before.read_region(Rect::new(0, 0, 80, 60)), "{depth}: off-canvas only"); + } +} diff --git a/crates/engine/src/edit_cmds.rs b/crates/engine/src/edit_cmds.rs index bcbcf8b6..49031f71 100644 --- a/crates/engine/src/edit_cmds.rs +++ b/crates/engine/src/edit_cmds.rs @@ -268,6 +268,8 @@ fn layer_via(s: &mut Session, cut: bool) -> Result { *crate::pixels_mut(&mut l)? = clip.surface; let nid = doc.insert_above(Some(id), l); *active = Some(nid); + // Photoshop deselects: a following Free Transform moves the whole new layer (#1096). + doc.selection = None; Ok(nid) })?; Ok(json!({"layer": nid.0})) @@ -917,6 +919,25 @@ mod tests { assert!(d.layers[2].name.ends_with("copy")); } + /// #1096: Layer via Copy and Cut deselect, as Photoshop does, so a following Free Transform + /// moves the whole new layer; one undo brings back both the selection and the old layers. + #[test] + fn layer_via_copy_and_cut_deselect_in_one_step() { + for cmd in ["layer.new.layerViaCopy", "layer.new.layerViaCut"] { + let mut s = session(); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 20})).unwrap(); + let (layers, steps) = (s.active().unwrap().doc.layers.len(), s.active().unwrap().history.past_len()); + s.execute(cmd, json!({})).unwrap(); + let st = s.active().unwrap(); + assert!(st.doc.selection.is_none(), "{cmd}: deselected"); + assert_eq!((st.doc.layers.len(), st.history.past_len()), (layers + 1, steps + 1), "{cmd}"); + assert!(s.undo()); + let st = s.active().unwrap(); + assert_eq!(st.doc.layers.len(), layers, "{cmd}: undo removes the layer"); + assert!(st.doc.selection.is_some(), "{cmd}: and restores the selection"); + } + } + fn active_content(s: &Session) -> LayerContent { let st = s.active().unwrap(); st.doc.layer(st.active_layer.unwrap()).unwrap().content.clone() diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index 643cb50f..ed06ea85 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -61,20 +61,27 @@ fn step_key(s: &Session, what: &str) -> String { /// Rewrite the active pixel layer's RGBA over the selection bounds (or the canvas). `f` gets the /// area, the pixels, and the selection coverage per pixel; results are blended back by coverage, -/// and transparency stays locked when the layer locks it. -fn map_pixels(s: &mut Session, label: &str, f: impl FnOnce(Rect, &mut [[f32; 4]], &[f32])) -> Result { +/// and transparency stays locked when the layer locks it. When `p` targets an alpha channel, the +/// Quick Mask or the layer mask (`Session::execute` fills that in from the Channels panel), that +/// grayscale surface is rewritten instead, as the core filters do (#935). +fn map_pixels(s: &mut Session, label: &str, p: &Value, f: impl FnOnce(Rect, &mut [[f32; 4]], &[f32])) -> Result { let id = layer_param(s, &Value::Null)?; + let channel = crate::channel_cmds::target_of(p) != crate::channel_cmds::Target::Pixels; s.edit(label, |doc, _| { let canvas = doc.bounds(); let selection = doc.selection.clone(); let area = selection.as_ref().map_or(canvas, |m| m.content_bounds().intersect(&canvas)); - let locks = doc.effective_locks(id); - let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - if locks.pixels || locks.all { - return Err(EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name))); - } - let lock_alpha = locks.transparency; - let surf = l.surface_mut().ok_or_else(|| EngineError::Other("not a pixel layer".into()))?; + let (surf, lock_alpha) = if channel { + let Some(surf) = crate::channel_cmds::channel_surface_for_filter(doc, Some(id), p)? else { return Ok(()) }; + (surf, false) + } else { + let locks = doc.effective_locks(id); + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + if locks.pixels || locks.all { + return Err(EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name))); + } + (l.surface_mut().ok_or_else(|| EngineError::Other("not a pixel layer".into()))?, locks.transparency) + }; if area.is_empty() { return Ok(()); } @@ -154,8 +161,8 @@ fn stroke(s: &mut Session, p: &Value) -> Result { /// Histogram equalization, like Photoshop's: one CDF over the R, G and B values of /// the selected pixels, applied to every channel. -fn equalize(s: &mut Session) -> Result { - map_pixels(s, "Equalize", |_, px, cover| { +fn equalize(s: &mut Session, p: &Value) -> Result { + map_pixels(s, "Equalize", p, |_, px, cover| { let mut hist = [0f64; 256]; for (p, &k) in px.iter().zip(cover) { if k > 0.0 && p[3] > 0.0 { @@ -186,8 +193,8 @@ fn equalize(s: &mut Session) -> Result { } /// Filter › Blur › Average: the selection (or layer) filled with its mean colour. -fn average(s: &mut Session) -> Result { - map_pixels(s, "Average", |_, px, cover| { +fn average(s: &mut Session, p: &Value) -> Result { + map_pixels(s, "Average", p, |_, px, cover| { let (mut sum, mut wsum, mut asum, mut n) = ([0f64; 3], 0f64, 0f64, 0f64); for (p, &k) in px.iter().zip(cover) { let w = (p[3] * k) as f64; @@ -240,7 +247,7 @@ fn clouds(s: &mut Session, p: &Value, difference: bool) -> Result { let seed = p.get("seed").and_then(Value::as_u64).unwrap_or(0) as u32; let size = s.active().map_or(256.0, |d| d.doc.size.width.max(d.doc.size.height) as f32); let base = (size / 4.0).clamp(16.0, 512.0); - map_pixels(s, if difference { "Difference Clouds" } else { "Clouds" }, |area, px, _| { + map_pixels(s, if difference { "Difference Clouds" } else { "Clouds" }, p, |area, px, _| { let w = area.width() as usize; for (i, p) in px.iter_mut().enumerate() { let (x, y) = ((area.x0 + (i % w) as i32) as f32, (area.y0 + (i / w) as i32) as f32); @@ -655,7 +662,7 @@ pub fn specs() -> Vec { })?; Ok(json!({"selected": true})) }), - spec!("image.adjustments.equalize", "Equalize", &["Image", "Adjustments"], None, "{}", has_pixels, |s, _| equalize(s)), + spec!("image.adjustments.equalize", "Equalize", &["Image", "Adjustments"], None, "{}", has_pixels, equalize), spec!("image.revealAll", "Reveal All", &["Image"], None, "{}", has_doc, |s, _| { let d = s.active().ok_or(EngineError::NoDocument)?; let all = reveal_all_bounds(&d.doc); @@ -760,7 +767,7 @@ pub fn specs() -> Vec { has_layer, show_only ), - spec!("filter.blur.average", "Average", &["Filter", "Blur"], None, "{}", has_pixels, |s, _| average(s)), + spec!("filter.blur.average", "Average", &["Filter", "Blur"], None, "{}", has_pixels, average), spec!("filter.render.clouds", "Clouds", &["Filter", "Render"], None, r##"{"seed":u32=0}"##, has_pixels, |s, p| clouds(s, p, false)), spec!("filter.render.differenceClouds", "Difference Clouds", &["Filter", "Render"], None, r##"{"seed":u32=0}"##, has_pixels, |s, p| clouds(s, p, true)), ] @@ -781,6 +788,68 @@ mod tests { &s.active().unwrap().doc } + #[test] + fn equalize_average_and_clouds_edit_the_targeted_channel() { + // #935: these four ran through `map_pixels`, which always rewrote the active layer, so + // with an alpha channel or the Quick Mask targeted they changed the layer and left the + // channel alone. + let cmds = ["image.adjustments.equalize", "filter.blur.average", "filter.render.clouds", "filter.render.differenceClouds"]; + for depth in [8, 16, 32] { + for target in ["quickMask", "alpha", "mask"] { + for cmd in cmds { + let mut s = session(depth); + s.tools.foreground = [0.0, 0.0, 0.0, 1.0]; + s.tools.background = [1.0, 1.0, 1.0, 1.0]; + s.execute("edit.fill", json!({"contents": "color", "color": "#808080"})).unwrap(); + let p = match target { + "quickMask" => { + s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + json!({"seed": 1}) + } + "alpha" => { + s.execute("channel.new", json!({"fill": "white"})).unwrap(); + s.execute("channel.target", json!({"channel": 0})).unwrap(); + json!({"seed": 1}) + } + _ => { + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + json!({"seed": 1, "target": "mask"}) + } + }; + // Two levels in the target, so Equalize and Average have something to change. + let edit_target = json!({ "target": if target == "alpha" { json!({"channel": 0}) } else { json!(target) } }); + s.edit("levels", |doc, active| { + let (surf, _) = crate::channel_cmds::target_surface(doc, *active, &edit_target).unwrap(); + surf.fill_rect(Rect::new(0, 0, 20, 30), &[0.4]); + surf.fill_rect(Rect::new(20, 0, 40, 30), &[0.5]); + Ok(()) + }) + .unwrap(); + let full = Rect::new(0, 0, 40, 30); + let read = |s: &Session| { + let d = doc(s); + let layer = d.layer(s.active().unwrap().active_layer.unwrap()).unwrap(); + let t = match target { + "quickMask" => d.quick_mask.as_ref().unwrap().surface.read_region(full), + "alpha" => d.channels[0].surface.read_region(full), + _ => layer.mask.as_ref().unwrap().surface.read_region(full), + }; + (layer.surface().unwrap().read_region(full), t) + }; + let ((layer0, target0), past) = (read(&s), s.active().unwrap().history.past_len()); + s.execute(cmd, p).unwrap(); + let (layer1, target1) = read(&s); + let case = format!("{cmd} on {target} at {depth}"); + assert_eq!(layer1, layer0, "{case}: the layer is untouched"); + assert_ne!(target1, target0, "{case}: the target changed"); + assert_eq!(s.active().unwrap().history.past_len(), past + 1, "{case}"); + s.undo(); + assert_eq!(read(&s).1, target0, "{case}: undo restores the target"); + } + } + } + } + fn active(s: &Session) -> &Layer { let d = s.active().unwrap(); d.doc.layer(d.active_layer.unwrap()).unwrap() diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 23539fa2..9c7ac2c4 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -117,7 +117,7 @@ pub(crate) fn list_images(dir: &str) -> Result> { /// Extensions the batch commands pick up from a folder. const OPENABLE: &[&str] = &[ "psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi", "ico", "pnm", "ppm", "pgm", "heic", "heif", - "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz", + "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz", "af", "afdesign", "afphoto", "afpub", ]; /// Whether saving `doc` as a TIFF writes Photoshop layer data (anything beyond a lone @@ -185,7 +185,15 @@ pub(crate) fn sanitize(name: &str) -> String { } pub(crate) fn import(name: &str, bytes: &[u8]) -> Result { - photocraft_io::import(name, bytes).map(|r| r.document).map_err(|e| EngineError::Other(format!("{name}: {e}"))) + let r = photocraft_io::import(name, bytes).map_err(|e| EngineError::Other(format!("{name}: {e}")))?; + // Auxiliary imports return only a document and cannot surface the preview's fidelity warning. + // Open has its own warning-preserving path; never silently place or process a thumbnail. + if r.preview_only { + return Err(EngineError::Other(format!( + "{name}: only this Affinity file's embedded preview could be read; open it with File › Open to see the warning, or export PSD or PNG from Affinity before using it here" + ))); + } + Ok(r.document) } /// What a headless save writes beyond the format: JPEG quality and TIFF layers. @@ -348,7 +356,10 @@ pub fn open_bytes_as(s: &mut Session, name: &str, bytes: &[u8], as_ext: Option<& } }; // Color Settings policies (preserve / convert / discard the embedded profile). - let (i, color) = s.open_document(doc, path); + let (i, color) = s.open_document(doc, path.filter(|_| !r.source_read_only)); + if let Some(st) = s.active_mut() { + st.source_read_only = r.source_read_only; + } // Import notes (e.g. how a camera raw was developed, or that only its preview opened). Ok(json!({"document": i, "color": color, "warnings": r.warnings})) } diff --git a/crates/engine/src/filters_ext.rs b/crates/engine/src/filters_ext.rs index 97386c08..9d3730fc 100644 --- a/crates/engine/src/filters_ext.rs +++ b/crates/engine/src/filters_ext.rs @@ -436,8 +436,8 @@ pub(crate) fn resolve(s: &Session, fp: &mut FilterParams, p: &Value) -> Result<( let layer = p.get("mapLayer").and_then(Value::as_u64); let img = if let Some(path) = p.get("mapPath").and_then(Value::as_str).filter(|p| !p.is_empty()) { let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("cannot read displacement map `{path}`: {e}")))?; - let imported = photocraft_io::import(path, &bytes).map_err(|e| EngineError::Other(format!("cannot open displacement map `{path}`: {e}")))?; - map_from_document(&imported.document, layer) + let imported = crate::file_cmds::import(path, &bytes).map_err(|e| EngineError::Other(format!("cannot open displacement map `{path}`: {e}")))?; + map_from_document(&imported, layer) } else if let Some(d) = p.get("mapDocument") { let docs = s.documents(); let found = match d { diff --git a/crates/engine/src/fx_visibility_cmds.rs b/crates/engine/src/fx_visibility_cmds.rs new file mode 100644 index 00000000..bc24d84b --- /dev/null +++ b/crates/engine/src/fx_visibility_cmds.rs @@ -0,0 +1,183 @@ +//! Layers panel: the eyes on a layer's effects rows (#1622). The eye on the "Effects" row shows or +//! hides every effect of that layer; the eye on one effect's row shows or hides that effect. The +//! effects keep their settings either way, and each toggle is one history step. + +use photocraft_doc::LayerId; +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::{EngineError, Result, Session}; + +const ID: &str = "layer.setEffectsVisible"; + +fn bad(msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: ID.into(), msg: msg.into() } +} + +fn has_effects(s: &Session) -> std::result::Result<(), String> { + let d = s.active().ok_or("no document open")?; + if d.doc.walk().iter().any(|(_, _, l)| !l.effects.items.is_empty()) { Ok(()) } else { Err("no layer has effects".into()) } +} + +/// `{"layer":id?,"index":n?,"visible":bool?}`: show or hide a layer's effects (no `visible`: +/// toggle). Without `index` it is the whole list (the "Effects" row); with it, the effect at that +/// position in the layer's list. +fn set_visible(s: &mut Session, p: &Value) -> Result { + let want = match p.get("visible") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_bool().ok_or_else(|| bad("`visible` must be true or false"))?), + }; + let index = match p.get("index") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad("`index` must be an effect position (0, 1, …)"))?), + }; + let st = s.active().ok_or(EngineError::NoDocument)?; + let id = match p.get("layer") { + None | Some(Value::Null) => st.active_layer.ok_or_else(|| bad("no layer given and no active layer"))?, + Some(v) => LayerId(v.as_u64().ok_or_else(|| bad("`layer` must be a layer id"))?), + }; + let l = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?; + if l.effects.items.is_empty() { + return Err(bad(format!("layer {} has no effects", id.0))); + } + let (current, name) = match index { + Some(i) => { + let e = l.effects.items.get(i).ok_or_else(|| bad(format!("layer {} has no effect at index {i}", id.0)))?; + (e.enabled(), e.label()) + } + None => (l.effects.enabled, "Layer Effects"), + }; + let visible = want.unwrap_or(!current); + if visible != current { + let label = format!("{} {name}", if visible { "Show" } else { "Hide" }); + s.edit(&label, |doc, _| { + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + match index { + Some(i) => l.effects.items.get_mut(i).ok_or_else(|| bad(format!("layer {} has no effect at index {i}", id.0)))?.set_enabled(visible), + None => l.effects.enabled = visible, + } + Ok(()) + })?; + } + Ok(json!({"layer": id.0, "index": index, "visible": visible})) +} + +pub fn specs() -> Vec { + vec![CommandSpec { + id: ID, + label: "Show/Hide Effects", + menu: &[], + shortcut: None, + params: r##"{"layer":id?,"index":n?,"visible":bool?} (no index: all of the layer's effects, the "Effects" row; index: the effect at that position in the layer's list; no visible: toggle)"##, + enabled: has_effects, + run: set_visible, + journal: true, + }] +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A red layer with a drop shadow and a blue colour overlay, and the background (no effects). + fn session() -> (Session, u64, u64) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32})).unwrap(); + let bg = s.active().unwrap().doc.layers[0].id.0; + let a = s.execute("layer.new.layer", json!({"name": "a"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + s.execute("layer.layerStyle.dropShadow", json!({"layer": a})).unwrap(); + s.execute("layer.layerStyle.colorOverlay", json!({"layer": a, "color": "#0000ff"})).unwrap(); + (s, a, bg) + } + + /// (the whole list is on, each effect is on). + fn state(s: &Session, id: u64) -> (bool, Vec) { + let d = s.active().unwrap(); + let fx = &d.doc.layer(LayerId(id)).unwrap().effects; + (fx.enabled, fx.items.iter().map(|e| e.enabled()).collect()) + } + + fn steps(s: &Session) -> usize { + s.active().unwrap().history.past_len() + } + + fn flat(s: &Session) -> photocraft_compose::Buffer { + photocraft_compose::flatten(&s.active().unwrap().doc) + } + + #[test] + fn the_effects_row_eye_hides_and_shows_the_whole_list() { + let (mut s, a, _) = session(); + let n = steps(&s); + assert_eq!(s.execute(ID, json!({"layer": a})).unwrap(), json!({"layer": a, "index": null, "visible": false})); + assert_eq!(state(&s, a), (false, vec![true, true]), "each effect keeps its own state"); + assert_eq!(steps(&s), n + 1, "one history step"); + assert_eq!(s.active().unwrap().history.undo_label(), Some("Hide Layer Effects")); + // The active layer is the default target. + assert_eq!(s.execute(ID, json!({})).unwrap()["visible"], true); + assert_eq!(state(&s, a), (true, vec![true, true])); + assert_eq!(s.active().unwrap().history.undo_label(), Some("Show Layer Effects")); + } + + #[test] + fn one_effect_eye_hides_only_that_effect_and_undoes() { + let (mut s, a, _) = session(); + assert_eq!(s.execute(ID, json!({"layer": a, "index": 1})).unwrap()["visible"], false); + assert_eq!(state(&s, a), (true, vec![true, false])); + assert_eq!(s.active().unwrap().history.undo_label(), Some("Hide Color Overlay")); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(state(&s, a), (true, vec![true, true])); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(state(&s, a), (true, vec![true, false])); + s.execute(ID, json!({"layer": a, "index": 1, "visible": true})).unwrap(); + assert_eq!(state(&s, a), (true, vec![true, true])); + } + + #[test] + fn hiding_changes_the_composite_and_keeps_the_settings() { + let (mut s, a, _) = session(); + let before = s.active().unwrap().doc.layer(LayerId(a)).unwrap().effects.items.clone(); + let shown = flat(&s); + s.execute(ID, json!({"layer": a, "visible": false})).unwrap(); + assert_ne!(flat(&s), shown, "hidden effects are not drawn"); + s.execute(ID, json!({"layer": a, "visible": true})).unwrap(); + assert_eq!(flat(&s), shown); + assert_eq!(s.active().unwrap().doc.layer(LayerId(a)).unwrap().effects.items, before); + } + + #[test] + fn asking_for_the_current_state_adds_no_history_step() { + let (mut s, a, _) = session(); + let n = steps(&s); + assert_eq!(s.execute(ID, json!({"layer": a, "visible": true})).unwrap()["visible"], true); + assert_eq!(s.execute(ID, json!({"layer": a, "index": 0, "visible": true})).unwrap()["visible"], true); + assert_eq!(steps(&s), n); + } + + #[test] + fn bad_params_fail_gracefully() { + let (mut s, a, bg) = session(); + let before = state(&s, a); + for p in [ + json!({"layer": bg}), + json!({"layer": 9999}), + json!({"layer": "x"}), + json!({"layer": -1}), + json!({"layer": a, "index": 2}), + json!({"layer": a, "index": -1}), + json!({"layer": a, "index": 1.5}), + json!({"layer": a, "index": "0"}), + json!({"layer": a, "index": u64::MAX}), + json!({"layer": a, "visible": "yes"}), + json!({"layer": a, "visible": 1}), + ] { + assert!(s.execute(ID, p.clone()).is_err(), "{p}"); + } + assert_eq!(state(&s, a), before, "a refused call changes nothing"); + let mut empty = Session::new(); + assert!(empty.execute(ID, json!({})).is_err()); + empty.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + assert!(empty.execute(ID, json!({})).is_err(), "no effects: disabled"); + } +} diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index f32689c9..303d1b0b 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -427,6 +427,9 @@ impl Session { }, move |s, r: photocraft_io::ImportResult| { let (index, color) = s.open_document(r.document, None); + if let Some(st) = s.active_mut() { + st.source_read_only = r.source_read_only; + } Ok(json!({"document": index, "name": name_a, "warnings": r.warnings, "color": color})) }, ) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b1a411a2..0401199d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -39,6 +39,7 @@ pub mod filters_ext; pub mod float_cmds; mod frame_cmds; pub mod fx_view_cmds; +pub mod fx_visibility_cmds; pub mod gallery_cmds; pub mod gradient_fill_cmds; pub mod group_view_cmds; @@ -160,6 +161,8 @@ pub struct DocState { /// Anchor of ⇧-click range selection (the last plainly or ⌘-clicked layer). pub layer_anchor: Option, pub path: Option, + /// An Affinity document (or its preview) must not acquire the native source as its Save path. + pub source_read_only: bool, /// Increments on every change; UIs re-render when it moves. pub revision: u64, pub saved_revision: u64, @@ -196,6 +199,7 @@ impl DocState { selected_layers: active_layer.into_iter().collect(), layer_anchor: active_layer, path, + source_read_only: false, revision: 1, saved_revision: 1, last_damage: None, diff --git a/crates/engine/src/notes_cmds.rs b/crates/engine/src/notes_cmds.rs index aac8efe3..54bbd60b 100644 --- a/crates/engine/src/notes_cmds.rs +++ b/crates/engine/src/notes_cmds.rs @@ -165,7 +165,7 @@ fn import(s: &mut Session, p: &Value) -> Result { /// File › Import › Notes… from file bytes (what a file picker returns). pub fn import_notes_from(s: &mut Session, name: &str, bytes: &[u8]) -> Result { - let src = photocraft_io::import(name, bytes).map_err(|e| EngineError::Other(format!("{name}: {e}")))?.document; + let src = crate::file_cmds::import(name, bytes)?; if src.notes.is_empty() { return Err(EngineError::Other(format!("{name} has no notes"))); } diff --git a/crates/engine/src/pick_cmds.rs b/crates/engine/src/pick_cmds.rs index 0dd983cb..70d5cbef 100644 --- a/crates/engine/src/pick_cmds.rs +++ b/crates/engine/src/pick_cmds.rs @@ -1,6 +1,6 @@ -//! Move tool Auto-Select: pick the topmost visible layer with pixels at a canvas point (Photoshop's -//! options bar "Auto-Select: Layer | Group", ⌘-click with the Move tool, and the canvas right-click -//! layer list). +//! Move tool Auto-Select: pick the topmost visible, not fully locked layer with pixels at a canvas +//! point (Photoshop's options bar "Auto-Select: Layer | Group", ⌘-click with the Move tool, and the +//! canvas right-click layer list). use photocraft_doc::{Document, LayerContent, LayerId}; use photocraft_geom::Rect; @@ -81,7 +81,9 @@ fn pick(s: &mut Session, p: &Value) -> Result { let names: Vec = hits.iter().filter_map(|id| doc.layer(*id)).map(|l| json!({"layer": l.id.0, "name": l.name})).collect(); return Ok(json!({ "layers": names })); } - let Some(&hit) = hits.first() else { return Ok(json!({ "layer": null })) }; + // Like Photoshop, Auto-Select clicks through a fully locked layer (its own Lock All or a + // locked group's) to the layer under it (#1641). The right-click list above still shows it. + let Some(&hit) = hits.iter().find(|id| !doc.effective_locks(**id).all) else { return Ok(json!({ "layer": null })) }; let target = if p.get("target").and_then(Value::as_str) == Some("group") { top_group(&doc, hit) } else { hit }; if p.get("select").and_then(Value::as_bool).unwrap_or(true) { let mode = p.get("mode").and_then(Value::as_str).unwrap_or("replace"); @@ -222,6 +224,42 @@ mod tests { assert_eq!(pick(&mut s, 5, 5, "layer"), bg.0); } + #[test] + fn a_fully_locked_layer_is_clicked_through() { + // #1641: a locked layer on top was picked, so the drag couldn't reach the layer under it. + let (mut s, a, _) = two_squares(); + s.execute("layer.new.layer", json!({"name": "Top"})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let top = s.active().unwrap().active_layer.unwrap(); + let lock = |s: &mut Session, id: u64, locks: Value| s.execute("layer.setProps", json!({"layer": id, "locks": locks})).unwrap(); + let pick = |s: &mut Session| s.execute("layer.pickAt", json!({"x": 5, "y": 5})).unwrap()["layer"].clone(); + assert_eq!(pick(&mut s), top.0); + // A position lock alone doesn't hide it from Auto-Select. + lock(&mut s, top.0, json!({"position": true})); + assert_eq!(pick(&mut s), top.0); + lock(&mut s, top.0, json!({"all": true})); + assert_eq!(pick(&mut s), a.0); + assert_eq!(s.active().unwrap().active_layer, Some(a)); + // The right-click layer list still names it. + let list = s.execute("layer.pickAt", json!({"x": 5, "y": 5, "list": true})).unwrap(); + assert_eq!(list["layers"][0]["layer"], top.0); + // A locked group locks the layers inside it. + lock(&mut s, top.0, json!({"all": false, "position": false})); + s.execute("layer.select", json!({"layer": top.0})).unwrap(); + let g = s.execute("layer.new.groupFromLayers", json!({"name": "G"})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(pick(&mut s), top.0); + lock(&mut s, g, json!({"all": true})); + assert_eq!(pick(&mut s), a.0); + // Nothing unlocked under the point: no pick. + lock(&mut s, a.0, json!({"all": true})); + let bg = s.active().unwrap().doc.layers[0].id; + assert_eq!(pick(&mut s), bg.0); + lock(&mut s, bg.0, json!({"all": true})); + assert_eq!(pick(&mut s), Value::Null); + } + #[test] fn hostile_params_are_errors_or_misses_not_panics() { let (mut s, _, _) = two_squares(); diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index ab12a34d..ecef505a 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -71,6 +71,14 @@ choice!( /// CPU disables image acceleration; the native window may still need hardware graphics. RenderingMode { Auto = "auto", Gpu = "gpu", Cpu = "cpu" } default Auto ); +choice!( + /// Linux display server of the desktop app's window (applies at next launch). `x11` runs + /// PhotoCraft through XWayland on a Wayland session, where native file drag and drop works + /// (winit 0.30 has none on Wayland, issue #386); fractional scaling may then look softer. + /// It needs an X server (`DISPLAY`); without one PhotoCraft starts as `auto`. Other + /// platforms ignore it. + LinuxDisplayServer { Auto = "auto", X11 = "x11" } default Auto +); choice!(UiFontSize { Tiny = "tiny", Small = "small", Medium = "medium", Large = "large" } default Small); choice!(LogDestination { Metadata = "metadata", TextFile = "textFile", Both = "both" } default Metadata); choice!(LogDetail { SessionsOnly = "sessionsOnly", Concise = "concise", Detailed = "detailed" } default Concise); @@ -412,6 +420,8 @@ pub struct Performance { /// sliders while they drag) render on a reduced copy: fast, but blocky when zoomed in. Off: /// they render at full resolution. pub low_resolution_previews: bool, + /// Linux display server (applies at next launch; see [`LinuxDisplayServer`]). + pub linux_display_server: LinuxDisplayServer, /// Memory budget of the layer-effect cache, in MB. pub effect_cache_mb: u32, pub legacy_compositing: bool, @@ -441,6 +451,7 @@ impl Default for Performance { rendering_mode: None, gpu_backend: GpuBackend::Auto, low_resolution_previews: true, + linux_display_server: LinuxDisplayServer::Auto, effect_cache_mb: 768, legacy_compositing: false, } @@ -879,9 +890,13 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ /// Is the preference at `path` (`"section.key"`) hidden from the Preferences dialog? pub fn is_hidden(path: &str) -> bool { - HIDDEN_UNTIL_IMPLEMENTED.contains(&path) + HIDDEN_UNTIL_IMPLEMENTED.contains(&path) || (!cfg!(target_os = "linux") && LINUX_ONLY.contains(&path)) } +/// Preferences that only do something on Linux; the dialog doesn't show them elsewhere. They +/// still load, save and round-trip on every platform. +pub const LINUX_ONLY: &[&str] = &["performance.linuxDisplayServer"]; + /// Choices of an enumerated preference (dotted path, e.g. `"cursors.painting"`). pub fn choices(path: &str) -> Option<&'static [&'static str]> { Some(match path { @@ -913,6 +928,7 @@ pub fn choices(path: &str) -> Option<&'static [&'static str]> { "rawDefaults.bitDepth" => RawDepth::NAMES, "rawDefaults.sharpenFor" => RawSharpen::NAMES, "performance.gpuBackend" => GpuBackend::NAMES, + "performance.linuxDisplayServer" => LinuxDisplayServer::NAMES, "performance.renderingMode" => RenderingMode::NAMES, _ => return None, }) @@ -1394,6 +1410,8 @@ fn prefs_set(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, e)); } } + // `colorSettings.blendTextGamma` lives in the compositor too, as `edit.colorSettings` sets it. + photocraft_compose::psblend::set_text_gamma(s.color.settings.blend_text_gamma); s.apply_prefs(); let view = s.prefs_view(); let out: Map = changes.iter().map(|(k, _)| (k.clone(), get_path(&view, k).cloned().unwrap_or(Value::Null))).collect(); @@ -1423,6 +1441,7 @@ fn prefs_reset(s: &mut Session, p: &Value) -> Result { } } s.prefs.edit(|_| ()); + photocraft_compose::psblend::set_text_gamma(s.color.settings.blend_text_gamma); s.apply_prefs(); if path.is_some_and(|path| keyed(path).is_some()) { // The removed override is absent, so reading its old path would report an error. diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index 99915b08..4cc86764 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -363,3 +363,10 @@ fn gpu_backend_round_trips_and_validates() { assert_eq!(GpuBackend::parse(n).map(GpuBackend::name), Some(*n)); } } + +#[test] +fn linux_only_preferences_show_only_on_linux() { + assert_eq!(is_hidden("performance.linuxDisplayServer"), !cfg!(target_os = "linux")); + assert!(!is_hidden("performance.gpuBackend")); + assert!(LINUX_ONLY.iter().all(|p| choices(p).is_some()), "every Linux-only preference is a real one"); +} diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 3eed2901..242bba76 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -81,10 +81,7 @@ fn parse_brush(s: &Session, p: &Value, cmd: &str) -> Result<(Stroke, Option, + cmd: String, renderer: photocraft_paint::StrokeRenderer, pre: std::sync::Arc, pre_surf: Surface, @@ -364,8 +362,21 @@ impl LiveRetouch { LivePaint::History(src) if src.format() != pre_surf.format() => LivePaint::History(src.convert(pre_surf.format())), other => other, }; - let mut live = - Self { doc: std::sync::Arc::new(doc), renderer, pre, pre_surf, id, params: p.clone(), paint, mode, opacity, sel, lock, tail: Rect::EMPTY }; + let mut live = Self { + doc: std::sync::Arc::new(doc), + cmd: cmd.into(), + renderer, + pre, + pre_surf, + id, + params: p.clone(), + paint, + mode, + opacity, + sel, + lock, + tail: Rect::EMPTY, + }; live.push(&stroke.points)?; Ok(live) } @@ -380,7 +391,9 @@ impl LiveRetouch { /// the commit, and pixels painted earlier in the stroke are never re-cloned. What finishing /// the stroke now would add is drawn too (a lone first dab shows on the press), and redrawn on /// every step. + /// Invalid coordinates reject the whole batch without changing the preview. pub fn push(&mut self, pts: &[StrokePoint]) -> Result { + crate::brush_cmds::check_coords(pts, &self.cmd)?; self.renderer.push(pts); let old = std::mem::replace(&mut self.tail, Rect::EMPTY); let mut dmg = Rect::EMPTY; @@ -867,6 +880,7 @@ fn dab_cmd(s: &mut Session, p: &Value, cmd: &str) -> Result { pub struct LiveDab { /// The active document with the stroke so far. pub doc: std::sync::Arc, + cmd: String, renderer: photocraft_paint::StrokeRenderer, done: usize, effect: DabEffect, @@ -899,6 +913,7 @@ impl LiveDab { let renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0).record_dabs(); let mut live = Self { doc: std::sync::Arc::new(doc), + cmd: cmd.into(), renderer, done: 0, effect, @@ -922,7 +937,9 @@ impl LiveDab { } /// Render more points; returns the rectangle that changed. + /// Invalid coordinates reject the whole batch without changing the preview. pub fn push(&mut self, pts: &[StrokePoint]) -> Result { + crate::brush_cmds::check_coords(pts, &self.cmd)?; self.renderer.push(pts); let ctx = self.renderer.ctx.clone(); let new: Vec<_> = self.renderer.dabs().get(self.done..).unwrap_or_default().to_vec(); diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index 1b6ddd13..bb13463e 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -1133,6 +1133,68 @@ fn live_clone_matches_the_commit() { } } +// Both preview implementations must reject an entire pointer batch before their renderer, +// coverage, tail or working surface changes. Compare a resumed stroke with a clean control. +macro_rules! live_coordinate_regression { + ($name:ident, $live:ty, [$($cmd:literal),+]) => { + #[test] + fn $name() { + for depth in DEPTHS { + let mut s = session(32, 32, depth, "rgb"); + paint_layer(&mut s, texture); + let st = s.active().unwrap(); + let (doc, revision, history) = (st.doc.clone(), st.revision, st.history.past_len()); + let id = st.active_layer.unwrap(); + for cmd in [$($cmd),+] { + let p = json!({"points": [[8, 16]], "source": [4, 8], "size": 4, "hardness": 100}); + let mut live = <$live>::begin(&s, cmd, &p).unwrap(); + let mut control = <$live>::begin(&s, cmd, &p).unwrap(); + // The first regression case fails fast even without the guard, before any + // enormous coordinate reaches the path walker. + for (x, y) in [ + (f64::NAN, 16.0), + (16.0, f64::NAN), + (f64::INFINITY, 16.0), + (16.0, f64::NEG_INFINITY), + (1e300, 16.0), + (16.0, -crate::brush_cmds::MAX_COORD - 1.0), + ] { + let shown = live.doc.clone(); + let bounds = live.bounds(); + let batch = [StrokePoint::new(12.0, 18.0, 1.0), StrokePoint::new(x, y, 1.0)]; + let err = live.push(&batch).unwrap_err(); + assert!(matches!(err, EngineError::BadParams { cmd: ref actual, ref msg } + if actual == cmd && msg.contains("point coordinates must be finite"))); + assert!(std::sync::Arc::ptr_eq(&shown, &live.doc), "{cmd}: invalid batch changed the preview"); + assert_eq!(live.bounds(), bounds, "{cmd}"); + } + let next = [StrokePoint::new(-4.0, 16.0, 1.0), StrokePoint::new(24.0, 16.0, 1.0)]; + assert_eq!(live.push(&next).unwrap(), control.push(&next).unwrap(), "{cmd}"); + assert_eq!(live.bounds(), control.bounds(), "{cmd}"); + let got = live.doc.layer(id).unwrap().surface().unwrap(); + let want = control.doc.layer(id).unwrap().surface().unwrap(); + assert_eq!(got.read_region(live.bounds()), want.read_region(control.bounds()), "{cmd}"); + assert_eq!(got.content_bounds(), want.content_bounds(), "{cmd}"); + } + let st = s.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&doc, &st.doc)); + assert_eq!((st.revision, st.history.past_len()), (revision, history)); + } + } + }; +} + +live_coordinate_regression!( + live_retouch_rejects_invalid_batches_without_changing_the_preview, + LiveRetouch, + ["paint.cloneStamp", "paint.healingBrush", "paint.historyBrush"] +); +live_coordinate_regression!( + live_dab_rejects_invalid_batches_without_changing_the_preview, + LiveDab, + ["paint.dodge", "paint.burn", "paint.sponge", "paint.blur", "paint.sharpen", "paint.smudge"] +); + /// The live Dodge shows what the commit paints, also where a stroke doubles back across the /// cells that remember each pixel's original colour (the live stroke edits one dab at a time). /// 32-bit, so the live working copy isn't quantised between dabs. @@ -1164,3 +1226,40 @@ fn live_dodge_matches_the_commit_across_cells() { assert!(changed); assert!(worst <= 1e-4, "live and commit differ by {worst}"); } + +#[test] +fn retouch_strokes_refuse_absurd_point_coordinates() { + // #976: a segment to (1e300, 0) has an infinite length and the dab walker would never stop, so + // every retouch tool's `points` must stay within ±1e6, as the painting tools' do. + let cmds = [ + "paint.dodge", + "paint.burn", + "paint.sponge", + "paint.blur", + "paint.sharpen", + "paint.smudge", + "paint.cloneStamp", + "paint.healingBrush", + "paint.spotHealing", + "paint.historyBrush", + "paint.patternStamp", + ]; + // A single far point first: cheap even without the bound, so a regression fails fast. + let far = [json!([[1_000_001, 0]]), json!([[0, 0], [0, -2_000_000]]), json!([[0, 0], [1e300, 0]]), json!([[1e300, 1e300]])]; + for depth in DEPTHS { + let mut s = session(32, 32, depth, "rgb"); + paint_layer(&mut s, |x, y| [x as f32 / 32.0, y as f32 / 32.0, 0.25, 1.0]); + s.execute("cloneSource.set", json!({"source": [8, 8]})).unwrap(); + let (before, past) = (rgba(&s, 16, 16), s.active().unwrap().history.past_len()); + for cmd in cmds { + for points in &far { + let err = s.execute(cmd, json!({ "points": points })).unwrap_err(); + assert!(err.to_string().contains("within ±"), "{cmd} {points} at {depth}: {err}"); + } + } + assert_eq!((rgba(&s, 16, 16), s.active().unwrap().history.past_len()), (before, past), "{depth}: nothing changed"); + // Control: a stroke on the canvas still retouches. + s.execute("paint.dodge", json!({"points": [[4, 16], [28, 16]], "size": 8})).unwrap(); + assert_ne!(rgba(&s, 16, 16), before, "{depth}"); + } +} diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index 94b7b558..02cf6268 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -64,7 +64,7 @@ pub fn encode_source(doc: &Document) -> Result> { /// Decodes smart-object contents (a `.pcraft` bundle, PSD/PSB, or any flat image format). pub fn decode_source(file_name: &str, bytes: &[u8]) -> Result { - photocraft_io::import(file_name, bytes).map(|r| r.document).map_err(|e| other(format!("can't read smart object contents \"{file_name}\": {e}"))) + crate::file_cmds::import(file_name, bytes).map_err(|e| other(format!("can't read smart object contents \"{file_name}\": {e}"))) } fn base_name(path: &str) -> String { diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index b426f354..b2ad24a5 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -264,11 +264,21 @@ pub fn split_selected(surf: &Surface, sel: &Surface) -> (Surface, Surface) { let mut lp = px.clone(); let mut rp = px; let w = src.width() as usize; + // A pixel the selection fully lifts out is reset to the surface's (transparent) default; + // keeping its colour would make `content_bounds` still count it, so the layer's bounds would + // include the area the pixels were moved from. + let mut rest_clear = rest.default_pixel(); + if rest_clear.last().is_some_and(|a| *a > 0.0) { + rest_clear.fill(0.0); + } for (i, (l, r)) in lp.chunks_exact_mut(n).zip(rp.chunks_exact_mut(n)).enumerate() { let (x, y) = (src.x0 + (i % w) as i32, src.y0 + (i / w) as i32); let k = sel.sample_channel(x, y, 0); l[n - 1] *= k; r[n - 1] *= 1.0 - k; + if r[n - 1] <= 0.0 { + r.iter_mut().zip(&rest_clear).for_each(|(d, c)| *d = *c); + } } lifted.write_region(src, &lp); lifted.prune(); @@ -491,6 +501,16 @@ mod tests { assert_eq!((sel.y0, sel.y1), (60, 70), "selection moves too"); } + #[test] + fn with_selection_bounds_drop_the_vacated_area() { + // #1097: moving every pixel through a selection left transparent but coloured pixels + // behind, so the layer bounds were the union of the old and new positions. + let mut s = session(); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 10})).unwrap(); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 50, 0]})).unwrap(); + assert_eq!(active_bounds(&s), Rect::new(60, 10, 80, 20)); + } + #[test] fn type_layers_transform_as_vectors() { let mut s = session(); diff --git a/crates/engine/src/variables_cmds.rs b/crates/engine/src/variables_cmds.rs index 94c89ed1..c6b2dbcc 100644 --- a/crates/engine/src/variables_cmds.rs +++ b/crates/engine/src/variables_cmds.rs @@ -193,7 +193,7 @@ fn replace_pixels(doc: &mut Document, layer: LayerId, path: &str, method: PixelM } let fmt = doc.pixel_format(); let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("read `{path}`: {e}")))?; - let src = photocraft_io::import(path, &bytes).map_err(|e| EngineError::Other(format!("`{path}`: {e}")))?.document; + let src = crate::file_cmds::import(path, &bytes)?; let img = crate::file_cmds::flattened(&src, fmt); let (iw, ih) = (src.size.width.max(1) as f64, src.size.height.max(1) as f64); let (tw, th) = (target.width() as f64, target.height() as f64); diff --git a/crates/engine/src/video_cmds.rs b/crates/engine/src/video_cmds.rs index c0286d61..1a6c92be 100644 --- a/crates/engine/src/video_cmds.rs +++ b/crates/engine/src/video_cmds.rs @@ -207,7 +207,7 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result Vec { + let mut src = Session::new(); + src.execute("file.new", json!({"width": 2, "height": 1, "background": "#dc283c"})).unwrap(); + let png = photocraft_io::export(&src.active().unwrap().doc, "preview.png", &Default::default()).unwrap().bytes; + // Observed v12 preview envelope, without a native object graph. + let mut bytes = vec![0; 72]; + bytes[..4].copy_from_slice(b"\x00\xffKA"); + bytes[4..6].copy_from_slice(&12u16.to_le_bytes()); + bytes[8..12].copy_from_slice(b"nsrP"); + bytes[12..16].copy_from_slice(b"#Inf"); + bytes[24..32].copy_from_slice(&72u64.to_le_bytes()); + bytes[64..68].copy_from_slice(b"Prot"); + bytes.extend(b"\xff\xff\xff\xffThmb"); + bytes.extend(1u32.to_le_bytes()); + bytes.extend((png.len() as u32 + 13).to_le_bytes()); + bytes.extend(29u32.to_le_bytes()); + bytes.extend(0u32.to_le_bytes()); + bytes.extend((png.len() as u32).to_le_bytes()); + bytes.push(1); + bytes.extend(png); + bytes +} + +#[test] +fn preview_never_saves_back_to_its_native_or_renamed_source() { + let dir = std::env::temp_dir().join(format!("photocraft-affinity-source-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let bytes = fixture(); + for name in ["source.af", "renamed.psd"] { + let path = dir.join(name).to_string_lossy().into_owned(); + std::fs::write(&path, &bytes).unwrap(); + let mut s = Session::new(); + let r = open_bytes_as(&mut s, &path, &bytes, None, Some(path.clone())).unwrap(); + assert!(r["warnings"].to_string().contains("embedded")); + assert!(s.active().unwrap().path.is_none(), "{name}"); + assert!(s.active().unwrap().source_read_only); + assert!(!s.is_enabled("file.revert")); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + // Explicit native export is refused before any file is written. + let native = dir.join("refused.af"); + assert!(s.execute("file.saveACopy", json!({"path": native})).is_err()); + assert!(!native.exists()); + let copy = dir.join(format!("{name}.pcraft")); + s.execute("file.saveACopy", json!({"path": copy})).unwrap(); + assert!(photocraft_io::import("copy.pcraft", &std::fs::read(copy).unwrap()).is_ok()); + assert_eq!(std::fs::read(path).unwrap(), bytes); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn background_preview_retains_the_source_protection_and_warning() { + let mut s = Session::new(); + let started = s.start_open("source.af", OpenSource::Bytes(Arc::new(fixture()))).unwrap(); + let result = match started { + Started::Job(id) => s.wait_job(id).unwrap(), + Started::Done(v) => v, + }; + assert!(result["warnings"].to_string().contains("no layers")); + assert!(s.active().unwrap().source_read_only); + assert!(s.active().unwrap().path.is_none()); +} + +#[test] +fn warningless_auxiliary_paths_cannot_silently_use_a_thumbnail() { + let bytes = fixture(); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let layers = s.active().unwrap().doc.layers.len(); + let error = photocraft_engine::file_cmds::place_bytes(&mut s, "source.af", bytes.clone(), None, &json!({})).unwrap_err().to_string(); + assert!(error.contains("embedded preview could be read"), "{error}"); + assert_eq!(s.active().unwrap().doc.layers.len(), layers); + assert!(photocraft_engine::smart_cmds::decode_source("source.af", &bytes).unwrap_err().to_string().contains("export PSD or PNG")); +} + +/// A minimal native document: one 10×10 px red square at (5, 5) on a 40×20 page. +fn native() -> Vec { + use photocraft_affinity::synth::{self, F, Method, tag}; + let node = |x: f64, y: f64| { + let mut r = x.to_le_bytes().to_vec(); + r.extend(y.to_le_bytes()); + r.extend([1, 0]); + r + }; + let red = F::Struct([1.0f32, 0.0, 0.0, 1.0].iter().flat_map(|v| v.to_le_bytes()).collect()); + let square = F::Def( + 3, + vec![tag(b"PCrv")], + vec![ + ( + tag(b"Crvs"), + F::Obj( + tag(b"PCvD"), + vec![( + tag(b"Data"), + F::Pos(vec![ + F::U8(0), + F::U32(1), + F::Bool(true), + F::Records(18, vec![node(5.0, 5.0), node(15.0, 5.0), node(15.0, 15.0), node(5.0, 15.0), node(5.0, 5.0)]), + ]), + )], + ), + ), + ( + tag(b"BFFl"), + F::Shared(vec![F::Def( + 4, + vec![tag(b"FDsc")], + vec![(tag(b"FDeF"), F::Def(5, vec![tag(b"FilS")], vec![(tag(b"Colr"), F::Def(6, vec![tag(b"RGBA")], vec![(tag(b"_col"), red)]))]))], + )]), + ), + ], + ); + let spread = F::Def( + 2, + vec![tag(b"Sprd")], + vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 40.0, 20.0])), (tag(b"SprT"), F::Bool(true)), (tag(b"Chld"), F::Shared(vec![square]))], + ); + let doc = synth::stream(&[(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]); + synth::container(&[("doc.dat", &doc, Method::Zstd)], None) +} + +#[test] +fn native_documents_open_read_only_and_can_be_placed() { + let bytes = native(); + let mut s = Session::new(); + let r = open_bytes_as(&mut s, "art.af", &bytes, None, Some("/source/art.af".into())).unwrap(); + assert!(!r["warnings"].to_string().contains("preview"), "{r}"); + let st = s.active().unwrap(); + assert!(st.path.is_none() && st.source_read_only); + assert_eq!((st.doc.size.width, st.doc.size.height), (40, 20)); + s.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + let before = s.active().unwrap().doc.layers.len(); + photocraft_engine::file_cmds::place_bytes(&mut s, "art.af", bytes, None, &json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.layers.len(), before + 1); +} diff --git a/crates/engine/tests/text_gamma.rs b/crates/engine/tests/text_gamma.rs index 4089dcb2..603dbae5 100644 --- a/crates/engine/tests/text_gamma.rs +++ b/crates/engine/tests/text_gamma.rs @@ -27,4 +27,21 @@ fn blend_text_gamma_setting() { s.execute("edit.colorSettings", json!({"blendTextGamma": true})).unwrap(); assert!((s.color.settings.blend_text_gamma - 1.45).abs() < 1e-6); assert!((photocraft_compose::psblend::text_gamma() - 1.45).abs() < 1e-6); + + // `prefs.set` and `prefs.reset` on `colorSettings` move the compositor's gamma too (#987). + // Same function, not a second test: tests in one binary run concurrently. + let in_sync = |s: &Session| (photocraft_compose::psblend::text_gamma() - s.color.settings.blend_text_gamma).abs() < 1e-6; + s.execute("edit.colorSettings", json!({"blendTextGamma": 1.8})).unwrap(); + s.execute("prefs.set", json!({"path": "colorSettings.blendTextGamma", "value": 1.0})).unwrap(); + assert_eq!(photocraft_compose::psblend::text_gamma(), 1.0); + assert!(in_sync(&s)); + s.execute("prefs.reset", json!({"path": "colorSettings.blendTextGamma"})).unwrap(); + assert!((photocraft_compose::psblend::text_gamma() - 1.45).abs() < 1e-6); + assert!(in_sync(&s)); + for reset in [json!({"path": "colorSettings"}), json!({})] { + s.execute("edit.colorSettings", json!({"blendTextGamma": 1.8})).unwrap(); + s.execute("prefs.reset", reset).unwrap(); + assert!((photocraft_compose::psblend::text_gamma() - 1.45).abs() < 1e-6); + assert!(in_sync(&s)); + } } diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index a71e53ec..1d6c019b 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -48,6 +48,7 @@ const F_STROKE_OUT: u32 = 1024u; // effect paint: outside stroke band const F_FIRST: u32 = 2048u; // outside strokes: nothing accumulated yet const F_CHANNELS: u32 = 4096u; // lerp: per-channel weights in p0 (channel restrictions) const F_LAB: u32 = 65536u; // Lab document: Normal mixes in CIELAB +const F_HDR: u32 = 262144u; // 32-bit float document: Add / Divide don't clip at 1 const F_QUANT: u32 = 32768u; // lerp: A rounded to p0.x steps (adjustment results, integer docs) const F_ADD_DIFF: u32 = 16384u; // lerp: A + (B - C) premultiplied (clips on pass-through groups) const F_TEXT_GAMMA: u32 = 8192u; // blend / atop / fx merge: type layer, mix coverage at gamma p4.w @@ -152,7 +153,11 @@ fn vivid_light_generic(cb: f32, cs: f32) -> f32 { return min(cb / (2.0 * (1.0 - cs)), 1.0); } +const F32_MAX: f32 = 3.40282347e38; + fn blend_channel(mode: i32, cb: f32, cs: f32) -> f32 { + // Linear Dodge / Divide clip at 1 at integer depths, at f32::MAX in 32-bit documents. + let hi = select(1.0, F32_MAX, (op.flags & F_HDR) != 0u); switch mode { case 3: { return min(cb, cs); } // Darken case 4: { return cb * cs; } // Multiply @@ -161,7 +166,7 @@ fn blend_channel(mode: i32, cb: f32, cs: f32) -> f32 { case 8: { return max(cb, cs); } // Lighten case 9: { return cb + cs - cb * cs; } // Screen case 10: { return color_dodge(cb, cs); } // ColorDodge - case 11: { return min(cb + cs, 1.0); } // LinearDodge + case 11: { return min(cb + cs, hi); } // LinearDodge case 13: { return hard_light(cs, cb); } // Overlay case 14: { return soft_light_ps(cb, cs); } // SoftLight case 15: { return hard_light(cb, cs); } // HardLight @@ -179,7 +184,7 @@ fn blend_channel(mode: i32, cb: f32, cs: f32) -> f32 { case 22: { return max(cb - cs, 0.0); } // Subtract case 23: { // Divide if (cs <= 0.0) { return select(1.0, 0.0, cb <= 0.0); } - return min(cb / cs, 1.0); + return min(cb / cs, hi); } default: { return cs; } // Normal, Dissolve, PassThrough } diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index ca50711f..76761e32 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -305,11 +305,10 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool, Effect::OuterGlow(g) | Effect::InnerGlow(g) => { // glow_map let inner = matches!(e, Effect::InnerGlow(_)); - let edge = inner && g.source == GlowSource::Edge; let center = inner && g.source == GlowSource::Center; match g.technique { GlowTechnique::Precise => { - let d = b.field(if edge { FieldKind::Inside } else { FieldKind::Outside }, g.size); + let d = b.field(if inner { FieldKind::Inside } else { FieldKind::Outside }, g.size); let solid = g.size * g.spread; let soft = (g.size - solid).max(1e-3); let m = b.push(stage(Kernel::MGlow, Some(d), None, [solid, soft, f32::from(u8::from(center)), 0.0], 0)); diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index b320ea93..30dc06a7 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -362,6 +362,8 @@ pub const F_FIRST: u32 = 2048; pub const F_CHANNELS: u32 = 4096; /// Lab document: Normal blending mixes in CIELAB (`psblend::LAB_MIX`). pub const F_LAB: u32 = 65536; +/// 32-bit float document: Linear Dodge (Add) and Divide don't clip at 1 (`psblend::HDR`). +pub const F_HDR: u32 = 262144; /// `Lerp`: A rounded to `p0.x` steps per unit (adjustment results on integer documents). pub const F_QUANT: u32 = 32768; /// `Lerp` as A + (B − C) premultiplied (layers clipped to pass-through groups). @@ -427,6 +429,9 @@ impl<'a> Planner<'a> { if self.cx.mode == photocraft_color::ColorMode::Lab { pass.flags |= F_LAB; } + if self.cx.depth == photocraft_color::SampleType::F32 { + pass.flags |= F_HDR; + } let (a, b, c, d) = (pass.a, pass.b, pass.c, pass.d); self.passes.push(pass); for s in [a, b, c, d].into_iter().flatten() { diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index 05d13a66..2329ece1 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -1244,6 +1244,42 @@ fn blend_mode_extremes() { } } +#[test] +fn float_documents_blend_past_white() { + // 32-bit documents hold values above 1; Linear Dodge (Add) and Divide don't clip them there + // (integer depths do, see `blend_mode_extremes`). Values and alphas are exact in half floats. + // Separable modes only: the non-separable ones' ClipColor is ill-conditioned past white (a + // grey above 1 has max − lum ≈ 0), so CPU and GPU rounding diverge there. + let Some(mut g) = gpu() else { return }; + let vals = [0.0f32, 0.25, 0.5, 1.0, 2.0, 4.0]; + let alphas = [1.0f32, 0.5, 0.25]; + for mode in BlendMode::LAYER_MODES.into_iter().filter(|m| m.is_separable()) { + let n = vals.len() as u32; + let mut d = Document::new("hdr", Size::new(n * 3, n), ColorMode::Rgb, SampleType::F32); + let fmt = d.pixel_format(); + let mut bg = Layer::raster("bg", fmt); + let mut top = Layer::raster("top", fmt); + for (i, &b) in vals.iter().enumerate() { + for (j, &s) in vals.iter().enumerate() { + for (k, &a) in alphas.iter().enumerate() { + let r = Rect::from_xywh((i * 3 + k) as i32, j as i32, 1, 1); + bg.surface_mut().unwrap().fill_rect(r, &[b, b * 0.5, b, 1.0]); + top.surface_mut().unwrap().fill_rect(r, &[s, s, s * 0.5, a]); + } + } + } + top.blend = mode; + d.layers.push(bg); + d.layers.push(top); + check(&mut g, &d, &format!("hdr {mode:?}")); + if mode == BlendMode::LinearDodge { + // 4 + 0.25 at full opacity (column 5 × 3, row 1): unclipped on the GPU too. + let px = render_to_vec(&mut g.comp, &g.device, &g.queue, &d, Rect::from_xywh(15, 1, 1, 1)).unwrap(); + assert!((px[0][0] - 4.25).abs() < 1e-3, "{:?}", px[0]); + } + } +} + #[test] fn stroked_shapes_with_clipped_layers() { let Some(mut g) = gpu() else { return }; diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index eb917910..763297d1 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -26,6 +26,7 @@ photocraft-algo = { workspace = true } photocraft-psd = { workspace = true } photocraft-codecs = { workspace = true } photocraft-raw = { workspace = true } +photocraft-affinity = { workspace = true } photocraft-text = { workspace = true } photocraft-paint = { workspace = true } photocraft-vector = { workspace = true } @@ -42,6 +43,9 @@ resvg = { version = "0.45", default-features = false, features = ["text", "syste resvg = { version = "0.45", default-features = false, features = ["text", "raster-images"] } [dev-dependencies] +crc32fast = "1.5" +photocraft-affinity = { workspace = true, features = ["synth"] } +flate2 = { workspace = true } photocraft-psd = { workspace = true, features = ["testgen"] } photocraft-raw = { workspace = true, features = ["testgen"] } proptest = { workspace = true } diff --git a/crates/io/src/adjust_map.rs b/crates/io/src/adjust_map.rs index fa01fb0f..6a025f98 100644 --- a/crates/io/src/adjust_map.rs +++ b/crates/io/src/adjust_map.rs @@ -112,7 +112,7 @@ pub enum Channels { pub fn parse(key: &[u8; 4], data: &[u8], cged: Option<&[u8]>, channels: Channels) -> Adjustment { // A CMYK Channel Mixer (any record using the fourth, black, source) has no RGB meaning: // keep it raw. Mixers written by `write` leave that source at 0 and map in every mode. - if key == b"mixr" && channels == Channels::Other && (0..).map_while(|r| bei16(data, 4 + r * 10 + 6)).any(|k| k != 0) { + if key == b"mixr" && matches!(channels, Channels::Cmyk | Channels::Other) && (0..).map_while(|r| bei16(data, 4 + r * 10 + 6)).any(|k| k != 0) { return unsupported(key, data); } let mut a = parse_any(key, data, cged); @@ -878,8 +878,10 @@ mod tests { for r in 0..4 { cmyk.extend(i16s(&std::array::from_fn::(|k| if k == r { 100 } else { 0 }))); } + assert!(matches!(parse(b"mixr", &cmyk, None, Channels::Cmyk), Adjustment::Unsupported { .. })); assert!(matches!(parse(b"mixr", &cmyk, None, Channels::Other), Adjustment::Unsupported { .. })); let ours = write(&Adjustment::ChannelMixer { matrix: [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]], monochrome: false }); + assert!(matches!(parse(b"mixr", &ours[0].1, None, Channels::Cmyk), Adjustment::ChannelMixer { .. })); assert!(matches!(parse(b"mixr", &ours[0].1, None, Channels::Other), Adjustment::ChannelMixer { .. })); assert!(matches!(parse(b"mixr", &[0, 1, 0, 0, 0, 100], None, Channels::Rgb), Adjustment::Unsupported { .. })); // Photo Filter: unknown version, HSB colour space, out-of-range version-3 Lab, truncated. diff --git a/crates/io/src/affinity.rs b/crates/io/src/affinity.rs new file mode 100644 index 00000000..f829326e --- /dev/null +++ b/crates/io/src/affinity.rs @@ -0,0 +1,620 @@ +//! Affinity documents (`.af`, `.afdesign`, `.afphoto`, `.afpub`): the native layers, curves, +//! shapes, artboards, text, images and masks `photocraft-affinity` reads, mapped to PhotoCraft +//! layers (groups, shape layers, type layers, embedded smart objects, pixel layers, layer and +//! vector masks). What could not be mapped is listed in the warnings. A file whose native data +//! can't be read opens as its embedded preview, with a warning saying why. + +use std::sync::Arc; + +use photocraft_affinity::model::{self as af, Align, Blend, Kind, Pixels}; +use photocraft_affinity::paint::{self as ap, Cap, GradientKind, Join}; +use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType}; +use photocraft_doc::text::{CharStyle, ParagraphRun, ParagraphStyle, TextAlign, TextRun, TextShape}; +use photocraft_doc::vector::{Knot, LineCap, LineJoin, Path, ShapeLayer, ShapeStroke, StrokeAlign, Subpath, VectorMask}; +use photocraft_doc::{ + Artboard, ArtboardBackground, Document, Fill, GradientStyle, Group, Layer, LayerContent, LayerMask, Locks, SmartObject, SmartSource, TextLayer, +}; +use photocraft_geom::{Affine, Point, Rect, Size}; +use photocraft_raster::Surface; + +use crate::{ImportResult, IoError}; + +pub use photocraft_affinity::is_affinity; + +/// Extensions recognized as Affinity documents. +pub const EXTENSIONS: &[&str] = &["af", "afphoto", "afdesign", "afpub"]; + +/// Pixels an image or mask is resampled into, after cropping to the canvas. +const MAX_PIXELS: u64 = 64 << 20; +/// Canvas side in pixels. +const MAX_SIDE: f64 = 300_000.0; + +pub fn has_extension(name: &str) -> bool { + name.rsplit(['/', '\\']).next().and_then(|n| n.rsplit_once('.')).is_some_and(|(_, ext)| EXTENSIONS.iter().any(|e| ext.eq_ignore_ascii_case(e))) +} + +pub(crate) fn import(name: &str, bytes: &[u8]) -> Result { + match photocraft_affinity::read(bytes, photocraft_affinity::Limits::default()) { + Ok(d) => { + let mut b = Builder::new(name, &d)?; + b.document(&d)?; + let mut warnings: Vec = d.warnings.iter().map(|w| format!("Affinity: not imported or approximated: {w}.")).collect(); + warnings.extend(b.warnings.iter().map(|w| format!("Affinity: {w}."))); + // Type layers draw from their model, within the import's raster bound: the sizes and + // frames come from an untrusted file. + let mut document = b.doc; + crate::text_import::prepare(&mut document); + // Save never writes back over the Affinity file. + Ok(ImportResult { document, warnings, source_read_only: true, preview_only: false }) + } + Err(native) => preview(name, bytes, &native), + } +} + +/// The embedded preview, for files whose native document can't be read. +fn preview(name: &str, bytes: &[u8], native: &photocraft_affinity::Error) -> Result { + let p = photocraft_affinity::preview(bytes).map_err(|e| IoError::Unsupported(format!("{native}; its embedded preview can't be read either ({e})")))?; + let opts = photocraft_codecs::DecodeOptions { + limits: photocraft_codecs::Limits { max_width: 4096, max_height: 4096, max_pixels: 4096 * 4096, max_alloc: 128 << 20 }, + ..Default::default() + }; + let img = photocraft_codecs::decode_with(p.png, &opts)?; + let mut r = crate::flat::image_to_document(name, &img)?; + if let Some(layer) = r.document.layers.first_mut() { + layer.name = "Affinity preview".into(); + layer.locks = Default::default(); + } + r.source_read_only = true; + r.preview_only = true; + r.warnings.insert( + 0, + format!( + "The native Affinity document could not be read ({native}). Opened only its embedded {}×{} PNG preview, which may be smaller than the document: no layers, vectors, text or pages. Save a new copy; the Affinity source cannot be saved back. Export PSD or PNG from Affinity for full-resolution artwork.", + p.width, p.height + ), + ); + Ok(r) +} + +struct Builder { + doc: Document, + warnings: Vec, + /// Document pixels (in the canvas) per Affinity document pixel: always 1; spreads are offset. + format: PixelFormat, + canvas: Rect, +} + +fn affine(a: af::Affine) -> Affine { + Affine { m: a.0 } +} + +fn point(p: af::Point) -> Point { + Point::new(p.x, p.y) +} + +impl Builder { + fn new(name: &str, d: &af::Document) -> Result { + // Spreads (Publisher pages) side by side, 64 px apart. + let mut w: f64 = 0.0; + let mut h: f64 = 0.0; + for (i, s) in d.spreads.iter().enumerate() { + w += (s.bounds.x1 - s.bounds.x0).max(1.0) + if i > 0 { 64.0 } else { 0.0 }; + h = h.max((s.bounds.y1 - s.bounds.y0).max(1.0)); + } + if !(w.is_finite() && h.is_finite()) || w > MAX_SIDE || h > MAX_SIDE { + return Err(IoError::Unsupported("the Affinity document is larger than 300 000 pixels on a side".into())); + } + let size = Size::new(w.ceil().max(1.0) as u32, h.ceil().max(1.0) as u32); + let mut doc = Document::new(name, size, ColorMode::Rgb, SampleType::U8); + doc.resolution_dpi = d.dpi as f32; + let canvas = Rect::new(0, 0, size.width as i32, size.height as i32); + Ok(Self { doc, warnings: Vec::new(), format: PixelFormat::RGBA8, canvas }) + } + + fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) { + self.warnings.push(w.to_string()); + } + } + + fn document(&mut self, d: &af::Document) -> Result<(), IoError> { + let pages = d.spreads.len() > 1 || d.spreads.iter().any(|s| s.pages.len() > 1); + let mut x = 0.0; + let mut layers = Vec::new(); + for (i, spread) in d.spreads.iter().enumerate() { + let to_doc = Affine::translate(x - spread.bounds.x0, -spread.bounds.y0); + let mut children: Vec = spread.nodes.iter().filter_map(|n| self.layer(n, to_doc, true)).collect(); + if pages { + // Each page becomes an artboard holding the art drawn on it. + let rects: Vec = if spread.pages.is_empty() { vec![spread.bounds] } else { spread.pages.clone() }; + let page = rects.first().copied().unwrap_or(spread.bounds); + let r = self.bbox(to_doc, page); + let mut g = Layer::new( + format!("Page {}", i + 1), + LayerContent::Group(Group { children: std::mem::take(&mut children), expanded: true, artboard: None }), + ); + if let LayerContent::Group(gr) = &mut g.content { + gr.artboard = Some(Artboard { rect: r, background: ArtboardBackground::White, preset: String::new() }); + } + if rects.len() > 1 { + self.warn("facing pages import as one artboard per spread"); + } + layers.push(g); + } else { + layers.append(&mut children); + } + x += (spread.bounds.x1 - spread.bounds.x0).max(1.0) + 64.0; + } + if d.spreads.first().is_some_and(|s| !s.transparent) && !self.doc_has_artboards(&layers) { + // An opaque page: a white background below the art. + let mut bg = Surface::with_default(self.format, &[1.0, 1.0, 1.0, 1.0]); + bg.prune(); + let mut l = Layer::new("Background", LayerContent::Raster(bg)); + l.locks.transparency = true; + layers.insert(0, l); + } + self.doc.layers = layers; + Ok(()) + } + + fn doc_has_artboards(&self, layers: &[Layer]) -> bool { + layers.iter().any(|l| matches!(&l.content, LayerContent::Group(g) if g.artboard.is_some())) + } + + /// Integer bounding box of `r` mapped by `m`, clipped to the canvas. + fn bbox(&self, m: Affine, r: af::Rect) -> Rect { + let pts = [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| m.apply(Point::new(x, y))); + let fold = |f: fn(f64, f64) -> f64, init: f64, g: fn(&Point) -> f64| pts.iter().map(g).fold(init, f); + let x0 = fold(f64::min, f64::INFINITY, |p| p.x).floor(); + let y0 = fold(f64::min, f64::INFINITY, |p| p.y).floor(); + let x1 = fold(f64::max, f64::NEG_INFINITY, |p| p.x).ceil(); + let y1 = fold(f64::max, f64::NEG_INFINITY, |p| p.y).ceil(); + let c = |v: f64, lo: i32, hi: i32| (v.clamp(f64::from(lo), f64::from(hi))) as i32; + Rect::new( + c(x0, self.canvas.x0, self.canvas.x1), + c(y0, self.canvas.y0, self.canvas.y1), + c(x1, self.canvas.x0, self.canvas.x1), + c(y1, self.canvas.y0, self.canvas.y1), + ) + } + + fn blend(&mut self, b: Blend) -> BlendMode { + match b { + Blend::Normal => BlendMode::Normal, + Blend::PassThrough => BlendMode::PassThrough, + Blend::Darken => BlendMode::Darken, + Blend::DarkerColor => BlendMode::DarkerColor, + Blend::Multiply => BlendMode::Multiply, + Blend::ColorBurn => BlendMode::ColorBurn, + Blend::Lighten => BlendMode::Lighten, + Blend::LighterColor => BlendMode::LighterColor, + Blend::Screen => BlendMode::Screen, + Blend::ColorDodge => BlendMode::ColorDodge, + Blend::Add => BlendMode::LinearDodge, + Blend::Overlay => BlendMode::Overlay, + Blend::SoftLight => BlendMode::SoftLight, + Blend::HardLight => BlendMode::HardLight, + Blend::VividLight => BlendMode::VividLight, + Blend::PinLight => BlendMode::PinLight, + Blend::LinearLight => BlendMode::LinearLight, + Blend::HardMix => BlendMode::HardMix, + Blend::Difference => BlendMode::Difference, + Blend::Exclusion => BlendMode::Exclusion, + Blend::Subtract => BlendMode::Subtract, + Blend::Hue => BlendMode::Hue, + Blend::Saturation => BlendMode::Saturation, + Blend::Luminosity => BlendMode::Luminosity, + Blend::Color => BlendMode::Color, + Blend::Average | Blend::Negation | Blend::Reflect | Blend::Glow | Blend::Erase => { + self.warn("the Average, Negation, Reflect, Glow and Erase blend modes import as Normal"); + BlendMode::Normal + } + } + } + + fn base(&mut self, n: &af::Node, content: LayerContent) -> Layer { + let mut l = Layer::new(if n.name.is_empty() { default_name(&n.kind) } else { n.name.clone() }, content); + l.visible = n.visible; + l.opacity = n.opacity as f32; + l.blend = self.blend(n.blend); + if n.locked { + l.locks = Locks { all: true, ..Locks::default() }; + } + l + } + + fn group(&mut self, n: &af::Node, children: Vec) -> Layer { + // Pass-through and Normal (isolated) groups map one to one. + self.base(n, LayerContent::Group(Group { children, expanded: false, artboard: None })) + } + + /// One Affinity node and its subtree; `to_doc` maps Affinity document pixels to the canvas. + fn layer(&mut self, n: &af::Node, to_doc: Affine, top: bool) -> Option { + let children: Vec = n.children.iter().filter_map(|c| self.layer(c, to_doc, false)).collect(); + let mut l = match &n.kind { + Kind::Layer | Kind::Group => self.group(n, children), + Kind::Unsupported if children.is_empty() => return None, + Kind::Unsupported => self.group(n, children), + Kind::Shape { path, fills, strokes, even_odd } => { + let path = self.path(path, to_doc, *even_odd); + let shape = self.shape(path.clone(), fills, strokes, to_doc); + if children.is_empty() { + self.base(n, LayerContent::Shape(shape)) + } else { + // A shape clips its children: a group masked by the outline, the shape at its bottom. + let mut own = Layer::new(if n.name.is_empty() { "Shape".to_string() } else { n.name.clone() }, LayerContent::Shape(shape)); + own.opacity = 1.0; + let mut kids = vec![own]; + kids.extend(children); + let mut g = self.group(n, kids); + g.vector_mask = Some(VectorMask { path, enabled: true, linked: true, density: 1.0, feather: 0.0 }); + if !strokes.is_empty() { + self.warn("strokes of shapes that clip other layers are clipped to the shape"); + } + g + } + } + Kind::Artboard { rect, background } => { + let r = self.bbox(to_doc, *rect); + let bg = match background.first() { + Some(ap::Paint::Solid(c)) => { + let c = color(*c); + if c.alpha <= 0.0 { ArtboardBackground::Transparent } else { ArtboardBackground::Custom(c) } + } + Some(_) => { + self.warn("artboard backgrounds other than a solid colour import as transparent"); + ArtboardBackground::Transparent + } + None => ArtboardBackground::Transparent, + }; + let mut g = self.group(n, children); + if top { + if let LayerContent::Group(gr) = &mut g.content { + gr.artboard = Some(Artboard { rect: r, background: bg, preset: String::new() }); + } + } else { + // PhotoCraft artboards are top-level groups: a nested one clips by a vector mask. + let p = Path::new(vec![Subpath::polygon(&[ + (f64::from(r.x0), f64::from(r.y0)), + (f64::from(r.x1), f64::from(r.y0)), + (f64::from(r.x1), f64::from(r.y1)), + (f64::from(r.x0), f64::from(r.y1)), + ])]); + g.vector_mask = Some(VectorMask { path: p, enabled: true, linked: true, density: 1.0, feather: 0.0 }); + self.warn("artboards inside groups import as masked groups"); + } + g + } + Kind::Text(t) => { + let layer = self.text(t, to_doc); + let l = self.base(n, LayerContent::Text(layer)); + if children.is_empty() { + l + } else { + let mut kids = vec![l]; + kids.extend(children); + self.group(n, kids) + } + } + Kind::Image(img) => match self.image(img, to_doc) { + Some(content) => self.base(n, content), + None if children.is_empty() => return None, + None => self.group(n, children), + }, + }; + if let Some(mask) = &n.mask { + l.vector_mask = Some(VectorMask { path: self.path(mask, to_doc, false), enabled: true, linked: true, density: 1.0, feather: 0.0 }); + } + if let Some(mask) = &n.pixel_mask + && let Some(surface) = self.mask(mask, to_doc) + { + l.mask = Some(LayerMask { surface, enabled: true, linked: true, density: 1.0, feather: 0.0 }); + } + Some(l) + } + + fn path(&self, p: &af::Path, to_doc: Affine, even_odd: bool) -> Path { + let m = to_doc; + let subpaths = p + .subpaths + .iter() + .map(|s| { + // Knots: each anchor with the control points of the segments arriving and leaving. + let mut knots: Vec = Vec::with_capacity(s.segments.len() + 1); + let start = m.apply(point(s.start)); + knots.push(Knot::corner(start.x, start.y)); + for [c1, c2, e] in &s.segments { + if let Some(prev) = knots.last_mut() { + prev.out_ctrl = m.apply(point(*c1)); + } + let e = m.apply(point(*e)); + let mut k = Knot::corner(e.x, e.y); + k.in_ctrl = m.apply(point(*c2)); + knots.push(k); + } + // A closed subpath that ends where it starts: the last knot is the first. + if s.closed + && knots.len() > 1 + && let (Some(f), Some(l)) = (knots.first().copied(), knots.last().copied()) + && (f.anchor.x - l.anchor.x).abs() < 1e-9 + && (f.anchor.y - l.anchor.y).abs() < 1e-9 + { + knots.pop(); + if let Some(first) = knots.first_mut() { + first.in_ctrl = l.in_ctrl; + } + } + Subpath { closed: s.closed, knots, op: Default::default() } + }) + .collect(); + let mut path = Path::new(subpaths); + if even_odd { + // One component filled even-odd, so inner subpaths cut holes as in Affinity. + path.fill_rule = photocraft_doc::vector::FillRule::EvenOdd; + for s in path.subpaths.iter_mut().skip(1) { + s.op = photocraft_doc::vector::PathOp::Join; + } + } + path + } + + fn fill(&mut self, p: &ap::Paint, frame: Rect, to_doc: Affine) -> Option { + match p { + ap::Paint::None => None, + ap::Paint::Solid(c) => Some(Fill::Solid(color(*c))), + ap::Paint::Gradient(g) => { + let style = match g.kind { + GradientKind::Linear => GradientStyle::Linear, + GradientKind::Radial => GradientStyle::Radial, + GradientKind::Conical => GradientStyle::Angle, + }; + let m = to_doc.mul(&affine(g.transform)); + let (o, e, v) = (m.apply(Point::new(0.0, 0.0)), m.apply(Point::new(1.0, 0.0)), m.apply(Point::new(0.0, 1.0))); + let (u, w) = ((e.x - o.x).hypot(e.y - o.y), (v.x - o.x).hypot(v.y - o.y)); + if style == GradientStyle::Radial && u > 0.0 && (w / u - 1.0).abs() > 0.01 { + self.warn("elliptical gradients import as circular ones"); + } + let (angle, scale, offset) = + photocraft_compose::gradient_fill::from_handles(style, [o.x as f32, o.y as f32], [e.x as f32, e.y as f32], frame, 0.0); + let stops = g.stops.iter().map(|s| (s.offset as f32, Color { alpha: 1.0, ..color(s.color) })).collect(); + let opacity_stops = g.stops.iter().map(|s| (s.offset as f32, s.color.alpha() as f32)).collect(); + let midpoints = g.stops.iter().take(g.stops.len().saturating_sub(1)).map(|s| s.half_point() as f32).collect(); + Some(Fill::Gradient { stops, angle, scale, style, reverse: false, opacity_stops, midpoints, offset, dither: false, align: true }) + } + } + } + + fn shape(&mut self, path: Path, fills: &[ap::Paint], strokes: &[ap::Stroke], to_doc: Affine) -> ShapeLayer { + let frame = path + .control_bounds() + .map(|(x0, y0, x1, y1)| Rect::new(x0.floor() as i32, y0.floor() as i32, x1.ceil() as i32, y1.ceil() as i32)) + .unwrap_or(self.canvas); + if fills.len() > 1 || strokes.len() > 1 { + self.warn("only one fill and one stroke per shape import"); + } + let fill = fills.first().and_then(|f| self.fill(f, frame, to_doc)); + let stroke = strokes.first().and_then(|s| { + let paint = self.fill(&s.paint, frame, to_doc)?; + if s.behind { + self.warn("strokes drawn behind their fill import in front of it"); + } + Some(ShapeStroke { + width: s.width as f32, + paint, + opacity: 1.0, + align: match s.align { + ap::Align::Center => StrokeAlign::Center, + ap::Align::Inside => StrokeAlign::Inside, + ap::Align::Outside => StrokeAlign::Outside, + }, + cap: match s.cap { + Cap::Butt => LineCap::Butt, + Cap::Round => LineCap::Round, + Cap::Square => LineCap::Square, + }, + join: match s.join { + Join::Miter => LineJoin::Miter, + Join::Round => LineJoin::Round, + Join::Bevel => LineJoin::Bevel, + }, + miter_limit: s.miter_limit as f32, + // In multiples of the width, as Photoshop stores them. + dashes: s.dash.as_ref().map(|(d, _)| d.iter().map(|v| (v / s.width.max(1e-9)) as f32).collect()).unwrap_or_default(), + dash_offset: s.dash.as_ref().map_or(0.0, |(_, o)| (o / s.width.max(1e-9)) as f32), + }) + }); + let mut sh = ShapeLayer { path, fill, stroke, live: None, cache: None, psd_raw: None }; + sh.cache = Some(photocraft_vector::render_shape(&sh, self.format, self.canvas)); + sh + } + + fn text(&mut self, t: &af::Text, to_doc: Affine) -> TextLayer { + let origin = match t.frame { + Some(f) => Point::new(f.x0, f.y0), + None => point(t.anchor), + }; + let m = to_doc.mul(&affine(t.transform)).mul(&Affine::translate(origin.x, origin.y)); + let k = m.determinant().abs().sqrt().max(1e-9); + let transform = m.mul(&Affine::scale(1.0 / k)); + let dpi = self.doc.resolution_dpi.max(1.0) as f64; + let mut text = String::new(); + let mut runs = Vec::new(); + for r in &t.runs { + // Affinity separates paragraphs with U+2029 and lines with U+2028. + let s = r.text.replace(['\u{2029}', '\u{2028}'], "\r"); + let style = CharStyle { + font_family: r.family.clone(), + postscript_name: (!r.postscript.is_empty()).then(|| r.postscript.clone()), + weight: r.weight.clamp(100, 900) as u16, + italic: r.italic, + size_pt: (r.size * k * 72.0 / dpi) as f32, + color: match &r.fill { + ap::Paint::Solid(c) => color(*c), + _ => Color::BLACK, + }, + tracking: (r.tracking * 1000.0) as f32, + leading_pt: r.leading.map(|l| (l * k * 72.0 / dpi) as f32), + ..CharStyle::default() + }; + text.push_str(&s); + runs.push(TextRun { len: s.len(), style }); + } + let align = match t.align { + Align::Left => TextAlign::Left, + Align::Center => TextAlign::Center, + Align::Right => TextAlign::Right, + Align::Justify => TextAlign::JustifyLeft, + }; + let mut layer = TextLayer { + text: text.clone(), + font_family: runs.first().map(|r| r.style.font_family.clone()).unwrap_or_default(), + size_pt: runs.first().map_or(12.0, |r| r.style.size_pt), + color: runs.first().map_or(Color::BLACK, |r| r.style.color), + transform, + runs, + paragraphs: vec![ParagraphRun { len: text.len(), style: ParagraphStyle { align, ..ParagraphStyle::default() } }], + ..TextLayer::default() + }; + if let Some(f) = t.frame { + layer.shape = TextShape::Box { x: 0.0, y: 0.0, width: ((f.x1 - f.x0) * k) as f32, height: ((f.y1 - f.y0) * k) as f32 }; + } + layer + } + + /// Straight RGBA8 pixels of an image (decoding an embedded file), with its size. + fn pixels(&mut self, img: &af::Image) -> Option<(u32, u32, Vec)> { + match &img.pixels { + Pixels::Rgba8(v) => Some((img.width, img.height, v.clone())), + Pixels::Encoded(bytes) => { + let opts = photocraft_codecs::DecodeOptions { + limits: photocraft_codecs::Limits { max_width: 65_535, max_height: 65_535, max_pixels: MAX_PIXELS, max_alloc: 512 << 20 }, + ..Default::default() + }; + match photocraft_codecs::decode_with(bytes, &opts) { + Ok(d) => { + let (w, h) = d.dimensions(); + Some((w, h, d.to_rgba8())) + } + Err(_) => { + self.warn("an embedded image that could not be decoded was left out"); + None + } + } + } + } + } + + fn image(&mut self, img: &af::Image, to_doc: Affine) -> Option { + let (w, h, rgba) = self.pixels(img)?; + // The pixel grid maps onto the Affinity image's own size, whatever the stored file's. + let m = to_doc + .mul(&affine(img.transform)) + .mul(&Affine { m: [f64::from(img.width) / f64::from(w.max(1)), 0.0, 0.0, f64::from(img.height) / f64::from(h.max(1)), 0.0, 0.0] }); + let surface = self.resample(w, h, &rgba, m, 4)?; + Some(match &img.pixels { + // A placed image keeps its file, as a smart object placed by the same transform. + Pixels::Encoded(bytes) => LayerContent::Smart(SmartObject::new( + SmartSource::Embedded { file_name: "Affinity image".into(), bytes: Arc::new(bytes.clone()) }, + m, + Some(surface), + )), + Pixels::Rgba8(_) => LayerContent::Raster(surface), + }) + } + + fn mask(&mut self, img: &af::Image, to_doc: Affine) -> Option { + let (w, h, rgba) = self.pixels(img)?; + let grey: Vec = rgba.as_chunks::<4>().0.iter().map(|p| p[0]).collect(); + let m = to_doc.mul(&affine(img.transform)); + self.resample(w, h, &grey, m, 1) + } + + /// Bilinear resampling of `src` (`w`×`h`, `channels` 4 = straight RGBA8 or 1 = grey) mapped + /// by `m` (source pixels to canvas pixels) into a surface; exact copy when `m` is an integer + /// translation. Pixels outside the source are transparent (grey: 0, hidden). + fn resample(&mut self, w: u32, h: u32, src: &[u8], m: Affine, channels: usize) -> Option { + let format = if channels == 1 { PixelFormat::GRAY8 } else { self.format }; + let mut out = if channels == 1 { Surface::with_default(format, &[0.0]) } else { Surface::new(format) }; + let r = self.bbox(m, af::Rect { x0: 0.0, y0: 0.0, x1: f64::from(w), y1: f64::from(h) }); + if r.width() == 0 || r.height() == 0 { + return Some(out); + } + if u64::from(r.width()) * u64::from(r.height()) > MAX_PIXELS { + self.warn("images larger than 64 megapixels on the canvas were left out"); + return None; + } + let [a, b, c, d, e, f] = m.m; + let exact = a == 1.0 && d == 1.0 && b == 0.0 && c == 0.0 && e.fract() == 0.0 && f.fract() == 0.0; + let inv = m.inverse()?; + let (rw, rh) = (r.width() as usize, r.height() as usize); + let mut buf = vec![0u8; rw * rh * channels]; + let at = |x: i64, y: i64, ch: usize| -> f64 { + if x < 0 || y < 0 || x >= i64::from(w) || y >= i64::from(h) { + return 0.0; + } + f64::from(src.get((y as usize * w as usize + x as usize) * channels + ch).copied().unwrap_or(0)) + }; + for (row, out_row) in buf.chunks_exact_mut(rw * channels).enumerate() { + for (col, px) in out_row.chunks_exact_mut(channels).enumerate() { + let p = inv.apply(Point::new(f64::from(r.x0) + col as f64 + 0.5, f64::from(r.y0) + row as f64 + 0.5)); + if exact { + let (x, y) = (p.x.floor() as i64, p.y.floor() as i64); + for (ch, v) in px.iter_mut().enumerate() { + *v = at(x, y, ch) as u8; + } + continue; + } + let (fx, fy) = (p.x - 0.5, p.y - 0.5); + let (x0, y0) = (fx.floor() as i64, fy.floor() as i64); + let (tx, ty) = (fx - x0 as f64, fy - y0 as f64); + let weights = + [((x0, y0), (1.0 - tx) * (1.0 - ty)), ((x0 + 1, y0), tx * (1.0 - ty)), ((x0, y0 + 1), (1.0 - tx) * ty), ((x0 + 1, y0 + 1), tx * ty)]; + if channels == 1 { + px[0] = weights.iter().map(|((x, y), wgt)| at(*x, *y, 0) * wgt).sum::().round().clamp(0.0, 255.0) as u8; + } else { + // Interpolate premultiplied colour so transparent pixels don't bleed. + let alpha: f64 = weights.iter().map(|((x, y), wgt)| at(*x, *y, 3) * wgt).sum(); + for (ch, v) in px.iter_mut().enumerate().take(3) { + let pm: f64 = weights.iter().map(|((x, y), wgt)| at(*x, *y, ch) * at(*x, *y, 3) / 255.0 * wgt).sum(); + *v = if alpha > 0.0 { (pm * 255.0 / alpha).round().clamp(0.0, 255.0) as u8 } else { 0 }; + } + px[3] = alpha.round().clamp(0.0, 255.0) as u8; + } + } + } + out.write_interleaved(r, &buf); + out.prune(); + Some(out) + } +} + +fn default_name(k: &Kind) -> String { + match k { + Kind::Layer => "Layer", + Kind::Group => "Group", + Kind::Artboard { .. } => "Artboard", + Kind::Shape { .. } => "Shape", + Kind::Text(_) => "Text", + Kind::Image(_) => "Image", + Kind::Unsupported => "Group", + } + .into() +} + +fn color(c: ap::Color) -> Color { + let f = |v: f64| v.clamp(0.0, 1.0) as f32; + match c { + ap::Color::Rgb { r, g, b, a } => Color::rgba(f(r), f(g), f(b), f(a)), + ap::Color::Cmyk { c, m, y, k, a } => { + let cmyk = Color { mode: ColorMode::Cmyk, c: [f(c), f(m), f(y), f(k)], alpha: f(a) }; + let [r, g, b] = cmyk.to_rgb(); + Color::rgba(r, g, b, f(a)) + } + ap::Color::Gray { v, a } => Color::rgba(f(v), f(v), f(v), f(a)), + ap::Color::Lab { l, a, b, alpha } => { + let lab = Color { mode: ColorMode::Lab, c: [f(l / 100.0), f((a + 128.0) / 255.0), f((b + 128.0) / 255.0), 0.0], alpha: f(alpha) }; + let [r, g, bl] = lab.to_rgb(); + Color::rgba(r, g, bl, f(alpha)) + } + } +} diff --git a/crates/io/src/annotations_map.rs b/crates/io/src/annotations_map.rs index 6b38319a..28ab181c 100644 --- a/crates/io/src/annotations_map.rs +++ b/crates/io/src/annotations_map.rs @@ -229,8 +229,14 @@ pub fn notes_from_blocks(doc: &Document) -> Vec { doc.metadata.psd_global_blocks.iter().find(|(_, k, _)| *k == ANNO).and_then(|(_, _, d)| parse_anno(d)).unwrap_or_default() } -/// Global blocks to write: the raw `Anno` block while it still describes `doc.notes`, else a -/// regenerated one in its place (appended when new, dropped when the last note is deleted). +/// Whether the document holds an `Anno` block that doesn't parse (its notes can't be shown). +pub fn anno_unreadable(doc: &Document) -> bool { + doc.metadata.psd_global_blocks.iter().find(|(_, k, _)| *k == ANNO).is_some_and(|(_, _, d)| parse_entries(d).is_none()) +} + +/// Global blocks to write: the raw `Anno` block while it still describes `doc.notes` (or doesn't +/// parse and no note was added), else a regenerated one in its place (appended when new, dropped +/// when the last note is deleted). pub fn export_blocks(doc: &Document, mut blocks: Vec) -> Vec { let at = blocks.iter().position(|(_, k, _)| *k == ANNO); let raw = at.map(|i| blocks[i].2.clone()); @@ -246,7 +252,9 @@ pub fn export_blocks(doc: &Document, mut blocks: Vec> = entries.unwrap_or_default().into_iter().filter_map(|e| if let Entry::Raw(r) = e { Some(r) } else { None }).collect(); @@ -395,6 +403,25 @@ mod tests { assert!(parse_anno(&[0, 2, 0, 1, 0, 0, 0, 9]).is_none()); } + /// Issue #944: an `Anno` block that doesn't parse was taken for "no notes" and dropped on save. + #[test] + fn export_blocks_keeps_an_unreadable_block() { + let mut d = Document::new("n", photocraft_doc::Size::new(10, 10), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8); + let bad: Vec = vec![(*b"8BIM", ANNO, Arc::new(vec![0, 2, 0, 1, 0, 0, 0, 9]))]; + d.metadata.psd_global_blocks = bad.clone(); + assert!(anno_unreadable(&d) && notes_from_blocks(&d).is_empty()); + let out = export_blocks(&d, bad.clone()); + assert_eq!(out.len(), 1); + assert!(Arc::ptr_eq(&out[0].2, &bad[0].2)); + // A note added by the user still writes a fresh block in its place. + d.notes.push(Note { text: "new".into(), ..Default::default() }); + let out = export_blocks(&d, bad); + assert_eq!(parse_anno(&out[0].2).unwrap()[0].text, "new"); + // A readable block is not reported. + d.metadata.psd_global_blocks = out; + assert!(!anno_unreadable(&d)); + } + #[test] fn scale_resource_roundtrip_and_keep_rules() { let s = MeasurementScale { pixel_length: 150.0, logical_length: 2.0, units: "mm".into() }; diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index e5e3313d..bc662cd0 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -103,7 +103,7 @@ pub(crate) fn image_to_document(name: &str, img: &Image) -> Result, + /// Save must not write back to the source (an Affinity document: PhotoCraft can't write it). + pub source_read_only: bool, + /// Only a stand-in picture of the file (an Affinity document whose native data couldn't be + /// read): Open shows it with its warning; Place and other auxiliary imports refuse it. + pub preview_only: bool, } /// The import note for a file whose horizontal and vertical resolutions (`x`, `y`, in pixels per @@ -152,7 +161,7 @@ pub fn is_psd(bytes: &[u8]) -> bool { bytes.starts_with(b"8BPS") } -/// Imports a file. PSD/PSB and camera raws are detected by magic; everything +/// Imports a file. PSD/PSB, Affinity containers and camera raws are detected by magic; everything /// else is decoded with `photocraft-codecs`. pub fn import(name: &str, bytes: &[u8]) -> Result { import_with(name, bytes, &photocraft_raster::Interrupt::NONE) @@ -171,7 +180,7 @@ pub fn import_with(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt) fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt) -> Result { // A declared native extension must reach its loader so malformed bundles retain format errors. if has_extension(name, photocraft_format::EXTENSION) || photocraft_format::is_pcraft(bytes) { - return Ok(ImportResult { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new() }); + 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)?; @@ -185,7 +194,10 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt) - let (mut document, warnings) = psd_import::psd_to_document_with(&file, ctl).ok_or(IoError::Cancelled)?; document.name = name.to_string(); text_import::prepare(&mut document); - return Ok(ImportResult { document, warnings }); + return Ok(ImportResult { document, warnings, source_read_only: false, preview_only: false }); + } + if affinity::is_affinity(bytes) || affinity::has_extension(name) { + return affinity::import(name, bytes); } if raw::is_raw(bytes) { return raw::import_raw(name, bytes); @@ -208,6 +220,9 @@ fn has_extension(name: &str, expected: &str) -> bool { /// bare extension). pub fn export(doc: &Document, name_or_ext: &str, opts: &ExportOptions) -> Result { let ext = extension(name_or_ext); + if affinity::EXTENSIONS.contains(&ext.as_str()) { + return Err(IoError::Unsupported("Affinity export is not implemented; save a new PSD, PNG or .pcraft copy".into())); + } if ext == photocraft_format::EXTENSION { let previews = photocraft_format::SaveOptions { thumbnail: Some(photocraft_compose::thumbnail(doc, 256)), diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 0d1ee6b0..fd881643 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -588,6 +588,9 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) doc.patterns = crate::pattern_map::from_global_blocks(&doc); // Notes (`Anno`) and the measurement scale (resource 1074); raw data stays for verbatim export. doc.notes = crate::annotations_map::notes_from_blocks(&doc); + if crate::annotations_map::anno_unreadable(&doc) { + warnings.push("the notes (Anno block) could not be read; they are kept unchanged on PSD save unless a note is added".into()); + } if let Some(scale) = crate::annotations_map::raw_scale(&doc) { doc.measurement.scale = scale; } diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 6b7abe76..58fc996b 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -121,7 +121,7 @@ pub fn import_svg(name: &str, bytes: &[u8]) -> Result { if doc.layers.is_empty() { conv.warnings.push("SVG: nothing to draw (an empty drawing)".to_string()); } - Ok(ImportResult { document: doc, warnings: conv.warnings }) + Ok(ImportResult { document: doc, warnings: conv.warnings, source_read_only: false, preview_only: false }) } /// The whole drawing rendered at `scale` (1 = its own size), as a straight-alpha RGBA buffer at diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index fd143bdd..46e423bc 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -114,7 +114,7 @@ pub(crate) fn import_layered(name: &str, img: &Image, layers: &[u8]) -> Result Vec { + let img = Image::from_raw(2, 1, ChannelLayout::Rgba, SampleType::U8, vec![220, 40, 60, 255, 0, 0, 0, 0]).unwrap(); + photocraft_codecs::encode(&img, Format::Png, &EncodeOptions::default()).unwrap() +} + +fn chunk(kind: &[u8; 4], data: &[u8]) -> Vec { + let mut tagged = kind.to_vec(); + tagged.extend(data); + let mut result = (data.len() as u32).to_be_bytes().to_vec(); + result.extend(&tagged); + result.extend(crc32fast::hash(&tagged).to_be_bytes()); + result +} + +/// A synthetic record envelope, not an Affinity document writer (there is no object graph). +fn file(png: &[u8]) -> Vec { + let mut b = vec![0; 72]; + b[..4].copy_from_slice(photocraft_affinity::MAGIC); + b[4..6].copy_from_slice(&12u16.to_le_bytes()); + b[8..12].copy_from_slice(b"nsrP"); + b[12..16].copy_from_slice(b"#Inf"); + b[24..32].copy_from_slice(&72u64.to_le_bytes()); + b[64..68].copy_from_slice(b"Prot"); + b.extend(b"\xff\xff\xff\xffThmb"); + b.extend(1u32.to_le_bytes()); + b.extend((png.len() as u32 + 13).to_le_bytes()); + b.extend(29u32.to_le_bytes()); + b.extend(0u32.to_le_bytes()); + b.extend((png.len() as u32).to_le_bytes()); + b.push(1); + b.extend(png); + b +} + +#[test] +fn preview_is_named_and_warns_at_its_actual_size() { + let r = import("art.af", &file(&png())).unwrap(); + assert_eq!((r.document.size.width, r.document.size.height), (2, 1)); + assert_eq!(r.document.layers.len(), 1); + assert_eq!(r.document.layers[0].name, "Affinity preview"); + assert!(r.source_read_only && r.preview_only); + assert!(r.warnings[0].contains("2×1")); + assert!(r.warnings[0].contains("native Affinity document could not be read") && r.warnings[0].contains("no layers"), "{}", r.warnings[0]); + let out = export(&r.document, "copy.png", &ExportOptions::default()).unwrap(); + let img = photocraft_codecs::decode(&out.bytes).unwrap(); + assert_eq!(img.data(), &[220, 40, 60, 255, 0, 0, 0, 0]); + let native = export(&r.document, "copy.pcraft", &ExportOptions::default()).unwrap(); + let back = import("copy.pcraft", &native.bytes).unwrap(); + assert_eq!(back.document.size, r.document.size); + assert!(!back.source_read_only && !back.preview_only); + for ext in photocraft_io::affinity::EXTENSIONS { + assert!(export(&r.document, ext, &ExportOptions::default()).unwrap_err().to_string().contains("Affinity export")); + } +} + +#[test] +fn magic_wins_over_the_name_and_renamed_legacy_files_are_rejected() { + assert!(import("renamed.png", &file(&png())).unwrap().source_read_only); + assert!(import("unknown", &file(&png())).unwrap().source_read_only); + assert!(import("fake.af", &png()).is_err()); + // Affinity 1 and 2 (container versions 8 to 11) store the same preview record. + let mut legacy = file(&png()); + legacy[4..6].copy_from_slice(&10u16.to_le_bytes()); + assert!(import("legacy.afphoto", &legacy).unwrap().preview_only); + legacy[4..6].copy_from_slice(&13u16.to_le_bytes()); + assert!(import("future.af", &legacy).unwrap_err().to_string().contains("unknown container version")); +} + +#[test] +fn preview_decoder_preserves_16_bit_gray_and_alpha() { + let img = Image::from_u16(2, 1, ChannelLayout::GrayA, &[12345, 65535, 0, 0]).unwrap(); + let encoded = photocraft_codecs::encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + let r = import("gray.af", &file(&encoded)).unwrap(); + assert_eq!(r.document.depth, photocraft_color::SampleType::U16); + assert_eq!(r.document.mode, photocraft_color::ColorMode::Grayscale); + let out = export(&r.document, "copy.png", &ExportOptions::default()).unwrap(); + let decoded = photocraft_codecs::decode(&out.bytes).unwrap(); + assert_eq!(decoded.sample_type(), SampleType::U16); + assert_eq!(decoded.data(), img.data()); +} + +#[test] +fn previewless_current_files_return_an_export_fallback() { + let mut bytes = file(&png()); + bytes[24..32].fill(0); + let error = import("no-preview.af", &bytes).unwrap_err().to_string(); + assert!(error.contains("no embedded preview"), "{error}"); +} + +#[test] +fn corruption_and_every_truncation_fail_without_a_document() { + let b = file(&png()); + for end in 0..b.len() { + assert!(import("bad.af", &b[..end]).is_err(), "{end}"); + } + let mut bad_crc = b.clone(); + // IHDR's CRC; the envelope reader rejects every PNG chunk's bad CRC. + bad_crc[130] ^= 1; + assert!(import("bad.af", &bad_crc).is_err()); +} + +#[test] +fn crc_failures_after_idat_cannot_be_tolerated_as_partial_pngs() { + let mut bad_iend = file(&png()); + *bad_iend.last_mut().unwrap() ^= 1; + assert!(import("bad-iend.af", &bad_iend).unwrap_err().to_string().contains("PNG chunk checksum")); + + let mut encoded = png(); + let tagged = b"tEXtnote\0preview"; + let mut chunk = ((tagged.len() - 4) as u32).to_be_bytes().to_vec(); + chunk.extend(tagged); + chunk.extend(crc32fast::hash(tagged).to_be_bytes()); + let end = encoded.len() - 12; + encoded.splice(end..end, chunk.clone()); + assert!(import("valid-ancillary.af", &file(&encoded)).is_ok()); + encoded[end + chunk.len() - 1] ^= 1; + assert!(import("bad-ancillary.af", &file(&encoded)).unwrap_err().to_string().contains("PNG chunk checksum")); +} + +#[test] +fn compressed_metadata_and_apng_fail_before_reaching_the_decoder() { + for kind in [b"zTXt", b"iTXt", b"iCCP", b"acTL", b"fcTL", b"fdAT"] { + let mut encoded = png(); + // A tiny declared payload stands in for arbitrarily large inflated metadata. It must + // return the format's unsupported error, not be passed to a decompressor. + let mut tagged = kind.to_vec(); + tagged.extend(b"preview\0\0\x78\x9c"); + let mut chunk = ((tagged.len() - 4) as u32).to_be_bytes().to_vec(); + chunk.extend(&tagged); + chunk.extend(crc32fast::hash(&tagged).to_be_bytes()); + let end = encoded.len() - 12; + encoded.splice(end..end, chunk); + let error = import("unsupported.af", &file(&encoded)).unwrap_err().to_string(); + assert!(error.contains("unsupported Affinity file"), "{kind:?}: {error}"); + assert!(error.contains("PNG metadata") || error.contains("animated PNG"), "{error}"); + } +} + +#[test] +fn many_valid_compressed_text_chunks_cannot_bypass_the_total_memory_budget() { + let mut compressor = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best()); + for _ in 0..1024 { + compressor.write_all(&[b'x'; 1024]).unwrap(); + } + let compressed = compressor.finish().unwrap(); + assert!(compressed.len() < 2048); + let mut tagged = b"zTXtnote\0\0".to_vec(); + tagged.extend(compressed); + let mut chunk = ((tagged.len() - 4) as u32).to_be_bytes().to_vec(); + chunk.extend(&tagged); + chunk.extend(crc32fast::hash(&tagged).to_be_bytes()); + let mut encoded = png(); + let end = encoded.len() - 12; + // A few MiB of encoded PNG would otherwise carry 1 GiB of aggregate text. + encoded.splice(end..end, chunk.repeat(1024)); + let error = import("text-bomb.af", &file(&encoded)).unwrap_err().to_string(); + assert!(error.contains("compressed PNG metadata"), "{error}"); +} + +#[test] +fn invalid_critical_chunks_after_idat_fail_before_png_finish() { + let original = png(); + for trailing in [chunk(b"IHDR", &original[16..29]), chunk(b"PLTE", &[0, 0, 0]), chunk(b"ABCD", &[])] { + let mut encoded = original.clone(); + let end = encoded.len() - 12; + encoded.splice(end..end, trailing); + let error = import("bad-order.af", &file(&encoded)).unwrap_err().to_string(); + assert!(error.contains("Affinity file"), "{error}"); + } + let mut encoded = original; + let end = encoded.len() - 12; + let mut interrupted = chunk(b"tEXt", b"note\0preview"); + interrupted.extend(chunk(b"IDAT", &[])); + encoded.splice(end..end, interrupted); + assert!(import("split-idat.af", &file(&encoded)).unwrap_err().to_string().contains("non-contiguous PNG image data")); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + #[test] + fn preview_and_decoder_survive_mutations(edits in prop::collection::vec((0usize..300, any::()), 0..16)) { + let mut b = file(&png()); + for (i, v) in edits { if let Some(byte) = b.get_mut(i) { *byte = v; } } + let _ = import("mutated.af", &b); + } +} + +mod native { + use super::*; + use photocraft_affinity::synth::{self, F, Method, tag}; + use photocraft_doc::LayerContent; + + fn rgba(r: f32, g: f32, b: f32, a: f32) -> F { + F::Struct([r, g, b, a].iter().flat_map(|v| v.to_le_bytes()).collect()) + } + + fn solid(id: u32, c: F) -> F { + F::Def( + id, + vec![tag(b"FDsc")], + vec![(tag(b"FDeF"), F::Def(id + 1, vec![tag(b"FilS")], vec![(tag(b"Colr"), F::Def(id + 2, vec![tag(b"RGBA")], vec![(tag(b"_col"), c)]))]))], + ) + } + + fn node(x: f64, y: f64) -> Vec { + let mut r = x.to_le_bytes().to_vec(); + r.extend(y.to_le_bytes()); + r.extend([1, 0]); + r + } + + /// A version-12 document at 144 ppi, 200×100: an artboard "Board" moved by (20, 10) holding + /// a red ring (two subpaths, filled even-odd) with a 4 px blue stroke. + pub(super) fn document(method: Method) -> Vec { + let square = |s: f64, o: f64| vec![node(o, o), node(o + s, o), node(o + s, o + s), node(o, o + s), node(o, o)]; + let ring = F::Def( + 20, + vec![tag(b"PCrv")], + vec![ + (tag(b"Desc"), F::Str("Ring".into())), + ( + tag(b"Crvs"), + F::Obj( + tag(b"PCvD"), + vec![( + tag(b"Data"), + F::Pos(vec![ + F::U8(0), + F::U32(2), + F::Bool(true), + F::Records(18, square(60.0, 0.0)), + F::Bool(true), + F::Records(18, square(20.0, 20.0)), + ]), + )], + ), + ), + (tag(b"BFFl"), F::Shared(vec![solid(21, rgba(1.0, 0.0, 0.0, 1.0))])), + (tag(b"LIFl"), F::Shared(vec![solid(24, rgba(0.0, 0.0, 1.0, 1.0))])), + ( + tag(b"LILn"), + F::Shared(vec![F::Def(27, vec![tag(b"LDsc")], vec![(tag(b"LDeL"), F::Def(28, vec![tag(b"LSty")], vec![(tag(b"Wght"), F::F64(4.0))]))])]), + ), + ], + ); + let board = F::Def( + 10, + vec![tag(b"ShpN")], + vec![ + (tag(b"Desc"), F::Str("Board".into())), + (tag(b"ABEn"), F::Bool(true)), + (tag(b"Shpe"), F::Def(11, vec![tag(b"ShNR")], vec![])), + (tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 120.0, 80.0])), + (tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 20.0, 0.0, 1.0, 10.0])), + (tag(b"BFFl"), F::Shared(vec![solid(12, rgba(1.0, 1.0, 1.0, 1.0))])), + (tag(b"Chld"), F::Shared(vec![ring])), + ], + ); + let spread = F::Def( + 2, + vec![tag(b"Sprd")], + vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 200.0, 100.0])), (tag(b"SprT"), F::Bool(true)), (tag(b"Chld"), F::Shared(vec![board]))], + ); + let doc = synth::stream(&[ + (tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(144.0))])), + (tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))])), + ]); + synth::container(&[("doc.dat", &doc, method)], Some(&png())) + } + + #[test] + fn artboards_shapes_and_paints_import_as_editable_layers() { + for method in [Method::Stored, Method::Zlib, Method::Zstd] { + let r = import("native.af", &document(method)).unwrap(); + assert!(r.source_read_only && !r.preview_only, "{:?}", r.warnings); + let d = &r.document; + assert_eq!((d.size.width, d.size.height, d.resolution_dpi), (200, 100, 144.0)); + let board = &d.layers[0]; + assert_eq!(board.name, "Board"); + let LayerContent::Group(g) = &board.content else { panic!("{:?}", board.content) }; + let ab = g.artboard.as_ref().unwrap(); + assert_eq!((ab.rect.x0, ab.rect.y0, ab.rect.x1, ab.rect.y1), (20, 10, 140, 90)); + let ring = &g.children[0]; + assert_eq!(ring.name, "Ring"); + let LayerContent::Shape(sh) = &ring.content else { panic!() }; + assert_eq!(sh.path.subpaths.len(), 2); + assert_eq!(sh.path.subpaths[0].knots.len(), 4, "the closing duplicate knot is merged"); + assert_eq!(sh.path.subpaths[0].knots[0].anchor, photocraft_geom::Point::new(20.0, 10.0)); + assert_eq!(sh.stroke.as_ref().unwrap().width, 4.0); + // The cache shows the ring: red on it, empty in its hole. + let cache = sh.cache.as_ref().unwrap(); + let px = |x: i32, y: i32| cache.to_interleaved(photocraft_geom::Rect::new(x, y, x + 1, y + 1)); + assert_eq!(px(30, 20), [255, 0, 0, 255]); + assert_eq!(px(50, 40)[3], 0, "even-odd hole"); + // Saved as a native PhotoCraft document and opened again. + let saved = export(d, "copy.pcraft", &ExportOptions::default()).unwrap(); + let back = import("copy.pcraft", &saved.bytes).unwrap(); + assert_eq!(back.document.layers.len(), d.layers.len()); + assert!(export(d, "copy.psd", &ExportOptions::default()).is_ok()); + assert!(export(d, "copy.af", &ExportOptions::default()).is_err()); + } + } + + #[test] + fn a_damaged_native_document_falls_back_to_its_preview_with_the_reason() { + let mut bytes = document(Method::Zlib); + bytes[80] ^= 0xFF; + let r = import("broken.af", &bytes).unwrap(); + assert!(r.preview_only); + assert!(r.warnings[0].contains("could not be read (damaged Affinity file"), "{}", r.warnings[0]); + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + #[test] + fn mutated_native_documents_never_panic(edits in prop::collection::vec((0usize..2048, any::()), 1..16), stored in any::()) { + let mut b = document(if stored { Method::Stored } else { Method::Zstd }); + let n = b.len(); + for (i, v) in edits { b[i % n] = v; } + let _ = import("mutated.af", &b); + } + } +} diff --git a/crates/io/tests/affinity_corpus.rs b/crates/io/tests/affinity_corpus.rs new file mode 100644 index 00000000..e4cb2921 --- /dev/null +++ b/crates/io/tests/affinity_corpus.rs @@ -0,0 +1,107 @@ +//! Public Affinity documents (`cargo xtask corpus --affinity`, pinned and sha256-verified) open +//! natively and look like Affinity's own picture of them: every document embeds a thumbnail +//! Affinity rendered when saving it. Each file's mean difference from that thumbnail (0–255 per +//! channel, both flattened on white, at the smaller of the two sizes) must stay under its ceiling, +//! set just above the difference measured when the importer landed. +#![cfg(feature = "corpus")] +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::path::PathBuf; + +use photocraft_io::{ExportOptions, export, import}; + +/// (file, ceiling). Measured differences when the importer landed are in the comments. +const FILES: &[(&str, f64)] = &[ + ("vector-art/mexican-guy.af", 3e+00), // 1.92: brush strokes as plain strokes + ("vector-art/mexican-man.af", 4e+00), // 2.74 + ("vector-art/mexican-woman.af", 4e+00), // 2.24 + ("vector-art/playing-cards.af", 4e+00), // 3.04: layer effects + ("vector-art/beer.afdesign", 4e+00), // 2.91 + ("vector-art/cactus.afdesign", 2e+00), // 0.57 + ("vector-art/car.afdesign", 2e+00), // 0.39 + ("vector-art/flowers.afdesign", 3e+00), // 2.09 + ("vector-art/lion-track.afdesign", 6e+00), // 4.41 + ("afdesignload/color.afdesign", 1e+00), // 0.00 + ("afdesignload/layer_mode.afdesign", 1e+00), // 0.00 + ("afdesignload/layer_test.afdesign", 1e+00), // 0.00 + ("afdesignload/margins.afdesign", 1e+00), // 0.00 + ("afdesignload/raster_test.afdesign", 2e+00), // 0.82: pixel layer + ("afdesignload/revision_test.afdesign", 1e+00), // 0.01 + ("afdesignload/shape_test.afdesign", 6e+00), // 4.60: cloud and heart shapes as ellipses + ("afdesignload/slice_test.afdesign", 1e+00), // 0.02 + ("afdesignload/test_path.afdesign", 2e+00), // 1.37 + ("jac21/SimpleLogo.afdesign", 2e+00), // 0.78 + ("jac21/SimpleLogoBanner.afdesign", 4e+00), // 2.72: placed images in a Display P3 document + ("asset-store-template/AssetStore.aftemplate", 1e+00), // 0.00: artboards +]; + +fn corpus(rel: &str) -> Vec { + let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/affinity").join(rel); + std::fs::read(&p).unwrap_or_else(|e| panic!("{}: {e}: run `cargo xtask corpus --affinity`", p.display())) +} + +/// Straight RGBA8 pixels flattened on white. +fn on_white(px: &[u8]) -> Vec { + px.chunks_exact(4) + .flat_map(|p| { + let a = f64::from(p[3]) / 255.0; + [0, 1, 2].map(|i| f64::from(p[i]) * a + 255.0 * (1.0 - a)) + }) + .collect() +} + +/// Resample `src` (`sw`×`sh`, 3 channels) to `dw`×`dh`: each target pixel averages the source +/// pixels it covers (at least one, so small documents scale up too). +fn shrink(src: &[f64], sw: usize, sh: usize, dw: usize, dh: usize) -> Vec { + let span = |d: usize, s: usize, n: usize| { + let a = (d * s / n).min(s - 1); + (a, ((d + 1) * s).div_ceil(n).clamp(a + 1, s)) + }; + let mut out = Vec::with_capacity(dw * dh * 3); + for ty in 0..dh { + let (y0, y1) = span(ty, sh, dh); + for tx in 0..dw { + let (x0, x1) = span(tx, sw, dw); + let mut sum = [0.0f64; 3]; + for y in y0..y1 { + for x in x0..x1 { + for (c, s) in sum.iter_mut().enumerate() { + *s += src[(y * sw + x) * 3 + c]; + } + } + } + let n = ((y1 - y0) * (x1 - x0)) as f64; + out.extend(sum.map(|s| s / n)); + } + } + out +} + +#[test] +fn public_affinity_documents_look_like_their_affinity_thumbnails() { + let mut failures = Vec::new(); + for (name, ceiling) in FILES { + let bytes = corpus(name); + let r = import(name, &bytes).unwrap(); + assert!(!r.preview_only, "{name} opened as its preview: {:?}", r.warnings); + let thumb = photocraft_codecs::decode(photocraft_affinity::preview(&bytes).unwrap().png).unwrap(); + let (tw, th) = thumb.dimensions(); + let png = export(&r.document, "x.png", &ExportOptions::default()).unwrap().bytes; + let flat = photocraft_codecs::decode(&png).unwrap(); + let (fw, fh) = flat.dimensions(); + // Several pages sit side by side; the thumbnail shows the first. + let fw_first = ((f64::from(fh) * f64::from(tw) / f64::from(th)).round() as u32).min(fw); + let rgba = flat.to_rgba8(); + let cropped: Vec = rgba.chunks_exact(fw as usize * 4).flat_map(|row| row[..fw_first as usize * 4].to_vec()).collect(); + // Compare at the smaller of the two sizes, so neither picture is enlarged. + let (cw, ch) = if fh < th { (fw_first, fh) } else { (tw, th) }; + let got = shrink(&on_white(&cropped), fw_first as usize, fh as usize, cw as usize, ch as usize); + let want = shrink(&on_white(&thumb.to_rgba8()), tw as usize, th as usize, cw as usize, ch as usize); + let diff = got.iter().zip(&want).map(|(a, b)| (a - b).abs()).sum::() / want.len() as f64; + eprintln!("{name}: {diff:.2} (ceiling {ceiling})"); + if diff > *ceiling { + failures.push(format!("{name}: {diff:.2} > {ceiling}")); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} diff --git a/crates/io/tests/annotations.rs b/crates/io/tests/annotations.rs index df8c3a12..2f50b829 100644 --- a/crates/io/tests/annotations.rs +++ b/crates/io/tests/annotations.rs @@ -147,3 +147,21 @@ fn a_note_icon_near_i32_max_saves_with_representable_geometry() { let back = import("x.psd", &to_psd(&doc)).unwrap().document; assert_eq!(back.notes[0].position, [42.0, 28.0]); } + +/// Issue #944: an `Anno` block the reader rejects opened as "no notes" without a word, and the +/// next save deleted it. It now opens with a warning and is written back byte for byte. +#[test] +fn an_unreadable_anno_block_warns_and_survives_a_save() { + // The block declares two annotations but holds one. + let mut broken = anno_with_icon(28, 42); + broken[4..8].copy_from_slice(&2u32.to_be_bytes()); + let imp = import("notes.psd", &psd_with_anno(broken.clone())).expect("import"); + assert!(imp.document.notes.is_empty()); + assert!(imp.warnings.iter().any(|w| w.contains("Anno")), "{:?}", imp.warnings); + let f = PsdFile::from_bytes(&to_psd(&imp.document)).unwrap(); + assert_eq!(f.global_block(b"Anno").expect("Anno block kept").data, broken); + // Control: the intact block reads with no warning. + let ok = import("notes.psd", &psd_with_anno(anno_with_icon(28, 42))).expect("import"); + assert_eq!(ok.document.notes.len(), 1); + assert!(!ok.warnings.iter().any(|w| w.contains("Anno"))); +} diff --git a/crates/plugins/src/apply.rs b/crates/plugins/src/apply.rs index a809228c..7a144a28 100644 --- a/crates/plugins/src/apply.rs +++ b/crates/plugins/src/apply.rs @@ -125,8 +125,8 @@ impl Plugin { let ctx = BandCtx { surface, extent, canvas, selection, fmt, ov, params: Value::Object(resolved) }; let run = |b: &Rect| { let r = self.run_band(&ctx, *b, &deadline).map(|data| encode_band(surface, *b, &data)); - if r.is_err() { - deadline.abort(); + if let Err(error) = &r { + deadline.abort(error.clone()); } r }; diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs index c978c607..e436133b 100644 --- a/crates/plugins/src/runtime.rs +++ b/crates/plugins/src/runtime.rs @@ -1,7 +1,6 @@ //! The wasmi host: loading, instantiating and calling a plug-in under resource limits. -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; use wasmi::{ Config, EnforcedLimits, Engine, Instance, Linker, Memory, Module, Store, StoreLimits, StoreLimitsBuilder, TypedFunc, TypedResumableCall, WasmParams, @@ -73,7 +72,7 @@ impl std::fmt::Debug for Plugin { pub(crate) struct Deadline { #[cfg(not(target_arch = "wasm32"))] at: std::time::Instant, - abort: Arc, + abort: Arc>>, } impl Deadline { @@ -83,20 +82,23 @@ impl Deadline { Self { #[cfg(not(target_arch = "wasm32"))] at: std::time::Instant::now() + std::time::Duration::from_millis(limits.wall_time_ms), - abort: Arc::new(AtomicBool::new(false)), + abort: Arc::new(Mutex::new(None)), } } - pub(crate) fn abort(&self) { - self.abort.store(true, Ordering::Relaxed); + pub(crate) fn abort(&self, error: Error) { + // Publish the cause with the cancellation state so siblings cannot replace it with a + // generic cancellation error, even when results are collected in a different order. + self.abort.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_or_insert(error); } fn check(&self) -> Result<()> { - if self.abort.load(Ordering::Relaxed) { - return Err(Error::Failed("stopped because another band failed".into())); + if let Some(error) = self.abort.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone() { + return Err(error); } #[cfg(not(target_arch = "wasm32"))] if std::time::Instant::now() >= self.at { - self.abort(); - return Err(Error::Limit("time budget".into())); + let error = Error::Limit("time budget".into()); + self.abort(error.clone()); + return Err(error); } Ok(()) } diff --git a/crates/plugins/tests/abi.rs b/crates/plugins/tests/abi.rs index f5c9e593..a75eedd8 100644 --- a/crates/plugins/tests/abi.rs +++ b/crates/plugins/tests/abi.rs @@ -227,6 +227,47 @@ fn budgets_stop_runaway_plugins() { assert_eq!(e, Error::Limit("instruction budget".into())); } +#[test] +fn parallel_bands_preserve_the_originating_error() { + let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap(); + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 256, 256), &[0.0, 0.0, 0.0, 1.0]); + s.fill_rect(Rect::new(0, 256, 256, 512), &[1.0, 0.0, 0.0, 1.0]); + for (body, expected) in [ + ("(i32.const 7)", Error::Failed("pc_filter returned error code 7".into())), + ("unreachable", Error::Trap("wasm `unreachable` instruction executed".into())), + ] { + // The later band fails immediately while the earlier one is still running (or hasn't + // started). Collecting in band order must not turn that failure into sibling cancellation. + let filter = format!( + "(if (result i32) (f32.gt (f32.load (local.get $buf)) (f32.const 0.5)) \ + (then {body}) (else (loop $spin (br $spin)) (i32.const 0)))" + ); + let limits = Limits { band_bytes: 1, fuel_base: 100_000_000, fuel_per_sample: 0, ..Limits::default() }; + let p = Plugin::load(&ok_module(&filter), limits).unwrap(); + let before = s.clone(); + let e = pool.install(|| p.apply(&s, Rect::new(0, 0, 256, 512), None, &json!({}))).unwrap_err(); + assert_eq!(e, expected); + assert_eq!(s, before, "a failed run must not change the input"); + } +} + +#[test] +fn parallel_bands_preserve_instruction_budget() { + let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap(); + let p = Plugin::load( + &ok_module("(loop $spin (br $spin)) (i32.const 0)"), + Limits { band_bytes: 1, fuel_base: 1_000_000, fuel_per_sample: 0, ..Limits::default() }, + ) + .unwrap(); + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 768, 512), &[0.25, 0.5, 0.75, 1.0]); + for _ in 0..8 { + let e = pool.install(|| p.apply(&s, Rect::new(0, 0, 768, 512), None, &json!({}))).unwrap_err(); + assert_eq!(e, Error::Limit("instruction budget".into())); + } +} + #[test] fn wall_clock_budget_stops_runaway_plugins() { let spin = ok_module("(loop $l (br $l)) (i32.const 0)"); diff --git a/crates/psd/src/tiff.rs b/crates/psd/src/tiff.rs index 15867ee1..1cd2057c 100644 --- a/crates/psd/src/tiff.rs +++ b/crates/psd/src/tiff.rs @@ -1056,7 +1056,11 @@ pub mod transcode { Ok(()) })?; mask_heights = mh; - t.sized_region(false, |x| x.u32s_to_end())?; + // Blending ranges are bytes (black low/high, white low/high per channel), so + // Photoshop stores them in the same order in either byte order: an Intel-order + // layered TIFF holds the default ranges as `00 00 ff ff`. Swapping them as u32s + // turned every range into `ff ff 00 00`, which hides every pixel of the layer. + t.sized_region(false, |x| x.rest())?; t.pascal(4)?; t.blocks() })?; @@ -2223,6 +2227,24 @@ mod tests { assert!(little.windows(table + samples.len()).any(|w| w[..table] == swapped[..] && w[table..] == *samples)); } + #[test] + fn blending_ranges_are_bytes_in_either_order() { + let mut file = testgen::layered(Version::Psd, ColorMode::Rgb, 8, Compression::Rle); + let ranges: Vec = [0u8, 0, 255, 255].repeat(10); + let marked: Vec = (1u8..=40).collect(); + for (i, l) in file.layer_info.as_mut().unwrap().layers.iter_mut().enumerate() { + l.blending_ranges.data = if i == 0 { marked.clone() } else { ranges.clone() }; + } + let d = ImageSourceData::from_psd(&file); + let (little, _) = d.to_bytes(ByteOrder::Little).unwrap(); + // Written verbatim (what Photoshop writes in an `II` TIFF), not swapped per u32. + assert!(little.windows(marked.len()).any(|w| w == marked)); + let (back, _) = ImageSourceData::from_bytes(&little).unwrap(); + for (a, b) in d.layer_info.as_ref().unwrap().layers.iter().zip(&back.layer_info.as_ref().unwrap().layers) { + assert_eq!(a.blending_ranges, b.blending_ranges); + } + } + #[test] fn psb_sized_data_uses_long_lengths_and_counts() { let file = testgen::layered(Version::Psb, ColorMode::Cmyk, 8, Compression::Rle); diff --git a/crates/raw/Cargo.toml b/crates/raw/Cargo.toml index 90d4c83d..55960b33 100644 --- a/crates/raw/Cargo.toml +++ b/crates/raw/Cargo.toml @@ -11,6 +11,8 @@ default = [] # Synthetic raw-file generators (DNG / CR2 / TIFF-EP writers and a lossless-JPEG # encoder) for tests and fuzz seeds. testgen = [] +# Real-file corpus tests (opt-in; `cargo xtask test-corpus`). Fail when the corpus is missing. +corpus = [] [dependencies] diff --git a/crates/raw/examples/inspect.rs b/crates/raw/examples/inspect.rs new file mode 100644 index 00000000..ca7b136a --- /dev/null +++ b/crates/raw/examples/inspect.rs @@ -0,0 +1,37 @@ +//! Prints the raw metadata of files: `cargo run -p photocraft-raw --example inspect -- a.cr2 b.nef` +//! (handy while adding corpus files; the oracle expectations in `tests/raw_corpus.rs` come from it). +use std::env; + +fn main() { + for path in env::args().skip(1) { + let bytes = match std::fs::read(&path) { + Ok(b) => b, + Err(e) => { + println!("{path}: read error {e}"); + continue; + } + }; + match photocraft_raw::decode(&bytes, &photocraft_raw::Limits::default()) { + Ok(s) => { + println!( + "{path}: {:?} make={:?} model={:?} {}x{} samples={} cfa={:?} wb={:?} black={:?} white={:?} crop={:?}..{:?} orientation={} warnings={}", + s.format, + s.make, + s.model, + s.width, + s.height, + s.samples, + s.cfa, + s.camera_wb, + s.black, + s.white, + s.active, + s.crop, + s.orientation, + s.warnings.len(), + ); + } + Err(e) => println!("{path}: decode error {e}"), + } + } +} diff --git a/crates/raw/src/cr2.rs b/crates/raw/src/cr2.rs index ee937471..a0fe896c 100644 --- a/crates/raw/src/cr2.rs +++ b/crates/raw/src/cr2.rs @@ -116,11 +116,18 @@ pub(crate) fn maker_note(t: &Tiff, ifds: &[Ifd]) -> Option { /// As-shot white-balance multipliers (R, G, B) from ColorData. fn as_shot_wb(t: &Tiff, mn: &Ifd) -> Option<[f64; 3]> { let e = mn.get(CANON_COLOR_DATA)?; - let v = t.uints(e); + // The entry is UNDEFINED: its bytes are a table of 16-bit words (file byte order), and the + // offsets below count words, not bytes. Reads stay inside the entry's own byte count. + let bytes = e.unit().checked_mul(e.count as usize)?; + let word = |off: usize| { + let at = off.checked_mul(2)?.checked_add(e.at)?; + (at.checked_add(2)? <= e.at.checked_add(bytes)?).then(|| t.u16_at(at)).flatten().map(f64::from) + }; // Word offsets of WB_RGGBLevelsAsShot used by the ColorData versions. for off in [0x3F, 0x47, 0x19, 0x22] { - let Some(l) = v.get(off..off + 4) else { continue }; - let [r, g1, g2, b] = [l[0], l[1], l[2], l[3]].map(f64::from); + let (Some(r), Some(g1), Some(g2), Some(b)) = (word(off), word(off + 1), word(off + 2), word(off + 3)) else { + continue; + }; let g = (g1 + g2) / 2.0; let plausible = (256.0..16384.0).contains(&g) && (g1 - g2).abs() <= g * 0.05 && (0.25..8.0).contains(&(r / g)) && (0.25..8.0).contains(&(b / g)); if plausible { @@ -345,3 +352,75 @@ mod tests { } } } +#[cfg(all(test, feature = "corpus"))] +mod wb_debug { + + /// The corpus PowerShot CR2: ColorData's WB_RGGBLevelsAsShot sits at word offset 0x47 (the + /// PowerShot SX50 HS's ColorData version), the same words its DNG conversion neutralises + /// (AsShotNeutral -> WB ≈ [1.66, 1.0, 1.74]; the ColorData words give [1.70, 1.0, 1.69] — + /// the converter re-derives them through the color matrix). The synthetic twin of this + /// oracle is `color_data_wb_as_words` above; it runs in every CI. + #[test] + fn power_shot_color_data_holds_the_as_shot_wb() { + let Ok(bytes) = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus/pixls/IMG_4059.CR2")) else { + panic!("corpus/pixls is fetched for corpus runs: cargo xtask corpus --pixls"); + }; + let s = crate::decode(&bytes, &crate::Limits::default()).expect("decode"); + let wb = s.camera_wb.expect("the PowerShot's as-shot WB is read now"); + assert!((wb[0] - 1.696).abs() < 0.01 && wb[1] == 1.0 && (wb[2] - 1.688).abs() < 0.01, "{wb:?}"); + // The file's white balance is used, not an estimate: no grey-world note in the develop. + let d = crate::develop_sensor(&s, &crate::DevelopOptions::default()).expect("develop"); + assert!(!d.warnings.iter().any(|w| w.contains("estimated automatically")), "{:?}", d.warnings); + } +} +#[cfg(test)] +mod wb_words { + use crate::testgen::Cr2Spec; + + /// ColorData arrives as UNDEFINED bytes that are 16-bit words; the offsets count words. + /// Regression for the byte-scaled lookup that never found the balance (synthetic twin of + /// the corpus oracle, runs in every CI). + #[test] + fn color_data_wb_as_words() { + let spec = Cr2Spec { + width: 8, + height: 8, + data: vec![2048u16; 64], + precision: 12, + components: 1, + slices: Vec::new(), + borders: None, + wb_rggb: Some([1613, 951, 951, 1605]), + orientation: 1, + model_id: None, + }; + let bytes = spec.build(); + let s = crate::decode(&bytes, &crate::Limits::default()).expect("decode"); + let wb = s.camera_wb.expect("the synthetic as-shot WB is read"); + assert!((wb[0] - 1613.0 / 951.0).abs() < 1e-9 && wb[1] == 1.0 && (wb[2] - 1605.0 / 951.0).abs() < 1e-9, "{wb:?}"); + } + + /// A ColorData entry too short for the offsets reads no balance instead of running past it. + #[test] + fn short_color_data_reads_no_wb() { + let spec = Cr2Spec { wb_rggb: None, ..minimal_spec() }; + let bytes = spec.build(); + let s = crate::decode(&bytes, &crate::Limits::default()).expect("decode"); + assert!(s.camera_wb.is_none()); + } + + fn minimal_spec() -> Cr2Spec { + Cr2Spec { + width: 8, + height: 8, + data: vec![2048u16; 64], + precision: 12, + components: 1, + slices: Vec::new(), + borders: None, + wb_rggb: None, + orientation: 1, + model_id: None, + } + } +} diff --git a/crates/raw/src/testgen.rs b/crates/raw/src/testgen.rs index 7a784b6e..c83a0481 100644 --- a/crates/raw/src/testgen.rs +++ b/crates/raw/src/testgen.rs @@ -663,7 +663,7 @@ pub struct Cr2Spec { pub slices: Vec, /// Image borders: left, top, right, bottom (inclusive), as in SensorInfo. pub borders: Option<[u16; 4]>, - /// As-shot RGGB levels written to ColorData at word offset 0x3F. + /// As-shot RGGB levels written to ColorData at word offset 0x47 (the PowerShot version). pub wb_rggb: Option<[u16; 4]>, pub orientation: u16, /// Canon model ID written to MakerNote tag 0x0010. @@ -703,9 +703,14 @@ impl Cr2Spec { mn.push((0x00E0, Val::Short(vec![34, w as u16, h as u16, 0, 0, l, tp, r, b, 0, 0, 0, 0, 0, 0, 0, 0]))); } if let Some(wb) = self.wb_rggb { - let mut cd = vec![0u16; 1273]; - cd[0x3F..0x43].copy_from_slice(&wb); - mn.push((0x4001, Val::Short(cd))); + // Canon writes ColorData (0x4001) as UNDEFINED bytes that are 16-bit words; the + // WB_RGGBLevelsAsShot of the PowerShot version sits at word offset 0x47 (the EOS + // versions use 0x3F — see as_shot_wb). + let mut cd = vec![0u8; 2 * 1273]; + for (i, v) in wb.iter().enumerate() { + cd[2 * (0x47 + i)..2 * (0x47 + i) + 2].copy_from_slice(&v.to_le_bytes()); + } + mn.push((0x4001, Val::Undefined(cd))); } let mut exif: Vec<(u16, Val)> = vec![(33434, Val::Rational(vec![(1, 100)]))]; if !mn.is_empty() { diff --git a/crates/raw/src/tiff.rs b/crates/raw/src/tiff.rs index 764cc870..5a623044 100644 --- a/crates/raw/src/tiff.rs +++ b/crates/raw/src/tiff.rs @@ -89,7 +89,7 @@ pub(crate) struct Entry { impl Entry { /// Size in bytes of one value of this entry's type (0 = unknown type). - fn unit(&self) -> usize { + pub(crate) fn unit(&self) -> usize { type_size(self.typ) } } diff --git a/crates/raw/tests/raw_corpus.rs b/crates/raw/tests/raw_corpus.rs new file mode 100644 index 00000000..354dec98 --- /dev/null +++ b/crates/raw/tests/raw_corpus.rs @@ -0,0 +1,153 @@ +//! Real-camera corpus tests against `corpus/pixls` (raw.pixls.us, public domain): one file per +//! decode path, oracle-checked against values measured when the corpus was pinned (the +//! `inspect` example prints them). Opt-in like every real-file corpus: +//! `cargo xtask corpus --pixls` fetches, `cargo xtask test-corpus` runs. +#![cfg(feature = "corpus")] + +use photocraft_raw::{Limits, RawError, RawFormat}; +use std::path::PathBuf; + +fn pixls(name: &str) -> PathBuf { + let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/pixls").join(name); + if !p.is_file() { + panic!("{} is missing: run `cargo xtask corpus --pixls` (or `--all`) first", p.display()); + } + p +} + +struct Want<'a> { + file: &'static str, + format: RawFormat, + make: &'static str, + model: &'static str, + /// Sensor width and height. + size: (usize, usize), + /// The four CFA colours, row-major. + cfa: [u8; 4], + black: &'a [f32], + white: [f32; 3], +} + +const WANTS: &[Want] = &[ + Want { + file: "IMG_4059.CR2", + format: RawFormat::Cr2, + make: "Canon", + model: "Canon PowerShot SX50 HS", + size: (4176, 3062), + cfa: [0, 1, 1, 2], + black: &[128.0; 4], + white: [4095.0; 3], + }, + // The same camera after DNG Converter: the sensor data round-trips identically. + Want { + file: "CRW_4061.DNG", + format: RawFormat::Dng, + make: "Canon", + model: "Canon PowerShot SX50 HS", + size: (4176, 3062), + cfa: [0, 1, 1, 2], + black: &[127.0], + white: [4095.0; 3], + }, + Want { + file: "JD1_8203.NEF", + format: RawFormat::Nef, + make: "NIKON CORPORATION", + model: "NIKON D3", + size: (4288, 2844), + cfa: [0, 1, 1, 2], + black: &[0.0], + white: [4095.0; 3], + }, + // Sony cRAW: the saturation level comes from the compressed code's range. + Want { + file: "DSC00009.ARW", + format: RawFormat::Arw, + make: "SONY", + model: "DSC-RX0", + size: (4832, 3224), + cfa: [0, 1, 1, 2], + black: &[0.0], + white: [16301.0; 3], + }, + // The G9's high-resolution mode: a 10480 × 7794 sensor cropped to 10368 × 7776. + Want { + file: "P1000475.RW2", + format: RawFormat::Rw2, + make: "Panasonic", + model: "DC-G9", + size: (10480, 7794), + cfa: [0, 1, 1, 2], + black: &[127.0, 128.0, 128.0, 127.0], + white: [4095.0; 3], + }, + // Four Thirds Olympus: GRBG instead of the usual RGGB. + Want { + file: "E_1__C106743_gredos.ORF", + format: RawFormat::Orf, + make: "OLYMPUS CORPORATION", + model: "E-1", + size: (2624, 1966), + cfa: [1, 0, 2, 1], + black: &[65.0; 4], + white: [4095.0; 3], + }, +]; + +#[test] +fn decode_matches_the_measured_oracles() { + for w in WANTS { + let bytes = std::fs::read(pixls(w.file)).unwrap_or_else(|e| panic!("{}: {e}", w.file)); + let s = photocraft_raw::decode(&bytes, &Limits::default()).unwrap_or_else(|e| panic!("{}: {e}", w.file)); + assert_eq!(s.format, w.format, "{}", w.file); + assert_eq!(s.make.as_deref(), Some(w.make), "{}", w.file); + assert_eq!(s.model.as_deref(), Some(w.model), "{}", w.file); + assert_eq!((s.width, s.height), w.size, "{}", w.file); + let cfa = s.cfa.as_ref().unwrap_or_else(|| panic!("{}: no CFA", w.file)); + assert_eq!(cfa.colors, w.cfa, "{}", w.file); + assert_eq!(s.black.values, w.black, "{}", w.file); + for ch in 0..3 { + assert!((s.white[ch] - w.white[ch]).abs() < 0.5, "{} white[{ch}] = {}", w.file, s.white[ch]); + } + } +} + +#[test] +fn developing_produces_the_crop_in_16bit_rgb() { + for w in WANTS { + let bytes = std::fs::read(pixls(w.file)).unwrap_or_else(|e| panic!("{}: {e}", w.file)); + let s = photocraft_raw::decode(&bytes, &Limits::default()).unwrap_or_else(|e| panic!("{}: {e}", w.file)); + let d = photocraft_raw::develop_sensor(&s, &photocraft_raw::DevelopOptions::default()).unwrap_or_else(|e| panic!("{}: {e}", w.file)); + assert_eq!((d.width as usize, d.height as usize), (s.crop.width, s.crop.height), "{}", w.file); + assert_eq!(d.rgb.len(), d.width as usize * d.height as usize * 3, "{}", w.file); + // A real exposure covers a sane share of the range and never leaves it. + let (mut lo, mut hi) = (u16::MAX, 0u16); + for v in &d.rgb { + lo = (*v).min(lo); + hi = (*v).max(hi); + } + // A real exposure covers a sane share of the range and never leaves it. The PowerShot + // CR2 and the RX0 ARW are the documented exceptions: neither carries an as-shot white + // balance we read, so the grey-world estimate brightens these scenes (the PowerShot's + // own DNG, with its file WB, develops blacks to ~0 — reading the maker-note WB of + // Canon and Sony is the follow-up). + let estimated_wb = matches!(w.file, "IMG_4059.CR2" | "DSC00009.ARW"); + let black_cap: u16 = if estimated_wb { 16384 } else { 4096 }; + assert!(lo < black_cap, "{}: developed blacks at {lo}", w.file); + assert!(hi > u16::MAX / 4, "{}: developed highlights at {hi}", w.file); + // Deterministic: a second develop of the same sensor gives the same pixels. + let d2 = photocraft_raw::develop_sensor(&s, &photocraft_raw::DevelopOptions::default()).unwrap(); + assert_eq!(d.rgb, d2.rgb, "{}", w.file); + } +} + +/// 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] +fn packed_orf_reports_the_documented_gap() { + let bytes = std::fs::read(pixls("_7061961_copy.ORF")).unwrap(); + let err = photocraft_raw::decode(&bytes, &Limits::default()).unwrap_err(); + assert!(matches!(err, RawError::Unsupported(ref m) if m.contains("Olympus compressed")), "{err}"); + assert!(photocraft_raw::embedded_preview(&bytes).is_some(), "the camera's JPEG preview is still reachable"); +} diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 3154790a..3a883eee 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -7,8 +7,8 @@ use std::collections::HashMap; use std::sync::Arc; use parley::FontContext; -use parley::fontique::{Blob, Collection, CollectionOptions, FontStyle, GenericFamily, SourceCache}; -use skrifa::raw::FileRef; +use parley::fontique::{Blob, Collection, CollectionOptions, FontStyle, FontWeight, FontWidth, GenericFamily, SourceCache}; +use skrifa::raw::{FileRef, TableProvider, types::Tag}; use skrifa::{MetadataProvider, string::StringId}; /// Family used when a style names no family or an unknown one. @@ -210,6 +210,31 @@ impl FontDb { self.fcx.collection.family_id(name).is_some() } + /// Whether the face selected for a character style exposes an OpenType GSUB feature. + /// + /// This deliberately checks the matched face, rather than every face in the family: a + /// regular face can lack a feature that its italic or bold sibling has (or vice versa). + pub(crate) fn selected_face_has_feature(&mut self, family: &str, weight: u16, italic: bool, feature: Tag) -> bool { + let family = if family.is_empty() || !self.has_family(family) { DEFAULT_FAMILY } else { family }; + let Some(info) = self.fcx.collection.family_by_name(family) else { + return false; + }; + let style = if italic { FontStyle::Italic } else { FontStyle::Normal }; + let Some(font) = info.match_font(FontWidth::default(), style, FontWeight::new(weight.clamp(1, 1000) as f32), true) else { + return false; + }; + let Some(blob) = font.load(Some(&mut self.fcx.source_cache)) else { + return false; + }; + let Ok(font) = skrifa::FontRef::from_index(blob.as_ref(), font.index()) else { + return false; + }; + font.gsub() + .ok() + .and_then(|gsub| gsub.feature_list().ok()) + .is_some_and(|features| features.feature_records().iter().any(|record| record.feature_tag() == feature)) + } + /// Faces of a family (weights, italics, variation axes). pub fn faces(&mut self, family: &str) -> Vec { let Some(info) = self.fcx.collection.family_by_name(family) else { diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 8046a133..d198f275 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -23,9 +23,30 @@ use parley::{ }; use photocraft_doc::TextLayer; use photocraft_doc::text::{Caps, CharStyle, Kerning, Orientation, TextAlign, TextDirection, TextShape}; +use skrifa::raw::types::Tag; use crate::fonts::FontDb; +/// Photoshop synthesizes small caps for faces without an OpenType `smcp` table. Keep the same +/// readable hierarchy for every font instead of silently rendering lowercase text unchanged. +const SYNTHETIC_SMALL_CAPS_SCALE: f32 = 0.7; + +/// The rendering path for a `SmallCaps` character style. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SmallCapsMode { + None, + OpenType, + Synthetic, +} + +fn small_caps_mode(caps: Caps, face_has_smcp: bool) -> SmallCapsMode { + match caps { + Caps::SmallCaps if face_has_smcp => SmallCapsMode::OpenType, + Caps::SmallCaps => SmallCapsMode::Synthetic, + _ => SmallCapsMode::None, + } +} + /// Index of the character run whose style a glyph uses, plus the vertical-type class of its /// characters ([`VClass`] as `u8`; always 0 in horizontal type). #[derive(Clone, Copy, Debug, Default, PartialEq)] @@ -449,6 +470,17 @@ impl Layouter { }; let mut ptext = String::with_capacity(prefix.len() + content.len()); ptext.push_str(prefix); + // A real `smcp` substitution must receive lowercase source text; only the fallback + // path uppercases it and scales it down. + let small_caps: Vec = out + .styles + .iter() + .map(|style| small_caps_mode(style.caps, fonts.selected_face_has_feature(&style.font_family, style.weight, style.italic, Tag::new(b"smcp")))) + .collect(); + // Ranges in `ptext` for lowercase characters rendered as synthetic small caps. + // They have the same UTF-8 length as their uppercase form, so the layer's byte-based + // run and caret offsets remain unchanged. + let mut synthetic_small_caps: Vec<(Range, f32)> = Vec::new(); for (i, ch) in content.char_indices() { // A forced line break ends the line but not the paragraph. The line breaker knows // it as a newline, which has the same length, so text offsets don't move. @@ -456,11 +488,16 @@ impl Layouter { ptext.push('\n'); continue; } - let caps = out.styles[style_at(prange.start + i)].caps; - if caps == Caps::AllCaps { + let style = &out.styles[style_at(prange.start + i)]; + let caps = style.caps; + if caps == Caps::AllCaps || small_caps[style_at(prange.start + i)] == SmallCapsMode::Synthetic { let up: String = ch.to_uppercase().collect(); if up.len() == ch.len_utf8() { ptext.push_str(&up); + if caps == Caps::SmallCaps && ch.is_lowercase() { + let end = ptext.len(); + synthetic_small_caps.push((end - up.len()..end, style.size_pt * k * SYNTHETIC_SMALL_CAPS_SCALE)); + } continue; } } @@ -474,7 +511,7 @@ impl Layouter { // Paragraph-start style as the default (covers the direction mark and empty // paragraphs), then every run piece intersecting this paragraph. let si0 = style_at(prange.start); - for p in style_props(&out.styles[si0], k, &fallback, si0 as u32) { + for p in style_props(&out.styles[si0], k, &fallback, si0 as u32, small_caps[si0]) { b.push_default(p); } for (ri, st) in out.styles.iter().enumerate() { @@ -484,7 +521,7 @@ impl Layouter { continue; } let range = (a - prange.start + prefix.len())..(z - prange.start + prefix.len()); - for p in style_props(st, k, &fallback, ri as u32) { + for p in style_props(st, k, &fallback, ri as u32, small_caps[ri]) { b.push(p, range.clone()); } if vertical { @@ -495,7 +532,7 @@ impl Layouter { if cls == VClass::Rotate || from >= to { return; } - let mut feats = feature_list(st); + let mut feats = feature_list(st, small_caps[ri]); feats.push("\"vert\" 1".into()); let r = (range.start + from)..(range.start + to); b.push(StyleProperty::FontFeatures(FontFeatures::Source(Cow::Owned(feats.join(", ")))), r.clone()); @@ -517,6 +554,9 @@ impl Layouter { } } } + for (range, size) in synthetic_small_caps { + b.push(StyleProperty::FontSize(size), range); + } b.build(&ptext) }; let indent_start = ps.start_indent_pt * k; @@ -1014,7 +1054,7 @@ impl skrifa::outline::OutlinePen for YMax { } /// OpenType feature settings of a character style (CSS `font-feature-settings` items). -fn feature_list(st: &CharStyle) -> Vec { +fn feature_list(st: &CharStyle, small_caps: SmallCapsMode) -> Vec { let mut feats: Vec = Vec::new(); // Optical and manual kerning replace the font's kerning table; a character with a manual // kern is manually kerned whatever its mode (as in Photoshop). @@ -1028,7 +1068,7 @@ fn feature_list(st: &CharStyle) -> Vec { if st.discretionary_ligatures { feats.push("\"dlig\" 1".into()); } - if st.caps == Caps::SmallCaps { + if small_caps == SmallCapsMode::OpenType { feats.push("\"smcp\" 1".into()); } for f in &st.features { @@ -1039,7 +1079,7 @@ fn feature_list(st: &CharStyle) -> Vec { feats } -fn style_props(st: &CharStyle, k: f32, fallback: &[String], idx: u32) -> Vec> { +fn style_props(st: &CharStyle, k: f32, fallback: &[String], idx: u32, small_caps: SmallCapsMode) -> Vec> { let px = (st.size_pt * k).max(0.01); let mut fam: Vec = Vec::new(); if !st.font_family.is_empty() { @@ -1052,7 +1092,7 @@ fn style_props(st: &CharStyle, k: f32, fallback: &[String], idx: u32) -> Vec = st.variations.iter().filter(|v| v.axis.len() == 4 && v.axis.is_ascii()).map(|v| format!("\"{}\" {}", v.axis, v.value)).collect(); vec![ StyleProperty::FontFamily(FontFamily::Source(Cow::Owned(fam.join(", ")))), @@ -1154,3 +1194,21 @@ fn first_ascent(line: &parley::Line<'_, RunBrush>) -> Option { } best } + +#[cfg(test)] +mod tests { + use super::{SmallCapsMode, feature_list, small_caps_mode}; + use photocraft_doc::text::{Caps, CharStyle}; + + #[test] + fn uses_real_small_caps_only_when_the_selected_face_supports_smcp() { + let style = CharStyle { caps: Caps::SmallCaps, ..Default::default() }; + let real = small_caps_mode(style.caps, true); + let synthetic = small_caps_mode(style.caps, false); + + assert_eq!(real, SmallCapsMode::OpenType); + assert!(feature_list(&style, real).iter().any(|feature| feature == "\"smcp\" 1")); + assert_eq!(synthetic, SmallCapsMode::Synthetic); + assert!(!feature_list(&style, synthetic).iter().any(|feature| feature == "\"smcp\" 1")); + } +} diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index db0e8f82..200acfbe 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -231,10 +231,57 @@ pub fn text_layer_from_tysh(data: &[u8], dpi: f32) -> Option { } else { layer.text = txt.unwrap_or_default().replace('\r', "\n"); } + // Some legacy PSDs store point text at a large local origin even though PointBase is zero. + // Their transform maps that origin onto the canvas, but maps our zero-based layout far away. + // Move the imported layout to the descriptor's ink origin so editing, engine commands, and + // writing the TySh all agree on the visible position (#1469). + if let Some([x, y]) = legacy_point_origin(&t) { + let [a, b, c, d, e, f] = layer.transform.m; + layer.transform.m = [a, b, c, d, a * x + c * y + e, b * x + d * y + f]; + } layer.sync_summary(); Some(layer) } +/// The local origin to fold into a legacy point-text transform, if present. +/// +/// Photoshop normally keeps point text close to its PointBase. The affected legacy files instead +/// use a zero PointBase plus a very large `boundingBox` origin, with an equally remote transform +/// translation that cancels it out. Do not apply this to ordinary point text: its transform origin +/// is already the layout origin, and shifting it by the ink bounds would move the layer. +fn legacy_point_origin(t: &TySh) -> Option<[f64; 2]> { + let e = engine_data(&t.text)?; + let point_base = e + .path(&["EngineDict", "Rendered", "Shapes", "Children"]) + .and_then(E::as_array) + .and_then(|children| children.first()) + .and_then(|shape| shape.path(&["Cookie", "Photoshop", "PointBase"])) + .and_then(E::as_array)?; + let [px, py] = point_base else { return None }; + if px.as_f64() != Some(0.0) || py.as_f64() != Some(0.0) { + return None; + } + let bounds = descriptor_rect(&t.text, "boundingBox")?; + let [a, b, c, d, e, f] = t.transform.m; + let values = [a, b, c, d, e, f, bounds[0], bounds[1]]; + if !values.iter().all(|v| v.is_finite()) { + return None; + } + let shifted = [a * bounds[0] + c * bounds[1] + e, b * bounds[0] + d * bounds[1] + f]; + let origin_distance = e.abs().max(f.abs()); + let shifted_distance = shifted[0].abs().max(shifted[1].abs()); + // A local bounding-box origin in the thousands and a transform origin beyond a normal canvas + // are the signature of this old writer. Requiring that the bounding-box origin is closer to + // the document prevents applying the correction again after export/import. + (bounds[0].abs().max(bounds[1].abs()) > 1024.0 && origin_distance > 4096.0 && shifted_distance < origin_distance).then_some([bounds[0], bounds[1]]) +} + +fn descriptor_rect(text: &Descriptor, key: &str) -> Option<[f64; 4]> { + let D::Descriptor(rect) = text.get(key)? else { return None }; + let bounds = [dnum(rect.get("Left"))?, dnum(rect.get("Top "))?, dnum(rect.get("Rght"))?, dnum(rect.get("Btom"))?]; + (bounds.iter().all(|v| v.is_finite()) && bounds[2] > bounds[0] && bounds[3] > bounds[1]).then_some(bounds) +} + pub(crate) fn arr_f(v: Option<&E>) -> Vec { v.and_then(E::as_array).map(|a| a.iter().filter_map(E::as_f64).collect()).unwrap_or_default() } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 423629a6..e1b23efe 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1,6 +1,6 @@ use photocraft_color::{Color, PixelFormat, SampleType}; use photocraft_doc::TextLayer; -use photocraft_doc::text::{CharStyle, FontFeature, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, TextRun, TextShape}; +use photocraft_doc::text::{Caps, CharStyle, FontFeature, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, TextRun, TextShape}; use photocraft_geom::Affine; use crate::{TextEngine, fonts}; @@ -55,6 +55,20 @@ fn metrics_are_stable_and_scale_with_dpi() { assert!(a.lines[0].ascent > 8.0 && a.lines[0].ascent < 13.0); } +#[test] +fn small_caps_are_synthesized_when_the_font_has_no_small_caps_feature() { + let style = CharStyle { font_family: "Inter".into(), size_pt: 40.0, caps: Caps::SmallCaps, ..Default::default() }; + let mut engine = TextEngine::new(); + let small = engine.layout(&styled("aA", style), 72.0); + let upper = engine.layout(&styled("AA", CharStyle { font_family: "Inter".into(), size_pt: 40.0, ..Default::default() }), 72.0); + assert_eq!(small.glyphs.len(), 2); + assert_eq!(small.glyphs[0].id, upper.glyphs[0].id, "lowercase uses the uppercase glyph"); + assert_eq!(small.glyphs[1].id, upper.glyphs[1].id, "uppercase remains uppercase"); + let size = |layout: &crate::TextLayout, glyph: usize| layout.faces[layout.glyphs[glyph].face as usize].size_px; + assert!((size(&small, 0) - 28.0).abs() < 0.01, "{}", size(&small, 0)); + assert!((size(&small, 1) - 40.0).abs() < 0.01, "{}", size(&small, 1)); +} + #[test] fn auto_and_explicit_leading() { let mut e = TextEngine::new(); @@ -669,6 +683,48 @@ fn psd_round_trips_antialias_opentype_and_warp() { } } +/// #1469: Some older PSD writers combine a zero PointBase with an enormous local ink origin. +/// The TySh transform cancels that origin, so importing it as a zero-based layout puts text far +/// off-canvas. Fold the descriptor origin into the imported transform and preserve it on a TySh +/// round trip so every editing and export path uses the same position. +#[test] +fn legacy_tysh_point_origin_imports_and_round_trips_on_canvas() { + use photocraft_psd::descriptor::{Descriptor, Id, Value as D}; + + let source = styled("Name", CharStyle { font_family: "Inter".into(), size_pt: 120.0, ..Default::default() }); + let mut tysh = crate::psd::parse_tysh(&crate::psd::build_tysh(&source, 72.0, None)).unwrap(); + tysh.transform = Affine { m: [4.1667, 0.0, 0.0, 4.1667, -32375.0, -32887.5] }; + let ink = [8050.0, 8160.0, 8220.0, 8300.0]; + let rect = |class| { + D::Descriptor( + Descriptor::new(class) + .with("Left", D::UnitFloat { unit: *b"#Pnt", value: ink[0] }) + .with("Top ", D::UnitFloat { unit: *b"#Pnt", value: ink[1] }) + .with("Rght", D::UnitFloat { unit: *b"#Pnt", value: ink[2] }) + .with("Btom", D::UnitFloat { unit: *b"#Pnt", value: ink[3] }), + ) + }; + tysh.text.items.retain(|(key, _)| !key.is("bounds") && !key.is("boundingBox")); + tysh.text.items.push((Id::new("bounds"), rect("bounds"))); + tysh.text.items.push((Id::new("boundingBox"), rect("boundingBox"))); + let data = crate::psd::write_tysh(&tysh); + + let imported = crate::psd::text_layer_from_tysh(&data, 72.0).unwrap(); + let expected = [4.1667, 0.0, 0.0, 4.1667, 1166.935, 1112.772]; + for (got, want) in imported.transform.m.iter().zip(expected) { + assert!((got - want).abs() < 0.001, "{:?}", imported.transform.m); + } + let (_, rendered) = TextEngine::new().render(&imported, 72.0, PixelFormat::RGBA8); + let placed = rendered.surface.content_bounds(); + assert!(!placed.is_empty(), "the normalized layer renders"); + assert!((1000..3000).contains(&placed.x0) && (500..2000).contains(&placed.y0), "{placed:?}"); + + let round_trip = crate::psd::text_layer_from_tysh(&crate::psd::build_tysh(&imported, 72.0, Some(ink.map(|v| v as f32))), 72.0).unwrap(); + for (got, want) in round_trip.transform.m.iter().zip(expected) { + assert!((got - want).abs() < 0.001, "{:?}", round_trip.transform.m); + } +} + /// Regression: a PSD whose text engine data has no `EngineDict` (or isn't a dictionary) /// panicked with `expect("EngineDict")` when the layer was written back (PSD export). #[test] diff --git a/crates/ui-egui/src/adjust_dialog.rs b/crates/ui-egui/src/adjust_dialog.rs index cdd4cac0..e1b863ae 100644 --- a/crates/ui-egui/src/adjust_dialog.rs +++ b/crates/ui-egui/src/adjust_dialog.rs @@ -182,7 +182,14 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map Some(tone::histograms(app, HistSource::Layer(id), adjust_editors::space_of(&values))), _ => None, }; - let cx = EditorCx { mem: dialog_mem(&kind), hist, gray, swatches: adjust_editors::swatches(app), dialog: true }; + let cx = EditorCx { + mem: dialog_mem(&kind), + hist, + gray, + swatches: adjust_editors::swatches(app), + gradients: adjust_editors::gradient_presets(app, &kind), + dialog: true, + }; let e = adjust_editors::editor(ui, &kind, &mut values, &cx); if e.changed && let Value::Object(v) = values diff --git a/crates/ui-egui/src/adjust_editors.rs b/crates/ui-egui/src/adjust_editors.rs index 02694a07..e444c10b 100644 --- a/crates/ui-egui/src/adjust_editors.rs +++ b/crates/ui-egui/src/adjust_editors.rs @@ -14,6 +14,8 @@ use egui::{Color32, Pos2, Rect, RichText, Sense, Stroke, StrokeKind, pos2, vec2} use photocraft_doc::adjust::{HueRange, ToneSpace}; use photocraft_doc::{Adjustment, LayerId}; use photocraft_engine::adjust_params::{self, HUE_RANGES, PHOTO_FILTERS}; +use photocraft_engine::presets::Group; +use photocraft_engine::presets::gradients::GradientPreset; use serde_json::{Value, json}; use crate::PhotocraftApp; @@ -79,6 +81,8 @@ pub struct EditorCx { pub gray: bool, /// Current foreground and background colours (Gradient Map preset). pub swatches: [[f32; 3]; 2], + /// The session's gradient preset library (Gradient Map's picker; empty for other kinds). + pub gradients: Vec>, /// Hosted by an Image › Adjustments dialog (false: the Properties panel). Photoshop's dialog /// sliders follow the mouse wheel; the panel's don't. pub dialog: bool, @@ -1109,22 +1113,34 @@ fn gradient_map(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let t = Tokens::get(ui.ctx()); let mut e = Edit::default(); let mut stops = read_stops(v); - let presets: [(&str, Vec); 5] = [ - (tl!("Black, White"), vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])]), - (tl!("Foreground to Background"), vec![(0.0, cx.swatches[0]), (1.0, cx.swatches[1])]), - (tl!("Violet, Orange"), vec![(0.0, [0.161, 0.039, 0.349]), (1.0, [1.0, 0.486, 0.0])]), - (tl!("Blue, Red, Yellow"), vec![(0.0, [0.039, 0.0, 0.698]), (0.5, [1.0, 0.0, 0.0]), (1.0, [1.0, 0.988, 0.0])]), - (tl!("Copper"), vec![(0.0, [0.592, 0.275, 0.102]), (0.4, [0.984, 0.847, 0.773]), (0.7, [0.424, 0.180, 0.086]), (1.0, [0.937, 0.859, 0.804])]), - ]; - let mut preset = usize::MAX; + // Photoshop's gradient picker: the same library as the Gradient tool and Gradients panel + // (built-in groups and the user's own), Foreground/Background resolved to the current colours. + let [fg, bg] = cx.swatches.map(|c| [c[0], c[1], c[2], 1.0]); ui.horizontal(|ui| { label(ui, tl!("Preset:")); - let mut opts: Vec<(usize, &str)> = vec![(usize::MAX, tl!("Custom"))]; - opts.extend(presets.iter().enumerate().map(|(i, p)| (i, p.0))); - if widgets::dropdown(ui, &format!("{:?}-gm-preset", cx.mem), &mut preset, &opts, 190.0) - && let Some(p) = presets.get(preset) - { - stops = p.1.clone(); + let mut picked = None; + egui::ComboBox::from_id_salt(cx.mem.with("gm-preset")).selected_text(tl!("Custom")).width(190.0).height(420.0).icon(widgets::chevron_icon).show_ui( + ui, + |ui| { + for g in &cx.gradients { + ui.label(RichText::new(&g.name).color(t.text_faint)); + for p in &g.items { + ui.horizontal(|ui| { + // A map has no transparency: colour stops only, shown opaque. + let rgba: Vec<(f32, [f32; 4])> = p.resolve(fg, bg).into_iter().map(|(at, c)| (at, [c[0], c[1], c[2], 1.0])).collect(); + let (r, swatch) = ui.allocate_exact_size(vec2(48.0, 16.0), Sense::click()); + crate::preset_panels::paint_gradient(ui, r, &rgba); + // Both, not short-circuited: the name is drawn either way. + if swatch.clicked() | ui.selectable_label(false, &p.name).clicked() { + picked = Some(rgba.iter().map(|(at, c)| (*at, [c[0], c[1], c[2]])).collect::>()); + } + }); + } + } + }, + ); + if let Some(p) = picked { + stops = p; write_stops(v, &stops); e.add(Edit::discrete(true)); } @@ -1202,6 +1218,11 @@ pub fn is_gray(mode: photocraft_doc::ColorMode) -> bool { matches!(mode, photocraft_doc::ColorMode::Grayscale | photocraft_doc::ColorMode::Duotone | photocraft_doc::ColorMode::Bitmap) } +/// The gradient preset library for a `kind` editor's picker (Gradient Map only; empty otherwise). +pub fn gradient_presets(app: &PhotocraftApp, kind: &str) -> Vec> { + if kind == "gradientMap" { app.session.presets.gradients.clone() } else { Vec::new() } +} + pub fn swatches(app: &PhotocraftApp) -> [[f32; 3]; 2] { let c = |x: [f32; 4]| [x[0], x[1], x[2]]; [c(app.session.tools.foreground), c(app.session.tools.background)] @@ -1313,7 +1334,7 @@ pub fn layer_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj }; let gray = app.session.active().is_some_and(|s| is_gray(s.doc.mode)); let hist = needs_histogram(kind).then(|| tone::histograms(app, HistSource::BelowLayer(id), space_of(&values))); - let cx = EditorCx { mem: layer_mem(id), hist, gray, swatches: swatches(app), dialog: false }; + let cx = EditorCx { mem: layer_mem(id), hist, gray, swatches: swatches(app), gradients: gradient_presets(app, kind), dialog: false }; let e = editor(ui, kind, &mut values, &cx); if e.changed { app.live_adjust = Some((id, values.clone())); diff --git a/crates/ui-egui/src/adjust_editors_tests.rs b/crates/ui-egui/src/adjust_editors_tests.rs index 33aa5aec..8e388c12 100644 --- a/crates/ui-egui/src/adjust_editors_tests.rs +++ b/crates/ui-egui/src/adjust_editors_tests.rs @@ -396,3 +396,39 @@ fn color_balance_properties_history_and_reset_like_photoshop() { assert_eq!(cb(&h), ([0.0; 3], [0.0; 3], [0.0; 3], false), "third Reset: still the defaults"); assert_eq!(steps(&h), before, "the edit and the Resets share one history step"); } + +fn map_stops(a: &Adjustment) -> Vec<(f32, [u8; 3])> { + let q = |v: f32| (v * 255.0).round() as u8; + match a { + Adjustment::GradientMap { stops, .. } => stops.iter().map(|(t, c)| (*t, [q(c[0]), q(c[1]), q(c[2])])).collect(), + other => panic!("{other:?}"), + } +} + +/// Issue #1588: the Gradient Map preset picker offers the session's gradient library (built-in +/// groups beyond Basics and the user's own saved gradients), and picking one writes its stops as +/// one history step. +#[test] +fn gradient_map_picker_offers_the_gradient_library() { + use photocraft_engine::presets::{Group, gradients}; + let mut h = app_harness("gradientMap", "rgb", 8); + // A short library so every row fits the popup: Basics, Blues and a user-saved group. + let mut lib: Vec<_> = gradients::builtin().into_iter().take(2).collect(); + lib.push(Group::new("Mine", vec![gradients::GradientPreset::new("My Teal", &["#008080", "#ffee00"])])); + h.state_mut().session.presets.gradients = lib; + h.run_steps(2); + let steps = |h: &Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().history.past_len(); + for (name, want) in + [("Blue 03", vec![(0.0, [0x00, 0xb4, 0xd8]), (1.0, [0x03, 0x04, 0x5e])]), ("My Teal", vec![(0.0, [0x00, 0x80, 0x80]), (1.0, [0xff, 0xee, 0x00])])] + { + let before = steps(&h); + // The picker sits right of its "Preset:" label. + let preset = label_rect(&h, "Preset:").expect("the preset picker"); + click(&mut h, Pos2::new(preset.right() + 60.0, preset.center().y)); + let item = h.query_by_label(name).unwrap_or_else(|| panic!("`{name}` in the picker")).rect(); + click(&mut h, item.center()); + h.run_steps(4); + assert_eq!(map_stops(&layer(&h).1), want, "{name}"); + assert_eq!(steps(&h), before + 1, "{name}: one history step"); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 40cac543..632df4ab 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -806,6 +806,13 @@ pub(crate) type NavigatorCache = (std::sync::Weak, u64, egui::TextureH /// How long adjustment-dialog settings must stay unchanged before the navigator shows them. const NAVIGATOR_SETTLE_MS: f64 = 200.0; +/// While a Move or Patch drag or a live paint stroke is under way, the navigator keeps its image +/// of document `idx` and catches up on release: each step of the drag is a new preview, and a +/// thumbnail composite of the whole document per step makes painting lag on large images. +fn navigator_waits_for_drag(app: &PhotocraftApp, idx: usize) -> bool { + crate::move_ui::showing(app) || crate::patch_preview::showing(app) || live_stroke(app, idx).is_some() +} + /// The Navigator panel's image of document `idx`. With the CPU canvas that is the canvas texture /// itself; with the GPU canvas a thumbnail cached per revision, so an edit to a huge document /// doesn't also pay a full-resolution CPU composite for the navigator. @@ -825,9 +832,8 @@ pub fn navigator_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usiz { return Some(t.id()); } - // While a Move or Patch drag is under way the navigator keeps its image and catches up on release. if let Some((_, _, t)) = &cached - && (crate::move_ui::showing(app) || crate::patch_preview::showing(app)) + && navigator_waits_for_drag(app, idx) { return Some(t.id()); } @@ -1098,6 +1104,10 @@ fn gpu_budget(app: &mut PhotocraftApp, gpu: &crate::gpu_canvas::GpuCanvas, idx: /// Live preview for an open filter dialog: run the filter on the proxy and upload it. fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u64, [u32; 2])> { + // Command dialogs always edit the active document. Never show their preview in another tab. + if app.session.active_index() != Some(idx) { + return None; + } if crate::adjust_preview::on_layer(app, idx) { return None; } @@ -1123,7 +1133,10 @@ fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u6 let buf = photocraft_compose::flatten(r); let t1 = crate::gpu_canvas::now_ms(); let (display, _) = canvas_display(app, &doc, None); - app.gpu.as_ref()?.upload_buffer_full(key, &texture_buffer(display.as_deref(), &buf), doc.depth); + // Headless/CPU tests can still inspect the computed preview without a GPU texture. + if let Some(gpu) = app.gpu.as_ref() { + gpu.upload_buffer_full(key, &texture_buffer(display.as_deref(), &buf), doc.depth); + } app.perf.record("filter-preview", r.size.area(), t1 - t0, crate::gpu_canvas::now_ms() - t1); } app.filter_preview = Some(crate::filter_dialog::FilterPreview { doc: doc_id, revision, hash, k, result }); @@ -2165,7 +2178,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if !fresh { let t0 = crate::gpu_canvas::now_ms(); let b = app.cached_bounds(u64::MAX - doc.id.0, sel).intersect(&vis); - let segs = crate::outline::outline_scaled(sel, b, step); + // 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 segs = if app.session.has_jobs() { crate::outline::outline_scaled_serial(sel, b, step) } else { crate::outline::outline_scaled(sel, b, step) }; app.perf.span("outline", crate::gpu_canvas::now_ms() - t0); app.outline_cache = Some((doc.id, key, std::sync::Arc::new(segs))); } @@ -2293,7 +2308,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let mods = ui.input(|i| i.modifiers); // Tools follow the left button; the right one opens the Brush Preset picker or erases // (Preferences › Tools, `paint_mouse`). - crate::paint_mouse::sync_tool_smoothing(app); + crate::paint_mouse::sync_tool_brush(app); let mut buttons = crate::paint_mouse::canvas_buttons(app, &response, tool); // The Crop tool's frame is edited from the press (before egui's drag threshold), so the // pixels past the canvas show at once, as in Photoshop. @@ -3411,7 +3426,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) Tool::Type | Tool::VerticalType if crate::type_tool::pointer_down(app, x, y, mods.shift) => return, _ => {} } - crate::paint_mouse::sync_tool_smoothing(app); + crate::paint_mouse::sync_tool_brush(app); let erase = tool == Tool::Eraser || std::mem::take(&mut app.secondary_erase); // ⇧-click after a stroke: a straight line from where it ended (stroke_constraint.rs). let active = app.session.active().map(|st| st.doc.id); @@ -4288,6 +4303,27 @@ mod tests { assert!(p.get("smoothing").is_none(), "the commit uses the session brush's smoothing, not a hard-coded one"); } + #[test] + fn navigator_keeps_its_image_during_a_live_brush_stroke() { + // #1631: every step of a live stroke is a new preview key, so a navigator keyed by it + // re-composited the whole document per frame while painting. It waits for the release. + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 80, "background": "transparent"})).unwrap(); + app.ui.tool = Tool::Brush; + let m = egui::Modifiers::NONE; + assert!(!navigator_waits_for_drag(&app, 0)); + tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 40.0, pressure: 1.0 }, m); + let mut keys = Vec::new(); + for x in [40.0, 70.0, 100.0] { + tool_event(&mut app, ToolEvent::Move { x, y: 40.0, pressure: 1.0 }, m); + keys.push(display_doc(&mut app, 0).1); + assert!(navigator_waits_for_drag(&app, 0), "painting at x = {x}"); + } + assert!(keys.windows(2).all(|k| k[0] != k[1]), "each step is a new preview: {keys:?}"); + tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 40.0 }, m); + assert!(!navigator_waits_for_drag(&app, 0), "the navigator catches up on release"); + } + #[test] fn smoothed_stroke_shows_its_catch_up_tail_while_drawing() { // #73: with smoothing the brush lags behind the pointer and catches up at the end; the @@ -4552,3 +4588,49 @@ mod transform_controls_tests { assert!(!transform_controls_hit(r, pos2(60.0, 4.0))); } } + +#[cfg(test)] +mod rotation_preview_isolation_tests { + use super::*; + use serde_json::json; + + /// A CPU-only app must be able to generate the actual rotation preview without a + /// GPU adapter; checking the result also verifies Preview off and document integrity. + #[test] + fn rotation_preview_is_computed_headlessly_without_mutating_document() { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 96, "height": 48})).unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + assert!(app.gpu.is_none()); + crate::filter_dialog::open(&mut app, "image.rotation.arbitrary").unwrap(); + let dialog = app.ui.dialogs.last_mut().unwrap(); + dialog.fields.insert("angle".into(), json!(90.0)); + dialog.fields.insert("direction".into(), json!("cw")); + + let (factor, _, size) = ensure_filter_preview(&mut app, 0).expect("CPU preview computed"); + assert_eq!(factor, 1); + assert_eq!(size, [48, 96]); + let original = &app.session.active().unwrap().doc; + assert_eq!([original.size.width, original.size.height], [96, 48]); + + app.ui.dialogs.last_mut().unwrap().fields.insert("__preview".into(), json!(false)); + assert!(ensure_filter_preview(&mut app, 0).is_none(), "disabling Preview must hide the preview"); + assert_eq!([app.session.active().unwrap().doc.size.width, app.session.active().unwrap().doc.size.height], [96, 48]); + } + + /// A dialog operates on the active document and cannot render a preview into a + /// background tab, even if that tab has a valid canvas index. + #[test] + fn filter_preview_is_not_returned_for_inactive_document() { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 96, "height": 48})).unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + crate::filter_dialog::open(&mut app, "image.rotation.arbitrary").unwrap(); + app.ui.dialogs.last_mut().unwrap().fields.insert("angle".into(), json!(90.0)); + assert!(ensure_filter_preview(&mut app, 0).is_some()); + + app.session.execute("file.new", json!({"width": 40, "height": 20})).unwrap(); + assert_eq!(app.session.active_index(), Some(1)); + assert!(ensure_filter_preview(&mut app, 0).is_none(), "an inactive tab must never inherit the command preview"); + } +} diff --git a/crates/ui-egui/src/chrome_ui.rs b/crates/ui-egui/src/chrome_ui.rs index dd3bbf86..e36083bf 100644 --- a/crates/ui-egui/src/chrome_ui.rs +++ b/crates/ui-egui/src/chrome_ui.rs @@ -124,6 +124,8 @@ pub fn status_bar_pro(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let (Some(st), Some(i)) = (app.session.active(), app.session.active_index()) else { ui.label(RichText::new(tl!("No document")).color(t.text_dim)); + // A command run with no document open still reports here (#1567: File › Automate › Batch). + status_message(app, ui, &t); return; }; let text = status_info_text(&st.doc, &app.ui.chrome.status_info, tl!(app.ui.tool.label()), &profile_name(&st.doc)); @@ -150,12 +152,18 @@ pub fn status_bar_pro(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } }); - if !app.ui.status.is_empty() { - let (r, _) = ui.allocate_exact_size(vec2(17.0, 16.0), Sense::hover()); - ui.painter().line_segment([r.center_top(), r.center_bottom()], Stroke::new(1.0, t.separator)); - let is_err = app.ui.status_error || app.ui.status.starts_with("Couldn"); - ui.label(RichText::new(&app.ui.status).color(if is_err { t.warning } else { t.text_faint })); + status_message(app, ui, &t); +} + +/// The latest status message after a separator, in the warning colour when it is an error. +fn status_message(app: &PhotocraftApp, ui: &mut egui::Ui, t: &Tokens) { + if app.ui.status.is_empty() { + return; } + let (r, _) = ui.allocate_exact_size(vec2(17.0, 16.0), Sense::hover()); + ui.painter().line_segment([r.center_top(), r.center_bottom()], Stroke::new(1.0, t.separator)); + let is_err = app.ui.status_error || app.ui.status.starts_with("Couldn"); + ui.label(RichText::new(&app.ui.status).color(if is_err { t.warning } else { t.text_faint })); } /// Home button at the very start of Photoshop 2026's options bar: toggles the Home (start) @@ -317,6 +325,21 @@ mod tests { assert_eq!(marquee_end("fixedRatio", w, h, false, [0.0, 0.0], [50.0, 20.0]), [50.0, 50.0]); } + #[test] + fn status_message_shows_with_no_document_open() { + // #1567: File › Automate › Batch with no recorded action and no document answered nothing. + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.actions.list.clear(); + let e = crate::menus::invoke(&mut app, &egui::Context::default(), "file.automate.batch", json!({})).unwrap_err(); + app.ui.status = e.clone(); + app.ui.status_error = true; + let mut h = Harness::builder().with_size(vec2(900.0, 40.0)).build_ui_state(|ui, app| status_bar_pro(app, ui), app); + h.run_steps(2); + assert!(h.query_by_label("No document").is_some()); + assert!(h.query_by_label(&e).is_some(), "status bar shows {e:?}"); + } + #[test] fn every_status_key_has_text() { let d = doc(); diff --git a/crates/ui-egui/src/clip_line_ui.rs b/crates/ui-egui/src/clip_line_ui.rs index 682bb9a6..2efcb732 100644 --- a/crates/ui-egui/src/clip_line_ui.rs +++ b/crates/ui-egui/src/clip_line_ui.rs @@ -5,6 +5,15 @@ //! directly above in the stack: a line inside an open group, under a group's last child or //! across rows hidden by a filter clips nothing. While ⌥ is held over a line the pointer is the //! clip cursor, and a click there goes to the line, not to the rows on either side of it. +//! +//! Measured on Photoshop 25.4 (Background, a pixel layer, an adjustment layer and a layer on +//! top; ⌥-hover and ⌥-click swept a pixel at a time across each line, the result read back by +//! script): +//! - the line takes the pointer from 3 px above it to 5 px below it; +//! - a click that clips keeps the active layer; +//! - a click that releases frees the layer above the line together with the clipped layers +//! stacked above it (in one history step), and makes it the active layer; +//! - the release cursor is the clip cursor struck through. use egui::{Pos2, Rangef, Rect, Sense, vec2}; use photocraft_doc::{Document, LayerContent, LayerId}; @@ -12,10 +21,13 @@ use serde_json::{Value, json}; use crate::layer_row_ui::{RIGHT_PAD, RowRects}; -/// Half the height of a line's hit band (the thumbnails stay clear of it). -const BAND: f32 = 3.0; +/// How far above and below a line its hit band reaches (Photoshop: 3 px up, 5 px down). +const ABOVE: f32 = 3.5; +const BELOW: f32 = 5.0; /// The eye column, left of the band: ⌥-click there shows only that layer. const EYE_W: f32 = 30.0; +/// Size of the clip cursors (points). +const CURSOR_SIZE: f32 = 22.0; /// A line between two layers. #[derive(Clone, Copy, Debug, PartialEq)] @@ -29,13 +41,35 @@ pub struct ClipLine { } impl ClipLine { - /// The Layer menu command a click runs. - pub fn command(&self) -> (String, Value) { - let id = if self.release { "layer.releaseClippingMask" } else { "layer.createClippingMask" }; - (id.into(), json!({"layer": self.upper.0})) + /// The commands a click runs: Create Clipping Mask on the upper layer, or Release Clipping + /// Mask on it and the clipped layers stacked above it (`key` makes that one history step) + /// before it becomes the active layer. + pub fn commands(&self, doc: &Document, key: &str) -> Vec<(String, Value)> { + if !self.release { + return vec![("layer.createClippingMask".into(), json!({"layer": self.upper.0}))]; + } + let mut out: Vec<(String, Value)> = + release_chain(doc, self.upper).into_iter().map(|id| ("layer.releaseClippingMask".into(), json!({"layer": id.0, "coalesce": key}))).collect(); + out.push(("layer.select".into(), json!({"layer": self.upper.0, "mode": "replace"}))); + out } } +/// The layers a release on `upper` frees: it and the clipped layers stacked directly above it. +fn release_chain(doc: &Document, upper: LayerId) -> Vec { + let mut out = vec![upper]; + let Some(mut path) = doc.path_of(upper) else { return out }; + while let Some(last) = path.pop() { + let Some(next) = last.checked_add(1) else { break }; + path.push(next); + match doc.layer_at(&path) { + Some(l) if l.clipped => out.push(l.id), + _ => break, + } + } + out +} + /// The line under `p` among the layer rows the panel drew (top to bottom), if two layers that /// can clip meet there. pub fn at(doc: &Document, rows: &[RowRects], p: Pos2) -> Option { @@ -44,7 +78,7 @@ pub fn at(doc: &Document, rows: &[RowRects], p: Pos2) -> Option { // Between the eye column and the right-hand indicators (the effects triangle takes clicks). let right = above.indicators.iter().chain(&below.indicators).map(|(_, r)| r.left()).fold(below.row.right() - RIGHT_PAD, f32::min); let y = below.row.top(); - let rect = Rect::from_x_y_ranges(Rangef::new(below.row.left() + EYE_W, right), Rangef::new(y - BAND, y + BAND)); + let rect = Rect::from_x_y_ranges(Rangef::new(below.row.left() + EYE_W, right), Rangef::new(y - ABOVE, y + BELOW)); if !rect.contains(p) { return None; } @@ -74,11 +108,14 @@ pub fn show(ui: &mut egui::Ui, doc: &Document, actions: &mut Vec<(String, Value) let resp = ui.interact(line.rect, ui.id().with("clip-line"), Sense::click()); let label = if line.release { "Release Clipping Mask" } else { "Create Clipping Mask" }; resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); - // The arrow's tip, (15, 20) of its 24-unit box, points at the line. - crate::icons::cursor(&ctx, "corner-right-down", p, vec2(15.0, 20.0) / 24.0, 18.0); + // PhotoCraft's own cursors: a hooked arrow onto the layer below, and the same arrow with a + // small "no" badge to release. The arrow's tip, (15, 16) of the 24-unit box, is the hot spot. + let icon = if line.release { "clip-release" } else { "clip-below" }; + crate::icons::cursor(&ctx, icon, p, vec2(15.0, 16.0) / 24.0, CURSOR_SIZE); ctx.set_cursor_icon(egui::CursorIcon::None); if resp.clicked() { - actions.push(line.command()); + let key = format!("clip-line:{}:{}", line.upper.0, ctx.cumulative_pass_nr()); + actions.extend(line.commands(doc, &key)); } } diff --git a/crates/ui-egui/src/clip_line_ui/tests.rs b/crates/ui-egui/src/clip_line_ui/tests.rs index cc9bdcce..ba894954 100644 --- a/crates/ui-egui/src/clip_line_ui/tests.rs +++ b/crates/ui-egui/src/clip_line_ui/tests.rs @@ -107,7 +107,7 @@ fn only_siblings_next_to_each_other_meet_at_a_line() { assert_eq!(at(&doc, &open, pos2(x, 64.0)), None, "a group's last child and the layer below the group"); let line = at(&doc, &open, pos2(x, 96.0)).expect("two top-level layers"); assert_eq!((line.upper, line.release), (d, false)); - assert_eq!(line.command(), ("layer.createClippingMask".to_string(), json!({"layer": d.0}))); + assert_eq!(line.commands(&doc, "k"), vec![("layer.createClippingMask".to_string(), json!({"layer": d.0}))]); assert_eq!(at(&doc, &open, pos2(10.0, 96.0)), None, "the eye column"); assert_eq!(at(&doc, &open, pos2(x, 90.0)), None, "inside a row"); // A closed group sits directly above the layer below it, and can be clipped to it. @@ -120,5 +120,91 @@ fn only_siblings_next_to_each_other_meet_at_a_line() { assert_eq!(at(&doc, &[row(g, 0.0), row(bg, 32.0)], pos2(x, 32.0)), None); doc.layer_mut(d).unwrap().clipped = true; let line = at(&doc, &closed, pos2(x, 64.0)).unwrap(); - assert_eq!(line.command(), ("layer.releaseClippingMask".to_string(), json!({"layer": d.0}))); + assert_eq!( + line.commands(&doc, "k"), + vec![ + ("layer.releaseClippingMask".to_string(), json!({"layer": d.0, "coalesce": "k"})), + ("layer.select".to_string(), json!({"layer": d.0, "mode": "replace"})), + ] + ); +} + +#[test] +fn the_band_reaches_three_above_and_five_below_the_line() { + // Photoshop 25.4: the line takes the pointer from 3 px above it to 5 px below it. + let (s, a, b) = two_layers(); + let doc = s.active().unwrap().doc.clone(); + let bg = doc.layers[0].id; + let rows = [row(b, 0.0), row(a, 32.0), row(bg, 64.0)]; + let x = 100.0; + for y in [61.0, 64.0, 68.9] { + assert_eq!(at(&doc, &rows, pos2(x, y)).map(|l| l.upper), Some(a), "y {y}"); + } + for y in [60.0, 69.5] { + assert_eq!(at(&doc, &rows, pos2(x, y)), None, "y {y}"); + } +} + +/// Background, Layer 1, CB (Color Balance), Top; Top active. +fn chain() -> (photocraft_engine::Session, [LayerId; 3]) { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + let l1 = s.execute("layer.new.layer", json!({"name": "Layer 1"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.newAdjustmentLayer.colorBalance", json!({})).unwrap(); + let cb = s.active().unwrap().active_layer.unwrap().0; + let top = s.execute("layer.new.layer", json!({"name": "Top"})).unwrap()["layer"].as_u64().unwrap(); + (s, [LayerId(l1), LayerId(cb), LayerId(top)]) +} + +/// A point on the line along the top of `layer`'s row, `dy` below it, right of the eye column. +fn on_line(h: &Harness<'_, PhotocraftApp>, layer: LayerId, dy: f32) -> Pos2 { + let r = recorded(&h.ctx).into_iter().find(|r| r.layer == layer.0).unwrap().row; + pos2(r.left() + 80.0, r.top() + dy) +} + +fn active(h: &Harness<'_, PhotocraftApp>) -> Option { + h.state().session.active().unwrap().active_layer +} + +fn steps(h: &Harness<'_, PhotocraftApp>) -> usize { + h.state().session.active().unwrap().history.entries().len() +} + +#[test] +fn releasing_frees_the_clipped_layers_above_too_and_selects_the_layer() { + // Photoshop 25.4: a release in the middle of a clipping chain frees the layer above the line + // and every clipped layer stacked above it, in one history step, and makes it active. + let (s, [l1, cb, top]) = chain(); + let mut h = harness(s); + // Clip CB onto Layer 1 (from inside the band, 4 px below the line), then Top onto CB. + let p = on_line(&h, l1, 4.0); + click(&mut h, p, Modifiers::ALT); + assert!(clipped(&h, cb) && !clipped(&h, top)); + assert_eq!(active(&h), Some(top), "clipping keeps the active layer"); + let p = on_line(&h, cb, -2.5); + click(&mut h, p, Modifiers::ALT); + assert!(clipped(&h, top) && clipped(&h, cb)); + let before = steps(&h); + let p = on_line(&h, l1, 2.0); + click(&mut h, p, Modifiers::ALT); + assert!(!clipped(&h, cb) && !clipped(&h, top), "the clipped layer above is released too"); + assert_eq!(active(&h), Some(cb), "the released layer becomes active"); + assert_eq!(steps(&h), before + 1, "one history step"); + assert!(h.state_mut().session.undo()); + assert!(clipped(&h, cb) && clipped(&h, top), "one Undo brings the chain back"); + // Releasing the top of a chain leaves the layers below it clipped. + let p = on_line(&h, cb, 0.0); + click(&mut h, p, Modifiers::ALT); + assert!(!clipped(&h, top) && clipped(&h, cb)); +} + +#[test] +fn outside_the_band_an_alt_click_does_not_clip() { + let (s, [l1, cb, _]) = chain(); + let mut h = harness(s); + for dy in [-4.5, 6.0] { + let p = on_line(&h, l1, dy); + click(&mut h, p, Modifiers::ALT); + assert!(!clipped(&h, cb), "{dy} px from the line"); + } } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2e682504..d8dca9db 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -375,6 +375,9 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) // Apply (nothing below can fail). if let Some(t) = tool { app.ui.tool = t; + // Each tool keeps its own brush (#218), so switch it in before `brushSize` + // below sets the new tool's size. + crate::paint_mouse::sync_tool_brush(app); } let gradient_before = app.ui.tool_options.clone(); if let Some(mode) = gradient_blend { @@ -770,6 +773,7 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value { }) }), "panels": app.ui.panels, + "view": app.ui.view, "views": app.ui.views, "dialogs": dialogs, "windows": app.ui.windows, @@ -922,6 +926,19 @@ mod tests { assert!(app.session.active().unwrap().doc.selection.is_some()); } + #[test] + fn ui_inspect_reports_the_view_preferences() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let before = call(&mut app, &ctx, "ui.inspect", json!({})); + assert_eq!(before.pointer("/result/view/show/selection_edges"), Some(&json!(true))); + assert_eq!(before.pointer("/result/view/screen_mode"), Some(&json!("standard"))); + assert_eq!(call(&mut app, &ctx, "ui.menu.invoke", json!({"id": "view.show.selectionEdges"}))["ok"], true); + let after = call(&mut app, &ctx, "ui.inspect", json!({})); + assert_eq!(after.pointer("/result/view/show/selection_edges"), Some(&json!(false))); + assert_eq!(after["result"]["view"], json!(app.ui.view)); + } + #[test] fn engine_execute_runs_with_defaults_but_menu_invoke_opens_the_dialog() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); @@ -1088,6 +1105,16 @@ mod tests { assert_eq!(app.session.journal.len(), journal_len); } + // Each tool keeps its own brush (#218), so `tool` + `brushSize` in one call sets the new + // tool's size rather than the one it was carrying. + call(&mut app, &ctx, "ui.set", json!({"tool": "eraser", "brushSize": 12.0})); + assert_eq!(app.session.tools.brush.size, 12.0, "the Eraser's own size"); + call(&mut app, &ctx, "ui.set", json!({"tool": "brush"})); + assert_eq!(app.session.tools.brush.size, 42.5, "the Brush gets its own back"); + call(&mut app, &ctx, "ui.set", json!({"tool": "eraser"})); + assert_eq!(app.session.tools.brush.size, 12.0); + call(&mut app, &ctx, "ui.set", json!({"tool": "brush", "brushSize": 42.5})); + // Values that cannot be represented by BrushSettings must report the command error and // leave both the brush and journal unchanged instead of mutating tool state directly. let journal_len = app.session.journal.len(); diff --git a/crates/ui-egui/src/direct_select.rs b/crates/ui-egui/src/direct_select.rs index 15b552bc..f5c58a5e 100644 --- a/crates/ui-egui/src/direct_select.rs +++ b/crates/ui-egui/src/direct_select.rs @@ -291,10 +291,11 @@ fn marquee(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2], add: bool) { } /// Paths with their anchors (selected ones filled), the shown handles, the drag's preview in -/// place of its path, and the marquee. -pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn Fn([f64; 2]) -> Pos2, accent: Color32) { +/// place of its path, and the marquee. `paths` false (View › Show › Target Path off) leaves only +/// the marquee. +pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn Fn([f64; 2]) -> Pos2, accent: Color32, paths: bool) { let ds = &app.ui.direct_selection; - for (r, path) in candidates(app) { + for (r, path) in if paths { candidates(app) } else { Vec::new() } { let path = match &ds.drag { Some(Drag::Edit { target, preview: Some(p), .. }) if *target == r => p.clone(), _ => path, diff --git a/crates/ui-egui/src/discard_ui.rs b/crates/ui-egui/src/discard_ui.rs index 708fa1b8..d20bae74 100644 --- a/crates/ui-egui/src/discard_ui.rs +++ b/crates/ui-egui/src/discard_ui.rs @@ -88,14 +88,23 @@ impl PhotocraftApp { } } -/// Called once per frame: holds back a window close request while there is unsaved work. +/// Called once per frame: holds back a window close request while there is unsaved work. Every +/// way of leaving (File › Exit / Quit, the close button, the OS's quit event, `app.quit`) ends up +/// here as a close request. pub fn guard_window_close(app: &mut PhotocraftApp, ctx: &egui::Context) { - if app.allow_close || !ctx.input(|i| i.viewport().close_requested()) { + if !ctx.input(|i| i.viewport().close_requested()) { return; } - if intercept(app, EXIT, &Value::Null) { + if !app.allow_close && intercept(app, EXIT, &Value::Null) { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + return; + } + // Leaving: through the platform's own quit where there is one (see `Services::quit`), with + // the window left open for it to close. + if let Some(quit) = app.services.quit.as_mut() { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + quit(); } } @@ -516,15 +525,20 @@ mod tests { assert!(h.state().session.documents().is_empty()); } - /// One frame with the window's close button pressed; whether the guard cancelled the close. - fn press_window_close(app: &mut PhotocraftApp) -> bool { + /// One frame with the window's close button pressed; the commands the guard sent. + fn window_close_commands(app: &mut PhotocraftApp) -> Vec { let mut info = egui::ViewportInfo::default(); info.events.push(egui::ViewportEvent::Close); let mut input = egui::RawInput::default(); input.viewports.insert(egui::ViewportId::ROOT, info); let mut out = egui::Context::default().run_ui(input, |ui| guard_window_close(app, ui.ctx())); out.textures_delta.clear(); - let commands = &out.viewport_output[&egui::ViewportId::ROOT].commands; + out.viewport_output.remove(&egui::ViewportId::ROOT).map(|o| o.commands).unwrap_or_default() + } + + /// One frame with the window's close button pressed; whether the guard cancelled the close. + fn press_window_close(app: &mut PhotocraftApp) -> bool { + let commands = &window_close_commands(app); let cancelled = commands.iter().any(|c| matches!(c, egui::ViewportCommand::CancelClose)); assert_eq!(commands.iter().any(|c| matches!(c, egui::ViewportCommand::Focus)), cancelled); cancelled @@ -557,4 +571,33 @@ mod tests { assert!(!press_window_close(&mut app)); assert!(app.discard.is_none()); } + + /// With a platform quit (macOS), leaving runs it and keeps the window open for it to close; + /// unsaved work still asks first. + #[test] + fn leaving_runs_the_platform_quit_instead_of_closing_the_window() { + use std::{cell::Cell, rc::Rc}; + let quits = Rc::new(Cell::new(0)); + let mut app = app_with_docs(1); + let counted = quits.clone(); + app.services.quit = Some(Box::new(move || counted.set(counted.get() + 1))); + // The close is cancelled for the quit to do it, with no prompt brought forward. + let cancels_only = |c: &[egui::ViewportCommand]| { + c.iter().any(|c| matches!(c, egui::ViewportCommand::CancelClose)) && !c.iter().any(|c| matches!(c, egui::ViewportCommand::Focus)) + }; + + assert!(cancels_only(&window_close_commands(&mut app)), "the window stays for the quit to close"); + assert_eq!(quits.get(), 1); + + make_dirty(&mut app, 0); + assert!(press_window_close(&mut app), "unsaved work asks first"); + assert_eq!(quits.get(), 1, "no quit while the prompt is up"); + assert!(app.discard.is_some()); + + // Don't Save: the prompt confirms, and the close it sends quits. + app.discard = None; + app.allow_close = true; + assert!(cancels_only(&window_close_commands(&mut app))); + assert_eq!(quits.get(), 2); + } } diff --git a/crates/ui-egui/src/file_open.rs b/crates/ui-egui/src/file_open.rs index 6339ac77..8c5f81c4 100644 --- a/crates/ui-egui/src/file_open.rs +++ b/crates/ui-egui/src/file_open.rs @@ -45,7 +45,7 @@ pub fn display_name(path: &str) -> String { impl PhotocraftApp { /// Open `bytes` read from the file at `path`: the document is named after the file, keeps - /// `path` for File › Save, and `path` goes to the top of Open Recent. Returns the import + /// `path` for File › Save unless it is a derivative preview, and `path` goes to the top of Open Recent. Returns the import /// warnings (also shown to the user). pub fn open_file(&mut self, path: &str, bytes: &[u8]) -> Result, String> { // Brushes/gradients go to the preset libraries (no document, no Open Recent entry). @@ -63,10 +63,11 @@ impl PhotocraftApp { } /// Record that the active document was just opened from `path`: File › Save writes back to it - /// (unless it's a template) and it goes to the top of Open Recent. + /// (unless it's a template or derivative preview) and it goes to the top of Open Recent. pub(crate) fn opened_from(&mut self, path: &str) { if !is_template(path) && let Some(st) = self.session.active_mut() + && !st.source_read_only { st.path = Some(path.to_string()); } diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index 5251c3aa..c43f1e20 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -80,6 +80,29 @@ fn open_file_sets_name_path_and_recent() { assert!(app.ui.notices.is_empty()); } +#[test] +fn affinity_preview_does_not_acquire_the_source_path_even_when_renamed() { + for automation in [false, true] { + for path in ["/pics/source.af", "/pics/renamed.psd"] { + let (mut app, written) = app_with(vec![None]); + // The fake importer supplies the preview; its source signature controls path policy. + if automation { + app.open_automation_bytes(&display_name(path), b"\x00\xffKA").unwrap(); + app.opened_from(path); + } else { + app.open_file(path, b"\x00\xffKA").unwrap(); + } + assert!(app.session.active().unwrap().source_read_only); + assert!(app.session.active().unwrap().path.is_none()); + assert_eq!(app.ui.recent_files.first().map(String::as_str), Some(path)); + // Save asks for a new file (cancelled here) instead of writing over the source. + assert_eq!(menus::invoke(&mut app, &egui::Context::default(), "file.save", json!({})).unwrap(), json!({"fileDialog": "save"})); + answer(&mut app); + assert!(written.borrow().is_empty()); + } + } +} + #[test] fn file_open_dialog_sets_path_so_save_writes_in_place() { // The dialog returns a full path: the document is named after the file, not the path. diff --git a/crates/ui-egui/src/gradient_ui.rs b/crates/ui-egui/src/gradient_ui.rs index c7347e7e..b102117f 100644 --- a/crates/ui-egui/src/gradient_ui.rs +++ b/crates/ui-egui/src/gradient_ui.rs @@ -562,15 +562,22 @@ pub fn paint_thumbnail(ui: &egui::Ui, layer: LayerId, f: &Fill, rect: Rect) -> b true } -/// Options-bar swatch of the current gradient. Both modes paint the selected preset (classic -/// drags no longer replace it with opaque foreground/background colours), so both show it. -pub fn preset_swatch(app: &PhotocraftApp, ui: &mut egui::Ui) { +/// Options-bar swatch of the current gradient. Both live and classic modes use the same +/// engine-owned preset, so its popup reuses the Gradients browser instead of keeping an +/// independent copy of the chosen gradient in the UI (#1651). +pub fn preset_swatch(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let stops = app.session.presets.gradient.resolve(app.session.tools.foreground, app.session.tools.background); - let (r, resp) = ui.allocate_exact_size(vec2(96.0, 20.0), Sense::hover()); + let (r, resp) = ui.allocate_exact_size(vec2(96.0, 20.0), Sense::click()); paint_ramp(ui.painter(), r, |u| photocraft_algo::paint::sample_stops(&stops, u)); ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); - let _ = resp.on_hover_text(tl!("The current gradient (pick one in Window › Gradients)")); + let resp = resp.on_hover_text(tl!("The current gradient (pick one in Window › Gradients)")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Gradients"))); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(260.0); + ui.set_max_width(330.0); + crate::preset_panels::gradients_panel(app, ui); + }); } /// Live mode: style, reverse, dither and blend mode changes in the options bar also edit the selected @@ -898,6 +905,24 @@ mod tests { assert!(thumbnail_image(&Fill::Solid(Color::WHITE)).is_none()); } + #[test] + fn options_bar_swatch_opens_the_real_preset_browser_and_selects_presets() { + use egui_kittest::{Harness, kittest::Queryable}; + let app = app_with_gradient("linear"); + let mut h = Harness::builder().with_size(vec2(440.0, 560.0)).build_ui_state(|ui, app: &mut PhotocraftApp| preset_swatch(app, ui), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Pro); + h.run_steps(2); + let original = h.state().session.presets.gradient.name.clone(); + h.get_by_label("Gradients").click(); + h.run_steps(2); + // The popup uses the existing presets UI, not a detached swatch or an editor-only copy. + h.get_by_label("Foreground to Transparent").click(); + h.run_steps(2); + assert_eq!(h.state().session.presets.gradient.name, "Foreground to Transparent"); + assert_ne!(h.state().session.presets.gradient.name, original); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 1, "choosing a tool preset does not edit document layers"); + } + #[test] fn live_drag_creates_a_fill_layer_and_handles_edit_it() { let mut app = app_with_gradient("linear"); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5b9feab5..04c6673e 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -996,6 +996,7 @@ Native file drag-and-drop is unavailable Nativní přetahování souborů ve Waylandu není dostupné Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Nativní přetahování souborů ve Waylandu zatím není podporováno. Použijte Soubor › Otevřít, nebo obrázek zkopírujte ve správci souborů a vložte ho pomocí {paste}. To drop files, start PhotoCraft under XWayland: `{command}` Chcete-li soubory přetahovat, spusťte PhotoCraft v XWayland: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Nebo nastavte Předvolby › Výkon › Zobrazovací server Linuxu na X11: PhotoCraft se pak vždy spustí v XWayland. Edit › Paste Úpravy › Vložit Duplicate Layers… Duplikovat vrstvy… Duplicate style Duplikovat styl @@ -2028,6 +2029,8 @@ CPU (no GPU acceleration) CPU (bez akcelerace GPU) DirectX 12 DirectX 12 GPU backend Grafický backend + Linux display server Zobrazovací server Linuxu + X11 X11 Metal Metal OpenGL OpenGL Use tablet pressure Použít přítlak tabletu diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 1d336fd2..0e2d243b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -372,10 +372,10 @@ Add Layer Below to Selection Ebene darunter zur Auswahl hinzufügen Stamp Down Nach unten stempeln Flatten Image Bild auf eine Ebene reduzieren - Matting Mattierung + Matting Randbereinigung Color Decontaminate… Farbreinigung… - Remove Black Matte Schwarze Mattierung entfernen - Remove White Matte Weiße Mattierung entfernen + Remove Black Matte Schwarzen Farbsaum entfernen + Remove White Matte Weißen Farbsaum entfernen Defringe… Randstörungen entfernen… Group Layers Ebenen gruppieren Ungroup Layers Ebenengruppe auflösen @@ -436,7 +436,7 @@ Contextual Alternates Kontextuelle Alternativen Discretionary Ligatures Optionale Ligaturen Swash Swash - Oldstyle Mediäval + Oldstyle Mediävalziffern Stylistic Alternates Stilistische Alternativen Titling Alternates Titel-Alternativen Ornaments Ornamente @@ -649,8 +649,8 @@ 4-up 4-fach 6-up 6-fach Consolidate All to Tabs Alle in Registerkarten zusammenfassen - Float in Window Im Fenster schweben - Float All in Windows Alle in Fenstern schweben + Float in Window In Fenster schweben lassen + Float All in Windows Alle in Fenstern schweben lassen New Window for Document Neues Fenster für Dokument Workspace Arbeitsbereich Essentials Essentials @@ -712,13 +712,13 @@ Other… Weitere… Remove Warp Split Verkrümmungsteilung entfernen Shape Form - Split Warp Crosswise Verkrümmung quer teilen + Split Warp Crosswise Verkrümmung kreuzweise teilen Split Warp Horizontally Verkrümmung horizontal teilen Split Warp Vertically Verkrümmung vertikal teilen Statistics… Statistiken… @plural {n} item|{n} items {n} Element|{n} Elemente Auto Automatisch - (print to the printer) (auf den Drucker drucken) + (print to the printer) (an den Drucker senden) + Add variable + Variable hinzufügen 30 frames @ 30 fps 30 Frames @ 30 fps 3D LUT File 3D-LUT-Datei @@ -729,7 +729,7 @@ Adaptive Wide Angle isn't open Adaptiver Weitwinkel ist nicht geöffnet Add Hinzufügen Add Layer Mask Ebenenmaske hinzufügen - Add Stop Stopp hinzufügen + Add Stop Farbstopp hinzufügen Add a layer style Ebenenstil hinzufügen Add a mask (from the selection; {key} inverts) Maske hinzufügen (aus der Auswahl; {key} kehrt um) Add an adjustment Anpassung hinzufügen @@ -791,10 +791,10 @@ Bilinear Bilinear Black Schwarz Black & White Schwarzweiß - Black Point Compensation Tiefpunktkompensation + Black Point Compensation Tiefenkompensierung Black, White Schwarz, Weiß Blacks Schwarz - Blending Fusionieren + Blending Überblenden Blending Options Fusionsoptionen Blue Blau Blue, Red, Yellow Blau, Rot, Gelb @@ -821,7 +821,7 @@ Canvas extension color: Farbe der Zeichenflächenerweiterung: Capture Erfassen Capture the visibility, position and style of every layer with + below, then switch between versions. Erfasse Sichtbarkeit, Position und Stil jeder Ebene mit + unten, und wechsle dann zwischen Versionen. - Catch-up on Stroke End Auf Strichende aufholen + Catch-up on Stroke End Am Strichende nachführen Center Mitte Center Crop Marks Mittige Beschnittmarken Center text Text zentrieren @@ -846,11 +846,11 @@ Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Klicken vergrößert · {key}-Klick verkleinert · nach rechts/links ziehen zoomt rein/raus Click with the Note tool to add a note. Klicke mit dem Notiz-Werkzeug, um eine Notiz hinzuzufügen. Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Klick: Ecke · Ziehen: weich · Ersten Punkt klicken: schließen · {key} fertig · Esc abbrechen - Clip to layer below Auf die Ebene darunter beschneiden + Clip to layer below Auf darunterliegende Ebene begrenzen Clipped Beschnitten Close Style Options Stiloptionen schließen Cmd Cmd - Coated CMYK Gestrichenes CMYK + Coated CMYK CMYK für gestrichenes Papier Color & Swatches Farbe & Farbfelder Color Balance Farbbalance Color Detail Farbdetails @@ -980,6 +980,7 @@ Native file drag-and-drop is unavailable Natives Ziehen und Ablegen von Dateien ist nicht verfügbar Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Natives Ziehen und Ablegen von Dateien wird unter Wayland noch nicht unterstützt. Nutze Datei › Öffnen oder kopiere das Bild im Dateimanager und füge es mit {paste} ein. To drop files, start PhotoCraft under XWayland: `{command}` Um Dateien abzulegen, starte PhotoCraft unter XWayland: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Oder stelle Voreinstellungen › Leistung › Linux-Anzeigeserver auf X11: PhotoCraft startet dann immer unter XWayland. Edit › Paste Bearbeiten › Einfügen Duplicate Layers… Ebenen duplizieren… Duplicate style Stil duplizieren @@ -1090,7 +1091,7 @@ Invert All Alle invertieren Invert has no settings. Invertieren hat keine Einstellungen. Italic Kursiv - Join us on Discord Tritt uns auf Discord bei + Join us on Discord Tritt unserem Discord bei Justify all Alles im Blocksatz Justify last centered Letzte Zeile zentriert Justify last left Letzte Zeile links @@ -1146,7 +1147,7 @@ Mask Options Maskenoptionen Masking Maskieren Master Gesamt - Matte Mattierung + Matte Hintergrundfarbe Median: Median: Menus Menüs Mesh Size Gitternetzgröße @@ -1222,7 +1223,7 @@ Pattern Size Mustergröße Pause Pause Percent Prozent - Perceptual Wahrnehmung + Perceptual Perzeptiv Perspective Warp is not active Perspektivische Verkrümmung ist nicht aktiv Perspective Warp: draw planes along the image's perspective, then switch to Warp Perspektivische Verkrümmung: zeichne Flächen entlang der Bildperspektive, wechsle dann zu Verkrümmen Photo Filter Foto-Filter @@ -1250,7 +1251,7 @@ Pressure Druck Pressure for Opacity Druck für Deckkraft Pressure for Size Druck für Größe - Prevent auto-nesting in and out of Artboards and Frames Automatisches Einbetten in und aus Zeichenbereichen und Rahmen verhindern + Prevent auto-nesting in and out of Artboards and Frames Automatisches Verschieben in oder aus Zeichenbereichen und Rahmen verhindern Preview Vorschau Previous frame Vorheriger Frame Previous note Vorherige Notiz @@ -1263,7 +1264,7 @@ Promote Hochstufen Protect Detail Details schützen Protect Foreground Color Vordergrundfarbe schützen - Protect Tones Farbtöne schützen + Protect Tones Tonwerte schützen Proximity Match Umgebungsabgleich Pulled String Mode Fadenmodus Puppet Warp is not active Marionetten-Verkrümmung ist nicht aktiv @@ -1278,7 +1279,7 @@ Rate Rate Recently Used Zuletzt verwendet Reconstruct Rekonstruieren - Record ● a sequence of edits, then play ▶ it on any document. Zeichne ● eine Folge von Bearbeitungen auf und gebe sie ▶ auf jedem Dokument wieder. + Record ● a sequence of edits, then play ▶ it on any document. Zeichne ● eine Folge von Bearbeitungen auf und gib sie ▶ auf jedem Dokument wieder. Red Rot Redefine style by current selection Stil anhand der aktuellen Auswahl neu definieren Reflected Gradient Reflektierter Verlauf @@ -1290,7 +1291,7 @@ Remove All Pins Alle Pins entfernen Remove Background Hintergrund entfernen Remove Planes Flächen entfernen - Remove stop Stopp entfernen + Remove stop Farbstopp entfernen Remove variable Variable entfernen Rename Umbenennen Rename Group… Gruppe umbenennen… @@ -1388,7 +1389,7 @@ Strength: Stärke: Strikethrough Durchgestrichen Stroke Kontur - Stroke Catch-up Strich-Aufholen + Stroke Catch-up Strich nachführen Stroke: Kontur: Style Stil Style Options Stiloptionen @@ -1694,7 +1695,7 @@ Do you want to save the changes you made to “{name}” before closing? Möchtest du die an „{name}“ vorgenommenen Änderungen vor dem Schließen speichern? Doc: {flat}/{layered} Dok: {flat}/{layered} Scratch: {size} Auslagerung: {size} - Untagged {mode} Nicht getaggtes {mode}-Profil + Untagged {mode} {mode} ohne Farbprofil Invalid {mode} profile Ungültiges {mode}-Profil Unnamed {mode} profile Unbenanntes {mode}-Profil {w} px x {h} px ({ppi} ppi) {w} px x {h} px ({ppi} ppi) @@ -1835,7 +1836,7 @@ Same folder Gleicher Ordner Save in background Im Hintergrund speichern Scratch Disks Auslagerungsfestplatten - Screen Bildschirm + Screen Negativ multiplizieren Screen resolution Bildschirmauflösung Scrubby slider acceleration Ziehregler-Beschleunigung Sessions only Nur Sitzungen @@ -1900,7 +1901,7 @@ Adjustment Preview Anpassungsvorschau Airbrush: keeps depositing paint while the pointer rests. Airbrush: legt weiter Farbe ab, solange der Zeiger ruht. Angle: Winkel: - Blend If Fusionieren wenn + Blend If Farbbereich Close Tab Group Registerkartengruppe schließen Collapse Einklappen Collapse All Groups Alle Gruppen einklappen @@ -2001,6 +2002,8 @@ CPU (no GPU acceleration) CPU (keine GPU-Beschleunigung) DirectX 12 DirectX 12 GPU backend GPU-Backend + Linux display server Linux-Anzeigeserver + X11 X11 Metal Metal OpenGL OpenGL Use tablet pressure Tablettdruck verwenden @@ -2221,7 +2224,7 @@ Show/Hide All Panels Alle Bedienfelder ein-/ausblenden Show/Hide Panels Bedienfelder ein-/ausblenden Could not open Öffnen nicht möglich - {path}: the path is not valid Unicode; rename the file and open it again. {path}: Der Pfad ist kein gültiges Unicode; benennen Sie die Datei um und öffnen Sie sie erneut. + {path}: the path is not valid Unicode; rename the file and open it again. {path}: Der Dateipfad enthält ungültige Zeichen. Benenne die Datei um und öffne sie erneut. # Fill dialog, layer style contours and Image Size Contents: Inhalt: @@ -2240,7 +2243,7 @@ Red Eye Rote Augen Pupil Size Pupillengröße Darken Amount Abdunklungsgrad - Click a red pupil to neutralize it Klicken Sie auf eine rote Pupille + Click a red pupil to neutralize it Klick auf die Pupille, um die Rotfärbung zu entfernen. open source Open Source …and {n} more …und {n} weitere @@ -2273,7 +2276,7 @@ fontWeight Medium Medium fontWeight SemiBold Halbfett fontWeight Bold Fett fontWeight ExtraBold Extrafett -fontWeight Black Schwarz +fontWeight Black Sehr fett (Black) fontWeight Italic Kursiv # Pattern Stamp tool @@ -2281,6 +2284,6 @@ fontWeight Italic Kursiv Pattern Stamp Musterstempel Impressionist Impressionistisch Rotate View Ansicht drehen - Rotate View Tool Ansicht drehen-Werkzeug + Rotate View Tool Ansicht-drehen-Werkzeug Reset View Ansicht zurücksetzen - Drag around the centre to rotate the view · Shift constrains to 15° Ziehen Sie um die Mitte, um die Ansicht zu drehen · Umschalt rastet auf 15° ein + Drag around the centre to rotate the view · Shift constrains to 15° Ansicht drehen: im Bogen ziehen · Umschalt: 15°-Schritte diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 38ee0c14..3fdc5ea0 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1040,6 +1040,8 @@ Keep Using CPU Συνέχεια με CPU Retry GPU Νέα προσπάθεια χρήσης GPU GPU Backend Σύστημα GPU + Linux display server Διακομιστής οθόνης Linux + X11 X11 Full-resolution preview unavailable Δεν είναι διαθέσιμη προεπισκόπηση πλήρους ανάλυσης No patterns Δεν υπάρχουν μοτίβα Enable {name} Ενεργοποίηση: {name} @@ -3332,6 +3334,7 @@ cameraRaw Yellow Κίτρινο Seafoam Θαλασσί Srgb sRGB To drop files, start PhotoCraft under XWayland: `{command}` Για να αποθέσετε αρχεία, εκκινήστε το PhotoCraft μέσω XWayland: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Ή ορίστε Προτιμήσεις › Επιδόσεις › Διακομιστής οθόνης Linux σε X11: το PhotoCraft θα εκκινεί τότε πάντα μέσω XWayland. Vulkan Vulkan WebP (lossless) WebP (χωρίς απώλειες) Webp WebP diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index f9e7c1b0..54f435c8 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -981,6 +981,7 @@ Native file drag-and-drop is unavailable El arrastrar y soltar archivos de forma nativa no está disponible en Wayland Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. El arrastrar y soltar archivos de forma nativa aún no está disponible en Wayland. Usa Archivo › Abrir, o copia la imagen en tu gestor de archivos y pégala con {paste}. To drop files, start PhotoCraft under XWayland: `{command}` Para soltar archivos, inicia PhotoCraft bajo XWayland: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. O establece Preferencias › Rendimiento › Servidor de pantalla de Linux en X11: PhotoCraft se iniciará siempre bajo XWayland. Edit › Paste Edición › Pegar Duplicate Layers… Duplicar capas… Duplicate style Duplicar estilo @@ -2013,6 +2014,8 @@ CPU (no GPU acceleration) CPU (sin aceleración por GPU) DirectX 12 DirectX 12 GPU backend Backend de GPU + Linux display server Servidor de pantalla de Linux + X11 X11 Metal Metal OpenGL OpenGL Use tablet pressure Usar la presión de la tableta diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 8e683c02..7e2300e8 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1981,6 +1981,8 @@ CPU (no GPU acceleration) CPU (sans accélération GPU) DirectX 12 DirectX 12 GPU backend Moteur GPU + Linux display server Serveur d'affichage Linux + X11 X11 Metal Metal OpenGL OpenGL Use tablet pressure Utiliser la pression de la tablette @@ -2471,6 +2473,7 @@ Native file drag-and-drop is unavailable Le glisser-déposer natif de fichiers n'est pas disponible Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Le glisser-déposer natif de fichiers n'est pas encore pris en charge sous Wayland. Utilisez Fichier › Ouvrir, ou copiez l'image dans votre gestionnaire de fichiers et collez-la avec {paste}. To drop files, start PhotoCraft under XWayland: `{command}` Pour déposer des fichiers, lancez PhotoCraft sous XWayland : `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Ou réglez Préférences › Performances › Serveur d'affichage Linux sur X11 : PhotoCraft démarre alors toujours sous XWayland. Edit › Paste Édition › Coller Use File › Open, or paste a copied image with {paste}. Utilisez Fichier › Ouvrir, ou collez une image copiée avec {paste}. Mixer Brush Tool Outil Pinceau mélangeur diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 101e2423..d0aa5b6e 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1975,6 +1975,8 @@ CPU (no GPU acceleration) CPU (tanpa akselerasi GPU) DirectX 12 DirectX 12 GPU backend Backend GPU + Linux display server Server tampilan Linux + X11 X11 Metal Metal OpenGL OpenGL Use tablet pressure Pakai tekanan tablet @@ -2460,6 +2462,7 @@ Native file drag-and-drop is unavailable Seret dan lepas berkas native tidak tersedia Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Seret dan lepas berkas native belum didukung di Wayland. Gunakan Berkas › Buka, atau salin gambar di pengelola berkas lalu tempel dengan {paste}. To drop files, start PhotoCraft under XWayland: `{command}` Untuk menjatuhkan berkas, jalankan PhotoCraft di XWayland: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Atau atur Preferensi › Performa › Server tampilan Linux ke X11: PhotoCraft lalu selalu berjalan di XWayland. Edit › Paste Edit › Tempel Use File › Open, or paste a copied image with {paste}. Gunakan Berkas › Buka, atau tempel gambar yang disalin dengan {paste}. Mixer Brush Tool Alat Kuas Pencampur diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index d1da7e07..5138ef1a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -980,6 +980,7 @@ Native file drag-and-drop is unavailable Il drag-and-drop nativo file non è disponibile su Wayland Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Il drag-and-drop nativo non è ancora supportato su Wayland. Usa File › Apri, oppure copia l'immagine nel file manager e incollala con {paste}. To drop files, start PhotoCraft under XWayland: `{command}` Per trascinare file, avvia PhotoCraft sotto XWayland: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Oppure imposta Preferenze › Prestazioni › Server grafico Linux su X11: PhotoCraft partirà sempre sotto XWayland. Edit › Paste Modifica › Incolla Duplicate Layers… Duplica livelli… Duplicate style Duplica stile @@ -2011,6 +2012,8 @@ CPU (no GPU acceleration) CPU (nessuna accelerazione GPU) DirectX 12 DirectX 12 GPU backend Backend GPU + Linux display server Server grafico Linux + X11 X11 Metal Metal OpenGL OpenGL Use tablet pressure Usa pressione tavoletta diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1e1bfda8..b4ed0ed5 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -986,6 +986,7 @@ Native file drag-and-drop is unavailable Wayland ではネイティブのファイルドラッグ&ドロップを利用できません Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Wayland ではネイティブのファイルドラッグ&ドロップはまだ利用できません。[ファイル › 開く] を使うか、ファイルマネージャーで画像をコピーして {paste} で貼り付けてください。 To drop files, start PhotoCraft under XWayland: `{command}` ファイルをドロップするには、XWayland 上で PhotoCraft を起動してください: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. または 環境設定 › パフォーマンス › Linux ディスプレイサーバー を X11 にすると、PhotoCraft は常に XWayland 上で起動します。 Edit › Paste [編集 › 貼り付け] Duplicate Layers… レイヤーを複製… Duplicate style スタイルを複製 @@ -2019,6 +2020,8 @@ CPU (no GPU acceleration) CPU(GPU アクセラレーションなし) DirectX 12 DirectX 12 GPU backend GPU バックエンド + Linux display server Linux ディスプレイサーバー + X11 X11 Metal Metal OpenGL OpenGL Use tablet pressure タブレットの筆圧を使用 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index c61d0360..6fa17c45 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1975,6 +1975,8 @@ CPU (no GPU acceleration) CPU(GPU 가속 없음) DirectX 12 DirectX 12 GPU backend GPU 백엔드 + Linux display server Linux 디스플레이 서버 + X11 X11 Metal Metal OpenGL OpenGL Use tablet pressure 태블릿 필압 사용 @@ -2001,6 +2003,7 @@ Native file drag-and-drop is unavailable 파일 드래그 앤 드롭을 사용할 수 없습니다 Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Wayland에서는 아직 파일 드래그 앤 드롭을 지원하지 않습니다. 파일 › 열기를 사용하거나, 파일 관리자에서 이미지를 복사한 뒤 {paste}(으)로 붙여넣으세요. To drop files, start PhotoCraft under XWayland: `{command}` 파일을 끌어다 놓으려면 XWayland에서 PhotoCraft를 실행하세요: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. 또는 환경 설정 › 성능 › Linux 디스플레이 서버를 X11로 설정하면 PhotoCraft가 항상 XWayland에서 시작됩니다. Edit › Paste 편집 › 붙여넣기 Use File › Open, or paste a copied image with {paste}. 파일 › 열기를 사용하거나, 복사한 이미지를 {paste}(으)로 붙여넣으세요. Invalid {mode} profile 유효하지 않은 {mode} 프로파일 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f12906d1..be885dbe 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2007,6 +2007,8 @@ CPU (no GPU acceleration) CPU (bez akceleracji GPU) DirectX 12 DirectX 12 GPU backend Backend GPU + Linux display server Serwer wyświetlania Linuksa + X11 X11 Metal Metal OpenGL OpenGL Use tablet pressure Użyj nacisku tabletu @@ -3209,6 +3211,7 @@ cameraRaw Roughness Chropowatość {path}: the path is not valid Unicode; rename the file and open it again. {path}: ścieżka nie jest prawidłowym Unicode; zmień nazwę pliku i otwórz go ponownie. Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Natywne przeciąganie i upuszczanie plików nie jest jeszcze obsługiwane w Wayland. Użyj Plik › Otwórz albo skopiuj obraz w menedżerze plików i wklej go za pomocą {paste}. To drop files, start PhotoCraft under XWayland: `{command}` Aby upuszczać pliki, uruchom PhotoCraft pod XWayland: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Albo ustaw Preferencje › Wydajność › Serwer wyświetlania Linuksa na X11: PhotoCraft będzie wtedy zawsze uruchamiany pod XWayland. Edit › Paste Edycja › Wklej Use File › Open, or paste a copied image with {paste}. Użyj Plik › Otwórz albo wklej skopiowany obraz za pomocą {paste}. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 9dfb1cbc..1a5fe80d 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1980,6 +1980,8 @@ CPU (no GPU acceleration) CPU (sem aceleração por GPU) DirectX 12 DirectX 12 GPU backend Backend da GPU + Linux display server Servidor de exibição Linux + X11 X11 Metal Metal OpenGL OpenGL Use tablet pressure Usar a pressão da mesa digitalizadora @@ -2467,6 +2469,7 @@ Native file drag-and-drop is unavailable Arrastar e soltar arquivos não está disponível Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. O recurso nativo de arrastar e soltar arquivos ainda não funciona no Wayland. Use Arquivo › Abrir ou copie a imagem no gerenciador de arquivos e cole-a com {paste}. To drop files, start PhotoCraft under XWayland: `{command}` Para soltar arquivos, execute o PhotoCraft no XWayland: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Ou defina Preferências › Desempenho › Servidor de exibição Linux como X11: o PhotoCraft passa a iniciar sempre no XWayland. Edit › Paste Editar › Colar Use File › Open, or paste a copied image with {paste}. Use Arquivo › Abrir ou cole uma imagem copiada com {paste}. Mixer Brush Tool Ferramenta Pincel misturador diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b9174871..f290472a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1395,6 +1395,7 @@ Native file drag-and-drop is unavailable Нативное перетаскивание файлов в Wayland недоступно Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Нативное перетаскивание файлов в Wayland пока не поддерживается. Откройте файл через Файл › Открыть или скопируйте изображение в файловом менеджере и вставьте его с помощью {paste}. To drop files, start PhotoCraft under XWayland: `{command}` Чтобы перетаскивать файлы, запустите PhotoCraft в XWayland: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Или выберите в Настройки › Производительность › Дисплейный сервер Linux значение X11: PhotoCraft будет всегда запускаться в XWayland. Edit › Paste Редактирование › Вставить Photo Фото Print Печать @@ -2179,6 +2180,8 @@ Font preview Предпросмотр шрифтов Full size tip Весь диаметр кисти GPU backend Бэкенд GPU + Linux display server Дисплейный сервер Linux + X11 X11 Gamut warning color Цвет предупреждения об охвате Gamut warning opacity Непрозрачность предупреждения об охвате General Основные diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 6f371867..94821070 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -986,6 +986,7 @@ Native file drag-and-drop is unavailable Wayland 上不支持原生文件拖放 Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Wayland 尚不支持原生文件拖放。请使用“文件 › 打开”,或在文件管理器中复制图像后使用 {paste} 粘贴。 To drop files, start PhotoCraft under XWayland: `{command}` 要拖放文件,请在 XWayland 下启动 PhotoCraft:`{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. 或将 首选项 › 性能 › Linux 显示服务器 设为 X11:PhotoCraft 将始终在 XWayland 下启动。 Edit › Paste “编辑 › 粘贴” Duplicate Layers… 复制图层… Duplicate style 复制样式 @@ -2069,6 +2070,8 @@ cameraRaw Blending 混合 CPU (no GPU acceleration) CPU(无 GPU 加速) DirectX 12 DirectX 12 GPU backend GPU 后端 + Linux display server Linux 显示服务器 + X11 X11 Metal Metal OpenGL OpenGL Use tablet pressure 使用数位板压感 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index edae3d5f..bda89324 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -987,6 +987,7 @@ Native file drag-and-drop is unavailable Wayland 不支援原生檔案拖放 Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Wayland 尚未支援原生檔案拖放。請使用「檔案 › 開啟」,或在檔案管理員中複製影像後使用 {paste} 貼上。 To drop files, start PhotoCraft under XWayland: `{command}` 若要拖放檔案,請在 XWayland 下啟動 PhotoCraft:`{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. 或將 偏好設定 › 效能 › Linux 顯示伺服器 設為 X11:PhotoCraft 之後一律在 XWayland 下啟動。 Edit › Paste 「編輯 › 貼上」 Duplicate Layers… 複製圖層… Duplicate style 複製樣式 @@ -2010,6 +2011,8 @@ CPU (no GPU acceleration) CPU (無 GPU 加速) DirectX 12 DirectX 12 GPU backend GPU 後端 + Linux display server Linux 顯示伺服器 + X11 X11 Metal Metal OpenGL OpenGL Use tablet pressure 使用繪圖板壓力 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 9985e54c..1daee4f3 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -21,12 +21,13 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("circle", include_bytes!("../../../assets/icons/circle.svg")), ("circle-dashed", include_bytes!("../../../assets/icons/circle-dashed.svg")), ("circle-dot", include_bytes!("../../../assets/icons/circle-dot.svg")), + ("clip-below", include_bytes!("../../../assets/icons/clip-below.svg")), + ("clip-release", include_bytes!("../../../assets/icons/clip-release.svg")), ("clock", include_bytes!("../../../assets/icons/clock.svg")), ("cloud", include_bytes!("../../../assets/icons/cloud.svg")), ("compass", include_bytes!("../../../assets/icons/compass.svg")), ("contrast", include_bytes!("../../../assets/icons/contrast.svg")), ("copy", include_bytes!("../../../assets/icons/copy.svg")), - ("corner-right-down", include_bytes!("../../../assets/icons/corner-right-down.svg")), ("crop", include_bytes!("../../../assets/icons/crop.svg")), ("diamond", include_bytes!("../../../assets/icons/diamond.svg")), ("direct-select", include_bytes!("../../../assets/icons/direct-select.svg")), diff --git a/crates/ui-egui/src/input_tests.rs b/crates/ui-egui/src/input_tests.rs index eb20783d..01b338d3 100644 --- a/crates/ui-egui/src/input_tests.rs +++ b/crates/ui-egui/src/input_tests.rs @@ -500,3 +500,21 @@ fn eyedropper_and_alt_sampling_show_a_pipette() { h.state_mut().ui.tool = crate::state::Tool::Eyedropper; assert_eq!(precise(&mut h), (egui::CursorIcon::Crosshair, windows)); } + +/// Esc while drawing with the Pen ends the path where it is, left open, and keeps it as the work +/// path, as ↩ does; it used to throw the path away (#1769). +#[test] +fn esc_ends_a_pen_path_and_keeps_it() { + let mut h = harness(); + h.state_mut().ui.tool = crate::state::Tool::Pen; + h.state_mut().ui.pen = Some(crate::vector_ui::PenPath { knots: vec![[[50.0, 50.0]; 3], [[150.0, 50.0]; 3], [[150.0, 120.0]; 3]], ..Default::default() }); + h.key_press(Key::Escape); + h.run_steps(2); + assert!(h.state().ui.pen.is_none(), "the Pen leaves drawing state"); + let doc = &h.state().session.active().unwrap().doc; + let path = doc.work_path.as_ref().expect("the path is kept as the work path"); + assert_eq!(path.subpaths.len(), 1); + assert!(!path.subpaths[0].closed, "left open"); + assert_eq!(path.subpaths[0].knots.len(), 3); + assert_eq!(h.state().ui.selected_path.as_deref(), Some("work")); +} diff --git a/crates/ui-egui/src/layer_row_ui/tests.rs b/crates/ui-egui/src/layer_row_ui/tests.rs index 44e8b04e..c18eccc8 100644 --- a/crates/ui-egui/src/layer_row_ui/tests.rs +++ b/crates/ui-egui/src/layer_row_ui/tests.rs @@ -152,6 +152,53 @@ fn a_configured_but_disabled_effect_stays_discoverable_in_the_panel() { assert!(effect_row.is_positive(), "a configured disabled effect remains visible for discovery"); } +/// #1622: the eyes on the effects rows work. The "Effects" eye hides and shows the whole list, an +/// effect's eye only that effect; each click is one history step, keeps the effect's settings and +/// never opens the Layer Style dialog. +#[test] +fn the_eyes_on_the_effects_rows_hide_and_show_effects() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + let id = s.execute("layer.new.layer", json!({"name": "Styled"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.layerStyle.dropShadow", json!({"layer": id})).unwrap(); + s.execute("layer.layerStyle.colorOverlay", json!({"layer": id})).unwrap(); + let mut h = harness(s, 1.0, "promedium", 290.0); + // (the whole list is on, each effect is on). + let fx = |h: &Harness<'_, PhotocraftApp>| { + let fx = &h.state().session.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().effects; + (fx.enabled, fx.items.iter().map(Effect::enabled).collect::>()) + }; + let steps = |h: &Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().history.past_len(); + let eye = |h: &Harness<'_, PhotocraftApp>, row: &str| { + let r = h.get_by_label(row).rect(); + pos2(r.left() + 15.0, r.center().y) + }; + let (settings, n) = (h.state().session.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().effects.items.clone(), steps(&h)); + assert_eq!(fx(&h), (true, vec![true, true])); + + let p = eye(&h, "Color Overlay"); + click(&mut h, p); + assert_eq!(fx(&h), (true, vec![true, false]), "only the clicked effect is hidden"); + assert_eq!(steps(&h), n + 1, "one history step"); + assert_eq!(h.state().session.active().unwrap().history.undo_label(), Some("Hide Color Overlay")); + // The row stays (its eye box empty), and a click there shows the effect again. + let p = eye(&h, "Color Overlay"); + click(&mut h, p); + assert_eq!(fx(&h), (true, vec![true, true])); + + let p = eye(&h, "Effects"); + click(&mut h, p); + assert_eq!(fx(&h), (false, vec![true, true]), "the Effects eye hides the list; each effect keeps its own eye"); + assert_eq!(h.state().session.active().unwrap().history.undo_label(), Some("Hide Layer Effects")); + let p = eye(&h, "Effects"); + click(&mut h, p); + assert_eq!(fx(&h), (true, vec![true, true])); + + assert_eq!(h.state().session.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().effects.items, settings, "settings survive"); + assert_eq!(steps(&h), n + 4); + assert!(h.state().ui.dialogs.is_empty(), "an eye click never opens the Layer Style dialog"); +} + fn groups_open(s: &photocraft_engine::Session) -> Vec { s.active().unwrap().doc.walk().into_iter().filter_map(|(_, _, l)| if let LayerContent::Group(g) = &l.content { Some(g.expanded) } else { None }).collect() } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 19c54c30..9910e4e6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -233,6 +233,8 @@ pub struct Recovered { pub type AppendTextFn = Box Result<(), String>>; /// Requests from the operating system since the last call (see [`OsEvent`]). pub type OsEventsFn = Box Vec>; +/// Ends the app the platform's own way (see [`Services::quit`]). +pub type QuitFn = Box; /// Where the OS pointer is now, in egui points within the window; `None` when unknown. pub type CursorPosFn = Box Option>; @@ -300,6 +302,11 @@ pub struct Services { pub read_displays: Option, /// The macOS menu bar, when the desktop app installed one; the in-window menus are hidden then. pub native_menu: Option, + /// Ends the app once leaving is settled (nothing unsaved, or the prompt answered), instead of + /// letting eframe close the window. macOS: `-[NSApplication terminate:]`. Closing the window + /// while AppKit's run loop is still going crashes or hangs Touch Bar Macs (#1575, #1458). + /// Without one, eframe closes the window and the app ends with it. + pub quit: Option, } /// A document histogram being computed off the UI thread: (document, revision, receiver of @@ -816,6 +823,9 @@ impl PhotocraftApp { // Edit › Color Settings policies apply on open; mismatches can ask what to do. // No path yet: a bare name isn't a location to save back to (`open_file` sets the path). let (_, color) = self.session.open_document(doc, None); + if let Some(st) = self.session.active_mut() { + st.source_read_only = photocraft_io::affinity::is_affinity(bytes); + } self.sync_views(); self.ui.status = format!("Opened {name}"); self.ui.status_error = false; @@ -861,6 +871,9 @@ impl PhotocraftApp { let (doc, warnings) = import(name, bytes)?; // The caller records the path it read from. self.session.add_document(doc, None); + if let Some(st) = self.session.active_mut() { + st.source_read_only = photocraft_io::affinity::is_affinity(bytes); + } self.sync_views(); self.ui.status = format!("Opened {name}"); self.ui.status_error = false; @@ -1121,6 +1134,9 @@ impl eframe::App for PhotocraftApp { return; } let t0 = gpu_canvas::now_ms(); + // Each painting tool keeps its own brush (#218), so switch the active tool's brush in + // before anything this frame reads it (the cursor, the options bar, a stroke). + paint_mouse::sync_tool_brush(self); // View › Screen Mode › Full Screen Mode: only the image, on black (F or Esc returns). screen_picker::show(&ctx); if screen_picker::busy(&ctx) && screen_picker::showing(&ctx) { diff --git a/crates/ui-egui/src/liquify_ui.rs b/crates/ui-egui/src/liquify_ui.rs index 02505763..33f31a4f 100644 --- a/crates/ui-egui/src/liquify_ui.rs +++ b/crates/ui-egui/src/liquify_ui.rs @@ -935,6 +935,50 @@ mod tests { assert!(open(&mut app, &ctx).is_ok()); } + /// Follow-up to #1494 / #1484: Alt is captured at stroke-down and stays recorded + /// through undo, redo and deterministic Liquify field reconstruction. + #[test] + fn alt_reversed_twirl_survives_replay_undo_and_redo() { + let ctx = egui::Context::default(); + let mut app = app_with_layer(); + open(&mut app, &ctx).unwrap(); + control(&mut app, &json!({"tool": "twirlCw", "size": 36, "rate": 80})).unwrap(); + + // Releasing Alt while dragging must not change the tool recorded at pointer-down. + pointer(&mut app, ToolEvent::Down { x: 53.0, y: 40.0, pressure: 1.0 }, egui::Modifiers::ALT); + pointer(&mut app, ToolEvent::Move { x: 58.0, y: 41.0, pressure: 1.0 }, egui::Modifiers::NONE); + pointer(&mut app, ToolEvent::Up { x: 58.0, y: 41.0 }, egui::Modifiers::NONE); + let d = app.distort.liquify.as_ref().unwrap(); + assert_eq!(d.opts.tool, LiquifyTool::TwirlCw, "Alt must not change the selected tool"); + assert_eq!(d.strokes.len(), 1); + assert_eq!(d.strokes[0].tool, LiquifyTool::TwirlCcw); + assert_eq!(LiquifyField::from_strokes(d.canvas, d.cell, &d.strokes), d.field); + + // A following stroke without Alt records the original tool. + pointer(&mut app, ToolEvent::Down { x: 70.0, y: 40.0, pressure: 1.0 }, egui::Modifiers::NONE); + pointer(&mut app, ToolEvent::Move { x: 75.0, y: 42.0, pressure: 1.0 }, egui::Modifiers::ALT); + pointer(&mut app, ToolEvent::Up { x: 75.0, y: 42.0 }, egui::Modifiers::ALT); + let d = app.distort.liquify.as_ref().unwrap(); + assert_eq!(d.strokes.len(), 2); + assert_eq!(d.strokes[1].tool, LiquifyTool::TwirlCw); + assert_eq!(LiquifyField::from_strokes(d.canvas, d.cell, &d.strokes), d.field); + + control(&mut app, &json!({"undo": true})).unwrap(); + let d = app.distort.liquify.as_ref().unwrap(); + assert_eq!(d.strokes.len(), 1); + assert_eq!(d.strokes[0].tool, LiquifyTool::TwirlCcw); + assert_eq!(d.redo[0].tool, LiquifyTool::TwirlCw); + assert_eq!(LiquifyField::from_strokes(d.canvas, d.cell, &d.strokes), d.field); + + control(&mut app, &json!({"redo": true})).unwrap(); + let d = app.distort.liquify.as_ref().unwrap(); + assert_eq!(d.strokes.len(), 2); + assert!(d.redo.is_empty()); + assert_eq!(d.strokes[0].tool, LiquifyTool::TwirlCcw); + assert_eq!(d.strokes[1].tool, LiquifyTool::TwirlCw); + assert_eq!(LiquifyField::from_strokes(d.canvas, d.cell, &d.strokes), d.field); + } + #[test] fn dialog_strokes_match_the_engine_and_commit_once() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 01fad74b..7bce021e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -898,6 +898,7 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 { let id = alt_click(id, ctx.input(|i| i.modifiers.alt)); if let Err(e) = invoke(app, &ctx, &id, json!({})) { app.ui.status = e; + app.ui.status_error = true; } } right diff --git a/crates/ui-egui/src/notices.rs b/crates/ui-egui/src/notices.rs index b79512c0..75889979 100644 --- a/crates/ui-egui/src/notices.rs +++ b/crates/ui-egui/src/notices.rs @@ -79,6 +79,7 @@ pub fn wayland_file_drop_guidance(app: &mut PhotocraftApp) { if let Some(command) = &app.services.xwayland_command { lines.push("To drop files, start PhotoCraft under XWayland: `{command}`".to_owned()); args.push(("command".to_owned(), command.clone())); + lines.push("Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland.".to_owned()); } let id = post(app, "Native file drag-and-drop is unavailable", lines, false, Some(WAYLAND_FILE_DROP_DISMISSED)); if let Some(notice) = app.ui.notices.iter_mut().find(|notice| notice.id == id) { @@ -229,6 +230,7 @@ mod tests { assert!(guidance.contains(&format!("paste it with {paste}")), "{guidance}"); // The relaunch command is the one for this install (an AppImage here), not a guess. assert!(guidance.contains("under XWayland: `WAYLAND_DISPLAY= '/apps/Photo Craft.AppImage'`"), "{guidance}"); + assert!(guidance.contains("Linux display server to X11"), "{guidance}"); assert_eq!(app.ui.notices[0].dismiss_pref.as_deref(), Some(WAYLAND_FILE_DROP_DISMISSED)); for i in 0..MAX_NOTICES { post(&mut app, format!("Transient {i}"), Vec::new(), false, None); diff --git a/crates/ui-egui/src/outline.rs b/crates/ui-egui/src/outline.rs index 9f12e407..bdb5c415 100644 --- a/crates/ui-egui/src/outline.rs +++ b/crates/ui-egui/src/outline.rs @@ -17,6 +17,16 @@ pub fn outline(mask: &Surface, bounds: Rect) -> Vec { /// Like [`outline`], sampling one pixel per `step`×`step` block (segments stay in document space). pub fn outline_scaled(mask: &Surface, bounds: Rect, step: u32) -> Vec { + outline_scaled_impl(mask, bounds, step, true) +} + +/// Trace without waiting for Rayon's shared worker pool. The UI uses this while background jobs +/// are active, because a long-running filter can occupy all the workers (#1554). +pub fn outline_scaled_serial(mask: &Surface, bounds: Rect, step: u32) -> Vec { + outline_scaled_impl(mask, bounds, step, false) +} + +fn outline_scaled_impl(mask: &Surface, bounds: Rect, step: u32, parallel: bool) -> Vec { if bounds.is_empty() { return Vec::new(); } @@ -42,12 +52,17 @@ pub fn outline_scaled(mask: &Surface, bounds: Rect, step: u32) -> Vec { } }; #[cfg(not(target_arch = "wasm32"))] - { + if parallel { use rayon::prelude::*; grid.par_chunks_mut(w).enumerate().for_each(|(gy, row)| sample_row(gy, row)); + } else { + grid.chunks_mut(w).enumerate().for_each(|(gy, row)| sample_row(gy, row)); } #[cfg(target_arch = "wasm32")] - grid.chunks_mut(w).enumerate().for_each(|(gy, row)| sample_row(gy, row)); + { + let _ = parallel; + grid.chunks_mut(w).enumerate().for_each(|(gy, row)| sample_row(gy, row)); + } let inside = |x: usize, y: usize| grid[y * w + x]; // Grid → document coordinates, clamped so coarse cells never draw outside the selection bounds. let r = Rect::new(gx0 * step, gy0 * step, gx1 * step, gy1 * step); @@ -131,6 +146,21 @@ mod tests { } } + #[test] + fn serial_outline_matches_parallel_for_scales_and_clipped_bounds() { + let mut mask = Surface::new(PixelFormat::GRAY8); + mask.fill_rect(Rect::new(3, 5, 110, 90), &[1.0]); + mask.fill_rect(Rect::new(17, 21, 45, 65), &[0.0]); + mask.fill_rect(Rect::new(73, 9, 88, 42), &[0.0]); + for step in [1, 2, 4, 8, 16] { + for bounds in [mask.content_bounds(), Rect::new(13, 19, 91, 77), Rect::new(200, 200, 240, 240)] { + let parallel = outline_scaled(&mask, bounds, step); + let serial = outline_scaled_serial(&mask, bounds, step); + assert_eq!(serial, parallel, "outline differs at step {step}, bounds {bounds:?}"); + } + } + } + #[test] fn empty_mask_has_no_outline() { let m = Surface::new(PixelFormat::GRAY8); diff --git a/crates/ui-egui/src/paint_mouse.rs b/crates/ui-egui/src/paint_mouse.rs index dade8092..51245cb0 100644 --- a/crates/ui-egui/src/paint_mouse.rs +++ b/crates/ui-egui/src/paint_mouse.rs @@ -5,38 +5,55 @@ //! with `"erase": true`, so one undo step, with the pen pressure and tilt of a normal stroke. use egui::{PointerButton, Response}; +use photocraft_engine::BrushSettings; use photocraft_engine::prefs::RightClickPaint; use crate::PhotocraftApp; use crate::state::Tool; -/// Smoothing is a tool option, kept per tool like Photoshop's options bar: switching between the -/// Brush and the Eraser saves the session brush's smoothing for the old tool and loads the new -/// tool's (Photoshop's 10 % the first time). Other tools leave it alone. -pub fn sync_tool_smoothing(app: &mut PhotocraftApp) { +/// The brush is a tool option, kept per tool like Photoshop's options bar: switching from the +/// Brush to the Eraser saves the session brush for the old tool and loads the new tool's, so each +/// keeps its own size, hardness, mode, opacity, dynamics and smoothing. A tool seen for the +/// first time takes what the brush already has, at Photoshop's 10 % smoothing. Other tools leave +/// the brush alone. +/// +/// The foreground and background colours, the erase flag and the stroke seed belong to the +/// session rather than to the tool, so a swap never carries them between tools: picking a colour +/// or painting with the Eraser works with any tool's brush. +pub fn sync_tool_brush(app: &mut PhotocraftApp) { let tool = app.ui.tool; - if !matches!(tool, Tool::Brush | Tool::Pencil | Tool::Eraser) || app.ui.smoothing_tool == Some(tool) { + if !has_brush_picker(tool) || app.ui.brush_tool == tool { return; } - let current = app.session.tools.brush.smoothing.clone(); - let next = match app.ui.smoothing_tool { - // The first smoothing tool takes what the brush has. - None => current, - Some(prev) => { - let saved = &mut app.ui.tool_smoothing; - match saved.iter_mut().find(|(t, _)| *t == prev) { - Some(e) => e.1 = current, - None => saved.push((prev, current)), - } - saved - .iter() - .find(|(t, _)| *t == tool) - .map(|(_, s)| s.clone()) - .unwrap_or_else(|| photocraft_engine::paint::brush::Smoothing { amount: 0.1, ..Default::default() }) - } + // The live brush is the outgoing tool's, so save it there before the new one takes over: any + // edit it made since it was last active is in it, so its copy never goes stale. + let current = app.session.tools.brush.clone(); + save_brush(app, app.ui.brush_tool, current.clone()); + app.session.tools.brush = match app.ui.tool_brushes.iter().find(|(t, _)| *t == tool) { + Some((_, b)) => session_fields(¤t, b), + // A tool seen for the first time takes what the brush has, at Photoshop's smoothing. + None => BrushSettings { smoothing: first_smoothing(), ..current }, }; - app.session.tools.brush.smoothing = next; - app.ui.smoothing_tool = Some(tool); + app.ui.brush_tool = tool; +} + +/// The brush `tool` had when it was last active. +fn save_brush(app: &mut PhotocraftApp, tool: Tool, brush: BrushSettings) { + match app.ui.tool_brushes.iter_mut().find(|(t, _)| *t == tool) { + Some(e) => e.1 = brush, + None => app.ui.tool_brushes.push((tool, brush)), + } +} + +/// The session's own brush fields, which a tool swap never carries: the colours, the erase flag +/// and the stroke seed (a stroke's replay seed is the command's, not the tool's). +fn session_fields(current: &BrushSettings, brush: &BrushSettings) -> BrushSettings { + BrushSettings { color: current.color, background: current.background, erase: current.erase, seed: current.seed, ..brush.clone() } +} + +/// Photoshop's 10 % stroke smoothing, what a tool's brush starts at. +fn first_smoothing() -> photocraft_engine::paint::brush::Smoothing { + photocraft_engine::paint::brush::Smoothing { amount: 0.1, ..Default::default() } } /// Which tool events this frame's canvas response produces. @@ -460,12 +477,12 @@ mod tests { } #[test] - fn smoothing_is_a_per_tool_option_that_presets_keep() { + fn the_brush_is_a_per_tool_option_that_presets_keep() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); let amount = |app: &PhotocraftApp| app.session.tools.brush.smoothing.amount; app.ui.tool = Tool::Brush; - sync_tool_smoothing(&mut app); + sync_tool_brush(&mut app); assert_eq!(amount(&app), 0.1, "Photoshop's default"); // Picking a preset (whose own smoothing is 0) keeps the tool's smoothing. let preset = app.session.tools.presets.iter().find(|p| p.brush.smoothing.amount == 0.0).map(|p| p.name.clone()).unwrap(); @@ -478,23 +495,87 @@ mod tests { assert_eq!((amount(&app), app.session.tools.brush.smoothing.pulled_string), (0.42, true)); // The Eraser has its own (10 % at first); switching back restores the Brush's. app.ui.tool = Tool::Eraser; - sync_tool_smoothing(&mut app); + sync_tool_brush(&mut app); assert_eq!(amount(&app), 0.1); app.session.tools.brush.smoothing.amount = 0.0; app.ui.tool = Tool::Brush; - sync_tool_smoothing(&mut app); + sync_tool_brush(&mut app); assert_eq!((amount(&app), app.session.tools.brush.smoothing.pulled_string), (0.42, true)); // Other tools leave it alone; the Eraser kept its 0 %. app.ui.tool = Tool::Move; - sync_tool_smoothing(&mut app); + sync_tool_brush(&mut app); assert_eq!(amount(&app), 0.42); app.ui.tool = Tool::Eraser; - // A stroke picks up the tool's smoothing even without a frame in between. + // A stroke picks up the tool's brush even without a frame in between. tool_event(&mut app, ToolEvent::Down { x: 5.0, y: 5.0, pressure: 1.0 }, Modifiers::NONE); assert_eq!(amount(&app), 0.0); tool_event(&mut app, ToolEvent::Up { x: 30.0, y: 5.0 }, Modifiers::NONE); } + /// #218: Photoshop keeps a brush per painting tool, so the size, hardness, mode, opacity and + /// dynamics of one tool never leak into another's, and each tool gets its own back. + #[test] + fn each_tool_keeps_its_own_brush() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + let brush = |app: &PhotocraftApp| app.session.tools.brush.clone(); + let size = |b: &BrushSettings| b.size; + let hardness = |b: &BrushSettings| b.hardness; + let opacity = |b: &BrushSettings| b.opacity; + let mode = |b: &BrushSettings| b.mode; + + app.ui.tool = Tool::Brush; + sync_tool_brush(&mut app); + app.run("tools.setBrush", json!({"brush": {"size": 40, "hardness": 0.2, "opacity": 0.7, "mode": "Multiply"}})).unwrap(); + assert_eq!(size(&brush(&app)), 40.0); + + // A tool seen for the first time starts from the brush the old tool left (Photoshop's + // 10 % smoothing), so nothing is lost by switching. + app.ui.tool = Tool::Eraser; + sync_tool_brush(&mut app); + assert_eq!(size(&brush(&app)), 40.0, "a new tool inherits the current brush"); + + // The Eraser edits its own size; the Brush keeps 40 px. + app.run("tools.setBrush", json!({"brush": {"size": 8, "hardness": 1.0}})).unwrap(); + app.ui.tool = Tool::Brush; + sync_tool_brush(&mut app); + assert_eq!(size(&brush(&app)), 40.0, "the Brush keeps its own size"); + assert_eq!(hardness(&brush(&app)), 0.2, "and its hardness"); + app.ui.tool = Tool::Eraser; + sync_tool_brush(&mut app); + assert_eq!(size(&brush(&app)), 8.0, "the Eraser keeps its own size"); + assert_eq!(hardness(&brush(&app)), 1.0, "and its hardness"); + + // Mode, opacity, flow and the dynamics sections are per tool too. + app.ui.tool = Tool::CloneStamp; + sync_tool_brush(&mut app); + app.run("tools.setBrush", json!({"brush": {"mode": "Screen", "opacity": 0.3}})).unwrap(); + app.ui.tool = Tool::Brush; + sync_tool_brush(&mut app); + assert_eq!((mode(&brush(&app)), opacity(&brush(&app))), (photocraft_color::BlendMode::Multiply, 0.7), "the Brush's own mode and opacity"); + app.ui.tool = Tool::CloneStamp; + sync_tool_brush(&mut app); + assert_eq!((mode(&brush(&app)), opacity(&brush(&app))), (photocraft_color::BlendMode::Screen, 0.3), "the Clone Stamp keeps its own"); + + // The colours, the erase flag and the seed belong to the session, so a swap keeps what is + // live now instead of restoring another tool's copy (a picked colour reaches every tool). + app.run("tools.setBrush", json!({"brush": {"color": [0.0, 1.0, 0.0, 1.0], "background": [1.0, 0.0, 1.0, 1.0], "erase": true, "seed": 42}})).unwrap(); + app.ui.tool = Tool::Brush; + sync_tool_brush(&mut app); + let b = brush(&app); + assert_eq!( + (b.color, b.background, b.erase, b.seed), + ([0.0, 1.0, 0.0, 1.0], [1.0, 0.0, 1.0, 1.0], true, 42), + "the session's own brush fields survive the swap" + ); + + // Tools with no brush of their own (the Move) leave the brush alone. + let before = brush(&app); + app.ui.tool = Tool::Move; + sync_tool_brush(&mut app); + assert_eq!(brush(&app), before); + } + #[test] fn eraser_tool_on_the_background_paints_the_background_colour() { // #76: the Eraser on the Background (locked transparency) paints the background colour. diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 58e380d2..3c5f075e 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -513,6 +513,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { return; } let tool = app.ui.tool; + // The options bar shows the active tool's brush: switch it in before drawing the + // chip, so a tool change swaps the brush with it (Photoshop keeps one per tool). + crate::paint_mouse::sync_tool_brush(app); // Brush edits here go through `tools.setBrush`, one journal entry per gesture (Rule 1). if (tool.is_brushlike() && !matches!(tool, Tool::Brush | Tool::Pencil | Tool::MixerBrush | Tool::Eraser)) || tool == Tool::QuickSelection { brush_preset_chip(ui, &app.session.tools.brush, &mut app.ui); @@ -527,7 +530,6 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { { return; } - crate::paint_mouse::sync_tool_smoothing(app); let brush_before = app.session.tools.brush.clone(); let mut brush = brush_before.clone(); let b = &mut brush; @@ -1558,7 +1560,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { crate::layer_reveal::scroll_to_row(ui, top); } if !l.effects.items.is_empty() && fx_collapsed.iter().all(|id| *id != l.id) { - effect_rows(app, ui, l, depth); + effect_rows(app, ui, l, depth, &mut actions); } crate::smart_ui::filter_rows(app, ui, l, depth, &mut actions); } @@ -1604,8 +1606,8 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { actions.push(("layer.new.group".into(), json!({}))); } actions.extend(footer_drop(ui, &group, footer_drag, "layer.groupLayers")); - let adj = icons::button(ui, "contrast", 26.0, false, tl!("Create new fill or adjustment layer")); - egui::Popup::menu(&adj).show(|ui| { + let adj = footer_menu_button(ui, "contrast", 26.0, tl!("Create new fill or adjustment layer")); + egui::Popup::menu(&adj).open_memory(footer_menu_right_click(&adj)).show(|ui| { ui.set_min_width(190.0); for c in photocraft_engine::command_specs().iter().filter(|c| c.id.starts_with("layer.newAdjustmentLayer.")) { if ui.button(tl!(c.label).trim_end_matches('…')).clicked() { @@ -1636,7 +1638,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { actions.push((crate::layer_menu_ui::add_mask_command(doc.selection.is_some(), alt).into(), json!({}))); } let fx = fx_button(ui, 26.0, tl!("Add a layer style")); - egui::Popup::menu(&fx).show(|ui| { + egui::Popup::menu(&fx).open_memory(footer_menu_right_click(&fx)).show(|ui| { ui.set_min_width(180.0); if ui.button(tl!("Blending Options…")).clicked() { crate::layer_style::open(app, None); @@ -1709,6 +1711,31 @@ fn footer_label(command: &str) -> &'static str { } } +/// A secondary click opens a Layers footer popup, as in Photoshop; a primary click still toggles +/// it (the `Popup::menu` default this replaces). +fn footer_menu_right_click(response: &egui::Response) -> Option { + if response.secondary_clicked() { + Some(egui::SetOpenCommand::Bool(true)) + } else if response.clicked() { + Some(egui::SetOpenCommand::Toggle) + } else { + None + } +} + +/// The small down-arrow makes it clear that the footer icon expands into a menu. +fn footer_menu_arrow(ui: &egui::Ui, rect: Rect) { + let t = Tokens::get(ui.ctx()); + let arrow = Rect::from_min_size(rect.right_bottom() - vec2(11.0, 10.0), vec2(9.0, 9.0)); + icons::paint(ui, arrow, "chevron-down", 8.0, t.text_dim); +} + +fn footer_menu_button(ui: &mut egui::Ui, icon: &str, size: f32, tip: &str) -> egui::Response { + let response = icons::button(ui, icon, size, false, tip); + footer_menu_arrow(ui, response.rect); + response +} + /// Photoshop's italic "fx" footer button (no icon-font equivalent). fn fx_button(ui: &mut egui::Ui, size: f32, tip: &str) -> egui::Response { let t = Tokens::get(ui.ctx()); @@ -1720,6 +1747,7 @@ fn fx_button(ui: &mut egui::Ui, size: f32, tip: &str) -> egui::Response { job.append("fx", 0.0, egui::TextFormat { font_id: egui::FontId::proportional(15.0), color: t.icon, italics: true, ..Default::default() }); let g = ui.painter().layout_job(job); ui.painter().galley(r.center() - g.size() / 2.0, g, t.icon); + footer_menu_arrow(ui, r); resp.on_hover_text(tip) } @@ -2744,8 +2772,9 @@ fn layer_drag_and_drop( } } -/// Photoshop shows a layer's effects as indented sub-rows ("Effects", then each effect). -fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usize) { +/// Photoshop shows a layer's effects as indented sub-rows ("Effects", then each effect). The eye +/// on "Effects" shows or hides them all, the eye on an effect's row just that one (#1622). +fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usize, actions: &mut Vec<(String, Value)>) { let t = Tokens::get(ui.ctx()); let indent = 30.0 + depth as f32 * 14.0 + 34.0; let mut rows: Vec<(String, bool, Option<&'static str>)> = vec![("Effects".into(), l.effects.enabled, None)]; @@ -2765,9 +2794,19 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi ui.painter().line_segment([pos2(rect.left() + 30.0, rect.top()), pos2(rect.left() + 30.0, rect.bottom())], Stroke::new(1.0, t.separator)); } let eye = Rect::from_min_size(pos2(rect.left() + 6.0, rect.center().y - 9.0), vec2(18.0, 18.0)); + // A hidden effect's eye box is left empty (still clickable), like a hidden layer's. if on { icons::paint(ui, eye, "eye", 12.0, t.icon); } + // The whole eye column of the row takes the click, so it never opens the Layer Style dialog. + let eye_cell = Rect::from_min_max(rect.left_top(), pos2((rect.left() + 30.0).min(rect.right()), rect.bottom())); + if ui.interact(eye_cell, ui.id().with(("fx-eye", l.id.0, i)), Sense::click()).clicked() { + let mut params = json!({"layer": l.id.0, "visible": !on}); + if let (Some(index), Some(o)) = (i.checked_sub(1), params.as_object_mut()) { + o.insert("index".into(), json!(index)); + } + actions.push(("layer.setEffectsVisible".into(), params)); + } let x = rect.left() + indent + if i == 0 { 0.0 } else { 16.0 }; if i == 0 { icons::paint(ui, Rect::from_center_size(pos2(x - 12.0, rect.center().y), vec2(14.0, 14.0)), "sparkles", 11.0, t.text_dim); @@ -3431,6 +3470,48 @@ mod layer_drag_edge_scroll_tests { } } +#[cfg(test)] +mod footer_menu_tests { + use super::*; + + // Exercise the actual egui pointer path, not just a mocked click flag. + #[test] + fn right_click_opens_footer_menu_and_primary_click_still_works() { + let ctx = egui::Context::default(); + let rect = Rect::from_min_size(Pos2::ZERO, vec2(300.0, 100.0)); + let pos = pos2(35.0, 20.0); + let frame = |events: Vec| { + let mut opened = false; + let mut output = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), events, ..Default::default() }, |ui| { + let response = ui.add_sized([100.0, 28.0], egui::Button::new("Footer menu")); + egui::Popup::menu(&response).open_memory(footer_menu_right_click(&response)).show(|ui| { + opened = true; + ui.label("Menu entry"); + }); + }); + output.textures_delta.clear(); + opened + }; + frame(Vec::new()); + frame(vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed: true, modifiers: egui::Modifiers::NONE }, + ]); + assert!( + frame(vec![egui::Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed: false, modifiers: egui::Modifiers::NONE }]), + "secondary release opens the popup" + ); + + // A primary click also remains supported by egui::Popup::menu. + frame(vec![egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }]); + frame(vec![egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }]); + assert!( + frame(vec![egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }]), + "primary release still opens the popup" + ); + } +} + #[cfg(test)] mod group_drag_selection_tests { use super::*; diff --git a/crates/ui-egui/src/pencil_tests.rs b/crates/ui-egui/src/pencil_tests.rs index 995f3ffc..53cce121 100644 --- a/crates/ui-egui/src/pencil_tests.rs +++ b/crates/ui-egui/src/pencil_tests.rs @@ -13,8 +13,12 @@ fn app() -> PhotocraftApp { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); app.run("file.new", json!({"width": 120, "height": 80, "background": "transparent"})).unwrap(); app.run("tools.setColors", json!({"foreground": "#000000", "background": "#ffffff"})).unwrap(); - app.run("tools.setBrush", json!({"brush": {"size": 3, "hardness": 0.0, "smoothing": {"amount": 0.0}}})).unwrap(); + app.run("tools.setBrush", json!({"brush": {"size": 3, "hardness": 0.0}})).unwrap(); + // Each tool keeps its own brush (#218): switch first, then set the Pencil's options, as the + // options bar does (its first frame takes the Brush's tip at Photoshop's 10 % smoothing). app.ui.tool = Tool::Pencil; + crate::paint_mouse::sync_tool_brush(&mut app); + app.run("tools.setBrush", json!({"brush": {"smoothing": {"amount": 0.0}}})).unwrap(); app } diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 913df38d..79aec0d8 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1022,7 +1022,14 @@ fn section_fields(ui: &mut egui::Ui, section: &str, obj: &mut Map Value::Bool(b) => { ui.label(""); let mut b = *b; - crate::widgets::checkbox(ui, &mut b, &label); + if path == "interface.systemTitleBar" && cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))) { + ui.vertical(|ui| { + crate::widgets::checkbox(ui, &mut b, &label); + ui.label(RichText::new(tl!("Applies at next launch.")).color(t.text_dim)); + }); + } else { + crate::widgets::checkbox(ui, &mut b, &label); + } obj.insert(k, json!(b)); } Value::String(s) if path == "interface.language" => { @@ -1130,7 +1137,11 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = f.get("overrides").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + // A malformed map (set over automation) is left as it is, so OK rejects it instead of + // saving the empty map drawn in its place. + let parsed = f.get("overrides").map(|v| serde_json::from_value::>(v.clone())); + let overrides_ok = parsed.as_ref().is_none_or(Result::is_ok); + let mut overrides = parsed.and_then(Result::ok).unwrap_or_default(); let mut hidden: Vec = f.get("hidden").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); let mut colors: BTreeMap = f.get("colors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); let mut selected = f.get("selected").and_then(Value::as_str).unwrap_or("").to_string(); @@ -1275,7 +1286,9 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) -> Result = serde_json::from_value(overrides).unwrap_or_default(); + // The map replaces every stored override, so a malformed one is an error, not "none". + let mut ov: BTreeMap = + serde_json::from_value(overrides).map_err(|e| format!("overrides must map command IDs to shortcut strings: {e}"))?; let items = shortcut_items(app); // Newly assigned shortcuts are checked first and take theirs from any holder, custom // overrides included; unchanged overrides only take theirs from defaults. @@ -1985,6 +2000,62 @@ mod tests { assert_eq!(restarted.session.prefs().interface.ui_font_size, prefs::UiFontSize::Large); } + #[cfg(all(not(target_arch = "wasm32"), not(target_os = "macos")))] + #[test] + fn system_title_bar_explains_next_launch_and_preserves_apply_and_cancel() { + use egui_kittest::{Harness, kittest::Queryable}; + + let (mut app, store) = app_with_store(); + app.run("prefs.set", json!({"values": {"interface.language": "en"}})).unwrap(); + app.custom_titlebar = true; + let id = open_preferences(&mut app, "interface"); + let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + app + }); + h.run_steps(4); + h.get_by_label("System title bar").scroll_to_me(); + h.run_steps(4); + h.get_by_label("Applies at next launch."); + for desired in [true, false] { + h.get_by_label("System title bar").click(); + h.run_steps(4); + assert_eq!(h.state().ui.dialogs.iter().find(|d| d.id == id).unwrap().fields["values"]["interface"]["systemTitleBar"], desired); + assert_eq!(h.state().session.prefs().interface.system_title_bar, !desired, "the draft is not applied yet"); + h.get_by_label("Applies at next launch."); + h.get_by_label("Apply").click(); + h.run_steps(4); + assert_eq!(h.state().session.prefs().interface.system_title_bar, desired); + assert_eq!(stored(&store)["interface"]["systemTitleBar"], desired); + assert!(h.state().custom_titlebar, "do not change the current window or restart it"); + let (reloaded, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(reloaded.session.prefs().interface.system_title_bar, desired); + h.get_by_label("Applies at next launch."); + } + h.get_by_label("System title bar").click(); + h.run_steps(4); + h.get_by_label("Cancel").click(); + h.run_steps(4); + assert!(h.state().ui.dialogs.iter().all(|d| d.id != id)); + assert!(!h.state().session.prefs().interface.system_title_bar); + assert_eq!(stored(&store)["interface"]["systemTitleBar"], false); + assert!(h.state().custom_titlebar); + } + + #[test] + fn next_launch_notice_does_not_mark_unrelated_interface_preferences() { + use egui_kittest::{Harness, kittest::Queryable}; + let obj = json!({"showTooltips": true}).as_object().unwrap().clone(); + let mut h = + Harness::new_ui_state(|ui, obj: &mut Map| section_fields(ui, "interface", obj, &[], crate::i18n::Lang::from_pref("en")), obj); + h.run_steps(4); + assert!(h.query_by_label("Applies at next launch.").is_none()); + h.get_by_label("Show tooltips").click(); + h.run_steps(4); + assert_eq!(h.state()["showTooltips"], false); + assert!(h.query_by_label("Applies at next launch.").is_none()); + } + #[test] fn preferences_confirm_after_apply_commits_later_edits() { let (mut app, _) = app_with_store(); @@ -2077,6 +2148,35 @@ mod tests { assert_eq!(effective_shortcut(&app, "file.open", Some("Cmd+O")), None, "File › Open stays cleared"); } + #[test] + fn shortcut_dialog_rejects_malformed_overrides() { + // #956: a non-map `overrides` was read as "no overrides" and wiped every custom shortcut. + let (mut app, _) = app_with_store(); + let ctx = egui::Context::default(); + app.run("edit.keyboardShortcuts", json!({"set": {"file.new": "Ctrl+Shift+N"}})).unwrap(); + let before = app.session.prefs().shortcuts.clone(); + assert!(!before.is_empty()); + for bad in [json!("bogus"), json!(["file.new"]), json!({"file.new": 5})] { + let id = crate::menus::invoke(&mut app, &ctx, "edit.keyboardShortcuts", json!({})).unwrap()["dialog"].as_u64().unwrap(); + app.ui.dialog_mut(id).unwrap().fields.insert("overrides".into(), bad.clone()); + assert!(crate::dialogs::confirm(&mut app, id).is_err(), "{bad} accepted"); + assert_eq!(app.session.prefs().shortcuts, before, "{bad} changed the shortcuts"); + } + + // Drawing the open dialog keeps the malformed value for OK to reject, instead of + // replacing it with the empty map it shows. + let id = crate::menus::invoke(&mut app, &ctx, "edit.keyboardShortcuts", json!({})).unwrap()["dialog"].as_u64().unwrap(); + app.ui.dialog_mut(id).unwrap().fields.insert("overrides".into(), json!("bogus")); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1280.0, 800.0)).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + app + }); + h.run_steps(4); + assert_eq!(h.state().ui.dialogs.iter().find(|d| d.id == id).unwrap().fields["overrides"], json!("bogus")); + assert!(crate::dialogs::confirm(h.state_mut(), id).is_err()); + assert_eq!(h.state().session.prefs().shortcuts, before); + } + #[test] fn hidden_menu_items_disappear() { let (mut app, _) = app_with_store(); diff --git a/crates/ui-egui/src/preset_panels.rs b/crates/ui-egui/src/preset_panels.rs index 6e9e61aa..d3c35bc1 100644 --- a/crates/ui-egui/src/preset_panels.rs +++ b/crates/ui-egui/src/preset_panels.rs @@ -813,6 +813,12 @@ pub fn select_tool_preset(app: &mut PhotocraftApp, name: &str) { let Some(r) = run(app, "tool.presets.select", json!({"preset": name})) else { return }; if let Some(tool) = r["tool"].as_str().and_then(Tool::from_name) { app.ui.tool = tool; + // Each tool keeps its own brush (#218), so switch it in before the preset's brush lands + // on it, and re-apply the brush the command just set for the old tool. + crate::paint_mouse::sync_tool_brush(app); + if let Some(b) = r["options"].get("brush").filter(|b| b.is_object()) { + let _ = app.run("tools.setBrush", json!({"brush": b})); + } } if let Some(o) = r["options"].get("toolOptions").and_then(Value::as_object) { let mut cur = serde_json::to_value(&app.ui.tool_options).unwrap_or_default(); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 4918f8ed..dc52e687 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -291,16 +291,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { } return; } - // Pen path in progress: ↩ finishes (open path), Esc cancels. - if app.ui.pen.is_some() { - if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter)) { - crate::vector_ui::pen_commit(app, false); - return; - } - if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) { - app.ui.pen = None; - return; - } + // Pen path in progress: ↩ and Esc both end it as an open path and keep it, as in Photoshop (#1769). + if app.ui.pen.is_some() && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter) || i.consume_key(Modifiers::NONE, Key::Escape)) { + crate::vector_ui::pen_commit(app, false); + return; } // Magnetic Lasso: ↩ closes, Esc cancels, ⌫ removes a fastening point (not Edit › Clear), // [ ] change the detection width (not the brush size). diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index e20f79e9..657a48f1 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -552,6 +552,11 @@ fn fifty() -> f32 { 50.0 } +/// The session brush starts out as the Brush's (the tool the app opens with). +fn default_brush_tool() -> Tool { + Tool::Brush +} + impl Default for ToolOptions { fn default() -> Self { Self { @@ -809,12 +814,13 @@ pub struct UiState { /// Selection-tool context menu opened by a plain canvas right-click. #[serde(default)] pub canvas_tool_menu: Option, - /// Smoothing is a per-tool option (Brush and Eraser each keep theirs): the tool whose - /// smoothing the session brush holds, and the other tools' saved values. + /// The brush is a per-tool option (the Brush, the Eraser and every retouching tool each keep + /// their own size, hardness, tip and dynamics, as in Photoshop): the tool whose brush the + /// session brush holds, and the other tools' saved brushes. + #[serde(default = "default_brush_tool")] + pub brush_tool: Tool, #[serde(default)] - pub smoothing_tool: Option, - #[serde(default)] - pub tool_smoothing: Vec<(Tool, photocraft_engine::paint::brush::Smoothing)>, + pub tool_brushes: Vec<(Tool, photocraft_engine::paint::BrushSettings)>, /// Pen path under construction. #[serde(default)] pub pen: Option, @@ -935,8 +941,8 @@ impl Default for UiState { brush_picker_list: crate::brush_picker::list_state(), layer_menu: None, canvas_tool_menu: None, - smoothing_tool: None, - tool_smoothing: Vec::new(), + brush_tool: Tool::Brush, + tool_brushes: Vec::new(), clone_source: None, clone_offset: None, pattern_stamp_phase: None, @@ -1039,6 +1045,29 @@ mod tests { assert_eq!(Tool::from_name("nope"), None); } + /// The README's "Everything in the box" tool heading and list name exactly the tools a user can + /// pick, `Tool::ALL` (#996). + #[test] + fn readme_tool_list_matches_tool_all() { + let readme = include_str!("../../../README.md"); + let (_, rest) = readme.split_once("

🧰 ").unwrap(); + let (count, rest) = rest.split_once(" tools

").unwrap(); + assert_eq!(count.parse::().unwrap(), Tool::ALL.len(), "README.md tool heading"); + let list = rest.trim_start().lines().next().unwrap(); + // "Rectangular and Elliptical Marquee" names two tools: "Rectangular Marquee", "Elliptical Marquee". + let mut named: Vec = Vec::new(); + for entry in list.split(" · ") { + match entry.split_once(" and ").and_then(|(first, rest)| Some((first, rest.split_once(' ')?))) { + Some((first, (second, noun))) => named.extend([format!("{first} {noun}"), format!("{second} {noun}")]), + None => named.push(entry.to_owned()), + } + } + named.sort(); + let mut labels: Vec = Tool::ALL.iter().map(|tool| tool.label().trim_end_matches(" Tool").to_owned()).collect(); + labels.sort(); + assert_eq!(named, labels, "README.md tool list"); + } + #[test] fn dialogs_open_and_close() { let mut s = UiState::default(); diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 182c6294..3032d8c1 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -4,7 +4,7 @@ //! typing session is one "Edit Type" history step and automation sees exactly what the user does. //! Offsets in [`TextEdit`] are character indices (the engine's unit); the layout works in bytes. -use std::sync::Arc; +use std::sync::{Arc, PoisonError}; use egui::{Color32, Pos2, Stroke}; use photocraft_doc::{Document, LayerContent, LayerId, TextLayer}; @@ -76,7 +76,7 @@ fn hit_layer(app: &mut PhotocraftApp, x: f64, y: f64) -> Option { /// they sit somewhere else than the glyphs the caret and selection are placed on. fn shows_own_layout(doc: &Document, t: &TextLayer) -> bool { let Some(cache) = &t.cache else { return false }; - let Ok(mut eng) = photocraft_text::shared().lock() else { return true }; + let mut eng = photocraft_text::shared().lock().unwrap_or_else(PoisonError::into_inner); let ours = eng.render(t, doc.resolution_dpi, doc.pixel_format()).1.surface.content_bounds(); let have = cache.content_bounds(); [(ours.x0, have.x0), (ours.y0, have.y0), (ours.x1, have.x1), (ours.y1, have.y1)].iter().all(|(a, b)| (a - b).abs() <= 1) diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index b2290679..e9ba7428 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -407,10 +407,12 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform } } }; + // View › Show › Target Path (under Extras) hides the paths; the Pen's path in progress stays. + let paths = app.ui.view.shows(app.ui.view.show.target_path); if crate::direct_select::shows(app, painter.ctx().input(|i| i.modifiers)) { // Direct Selection (or the Pen with ⌘/Ctrl held) draws the paths it edits (#790). - crate::direct_select::draw_overlay(app, painter, &to_scr, accent); - } else { + crate::direct_select::draw_overlay(app, painter, &to_scr, accent, paths); + } else if paths { if vector_tool { if let Some(wp) = &doc.work_path { draw_path(wp, tool == Tool::PathSelection); @@ -1025,6 +1027,55 @@ mod tests { assert!(!entries.iter().any(|(_, id, _)| *id == "path.delete")); } + /// How many shapes the path overlay paints for `app` this frame. + fn overlay_shapes(app: &PhotocraftApp, ctx: &egui::Context) -> usize { + let doc = app.session.active().unwrap().doc.clone(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let rect = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 400.0)); + let painter = ui.ctx().layer_painter(egui::LayerId::background()).with_clip_rect(rect); + let xf = ViewXform { rect, zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0 }; + draw_overlay(app, &painter, &xf, &doc); + }); + out.textures_delta.clear(); + out.shapes.len() + } + + #[test] + fn show_target_path_and_extras_hide_the_path_outlines() { + // #1119: View › Show › Target Path only flipped its checkmark; the canvas kept drawing the + // work path and the active shape's path. + let ctx = egui::Context::default(); + let mut app = app(); + let path = json!({"subpaths": [{"closed": true, "knots": [ + {"anchor": [10, 10], "in": [10, 10], "out": [10, 10]}, + {"anchor": [80, 10], "in": [80, 10], "out": [80, 10]}, + {"anchor": [80, 80], "in": [80, 80], "out": [80, 80]} + ]}]}); + app.run("path.set", json!({"path": path})).unwrap(); + assert!(app.session.active().unwrap().doc.work_path.is_some()); + finish_shape(&mut app, Tool::Rectangle, [100.0, 100.0], [160.0, 150.0], egui::Modifiers::NONE); + let toggle = |app: &mut PhotocraftApp, id: &str| crate::menus::invoke(app, &ctx, id, Value::Null).unwrap(); + for tool in [Tool::Pen, Tool::PathSelection, Tool::DirectSelection, Tool::Rectangle] { + app.ui.tool = tool; + assert!(app.ui.view.show.target_path && app.ui.view.extras); + assert!(overlay_shapes(&app, &ctx) > 0, "{tool:?}: the paths are drawn by default"); + toggle(&mut app, "view.show.targetPath"); + assert!(!app.ui.view.show.target_path); + assert_eq!(overlay_shapes(&app, &ctx), 0, "{tool:?}: Target Path off"); + toggle(&mut app, "view.show.targetPath"); + toggle(&mut app, "view.extras"); + assert!(!app.ui.view.extras); + assert_eq!(overlay_shapes(&app, &ctx), 0, "{tool:?}: Extras off"); + toggle(&mut app, "view.extras"); + assert!(overlay_shapes(&app, &ctx) > 0, "{tool:?}: shown again"); + } + // The Pen's path in progress is a gesture, not the target path: it stays visible. + app.ui.tool = Tool::Pen; + app.ui.pen = Some(PenPath { knots: vec![[[20.0, 20.0]; 3], [[60.0, 40.0]; 3]], ..Default::default() }); + toggle(&mut app, "view.show.targetPath"); + assert!(overlay_shapes(&app, &ctx) > 0, "the pen path in progress is still drawn"); + } + #[test] fn shape_drag_creates_layers_with_modifiers() { let mut app = app(); diff --git a/docs/architecture.md b/docs/architecture.md index 81bd2406..b045058f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -99,6 +99,7 @@ photocraft/ │ ├─ adobe-assets/ photocraft-adobe-assets .abr .asl .aco/.ase .grd .pat .csh .atn .cube/.3dl, ACR .xmp presets (standalone, like psd) │ ├─ codecs/ photocraft-codecs png/jpeg/tiff/webp/gif/bmp/avif decode/encode, heif/heic decode (feature `heif`) │ ├─ heif/ photocraft-heif optional HEIF/HEIC decoder (heic-rs, pure Rust); used only by codecs behind its `heif` feature (standalone) +│ ├─ affinity/ photocraft-affinity bounded native Affinity reader: archive, object stream, layers, vectors, text, pixels (standalone; read only) │ ├─ raw/ photocraft-raw clean-room camera RAW decode (DNG, CR2, TIFF/EP) + develop pipeline (standalone, like psd) │ ├─ format/ photocraft-format native document format (.pcraft bundle): manifest + content-addressed tiles │ ├─ io/ photocraft-io import/export orchestration; doc ⇄ PSD mapping; PDF/SVG import (features) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 631cc4e9..366a3f1c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -37,19 +37,19 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `engine.execute {command, params}`: run any engine or UI command by id. Engine commands run directly with their default params and never open a dialog. Use `ui.menu.invoke` for menu-click behaviour, which opens a command's dialog when no params are given. `params` must be a JSON object (omit it or pass `null` for none); an array, string, number or boolean is an error naming the command, in every transport (control channel, `serve`, MCP and the CLI) - `engine.commands`: list commands with enablement - `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, window size). The menu tree is not included; use `ui.menu.list`. `view` holds the View/Window/Type preferences: `screen_mode`, `extras`, `show` and `snap_to` flags, `flip_horizontal`, `arrange` (Window › Arrange layout), pixel aspect, font preview size, language options. `perf.timings.gpuInfo` holds the graphics adapter, backend, driver, the backend chosen at launch and why, the canvas renderer (`gpu`/`cpu`) and, after a device loss, `lost` (`help.systemInfo` returns the same as `info`) -- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerView?}`: change UI state; any other field is an error, checked before anything changes (`theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `brushesView` and `brushPickerView` are `list` or `grid` for the Brushes panel and the Brush Preset picker; `brushPicker` is `[x, y]` in screen points to open the Brush Preset picker there (as a right-click with a painting tool does) or `null` to close it, and picking a preset in it is `tools.setBrush {"preset": name}`; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background}`, whether the Color panel edits the background colour) +- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerView?, gradientBlendMode?, gradientClassic?}`: change UI state; any other field is an error, checked before anything changes (`theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `brushesView` and `brushPickerView` are `list` or `grid` for the Brushes panel and the Brush Preset picker; `brushPicker` is `[x, y]` in screen points to open the Brush Preset picker there (as a right-click with a painting tool does) or `null` to close it, and picking a preset in it is `tools.setBrush {"preset": name}`; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background}`, whether the Color panel edits the background colour) - `typeTransform` is `null` outside a temporary Ctrl/Cmd Type-tool drag. During a drag it reports its source document/revision, original affine, oriented `frame` (layer, local bounds, document-space corners and pivot), and captured `gesture`. Drive it with `ui.pointer` and `command: true` at `down`; `move`/`up` may release the modifier. The document/history remain unchanged during preview, and `up` applies one `type.edit` within `textEdit`'s session. Pressing Escape during the drag cancels only the preview. This pointer state is never restored from saved UI state. -- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?}`: change UI state; any other field is an error, checked before anything changes (`theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background}`, whether the Color panel edits the background colour) -- `ui.menu.invoke {id, wait?}` / `ui.menu.list`: activate a menu item by id; list the menu tree. A menu item that starts a background job (a filter without a dialog, such as Blur More) replies with the job's result once it has been applied; with `"wait": false` the reply is `{job, pending: true}` at once +- `ui.menu.invoke {id, params?, wait?}` / `ui.menu.list`: activate a menu item by id; list the menu tree. A menu item that starts a background job (a filter without a dialog, such as Blur More) replies with the job's result once it has been applied; with `"wait": false` the reply is `{job, pending: true}` at once - `ui.set` also accepts `gradientBlendMode` (a layer blend mode name, such as `Difference`) and `gradientClassic` (boolean) for the Gradient tool options bar. +- Each painting tool keeps its own brush, as in Photoshop: `tools.setBrush` and `ui.set {brushSize}` change the active tool's brush, and switching tools swaps in that tool's own (size, hardness, mode, opacity, flow, dynamics, smoothing). A tool used for the first time starts from the brush in hand. The colours, the erase flag and the stroke seed stay shared. So `ui.set {tool: "eraser", brushSize: 12}` sets the Eraser's size, and `ui.set {tool: "brush"}` afterwards brings the Brush's own size back. Both gradient modes use the current preset, including its opacity stops. Select `Foreground to Transparent` through `gradient.presets.select`, or pass custom `stops` and `transparency: [[0,100],[1,0]]` (location 0..1, opacity 0..100). Use `applyToLayer: false` to edit only the tool preset. Classic gradient drags dispatch `paint.gradient` without overriding them. - `ui.dialog.open {kind, fields?}` (kinds `newDocument`, `about`, `layerStyle {effect?}`, `colorPicker {target: foreground|background}`, `command {command}`) / `ui.dialog.set {dialog, field, value}` / `ui.dialog.confirm {dialog, wait?}` / `ui.dialog.cancel {dialog}`. Like `ui.menu.invoke`, `ui.dialog.confirm` waits for a background job its command starts (a filter dialog's OK) and replies with the result; `"wait": false` replies `{job, pending: true}` at once - Layer Style (`layerStyle {effect?}` opens on that effect kind): the fields hold the dialog's whole state, so agents read and drive it like a user. `effects` lists every effect instance in the layer's order as `{id, kind, on, params, fx}` (`fx` is the effect snapshot the dialog loaded; OK edits that snapshot with `params`, so anything the dialog doesn't model survives). `selected` picks the page (`blendingOptions` or an instance id), `preview` (default on) gates the live canvas preview, `p:blendingOptions` edits blend mode/opacity/fill opacity, and `patternList` names the usable patterns. OK replaces the layer's effects in one step (`layer.layerStyle.replace`); Cancel discards. "New Style…" saves the pending state via `style.presets.new {effects}`, a swatch click applies a style preset, and Make/Reset Default use `layer.layerStyle.makeDefault` / `layer.layerStyle.defaultFor` - - Preferences (`ui.menu.invoke {id: "edit.preferences.interface"}`) edits the sections in its `values` field. `ui.dialog.apply {dialog}` saves those values through `prefs.set` and keeps the same dialog and section open. `ui.dialog.confirm` saves and closes; `ui.dialog.cancel` discards only edits made since the last successful Apply. Invalid values return an error without closing the Apply dialog or changing the saved preferences. Settings marked for the next launch still require a restart. + - Preferences (`ui.menu.invoke {id: "edit.preferences.interface"}`) exposes the sections in its `values` field. Apply (`ui.dialog.apply`) and OK (`ui.dialog.confirm`) on Preferences are refused over control because automation denies whole-preferences updates and filesystem-bearing preference sections. A refused `ui.dialog.apply` returns an error and keeps the dialog open; a refused `ui.dialog.confirm` returns an error but closes and removes the dialog. `ui.dialog.cancel` closes the dialog without saving. Individual safe preferences can instead be changed with `engine.execute` using `prefs.set` (e.g. `{"command": "prefs.set", "params": {"path": "interface.showTooltips", "value": false}}`). Settings marked for the next launch still require a restart. - Edit › Fill… (`ui.menu.invoke {id: "edit.fill"}`, Shift+F5, Shift+Backspace) opens the Fill dialog; its fields are `edit.fill`'s params (`contents`, `color`, `pattern`, `colorAdaptation`, `mode`, `opacity`, `preserveTransparency`), and OK remembers them in the preferences (`dialogs["edit.fill"]`). - A pixel tool pressed on a type, shape, Smart Object or fill layer (e.g. through `ui.pointer`) opens the "Rasterize?" prompt instead of painting: a dialog with `__rasterize` (`type|shape|smartObject|fill`), `message`, `layer`, `tool` and `at`. `ui.dialog.confirm` runs the `layer.rasterize.*` command and then paints at `at` (two history states, `{"rasterized", "painted"}`); `ui.dialog.cancel` does nothing. - `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows -- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. +- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], tool?, modifiers?, button?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen). Modifier flags (`shift`, `alt`, `command`, `ctrl`, `space`) go either at the top level or grouped under `modifiers`; when `modifiers` is present, top-level flags are ignored. `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing). `button: "right"` (or `"secondary"`) is the right button: with the Move tool (or `command: true` with any tool) it opens the canvas layer menu (`layerMenu` in `ui.inspect` lists the layers there, topmost first; select one with `layer.select`); with Marquee, Lasso, Magic Wand, Object Selection, or Pen it opens a tool context menu (`canvasToolMenu` in `ui.inspect`, with ordered command ids, separators, and enabled states); with a painting tool it opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase. The menus open at the screen position of the pressed document point. Use `ui.context.choose {id}` for enabled tool-context actions. The Pen menu is documented in `docs/context-menu-parity.md`. With a Color Picker as the top dialog, `down` and `move` sample the image into its new colour instead (its eyedropper, like a click on the canvas) and the tool is not driven. - Magnetic Lasso (`tool: "magneticLasso"`): a `down`/`up` pair is a click. The first sets the first fastening point (its modifiers set the selection mode); later clicks fasten a point, or close the border on the first point. `move` events trace the border with or without a button held, fastening points by themselves; with `alt: true` a click draws a straight segment and a drag a freehand one. `ui.inspect` shows the border in progress under `magnetic` (`path`, `anchors`, `live`, `mode`). `ui.key` Enter closes it along the edges, Backspace removes the last fastening point, Escape cancels. To select along edges in one call instead, use the `select.magneticLasso` command with rough points around the shape. - `ui.click {x, y, button?, count?}` / `ui.move {x, y}`: synthetic pointer input in screen points (`button`: `left` (default), `right` or `middle`). `count` (default 1, `2` for a double-click) is at most 256; a larger one is an error and queues nothing - `ui.key {key, command?, shift?, alt?, ctrl?}` (flags may also be grouped under `modifiers`): press and release a key, e.g. `{"key": "ArrowLeft", "shift": true}` @@ -60,7 +60,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i base64 PNG data; a path is relative to the automation write root. Raises the window first (default) because occluded macOS windows stop rendering - `ui.focus`: bring the main window to the front -- `app.open {path}` / `app.save {path}`: relative file I/O through the configured automation roots (`app.open` reads under the read root, `app.save` writes under the write root; absolute paths, `..` and paths escaping the root are refused, and both fail closed when no root was granted). Both reply with `warnings` (import/export notes such as "adjustment layer flattened"; `[]` when none), also shown to the user in the status bar and as a notice (`notices` in `ui.inspect`); `app.open` also returns the `path` and document `name`, `app.save` the `path` written. `app.save` without `path` writes back only to the document's own PSD, PSB or `.pcraft` file, like File › Save. Automation opens and saves never fire script events. Use these two rather than `file.open`, `file.save`, `file.saveAs` or `file.saveACopy`, which the control channel refuses (see [Engine commands](#engine-commands)) +- `app.open {path}` / `app.save {path?}`: relative file I/O through the configured automation roots (`app.open` reads under the read root, `app.save` writes under the write root; absolute paths, `..` and paths escaping the root are refused, and both fail closed when no root was granted). Both reply with `warnings` (import/export notes such as "adjustment layer flattened"; `[]` when none), also shown to the user in the status bar and as a notice (`notices` in `ui.inspect`); `app.open` also returns the `path` and document `name`, `app.save` the `path` written. `app.save` without `path` writes back only to the document's own PSD, PSB or `.pcraft` file, like File › Save. Automation opens and saves never fire script events. Use these two rather than `file.open`, `file.save`, `file.saveAs` or `file.saveACopy`, which the control channel refuses (see [Engine commands](#engine-commands)) - `app.quit` ## Engine commands @@ -227,7 +227,7 @@ How each MCP tool maps onto control methods in bridge mode: | `doc_new {…}` | `engine.execute {command: "file.new", params}` | | `doc_inspect` | `engine.execute {command: "document.inspect"}` | | `doc_open {path}` | `app.open {path}` | -| `doc_save {path}` / `doc_export {path}` | `app.save {path}` | +| `doc_save {path?}` / `doc_export {path}` | `app.save {path?}` | | `doc_render_preview {max_side?}` | `ui.screenshot`, returned directly as PNG image content | | `session_list`, `ui_inspect` | `ui.inspect` | | `ui_screenshot {max_side?}` | `ui.screenshot`, returned as PNG image content | @@ -248,8 +248,11 @@ lines are visible. The protocol is unencrypted and must remain on loopback; do n it to an untrusted host. Filesystem access fails closed unless launch-time read and/or write roots are granted with -`--automation-read-root` and `--automation-write-root` (or -`PHOTOCRAFT_AUTOMATION_READ_ROOT` / `PHOTOCRAFT_AUTOMATION_WRITE_ROOT`). Request paths must be +`--automation-read-root` and `--automation-write-root`. The environment-variable equivalents +(`PHOTOCRAFT_AUTOMATION_READ_ROOT` / `PHOTOCRAFT_AUTOMATION_WRITE_ROOT`) are supported by +the desktop `photocraft` process, but not by headless `photocraft-cli mcp` or `serve`; +for those CLI modes, supply the flags explicitly. In MCP bridge mode, configure roots on the +desktop process rather than on the bridging CLI. Request paths must be non-empty, forward-slash relative paths beneath the applicable root. Absolute paths, parent traversal, alternate separators, drive/device/stream prefixes, malformed components, and link escapes are rejected before file effects. Read and write authority are independent; the parent of @@ -275,7 +278,7 @@ no MCP framing, no app start-up per command. Configure its file access with the | `engine.commands` | `{filter?}`: registry with params docs and enablement | | `session.list` | open documents and the active index | | `doc.open` / `doc.new` | `{path}` / `file.new` params | -| `doc.save` | `{path?, format?, quality?, index?}` (`.pcraft` native, else export by extension). Without `path` only a PSD, PSB or `.pcraft` document is written back to its own file, in its own format; anything else is an error and the file is left unchanged. A successful layered save (PSD, PSB or `.pcraft`) records the current revision as saved and makes the file the document's path, so `session.list` reports `dirty: false`; a flat export is a copy and leaves the document dirty | +| `doc.save` | `{path?, format?, quality?, index?, tiffLayers?}` (`.pcraft` native, else export by extension; set `tiffLayers: true` to preserve TIFF layers rather than exporting a flattened TIFF). Without `path` only a PSD, PSB or `.pcraft` document is written back to its own file, in its own format; anything else is an error and the file is left unchanged. A successful layered save (PSD, PSB or `.pcraft`) records the current revision as saved and makes the file the document's path, so `session.list` reports `dirty: false`; a flat export is a copy and leaves the document dirty | | `doc.inspect` | `{index?}`: same JSON as `document.inspect` | | `doc.render` | `{index?, maxSide? (1024; 0 = full), path?}`: PNG to `path`, else `{mime, base64}` | | `doc.select` / `doc.close` | `{index}` / `{index?}` | diff --git a/docs/development.md b/docs/development.md index 5b3f6f23..3e163bc1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -5,6 +5,29 @@ - Rust stable (1.95+). Add the web target with `rustup target add wasm32-unknown-unknown`. - macOS, Windows or Linux. Linux needs `libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev libgtk-3-dev`. +### Windows source builds + +Use the Rust MSVC toolchain and install Visual Studio or Microsoft C++ Build Tools with the +**Desktop development with C++** workload. Follow Microsoft's +[Rust setup guide](https://learn.microsoft.com/en-us/windows/dev-environment/rust/setup), +then open a new terminal and check `cargo --version` and `rustc --version`. + +On Windows 11, Smart App Control can block Cargo, rustc or executables generated during a build. +If a build reports `An Application Control policy has blocked this file. (os error 4551)` +([#1585](https://github.com/storytold/photocraft/issues/1585)), check: + +- **Windows Security → App & browser control → Smart App Control settings** for its status. +- **Event Viewer → Applications and Services Logs → Microsoft → Windows → CodeIntegrity → + Operational** for the blocked executable and policy, especially on managed machines. + +This error indicates an application-control restriction; moving `CARGO_TARGET_DIR` or +reinstalling Rust does not establish that the blocked executable is trusted. See Microsoft's +[Smart App Control FAQ](https://support.microsoft.com/en-us/windows/security/threat-malware-protection/smart-app-control-frequently-asked-questions) +for the available controls; there is no per-app exception. On a managed device, ask your +administrator about an approved development environment. If you only want to run PhotoCraft, +use the [packaged Windows release](https://github.com/storytold/photocraft/releases) to avoid +building locally; the downloaded app is still subject to Windows application-control checks. + ## Build and run ```sh @@ -65,8 +88,8 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`. | `RUST_LOG` | Log levels for standard error and the log file (see [Logs](#logs)) | | `PHOTOCRAFT_CONTROL_TOKEN` | 64-hex bearer token for control TCP (avoid on shared systems where environment inspection is possible) | | `PHOTOCRAFT_CONTROL_TOKEN_FILE` | Read, or create for a server, the control bearer-token file | -| `PHOTOCRAFT_AUTOMATION_READ_ROOT` | Directory capability for automation reads; requests use relative paths | -| `PHOTOCRAFT_AUTOMATION_WRITE_ROOT` | Separate directory capability for automation writes; requests use relative paths | +| `PHOTOCRAFT_AUTOMATION_READ_ROOT` | Desktop app only: automation read root. Headless CLI modes require the `--automation-read-root` flag | +| `PHOTOCRAFT_AUTOMATION_WRITE_ROOT` | Desktop app only: automation write root. Headless CLI modes require the `--automation-write-root` flag | | `PHOTOCRAFT_CPU_CANVAS=1` | Force the CPU canvas path instead of the wgpu shader canvas | | `PHOTOCRAFT_NATIVE_WAYLAND=1` | Linux: stay on native Wayland when a pen is attached (by default the window then opens through Xwayland, because Wayland gives the app no pen input; #639) | | `WGPU_BACKEND=dx12` | Pick the wgpu backend(s) (`vulkan`, `dx12`, `metal`, `gl`); overrides `performance.gpuBackend` and the startup fallback | @@ -138,6 +161,8 @@ Keep one `PcraftWriter` per open document: re-saving then only compresses and wr - **Headless:** `photocraft-cli mcp --automation-read-root --automation-write-root `. It drives an in-process engine session and has no file authority when a root is omitted. - **Live app:** start `photocraft --control 7878 --control-token-file --automation-read-root --automation-write-root `, then run `photocraft-cli mcp --bridge 127.0.0.1:7878 --control-token-file `. The desktop process owns the roots. See `docs/control-protocol.md#mcp-bridge`. +For headless MCP clients, pass absolute paths to deliberately chosen trusted workspace directories in the launch arguments. Desktop `PHOTOCRAFT_AUTOMATION_READ_ROOT` and `PHOTOCRAFT_AUTOMATION_WRITE_ROOT` environment variables do **not** grant headless CLI access. Omitting a root flag deliberately denies that direction of access. + Tools: - `session_list` @@ -153,7 +178,7 @@ Claude Code (`.mcp.json` in the repo root, or `claude mcp add`): "mcpServers": { "photocraft": { "command": "/path/to/photocraft/target/release/photocraft-cli", - "args": ["mcp"] + "args": ["mcp", "--automation-read-root", "/absolute/path/to/trusted/workspace", "--automation-write-root", "/absolute/path/to/trusted/workspace"] }, "photocraft-live": { "command": "/path/to/photocraft/target/release/photocraft-cli", @@ -165,7 +190,10 @@ Claude Code (`.mcp.json` in the repo root, or `claude mcp add`): ```sh cargo build --release -p photocraft-cli -claude mcp add photocraft -- "$PWD/target/release/photocraft-cli" mcp +mkdir -p "$PWD/photocraft-work" +claude mcp add photocraft -- "$PWD/target/release/photocraft-cli" mcp \ + --automation-read-root "$PWD/photocraft-work" \ + --automation-write-root "$PWD/photocraft-work" ``` `doc_inspect` (and the engine command `document.inspect`) reports the layer tree with kinds, @@ -178,7 +206,7 @@ capability-scoped export, resize/crop, CMYK + native save) driven purely over MC Without MCP, `photocraft-cli serve [--port N]` keeps a headless session open and answers JSON lines (see `docs/control-protocol.md#headless-server`). -A typical agent loop: +A typical agent loop (place inputs under the configured read root and outputs under the write root): 1. `doc_open {path}` 2. `command_list {filter:"blur"}` @@ -355,6 +383,7 @@ against committed **sha256 manifests**. All pins are in one place: | `corpus/psd-tools/` | the complete psd-tools test set (309 files) | psd-tools upstream (MIT) | `xtask/psd-tools-corpus.sha256` | | `corpus/heif/` | 9 small HEIC/HEIF files (checkerboards, RGB strips, a grid-tiled photo with EXIF/XMP, each with Apple's decode as `.ref.png`; a 10-bit RGBA file with its source PNG), for the `heif` feature | heic-rs (MIT OR Apache-2.0) and pillow-heif (BSD-3-Clause) upstreams | `xtask/heif-corpus.sha256` | | `corpus/exr/` | the 5 deep OpenEXR test images (scanline deep data with half colour and u32 ID channels; 2.3 MB), checked against the ID manifests of their upstream sidecars | OpenEXR upstream at v3.5.2 (BSD-3-Clause) | `xtask/exr-corpus.sha256` | +| `corpus/affinity/` | 21 public Affinity documents (four Affinity 3 `.af` files, `.afdesign` vector art, layer/shape/raster test files, a template with artboards), each holding Affinity's own render as its thumbnail | vector-art (CC0), AFDesignLoad, Jac21/Branding and AssetStoreTemplate (MIT) upstreams | `xtask/affinity-corpus.sha256` | | `corpus/pngsuite/` | PngSuite | schaik.com release archive (public domain) | (fixed archive) | ```sh @@ -362,7 +391,7 @@ cargo xtask corpus # where each corpus lives, its pin, present o cargo xtask corpus --all # fetch everything missing or stale (cold: about 15 s; verified copies are left alone) cargo xtask test-corpus # fetch, then cargo test --release --features corpus (+ heif on codecs, io) on psd, codecs, io, engine cargo xtask test-corpus -p io # narrow to one crate (repeat -p for more) -cargo xtask test-corpus --changed # only if psd, io, codecs, compose, gpu, text or format changed vs origin/main +cargo xtask test-corpus --changed # only if psd, io, codecs, compose, gpu, text, format or affinity changed vs origin/main cargo xtask test-corpus -- --nocapture # pass arguments to the test binaries (per-file tables) scripts/fetch-corpus.sh # the same as cargo xtask corpus --all ``` diff --git a/docs/plugins.md b/docs/plugins.md index 4ff552fb..e993a697 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -49,7 +49,9 @@ build. Limits (`photocraft_plugins::Limits`, defaults): Every band runs in a **fresh instance**, so no state survives between calls, and parallel bands never share memory. Traps, exhausted budgets, malformed modules, bad manifests, out-of-range pointers and non-zero return codes all become errors; the document is left untouched (the edit is -transactional). Output samples that are NaN or infinite keep their original value; integer-depth +transactional). When a failure stops sibling bands, those bands retain the originating error +instead of replacing it with a generic cancellation message. Output samples that are NaN or +infinite keep their original value; integer-depth documents and alpha are clamped to 0–1. wasmi is built with its *portable* (loop) dispatcher: the default tail-call dispatcher relies on LLVM diff --git a/docs/roadmap.md b/docs/roadmap.md index dc049540..cf4300b6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -28,6 +28,17 @@ readiness; treat it as an upper bound. **Menu parity: 532 / 625 (85.1%)** on 2026-10-01, up from 224 (35.8%) the day before. See [`parity.md`](parity.md). +2026-10-08: Affinity `.af`, `.afdesign`, `.afphoto` and `.afpub` documents (container versions +8–12) open **natively** ([format notes](../crates/affinity/README.md)): pages and artboards, layers +and groups, curves and geometric shapes with fills, gradients and strokes, text as type layers, +placed images as smart objects, pixel layers and masks, as an 8-bit RGB document without a source +save path. Measured against the thumbnail Affinity embeds in each file, 21 pinned public documents +(`cargo xtask corpus --affinity`) differ by 0–4.6 of 255 on average. Layer effects, adjustments, +live filters, brush strokes, special shapes, master pages and CMYK/Lab/16-bit document colour are +approximated or left out, each with a warning; damaged or unknown files fall back to the embedded +preview. Affinity writing is not implemented: no Affinity installation was available to check +written files, so `.af` export stays unsupported. + ## Honest parity assessment (2026-10-05) This is the reference answer to "how close are we to Photoshop parity, really". Agents: read it @@ -172,4 +183,3 @@ Each milestone has a **definition of done (DoD)** and must leave `main` green on | **M10** | Smart features | `ml` (ort native / ort-web on the web), Select Subject/Object/Sky, Remove BG, Remove tool, content-aware fill, healing, AI denoise, RAW develop | Quality benchmarks on a public dataset; timing budgets | | **M11** | Automation + formats | MCP server, batch, scripting, remaining formats (JP2, DICOM, DPX…), C2PA | An agent completes 10 scripted edit tasks via MCP | | **M12** | Pro parity | CMYK/Lab UI, print, HDR display, photomerge/HDR merge, timeline, layer comps, artboards, symmetry, neural filters | `xtask parity` ≥ 90% of Photoshop menu checklist | - diff --git a/docs/scorecard.md b/docs/scorecard.md index 7c7d9e68..6b0b086e 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -6,15 +6,15 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| -| [Performance](#performance) | 3 | 22 | 13 | 47 scenarios: 3 within budget, 22 over budget or failing, 15 tracked only, 6 not run, 7 not measurable yet (baseline 2026-10-05) | +| [Performance](#performance) | 3 | 22 | 13 | 47 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 6 not run, 7 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 4 | 12 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | | [Tools](#tools) | 11 | 14 | 8 | | -| [Workspace and UI](#workspace-and-ui) | 0 | 7 | 16 | settings that do nothing: 55 of 140 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 0 | 7 | 16 | settings that do nothing: 55 of 141 (target 0) | | [Type](#type) | 0 | 3 | 6 | | | [Automation](#automation) | 3 | 2 | 6 | 627 of 627 menu items live (100.0%) | -| [Reliability](#reliability) | 4 | 1 | 2 | never-crash lints on 27 of 27 crates; panic_hunt present | +| [Reliability](#reliability) | 4 | 1 | 2 | never-crash lints on 28 of 28 crates; panic_hunt present | | [Distribution](#distribution) | 4 | 4 | 3 | | -| [Open bugs](#open-bugs) | 12 | 4 | 16 | | +| [Open bugs](#open-bugs) | 16 | 2 | 14 | | ## Performance @@ -157,13 +157,13 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 55 of 140** (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: 55 of 141** (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.beep_when_done`, `general.export_clipboard`, `general.resize_image_during_place`, `general.always_create_smart_objects_when_placing`, `general.animated_zoom`, `general.zoom_resizes_windows`, `interface.show_channels_in_color`, `interface.dynamic_color_sliders`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.open_documents_as_tabs`, `workspace.enable_floating_document_window_docking`, `workspace.large_tabs`, `workspace.enable_narrow_options_bar`, `tools.enable_flick_panning`, `tools.vary_round_brush_hardness_on_hud`, `tools.show_transformation_values`, `tools.double_click_layer_mask_launches_select_and_mask`, `file_handling.image_previews`, `file_handling.lowercase_extension`, `file_handling.save_in_background`, `file_handling.ignore_exif_profile_tag`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `cursors.brush_preview_color`, `units_and_rulers.type_units`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `units_and_rulers.print_resolution`, `units_and_rulers.screen_resolution`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.smart_quotes`, `type_.missing_glyph_protection`, `type_.show_font_names_in_english`, `type_.use_esc_to_commit`, `type_.text_engine`, `type_.font_preview`, `type_.fill_new_type_layers_with_placeholder`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `enhanced_controls.zoom_with_trackpad_pinch`, `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`. | Id | Target | Status | Issue | Note | |---|---|---|---|---| -| UI-217-1 | Tool options, last Export As / Save for Web / Fill settings and font recents persist | partial | [#217](https://github.com/storytold/photocraft/issues/217) | Fill (fill_ui.rs:72) and Liquify (liquify_ui.rs:64, #418) keep their settings in Preferences (prefs.dialogs); no tool_options/export fields in Preferences; export_dialog.rs:21 png/85 | +| UI-217-1 | Tool options, last Export As / Save for Web / Fill settings and font recents persist | partial | [#217](https://github.com/storytold/photocraft/issues/217) | Fill (fill_ui.rs:72) and Liquify (liquify_ui.rs:64, #418) keep their settings in Preferences (prefs.dialogs); no tool_options/export fields in Preferences; export_dialog.rs:21 png/85. Each painting tool keeps its own brush (paint_mouse.rs sync_tool_brush, #218), but those live in UiState, so they are lost at quit like the other tool options | | UI-217-2 | Window size and dock width remembered across restarts | missing | [#217](https://github.com/storytold/photocraft/issues/217) | main.rs:46 native_options(): fixed 1440x900, centred on the main monitor (#419); dock.rs:529 snapshot omits the width | | UI-217-3 | File > Open starts in the last-used folder | missing | [#217](https://github.com/storytold/photocraft/issues/217) | services.rs:110 FileDialog::new() without set_directory | | UI-217-4 | Preferences all take effect; recent files persist and honour recent_file_count (#204) | missing | [#217](https://github.com/storytold/photocraft/issues/217) | See the prefs audit and BUG-204-* | @@ -221,7 +221,7 @@ Menu wiring (`docs/parity.md`): 627 of 627 menu items live (100.0%). This counts ## Reliability -Never-crash attribute on 27 of 27 crate roots. +Never-crash attribute on 28 of 28 crate roots. | Id | Target | Status | Issue | Note | |---|---|---|---|---| @@ -259,12 +259,12 @@ Never-crash attribute on 27 of 27 crate roots. | BUG-203-4 | Unwritable directory errors clearly, never falls back to writing in place | done | [#203](https://github.com/storytold/photocraft/issues/203) | atomic.rs maps read-only dir, disk-full and cross-device rename errors to messages that say the original was not changed; there is no in-place fallback path | | BUG-203-5 | On any failure the original file is untouched and the temp file removed | done | [#203](https://github.com/storytold/photocraft/issues/203) | atomic.rs removes the temp file on every failure; tests assert the original bytes survive write and rename failures | | BUG-203-6 | Failure-injection tests: mid-write, rename and read-only dir errors keep original bytes | done | [#203](https://github.com/storytold/photocraft/issues/203) | format/src/atomic/tests.rs: Faulty AtomicFs injects mid-write StorageFull, rename failure and a read-only dir; locked-target retries, symlink and file-mode preservation covered too | -| BUG-204-1 | Interface prefs (UI scale, font size, channels in colour, sliders, tooltips) wired or hidden | missing | [#204](https://github.com/storytold/photocraft/issues/204) | prefs.rs:196-202; no reader outside prefs.rs (see the prefs audit) | -| BUG-204-2 | workspace.large_tabs, animated_zoom, auto_show_home_screen wired or hidden | missing | [#204](https://github.com/storytold/photocraft/issues/204) | prefs.rs:163,166,229; no reader outside prefs.rs | -| BUG-204-3 | RAW prefs and ask_before_saving_layered_tiff read, or hidden | partial | [#204](https://github.com/storytold/photocraft/issues/204) | ask_before_saving_layered_tiff drives the TIFF Options prompt (ui-egui tiff_options_ui.rs); raw_defaults (prefs.rs:712) still has no readers | +| BUG-204-1 | Interface prefs (UI scale, font size, channels in colour, sliders, tooltips) wired or hidden | done | [#204](https://github.com/storytold/photocraft/issues/204) | prefs_ui.rs tick reads ui_scale (sync_display_scale), ui_font_size (theme::set_ui_font_size) and show_tooltips (sync_tooltips); show_channels_in_color and dynamic_color_sliders are in prefs::HIDDEN_UNTIL_IMPLEMENTED | +| BUG-204-2 | workspace.large_tabs, animated_zoom, auto_show_home_screen wired or hidden | done | [#204](https://github.com/storytold/photocraft/issues/204) | large_tabs and animated_zoom are in prefs::HIDDEN_UNTIL_IMPLEMENTED; auto_show_home_screen drives the Home screen (chrome_ui.rs home_button, canvas.rs) | +| BUG-204-3 | RAW prefs and ask_before_saving_layered_tiff read, or hidden | done | [#204](https://github.com/storytold/photocraft/issues/204) | ask_before_saving_layered_tiff drives the TIFF Options prompt (ui-egui tiff_options_ui.rs); raw_defaults.open_in_camera_raw drives the Camera Raw dialog (camera_raw_ui.rs); the other raw_defaults fields are in prefs::HIDDEN_UNTIL_IMPLEMENTED | | BUG-204-4 | Recent files persisted through prefs across restarts | done | [#204](https://github.com/storytold/photocraft/issues/204) | crates/ui-egui/src/lib.rs::push_recent stores the list in preferences; prefs_ui::load restores it and recent_files_survive_a_restart_and_honour_the_count verifies reload | | BUG-204-5 | Recent-files list honours recent_file_count (default 20) | done | [#204](https://github.com/storytold/photocraft/issues/204) | lib.rs push_recent caps the list with recent_cap (recent_file_count); menus.rs covers default and custom limits | -| BUG-204-6 | Test: every Prefs field is read somewhere or on a hidden allowlist | partial | [#204](https://github.com/storytold/photocraft/issues/204) | The scorecard's prefs audit counts unread fields; no engine test or allowlist yet | +| BUG-204-6 | Test: every Prefs field is read somewhere or on a hidden allowlist | done | [#204](https://github.com/storytold/photocraft/issues/204) | crates/engine/tests/prefs_usage.rs every_preference_is_read_or_hidden: each field is read outside prefs.rs or listed in prefs::HIDDEN_UNTIL_IMPLEMENTED, never both | | BUG-205-1 | Free Transform with a selection moves only the selected part of a linked mask | missing | [#205](https://github.com/storytold/photocraft/issues/205) | transform_cmds.rs:127-130 warps the whole linked mask even when the selection split pixels | | BUG-205-2 | Live transform preview draws the layer with its mask applied | missing | [#205](https://github.com/storytold/photocraft/issues/205) | transform_tool.rs:173 preview_image uses the raw surface | | BUG-205-3 | edit.transform honours target mask: transforms only a targeted, unlinked mask | missing | [#205](https://github.com/storytold/photocraft/issues/205) | transform_cmds.rs:214-270 never reads a target param | diff --git a/docs/ui-design.md b/docs/ui-design.md index bf934f3e..dfd48c49 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -88,6 +88,9 @@ Preferences has **Apply**, **OK** and **Cancel**. Apply saves the edited section dialog open; it is disabled when the values match the saved preferences. OK saves and closes. Cancel discards only changes made since the last successful Apply. A failed Apply leaves the draft open for correction. Settings marked for the next launch still require a restart. +On Windows and Linux, System Title Bar displays “Applies at next launch.” beneath its checkbox, +including before editing and after Apply. Saving this preference does not restart the app or +change the current window's decorations; the next launch reads the saved choice. ## High DPI and 4K displays diff --git a/scorecard/bugs.toml b/scorecard/bugs.toml index d69f0f13..0a77c82e 100644 --- a/scorecard/bugs.toml +++ b/scorecard/bugs.toml @@ -1,7 +1,7 @@ # Scorecard checklist: open bugs (#203–#208). # Each item: id, short target, status (done | partial | missing), issue, note (evidence). # Statuses must be true on main. BUG-203 verified against 47f9306 on 2026-10-07; the rest -# against 4b53f62 on 2026-10-05. +# against 4b53f62 on 2026-10-05, except BUG-204 against ec350d6 on 2026-10-09. area = "bugs" title = "Open bugs" order = 9 @@ -51,23 +51,23 @@ note = "format/src/atomic/tests.rs: Faulty AtomicFs injects mid-write StorageFul [[item]] id = "BUG-204-1" target = "Interface prefs (UI scale, font size, channels in colour, sliders, tooltips) wired or hidden" -status = "missing" +status = "done" issue = 204 -note = "prefs.rs:196-202; no reader outside prefs.rs (see the prefs audit)" +note = "prefs_ui.rs tick reads ui_scale (sync_display_scale), ui_font_size (theme::set_ui_font_size) and show_tooltips (sync_tooltips); show_channels_in_color and dynamic_color_sliders are in prefs::HIDDEN_UNTIL_IMPLEMENTED" [[item]] id = "BUG-204-2" target = "workspace.large_tabs, animated_zoom, auto_show_home_screen wired or hidden" -status = "missing" +status = "done" issue = 204 -note = "prefs.rs:163,166,229; no reader outside prefs.rs" +note = "large_tabs and animated_zoom are in prefs::HIDDEN_UNTIL_IMPLEMENTED; auto_show_home_screen drives the Home screen (chrome_ui.rs home_button, canvas.rs)" [[item]] id = "BUG-204-3" target = "RAW prefs and ask_before_saving_layered_tiff read, or hidden" -status = "partial" +status = "done" issue = 204 -note = "ask_before_saving_layered_tiff drives the TIFF Options prompt (ui-egui tiff_options_ui.rs); raw_defaults (prefs.rs:712) still has no readers" +note = "ask_before_saving_layered_tiff drives the TIFF Options prompt (ui-egui tiff_options_ui.rs); raw_defaults.open_in_camera_raw drives the Camera Raw dialog (camera_raw_ui.rs); the other raw_defaults fields are in prefs::HIDDEN_UNTIL_IMPLEMENTED" [[item]] id = "BUG-204-4" @@ -86,9 +86,9 @@ note = "lib.rs push_recent caps the list with recent_cap (recent_file_count); me [[item]] id = "BUG-204-6" target = "Test: every Prefs field is read somewhere or on a hidden allowlist" -status = "partial" +status = "done" issue = 204 -note = "The scorecard's prefs audit counts unread fields; no engine test or allowlist yet" +note = "crates/engine/tests/prefs_usage.rs every_preference_is_read_or_hidden: each field is read outside prefs.rs or listed in prefs::HIDDEN_UNTIL_IMPLEMENTED, never both" [[item]] id = "BUG-205-1" diff --git a/scorecard/ui.toml b/scorecard/ui.toml index 5b7d8425..5fe1c757 100644 --- a/scorecard/ui.toml +++ b/scorecard/ui.toml @@ -10,7 +10,7 @@ id = "UI-217-1" target = "Tool options, last Export As / Save for Web / Fill settings and font recents persist" status = "partial" issue = 217 -note = "Fill (fill_ui.rs:72) and Liquify (liquify_ui.rs:64, #418) keep their settings in Preferences (prefs.dialogs); no tool_options/export fields in Preferences; export_dialog.rs:21 png/85" +note = "Fill (fill_ui.rs:72) and Liquify (liquify_ui.rs:64, #418) keep their settings in Preferences (prefs.dialogs); no tool_options/export fields in Preferences; export_dialog.rs:21 png/85. Each painting tool keeps its own brush (paint_mouse.rs sync_tool_brush, #218), but those live in UiState, so they are lost at quit like the other tool options" [[item]] id = "UI-217-2" diff --git a/xtask/affinity-corpus.sha256 b/xtask/affinity-corpus.sha256 new file mode 100644 index 00000000..933e4dcf --- /dev/null +++ b/xtask/affinity-corpus.sha256 @@ -0,0 +1,21 @@ +cc8ca0db0d8fc545eb29153be900f153fa6b7b7eac068d05e54a502aba1264fe afdesignload/color.afdesign +9a17f500d09fb3b1a70d24aabdc2882955ec3eac3700a474549e6100be3a8484 afdesignload/layer_mode.afdesign +77ae60c02749224fe2c52691c951568138eaed5f8584ebc5da83715fae0234dd afdesignload/layer_test.afdesign +04944918ff8e29c5c1b466b7e43c232f7ab80db15fb934080bbcd7baaf093e22 afdesignload/margins.afdesign +35afa31b89f2b9970eb3701ee64e2a19476383637e8b07689334d6ffe556b593 afdesignload/raster_test.afdesign +e56c35f876277b101790ff4384cfde103a093ed6c81790a2d62176de21da339f afdesignload/revision_test.afdesign +08946c60c0da1c8aeea92d65acfb244721eeb3b480ed358609f4a48bbecfc5b9 afdesignload/shape_test.afdesign +7306ab4fcbdc225d816f32a0d2aca15ac887053f60170459ac947e70f4ef343d afdesignload/slice_test.afdesign +e5d04bac0235af9386aea251cdd6903ed7810d335fdb6194d0483ea30b14e24a afdesignload/test_path.afdesign +e1b121d7c3d02356baa5dea512f5aa1fe77249c28d7b227e29cab2bc67ec323a asset-store-template/AssetStore.aftemplate +fbb196e918e302c16f8de9f2e6dffaef70ab3c317b32a9f44607fd4b2ead0206 jac21/SimpleLogo.afdesign +9d874245936483de3ec04cca48b383028d33666801dff1814246a44e695e29b8 jac21/SimpleLogoBanner.afdesign +87a7bd93de16c404f6c6dd9cdebff96ec47832a3b342d2ce0af73d9a87e209e0 vector-art/beer.afdesign +09ba790c428e8e09dc0b9ddebb4f3db6daefaa2af1da492ee34e73fba1b8af69 vector-art/cactus.afdesign +c3bcd0395b407493bc9d2877d424029b70f0e4c817a56e893f7ac74973df52e5 vector-art/car.afdesign +112562bc91e7cccf003d34364430544208373c4fb4bb4ab9acdaf27cf0125047 vector-art/flowers.afdesign +023531b8216f2d4da24ab715e3b313506712e50d5f5bf7da927f434def1d0ea2 vector-art/lion-track.afdesign +c499cf3ca73cf7cd3e2e65bed048d1b8ecf03e7167b74d54f0ae6db31868cab8 vector-art/mexican-guy.af +730e3fae51d9044ada58f51c7b5f9327d99e8d62376453bc251d0d26cda47743 vector-art/mexican-man.af +5f5fccc0bd8be2bb3b2a07461ec77a012a61eef1883485d7c80f72d4383231ce vector-art/mexican-woman.af +0a52bb923c949c566a4339b996bce1a67b683f61def1a73dfefb910effaa450d vector-art/playing-cards.af diff --git a/xtask/pixls.sha256 b/xtask/pixls.sha256 new file mode 100644 index 00000000..3878b453 --- /dev/null +++ b/xtask/pixls.sha256 @@ -0,0 +1,7 @@ +042286653fbae5b085bef4a4e626145385ea6e82e817f166b4904abcef64457c E_1__C106743_gredos.ORF +32e463ae38ae8d8cd1a356af0fc479f2513366c41801d46d4b167cf899656892 CRW_4061.DNG +6c2ddd232f5432fb7bd0e28efc12d66995cedcd847f801fd854bfdfb180db50c IMG_4059.CR2 +7c24fefb1f73ad45c42a0be0078e15b7e91deab1d623efceebfc3c4d0d06135c P1000475.RW2 +880c60c5f611adf6b70de3d099f8433492de3a3d96866d570122f17f0a651fc8 JD1_8203.NEF +ab0d54e3819c9372df0fa98c8686dba0ae87c9d43526a5dd6fd26bd2a664483d _7061961_copy.ORF +f602f8ddeeb9b062f60bfdd3ba0d560c8e54f1f64dbc20e18df5222e2f37a428 DSC00009.ARW diff --git a/xtask/src/corpus.rs b/xtask/src/corpus.rs index be62e6fa..5f9ec78a 100644 --- a/xtask/src/corpus.rs +++ b/xtask/src/corpus.rs @@ -10,14 +10,26 @@ use crate::{cargo, root, run}; /// Crates with corpus tests (behind their `corpus` feature). pub const CORPUS_CRATES: &[&str] = &["photocraft-psd", "photocraft-codecs", "photocraft-io", "photocraft-engine"]; +/// Opt-in corpus crates: fetched and tested only with `test-corpus --pixls`. +pub const PIXLS_CRATES: &[&str] = &["photocraft-raw"]; /// Corpus crates with a `heif` feature: test-corpus enables it so the HEIF corpus tests run. const HEIF_CRATES: &[&str] = &["photocraft-codecs", "photocraft-io"]; /// Paths whose changes make `test-corpus --changed` run (the file-format and rendering crates). -const CRITICAL: &[&str] = &["crates/psd/", "crates/io/", "crates/codecs/", "crates/heif/", "crates/compose/", "crates/gpu/", "crates/text/", "crates/format/"]; - -/// `cargo xtask corpus [--all | --pngsuite | --download | --psd | --psd-tools | --heif | --exr | --photoshop [--local]] [--update-manifest]` +const CRITICAL: &[&str] = &[ + "crates/psd/", + "crates/io/", + "crates/codecs/", + "crates/heif/", + "crates/compose/", + "crates/gpu/", + "crates/text/", + "crates/format/", + "crates/affinity/", + "crates/raw/", +]; +/// `cargo xtask corpus [--all | --pngsuite | --download | --psd | --psd-tools | --heif | --exr | --pixls | --affinity | --photoshop [--local]] [--update-manifest]` pub fn cmd(args: &[&str]) -> Result<(), String> { let update = args.contains(&"--update-manifest"); let mut did = false; @@ -40,10 +52,18 @@ pub fn cmd(args: &[&str]) -> Result<(), String> { corpus_pins::HEIF.fetch(update)?; did = true; } + if args.contains(&"--pixls") { + crate::pinned::fetch_pixls(update)?; + did = true; + } if args.contains(&"--exr") { corpus_pins::EXR.fetch(update)?; did = true; } + if args.contains(&"--affinity") { + corpus_pins::AFFINITY.fetch(update)?; + did = true; + } if args.contains(&"--photoshop") { if args.contains(&"--local") { corpus_pins::PHOTOSHOP.fetch_local(&local_clone()?, update)?; @@ -119,8 +139,11 @@ fn list() { pillow-heif@{} (BSD-3-Clause), manifest xtask/heif-corpus.sha256. Fetch: --heif corpus/exr/ [{}] the deep OpenEXR test images (BSD-3-Clause), openexr@{} manifest xtask/exr-corpus.sha256. Fetch: --exr + corpus/affinity/ [{}] 21 public Affinity 1–3 documents (CC0, MIT): vector-art, AFDesignLoad, + Jac21/Branding, AssetStoreTemplate; manifest xtask/affinity-corpus.sha256. Fetch: --affinity corpus/pngsuite/ [{}] PngSuite (public domain), {PNGSUITE_URL}. Fetch: --pngsuite - corpus/tiff/, corpus/raw/ optional, copied in by hand + corpus/pixls/ [opt-in] real camera raws from raw.pixls.us (public domain), one per decode + path plus the known-unsupported packed ORF. Fetch: --pixls (opt-in) Pins: xtask/src/corpus_pins.rs. Moving one: change it, then -- --update-manifest.", corpus.display(), @@ -134,6 +157,7 @@ Pins: xtask/src/corpus_pins.rs. Moving one: change it, then -- --update-ma &PILLOW_HEIF_COMMIT[..12], status(corpus_pins::EXR.is_current()), &OPENEXR_COMMIT[..12], + status(corpus_pins::AFFINITY.is_current()), status(png_ok), ); } @@ -180,6 +204,7 @@ pub fn test_cmd(args: &[&str]) -> Result<(), String> { Some(i) => (&args[..i], &args[i + 1..]), None => (args, &[][..]), }; + let mut raw_changed = false; if ours.contains(&"--changed") { let changed = changed_files()?; let hits: Vec<&String> = changed.iter().filter(|f| CRITICAL.iter().any(|c| f.starts_with(c))).collect(); @@ -188,6 +213,7 @@ pub fn test_cmd(args: &[&str]) -> Result<(), String> { return Ok(()); } println!("test-corpus --changed: {} critical files changed (e.g. {}); running the corpus tests", hits.len(), hits[0]); + raw_changed = hits.iter().any(|f| f.starts_with("crates/raw/")); } let mut crates: Vec = Vec::new(); let mut it = ours.iter(); @@ -196,18 +222,27 @@ pub fn test_cmd(args: &[&str]) -> Result<(), String> { "-p" | "--package" => { let name = it.next().ok_or("-p needs a crate name")?; let full = if name.starts_with("photocraft-") { (*name).to_string() } else { format!("photocraft-{name}") }; - if !CORPUS_CRATES.contains(&full.as_str()) { - return Err(format!("{full} has no corpus tests (crates: {})", CORPUS_CRATES.join(", "))); + if !CORPUS_CRATES.contains(&full.as_str()) && !PIXLS_CRATES.contains(&full.as_str()) { + return Err(format!("{full} has no corpus tests (crates: {} or the opt-in {})", CORPUS_CRATES.join(", "), PIXLS_CRATES.join(", "))); } crates.push(full); } - "--changed" | "--local" => {} + "--changed" | "--local" | "--pixls" => {} other => return Err(format!("test-corpus: unknown argument `{other}`")), } } if crates.is_empty() { crates = CORPUS_CRATES.iter().map(|s| (*s).to_string()).collect(); } + // `--pixls` opts in (and fetches), and a raw change under --changed does too: the raw + // corpus tests are not part of the default set until a maintainer decides otherwise. + let wants_pixls = ours.contains(&"--pixls") || crates.iter().any(|c| PIXLS_CRATES.contains(&c.as_str())) || raw_changed; + if wants_pixls && !crates.iter().any(|c| c == "photocraft-raw") { + crates.push("photocraft-raw".to_string()); + } + if wants_pixls { + crate::pinned::fetch_pixls(false)?; + } fetch_all(ours.contains(&"--local"), false)?; let mut c = cargo(); c.args(["test", "--release", "--lib", "--tests"]); diff --git a/xtask/src/corpus_pins.rs b/xtask/src/corpus_pins.rs index 5a09881d..ef7bebe0 100644 --- a/xtask/src/corpus_pins.rs +++ b/xtask/src/corpus_pins.rs @@ -26,6 +26,15 @@ pub const PILLOW_HEIF_COMMIT: &str = "b16be1196dfa465a342d68894696e685ca3655cb"; /// https://github.com/AcademySoftwareFoundation/openexr (BSD-3-Clause): `src/test/bin/test_images` /// at the v3.5.2 tag (2026-10-03). pub const OPENEXR_COMMIT: &str = "69b2604fc76e370615438bdc8d2cd95b9349c12e"; +/// https://github.com/samuel-etver/vector-art (CC0-1.0): `simple/`, four Affinity 3 `.af` files and +/// Designer `.afdesign` files (main, 2026-04-22). +pub const VECTOR_ART_COMMIT: &str = "255f8add3c8f0740196e22bd59502b811b532f0b"; +/// https://github.com/NickBeeuwsaert/AFDesignLoad (MIT): `testDesigns/`, purpose-made Designer files. +pub const AFDESIGNLOAD_COMMIT: &str = "a18dd50a7079fb861a28835eeefa0d015f03f4a1"; +/// https://github.com/Jac21/Branding (MIT): Designer logos with artboards and placed images. +pub const JAC21_BRANDING_COMMIT: &str = "57ae3f45bf4637f6927c0b07de25b922c55f944f"; +/// https://github.com/eviltwo/AssetStoreTemplate (MIT): an Affinity template with artboards. +pub const ASSET_STORE_TEMPLATE_COMMIT: &str = "f671981ee89d15526285b3cc87b4393d3defe644"; /// PngSuite (public domain), a fixed release archive. pub const PNGSUITE_URL: &str = "http://www.schaik.com/pngsuite/PngSuite-2017jul19.tgz"; @@ -163,5 +172,74 @@ The `*.deep.exr` files of https://github.com/AcademySoftwareFoundation/openexr/t }, }; +/// `corpus/affinity`: public Affinity documents saved by Affinity 1.x to 3.x, chosen for having no +/// personal paths in their metadata. Each embeds Affinity's own render of itself (its thumbnail), +/// the oracle `photocraft-io`'s corpus test compares the imported document against. The manifest +/// selects the files. +pub const AFFINITY: PinnedCorpus = PinnedCorpus { + name: "affinity", + dest: "affinity", + upstreams: &[ + Upstream { + prefix: "vector-art/", + repo: "samuel-etver/vector-art", + commit: VECTOR_ART_COMMIT, + subdir: "simple", + extras: &[("LICENSE", "vector-art/LICENSE")], + }, + Upstream { + prefix: "afdesignload/", + repo: "NickBeeuwsaert/AFDesignLoad", + commit: AFDESIGNLOAD_COMMIT, + subdir: "testDesigns", + extras: &[("LICENSE", "afdesignload/LICENSE")], + }, + Upstream { + prefix: "jac21/", + repo: "Jac21/Branding", + commit: JAC21_BRANDING_COMMIT, + subdir: "Logos/JC/DesignerFiles/Affinity", + extras: &[("LICENSE.md", "jac21/LICENSE.md")], + }, + Upstream { + prefix: "asset-store-template/", + repo: "eviltwo/AssetStoreTemplate", + commit: ASSET_STORE_TEMPLATE_COMMIT, + subdir: "AssetStoreTemplate", + extras: &[("LICENSE", "asset-store-template/LICENSE")], + }, + ], + manifest: "xtask/affinity-corpus.sha256", + exts: &["af", "afdesign", "aftemplate"], + subset: true, + sources_md: |c| { + format!( + "# Affinity test corpus\n\nPublic Affinity documents, unmodified, with their licences next to them: \ + https://github.com/samuel-etver/vector-art/tree/{VECTOR_ART_COMMIT}/simple (`vector-art/`, CC0-1.0), \ + https://github.com/NickBeeuwsaert/AFDesignLoad/tree/{AFDESIGNLOAD_COMMIT}/testDesigns (`afdesignload/`, MIT, Copyright (c) 2015 Nick Beeuwsaert), \ + https://github.com/Jac21/Branding/tree/{JAC21_BRANDING_COMMIT}/Logos/JC/DesignerFiles/Affinity (`jac21/`, MIT, Copyright (c) 2018 Jeremy Cantu) and \ + https://github.com/eviltwo/AssetStoreTemplate/tree/{ASSET_STORE_TEMPLATE_COMMIT}/AssetStoreTemplate (`asset-store-template/`, MIT). \ + Selected by and sha256-verified against `{}` by `cargo xtask corpus --affinity`. Gitignored; never commit these files.\n", + c.manifest + ) + }, +}; /// Every pinned corpus, in fetch order. -pub const ALL: &[&PinnedCorpus] = &[&PSD_MIXED, &PSD_TOOLS, &PHOTOSHOP, &HEIF, &EXR]; +pub const ALL: &[&PinnedCorpus] = &[&PSD_MIXED, &PSD_TOOLS, &PHOTOSHOP, &HEIF, &EXR, &AFFINITY]; + +/// `corpus/pixls`: real camera raws from (released into the public +/// domain / CC0 by their photographers; see ATTRIBUTION.md). One file per decode path +/// `photocraft-raw` supports, plus the packed-ORF known-unsupported oracle. Fetched as single +/// files (the server 301-redirects `/data/` to `/download/`), verified against +/// `xtask/pixls.sha256`. +pub const PIXLS_BASE: &str = "https://raw.pixls.us/data/"; +/// (url path, file name in `corpus/pixls`) +pub const PIXLS_FILES: &[(&str, &str)] = &[ + ("Canon/PowerShot%20SX50%20HS/IMG_4059.CR2", "IMG_4059.CR2"), + ("Canon/PowerShot%20SX50%20HS/CRW_4061.DNG", "CRW_4061.DNG"), + ("Nikon/D3/JD1_8203.NEF", "JD1_8203.NEF"), + ("Sony/DSC-RX0/DSC00009.ARW", "DSC00009.ARW"), + ("Panasonic/DC-G9/P1000475.RW2", "P1000475.RW2"), + ("Olympus/E-1/E_1__C106743_gredos.ORF", "E_1__C106743_gredos.ORF"), + ("Olympus/E-5/_7061961_copy.ORF", "_7061961_copy.ORF"), +]; diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 7a97c94f..7d567d4b 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -42,6 +42,7 @@ pub const TABLE: &[(&str, Class)] = &[ // Optional HEIF/HEIC decoder (heic-rs), used only by `codecs` behind its `heif` feature. ("heif", Class::Standalone), ("raw", Class::Standalone), + ("affinity", Class::Standalone), ("adobe-assets", Class::Standalone), // Pen tablet input (the one isolated `unsafe` helper: AppKit interop on macOS). ("tablet", Class::Standalone), diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 634a6aa5..50f3b6b1 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -25,13 +25,14 @@ commands: layers enforce the crate dependency layering (plan/architecture.md §3) wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates ci fmt --check, clippy -D warnings, test, layers, wasm (stops at first failure) - corpus [--all | --pngsuite | --psd | --psd-tools | --photoshop] [--local] [--update-manifest] + corpus [--all | --pngsuite | --psd | --psd-tools | --pixls | --photoshop] [--local] [--update-manifest] show where test corpora live and their pins (xtask/src/corpus_pins.rs), or fetch - them into corpus/ (pinned commits, sha256-verified; --all = every corpus; + them into corpus/ (pinned commits, sha256-verified; --all = every corpus but pixls, --photoshop --local copies from ../photocraft-corpus or $PHOTOCRAFT_CORPUS_REPO) - test-corpus [-p ]... [--changed] [--local] [-- ] + test-corpus [-p ]... [--changed] [--pixls] [--local] [-- ] fetch every corpus, then cargo test --release --features corpus on the corpus - crates; --changed runs only if psd/io/codecs/compose/gpu/text/format changed; + crates; --changed runs only if psd/io/codecs/compose/gpu/text/format/raw changed; + --pixls (or -p raw) also fetches corpus/pixls and runs the raw corpus tests; --local takes corpus/photoshop from the photocraft-corpus authoring clone stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) parity Photoshop menu parity; rewrites docs/parity.md diff --git a/xtask/src/perf.rs b/xtask/src/perf.rs index ac9fa993..d2a3a75b 100644 --- a/xtask/src/perf.rs +++ b/xtask/src/perf.rs @@ -459,14 +459,16 @@ pub fn render_table(outcomes: &[Outcome], with_baseline: bool) -> String { } /// Counts for the scorecard summary: (within budget, over budget or failing, tracked only, not measurable). +/// Not-run scenarios are in none of them; the scorecard counts those separately. pub fn counts(outcomes: &[Outcome]) -> (usize, usize, usize, usize) { let mut c = (0, 0, 0, 0); for o in outcomes { match o.status { Status::Pass => c.0 += 1, Status::Over | Status::Broken | Status::Regressed | Status::Missing(_) => c.1 += 1, + Status::Tracked => c.2 += 1, Status::NotMeasurable(_) => c.3 += 1, - _ => c.2 += 1, + Status::NotRun => {} } } c @@ -1039,6 +1041,7 @@ not_measurable = "needs a frame harness" let out = evaluate(&b, &BTreeMap::new(), None, 15.0, true); assert_eq!(out[0].status, Status::NotRun); assert!(!out[0].status.fails()); + assert_eq!(counts(&out), (0, 0, 0, 1), "not-run scenarios are not tracked; the scorecard counts them separately"); } #[test] diff --git a/xtask/src/pinned.rs b/xtask/src/pinned.rs index 130e19d3..917c0b86 100644 --- a/xtask/src/pinned.rs +++ b/xtask/src/pinned.rs @@ -263,3 +263,52 @@ impl PinnedCorpus { self.install(&found, |f| Some(src.join(&f[up.prefix.len()..])), &extras, true, &format!("copied from the local clone {}", clone.display())) } } + +/// Hex sha256 of a corpus file (reused by every corpus fetch, `xtask/pixls.sha256` included). +fn file_hex(path: &std::path::Path) -> Option { + std::fs::read(path).ok().map(|b| crate::sha256::hex(&b)) +} + +/// Fetches `corpus/pixls` — the raw.pixls.us camera files pinned in `corpus_pins::PIXLS_FILES` — +/// and verifies them against `xtask/pixls.sha256`, the same manifest form every corpus uses. +/// raw.pixls.us serves single files and redirects `/data/` to `/download/`, so this is a plain +/// per-file download rather than a git upstream; the manifest discipline is the same +/// (`--update-manifest` re-pins). Opt-in: not part of `--all` or CI until a maintainer says so. +pub fn fetch_pixls(update: bool) -> Result<(), String> { + let dest = crate::root().join("corpus").join("pixls"); + std::fs::create_dir_all(&dest).map_err(|e| format!("create {}: {e}", dest.display()))?; + let manifest = crate::root().join("xtask").join("pixls.sha256"); + if update { + let mut names: Vec<&str> = crate::corpus_pins::PIXLS_FILES.iter().map(|(_, n)| *n).collect(); + names.sort_unstable(); + let mut out = String::new(); + for name in names { + let sum = file_hex(&dest.join(name)).ok_or_else(|| format!("pixls {name}: not downloaded yet"))?; + out.push_str(&format!("{sum} {name}\n")); + } + std::fs::write(&manifest, out).map_err(|e| format!("write {}: {e}", manifest.display()))?; + println!("pixls: re-hashed {} files into {}", crate::corpus_pins::PIXLS_FILES.len(), manifest.display()); + return Ok(()); + } + let want: std::collections::HashMap = std::fs::read_to_string(&manifest) + .map_err(|e| format!("read {}: {e} (run `cargo xtask corpus --pixls` once)", manifest.display()))? + .lines() + .filter_map(|l| l.split_once(" ").map(|(s, n)| (n.to_string(), s.to_string()))) + .collect(); + for (path, name) in crate::corpus_pins::PIXLS_FILES { + let file = dest.join(name); + if file_hex(&file).as_deref() == want.get(*name).map(String::as_str) { + continue; + } + let url = format!("{}{}", crate::corpus_pins::PIXLS_BASE, path); + let mut curl = Command::new("curl"); + curl.args(["-fsSL", "--retry", "3", "-A", USER_AGENT, "-o"]).arg(&file).arg(&url); + run(curl, &format!("curl {url}"))?; + let got = file_hex(&file).ok_or_else(|| format!("pixls {name}: unreadable"))?; + if Some(got.as_str()) != want.get(*name).map(String::as_str) { + return Err(format!("pixls {name}: sha256 mismatch (pin the file you meant, then --update-manifest)")); + } + } + println!("pixls: {} files verified in {}", crate::corpus_pins::PIXLS_FILES.len(), dest.display()); + Ok(()) +}