Merge #1636 into #1706: keep the pixls opt-in, fetch_all takes update

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
This commit is contained in:
Claude 2026-10-09 13:56:23 +00:00
commit 42e60e3602
No known key found for this signature in database
172 changed files with 8440 additions and 393 deletions

View File

@ -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

View File

@ -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) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg` and `direct-select.svg` (110 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `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 | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-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 | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/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 <https://github.com/lucide-icons/lucide> `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 <https://github.com/lucide-icons/lucide> `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 | <https://thenounproject.com/icon/magnetic-lasso-tool-177513/> | 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 | <http://www.schaik.com/pngsuite/> | 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.

15
Cargo.lock generated
View File

@ -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",

View File

@ -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" }

View File

@ -178,8 +178,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
<table>
<tr>
<td width="33%" valign="top">
<h4>🧰 34 tools</h4>
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
<h4>🧰 52 tools</h4>
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
</td>
<td width="33%" valign="top">
<h4>🖌️ A real brush engine</h4>
@ -199,7 +199,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
</td>
<td width="33%" valign="top">
<h4>🗂️ Formats</h4>
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 <code>--features heif</code> build feature), and the native <code>.pcraft</code> 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 <code>--features heif</code> build feature), and the native <code>.pcraft</code> format. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) 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.
</td>
<td width="33%" valign="top">
<h4>🪄 The everyday essentials</h4>

View File

@ -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()))
}

View File

@ -12,11 +12,12 @@ use wasm_bindgen::JsCast as _;
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// 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";

View File

@ -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]

View File

@ -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::<photocraft_engine::prefs::Performance>(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);

View File

@ -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)

View File

@ -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"),
});
}

View File

@ -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);

View File

@ -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<String> {
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<Box<dyn Future<Output = Option<FileDialogAnswer>> + 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.

View File

@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own clipping-mask cursor (not from an icon set): a hooked arrow down onto the layer below. -->
<path d="M5 4h4.5a5.5 5.5 0 0 1 5.5 5.5V16" />
<path d="m11.5 12.5 3.5 3.5 3.5-3.5" />
<path d="M5 20.5h14" />
</svg>

After

Width:  |  Height:  |  Size: 440 B

View File

@ -0,0 +1,18 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own release-clipping-mask cursor: clip-below.svg unchanged, plus a small "no" badge (thinner strokes keep it legible at cursor size). -->
<path d="M5 4h4.5a5.5 5.5 0 0 1 5.5 5.5V16" />
<path d="m11.5 12.5 3.5 3.5 3.5-3.5" />
<path d="M5 20.5h14" />
<circle cx="20" cy="4" r="3.25" stroke-width="1.5" />
<path d="m17.7 1.7 4.6 4.6" stroke-width="1.5" />
</svg>

After

Width:  |  Height:  |  Size: 591 B

View File

@ -1,14 +0,0 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m10 15 5 5 5-5" />
<path d="M4 4h7a4 4 0 0 1 4 4v12" />
</svg>

Before

Width:  |  Height:  |  Size: 277 B

View File

@ -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

View File

@ -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 |

View File

@ -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.

View File

@ -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).

View File

@ -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"

View File

@ -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 }

149
crates/affinity/README.md Normal file
View File

@ -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.

5
crates/affinity/fuzz/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
target/
corpus/
artifacts/
coverage/
Cargo.lock

View File

@ -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

View File

@ -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);
}
});

View File

@ -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 });
});

View File

@ -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 });
});

View File

@ -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<Tag> {
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<ObjId> {
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<ObjId> {
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<f64> {
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<i64> {
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<bool> {
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<const N: usize>(&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<Item = &Value> {
self.object(id).into_iter().flat_map(|o| o.fields.iter().map(|(_, v)| v))
}
}

View File

@ -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<u16> = 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<String, Entry>,
/// 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<const N: usize>(&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<u8, Error> {
Ok(self.array::<1>()?[0])
}
pub(crate) fn u16(&mut self) -> Result<u16, Error> {
Ok(u16::from_le_bytes(self.array()?))
}
pub(crate) fn u32(&mut self) -> Result<u32, Error> {
Ok(u32::from_le_bytes(self.array()?))
}
pub(crate) fn u64(&mut self) -> Result<u64, Error> {
Ok(u64::from_le_bytes(self.array()?))
}
}
fn offset(v: u64) -> Result<usize, Error> {
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<Header, Error> {
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<Self, Error> {
let header = header(bytes)?;
// Newest record per id: (table date, table index, record).
let mut latest: HashMap<u32, (u64, usize, Option<Entry>)> = HashMap::new();
let mut names: HashMap<u32, String> = 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<Item = &str> {
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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, Error> {
read_bounded(flate2::read::ZlibDecoder::new(data), size)
}
fn unzstd(data: &[u8], size: usize) -> Result<Vec<u8>, 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;
}
}

View File

@ -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<Path> {
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<Point>,
out: Option<Point>,
/// 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<Vec<Anchor>> {
let mut out: Vec<Anchor> = Vec::with_capacity(records.len() / 3 + 1);
let mut pending_in = None;
for (i, v) in records.iter().enumerate() {
let (p, role) = record(v)?;
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<SubPath> {
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<Vec<(usize, f64)>> {
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(),
)
}

View File

@ -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;

View File

@ -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<SubPath>,
}
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<Self> {
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<f64>,
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<TextRun>,
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<Rect>,
/// 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<u8>),
/// The original file a placed image was made from (PNG, JPEG…), as embedded.
Encoded(Vec<u8>),
}
#[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<Paint>,
},
/// 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<Paint>,
strokes: Vec<Stroke>,
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<Path>,
/// Pixel mask: grey levels (white shows the node, black hides it); outside it the node is hidden.
pub pixel_mask: Option<Image>,
/// Bottom to top.
pub children: Vec<Node>,
}
#[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<Rect>,
/// Transparent background; otherwise the page is white.
pub transparent: bool,
/// Bottom to top.
pub nodes: Vec<Node>,
}
#[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<Spread>,
/// Application that last saved the file, as stored (`Affinity 3.0.2`), when present.
pub saved_by: Option<String>,
/// Content that was not imported or was approximated, one line per kind, deduplicated.
pub warnings: Vec<String>,
}
/// 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<Document, Error> {
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<String, usize>,
nodes: usize,
active: HashSet<ObjId>,
}
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<String>) {
*self.warnings.entry(what.into()).or_default() += 1;
}
fn document(mut self) -> Result<Document, Error> {
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<Node>, Option<Image>), 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<Option<Node>, 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<Kind, Error> {
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<Path>, Option<Image>), Error> {
let s = self.s;
let attached = s.objs(id, b"AdCh");
let mut mask: Option<Path> = None;
let mut pixels: Option<Image> = 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<Rect> {
if let Some(md) = s.obj(spread, b"SpMd") {
let v: Vec<Rect> = 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",
}
}

View File

@ -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<Stop>,
/// 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>, 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<Vec<f64>> {
match v {
Value::Floats(f) => Some(f.clone()),
Value::Bytes(b) if b.len() % 4 == 0 => Some(b.as_chunks::<4>().0.iter().map(|c| f64::from(f32::from_le_bytes(*c))).collect()),
_ => None,
}
}
/// A colour object (`RGBA`, `HSLA`, `CMYK`, `GRAY`, `LABA`, `Pant`).
pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option<Color> {
let s = r.s;
let class = s.class(id)?;
let col = || -> Option<Vec<f64>> {
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<f64> { 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<Paint> {
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<Vec<f64>> = 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<ObjId> {
let items = r.s.objs(id, list);
let i = r.s.int(id, index).and_then(|i| usize::try_from(i).ok()).unwrap_or(0);
items.get(i).or_else(|| items.first()).copied()
}
/// The fills of `list` (artboard backgrounds use `BFFl`).
pub(crate) fn fills(r: &mut Reader, id: ObjId, local: Affine, list: &[u8; 4]) -> Vec<crate::paint::Paint> {
let d = current(r, id, list, b"BFCr").or_else(|| r.s.obj(id, b"BFil"));
d.and_then(|d| descriptor(r, d, local)).filter(|p| *p != Paint::None).into_iter().collect()
}
/// A node's fill and stroke. `local` maps the node's space to document pixels.
pub(crate) fn node_paint(r: &mut Reader, id: ObjId, local: Affine) -> (Vec<Paint>, Vec<Stroke>) {
let s = r.s;
let fills = fills(r, id, local, b"BFFl");
if r.s.objs(id, b"BFFl").len() > 1 || r.s.objs(id, b"LIFl").len() > 1 {
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<Stroke> {
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<f64> = a.iter().filter_map(|x| if let Value::Float(f) = x { Some(*f) } else { None }).collect();
while v.len() >= 2 && v.iter().rev().take(2).all(|x| *x == 0.0) {
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 })
}

View File

@ -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<u32, Error> {
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<u32, Error> {
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<Preview<'_>, 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 })
}

View File

@ -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<Option<Image>, 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<Option<Image>, 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<Option<Image>, 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<Option<Image>, 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<u8>, 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<f64> = 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<Vec<u8>, 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<String, Vec<u8>>,
) -> Result<Vec<u8>, 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<u8> = 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);
}
}
}
}

View File

@ -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<f64> {
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<Item = Point>) -> Option<SubPath> {
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<Path> {
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<Point> {
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) }
}

View File

@ -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<i64>),
Floats(Vec<f64>),
/// Fixed-size record (curve nodes, ids) or fixed-size struct (colour components).
Bytes(Vec<u8>),
Blob(Vec<u8>),
/// Name of another archive entry (raster tiles, embedded files).
Entry(String),
Flags(u64),
/// `None` is a null object.
Obj(Option<ObjId>),
Array(Vec<Value>),
}
#[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<Tag>,
/// 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<Object>,
}
/// 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<Object>,
/// Shared ids, defined at first use and referenced backwards.
shared: std::collections::HashMap<u32, ObjId>,
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<Stream, Error> {
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<ObjId, Error> {
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<T>(&mut self, n: usize) -> Result<Vec<T>, 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<usize, Error> {
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<Vec<(Tag, Value)>, 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<String, Error> {
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<Value, Error> {
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<Value, Error> {
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<Value, Error> {
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<Tag>) -> Result<Value, Error> {
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<Value, Error> {
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)))
}
}

View File

@ -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<f64>),
/// Fixed-size struct (colour components), 1..64 bytes.
Struct(Vec<u8>),
/// Array of fixed-size records, all `size` bytes.
Records(u16, Vec<Vec<u8>>),
U8s(Vec<u8>),
Entry(String),
/// Shared object: `Def` defines id with class chain and fields, `Ref` links back.
Def(u32, Vec<Tag>, Vec<(Tag, F)>),
Ref(u32),
Null,
/// Inline object.
Obj(Tag, Vec<(Tag, F)>),
/// Positional (untagged) object.
Pos(Vec<F>),
/// Array of shared objects / links.
Shared(Vec<F>),
/// Array of inline objects of one class.
Objs(Tag, Vec<Vec<(Tag, F)>>),
/// Raw bytes appended verbatim (malformed-input tests).
Raw(Vec<u8>),
}
pub fn tag(s: &[u8; 4]) -> Tag {
Tag::of(s)
}
fn put_tag(out: &mut Vec<u8>, 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<u8>, 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<u8>, 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<u8> {
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<u8> {
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
}

View File

@ -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<u8> {
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<u8> {
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::<u8>(), 0..2048)) {
let _ = header(&b);
let _ = preview(&b);
}
#[test]
fn mutations_never_panic(edits in prop::collection::vec((0usize..200, any::<u8>()), 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<u8> {
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::<u8>()), 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);
}
}
}

147
crates/affinity/src/text.rs Normal file
View File

@ -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<Text> {
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<char> = 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<T: Copy>(v: Option<&Value>, i: usize, f: impl Fn(&Value) -> Option<T>) -> Option<T> {
match v? {
Value::Array(a) => a.get(i).and_then(f),
_ => None,
}
}
fn float(v: &Value) -> Option<f64> {
match v {
Value::Float(f) if f.is_finite() => Some(*f),
_ => None,
}
}
fn int(v: &Value) -> Option<i64> {
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
}
}
}

View File

@ -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<PathBuf>) {
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<PathBuf> {
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::<String, usize>::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::<usize>();
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"));
}

View File

@ -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 {

View File

@ -13,22 +13,23 @@ use crate::AutomationError;
pub struct Opened {
pub document: Document,
pub warnings: Vec<String>,
pub source_read_only: bool,
}
/// Decode a document that was read through a filesystem capability.
pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<Opened, AutomationError> {
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<Opened, AutomationError> {
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();

View File

@ -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,

View File

@ -670,11 +670,9 @@ impl PhotocraftMcp {
async fn save_impl(&self, p: SaveParams) -> Result<CallToolResult, McpError> {
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| {

View File

@ -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<Vec<u8>>) -> (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}]})
}

View File

@ -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];

View File

@ -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`]).

View File

@ -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<R>(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.

View File

@ -16,10 +16,18 @@ use photocraft_raster::Surface;
const FORMAT: PixelFormat = PixelFormat { mode: ColorMode::Grayscale, sample: SampleType::F32, alpha: false };
struct Cache {
map: HashMap<u64, (Surface, u64)>,
map: HashMap<u64, CacheEntry>,
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<Surface>,
}
fn cache() -> &'static Mutex<Cache> {
static C: OnceLock<Mutex<Cache>> = 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<Surface> {
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<Surface> {
}
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);

View File

@ -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<bool> = 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<bool> = 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),
}
}

View File

@ -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]

View File

@ -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 {

View File

@ -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).

View File

@ -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<Option<&'a str>> {
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<Option<bool>> {
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<f32> {
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<Adjustment> {
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<Adjustment> {
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<Adjust
_ => (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<Adjust
loaded = Some((f, label));
}
}
if let Some(text) = p.get("data").and_then(Value::as_str) {
let file_name = p.get("fileName").and_then(Value::as_str).unwrap_or("lut.cube");
let file_name = opt_str(CMD, p, "fileName")?;
if let Some(text) = opt_str(CMD, p, "data")? {
let file_name = file_name.unwrap_or("lut.cube");
let f = photocraft_cms::lutfile::parse(file_name, text.as_bytes()).map_err(|e| bad(CMD, e.0))?;
loaded = Some((f, base_name(file_name)));
}
if let Some(path) = p.get("file").and_then(Value::as_str).filter(|s| !s.is_empty()) {
if let Some(path) = opt_str(CMD, p, "file")?.filter(|s| !s.is_empty()) {
let bytes = read_file(path).ok_or_else(|| bad(CMD, format!("can't read {path}")))?;
let f = photocraft_cms::lutfile::parse(path, &bytes).map_err(|e| bad(CMD, format!("{path}: {}", e.0)))?;
loaded = Some((f, base_name(path)));
@ -109,13 +145,16 @@ pub fn lookup_from_params(p: &Value, base: Option<&Adjustment>) -> Result<Adjust
size = f.size as u32;
lut = Some(std::sync::Arc::new(f.data));
}
if let Some(i) = p.get("interpolation").and_then(Value::as_str) {
tetrahedral = i == "tetrahedral";
match opt_str(CMD, p, "interpolation")? {
None => {}
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<Vec<u8>> {
pub fn update_adjustment(existing: &Adjustment, p: &Value) -> Option<Result<Adjustment>> {
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,
}

View File

@ -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<Value>, 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");

View File

@ -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])),
] {

View File

@ -74,11 +74,17 @@ pub fn parse_points(p: &Value, cmd: &str) -> Result<Vec<StrokePoint>> {
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<photocraft_doc::Document>,
/// 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<Rect> {
check_coords(pts, &self.cmd)?;
self.renderer.push(pts);
if let Some((axis, mirror)) = &mut self.mirror {
let reflected = axis.reflect_points(pts);

View File

@ -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);

View File

@ -1165,6 +1165,7 @@ fn build() -> Vec<CommandSpec> {
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

View File

@ -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");
}
}

View File

@ -268,6 +268,8 @@ fn layer_via(s: &mut Session, cut: bool) -> Result<Value> {
*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()

View File

@ -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<Value> {
/// 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<Value> {
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<Value> {
/// 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<Value> {
map_pixels(s, "Equalize", |_, px, cover| {
fn equalize(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
}
/// Filter › Blur › Average: the selection (or layer) filled with its mean colour.
fn average(s: &mut Session) -> Result<Value> {
map_pixels(s, "Average", |_, px, cover| {
fn average(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<CommandSpec> {
})?;
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<CommandSpec> {
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()

View File

@ -117,7 +117,7 @@ pub(crate) fn list_images(dir: &str) -> Result<Vec<String>> {
/// 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<Document> {
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}))
}

View File

@ -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 {

View File

@ -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<String>) -> 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<Value> {
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<CommandSpec> {
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<bool>) {
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");
}
}

View File

@ -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}))
},
)

View File

@ -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<LayerId>,
pub path: Option<String>,
/// 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,

View File

@ -165,7 +165,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
/// File › Import › Notes… from file bytes (what a file picker returns).
pub fn import_notes_from(s: &mut Session, name: &str, bytes: &[u8]) -> Result<Value> {
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")));
}

View File

@ -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<Value> {
let names: Vec<Value> = 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();

View File

@ -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<Value> {
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<String, Value> = 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<Value> {
}
}
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.

View File

@ -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");
}

View File

@ -81,10 +81,7 @@ fn parse_brush(s: &Session, p: &Value, cmd: &str) -> Result<(Stroke, Option<Laye
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.
if pts.iter().any(|q| !(q.x.abs() <= crate::brush_cmds::MAX_COORD && q.y.abs() <= crate::brush_cmds::MAX_COORD)) {
return Err(bad(cmd, format!("point coordinates must be finite and within ±{}", crate::brush_cmds::MAX_COORD)));
}
crate::brush_cmds::check_coords(&pts, cmd)?;
let base = s.tools.brush.clone();
let pct = |k: &str, d: f32, lo: f32, hi: f32| num(p, k, d).clamp(lo, hi) / 100.0;
let brush = BrushSettings {
@ -318,6 +315,7 @@ enum LivePaint {
pub struct LiveRetouch {
/// The active document with the stroke so far.
pub doc: std::sync::Arc<Document>,
cmd: String,
renderer: photocraft_paint::StrokeRenderer,
pre: std::sync::Arc<Document>,
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<Rect> {
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<Value> {
pub struct LiveDab {
/// The active document with the stroke so far.
pub doc: std::sync::Arc<Document>,
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<Rect> {
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();

View File

@ -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}");
}
}

View File

@ -64,7 +64,7 @@ pub fn encode_source(doc: &Document) -> Result<Vec<u8>> {
/// Decodes smart-object contents (a `.pcraft` bundle, PSD/PSB, or any flat image format).
pub fn decode_source(file_name: &str, bytes: &[u8]) -> Result<Document> {
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 {

View File

@ -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();

View File

@ -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);

View File

@ -207,7 +207,7 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result<Vec<Sur
for f in &files {
let bytes = std::fs::read(f).map_err(|e| EngineError::Other(format!("read `{}`: {e}", f.display())))?;
let name = f.to_string_lossy();
let doc = photocraft_io::import(&name, &bytes).map_err(|e| EngineError::Other(format!("`{}`: {e}", f.display())))?.document;
let doc = crate::file_cmds::import(&name, &bytes)?;
frames.push(crate::file_cmds::flattened(&doc, fmt));
}
Ok(frames)

View File

@ -0,0 +1,148 @@
//! An Affinity document is never a save target for its source; a native one can be placed, a
//! preview fallback cannot.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::sync::Arc;
use photocraft_engine::Session;
use photocraft_engine::file_cmds::open_bytes_as;
use photocraft_engine::jobs::{OpenSource, Started};
use serde_json::json;
fn fixture() -> Vec<u8> {
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<u8> {
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);
}

View File

@ -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));
}
}

View File

@ -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
}

View File

@ -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));

View File

@ -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() {

View File

@ -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 };

View File

@ -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 }

View File

@ -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::<i16, 5, _>(|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.

620
crates/io/src/affinity.rs Normal file
View File

@ -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<ImportResult, IoError> {
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<String> = 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<ImportResult, IoError> {
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<String>,
/// 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<Self, IoError> {
// 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<Layer> = 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<af::Rect> = 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>) -> 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<Layer> {
let children: Vec<Layer> = 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<Knot> = 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<Fill> {
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<u8>)> {
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<LayerContent> {
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<Surface> {
let (w, h, rgba) = self.pixels(img)?;
let grey: Vec<u8> = 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<Surface> {
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::<f64>().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))
}
}
}

View File

@ -229,8 +229,14 @@ pub fn notes_from_blocks(doc: &Document) -> Vec<Note> {
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<photocraft_doc::PsdGlobalBlock>) -> Vec<photocraft_doc::PsdGlobalBlock> {
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<photocraft_doc::PsdGlobalBl
Note { color: y.color, ..x.clone() } == *y && cx.iter().zip(cy).all(|(p, q)| (p - q).abs() < 1.0 / 512.0)
})
};
if parsed.as_deref().zip(want.as_deref()).is_some_and(|(p, w)| same(p, w)) || (raw.is_none() && doc.notes.is_empty()) {
// With no notes, a block we can't read is kept as is: it isn't "the last note deleted", and
// dropping it would delete the annotations it holds (issue #944).
if parsed.as_deref().zip(want.as_deref()).is_some_and(|(p, w)| same(p, w)) || (entries.is_none() && doc.notes.is_empty()) {
return blocks;
}
let extra: Vec<Vec<u8>> = 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<photocraft_doc::PsdGlobalBlock> = 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() };

View File

@ -103,7 +103,7 @@ pub(crate) fn image_to_document(name: &str, img: &Image) -> Result<ImportResult,
if !img.meta.text.is_empty() {
warnings.push(format!("{} text metadata entries are not kept in the document", img.meta.text.len()));
}
Ok(ImportResult { document: doc, warnings })
Ok(ImportResult { document: doc, warnings, source_read_only: false, preview_only: false })
}
/// `Some(surface)` when the document is exactly one visible, unmasked,

View File

@ -15,6 +15,9 @@
//! layer; unsupported raw variants fall back to the embedded JPEG preview.
//! * Layered TIFFs (Photoshop layer data in tags 37724 and 34377) open with their
//! layers through the PSD path and are written back the same way; see `tiff_layers`.
//! * Affinity documents (`.af`, `.afdesign`, `.afphoto`, `.afpub`) open natively
//! with no source save path, what isn't imported listed in the warnings; a file
//! whose native data can't be read opens as its embedded preview; see `affinity`.
//! * Every other format goes through `photocraft-codecs` as a single
//! "Background" layer (depth and Gray/RGB/CMYK model preserved).
//!
@ -25,6 +28,7 @@
pub mod abr_map;
mod adjust_map;
pub mod affinity;
pub mod annotations_map;
pub mod blocks;
mod channel_map;
@ -92,6 +96,11 @@ pub struct ImportResult {
pub document: Document,
/// Human-readable notes about anything approximated or dropped.
pub warnings: Vec<String>,
/// 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<ImportResult, IoError> {
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<ImportResult, IoError> {
// 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<ExportResult, IoError> {
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)),

View File

@ -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;
}

View File

@ -121,7 +121,7 @@ pub fn import_svg(name: &str, bytes: &[u8]) -> Result<ImportResult, IoError> {
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

View File

@ -114,7 +114,7 @@ pub(crate) fn import_layered(name: &str, img: &Image, layers: &[u8]) -> Result<I
if !img.meta.text.is_empty() {
warnings.push(format!("{} text metadata entries are not kept in the document", img.meta.text.len()));
}
Ok(ImportResult { document: doc, warnings })
Ok(ImportResult { document: doc, warnings, source_read_only: false, preview_only: false })
}
/// Interleaved native-endian samples → planar big-endian planes (PSD merged-image order),

332
crates/io/tests/affinity.rs Normal file
View File

@ -0,0 +1,332 @@
//! Affinity documents: native import (layers, artboards, shapes, paints, text, masks) and the
//! embedded preview only as a warned fallback that keeps its pixels and alpha; never written back.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType};
use photocraft_io::{ExportOptions, export, import};
use proptest::prelude::*;
use std::io::Write as _;
fn png() -> Vec<u8> {
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<u8> {
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<u8> {
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::<u8>()), 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<u8> {
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<u8> {
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::<u8>()), 1..16), stored in any::<bool>()) {
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);
}
}
}

View File

@ -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<u8> {
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<f64> {
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<f64> {
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<u8> = 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::<f64>() / 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"));
}

View File

@ -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")));
}

View File

@ -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
};

View File

@ -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<AtomicBool>,
abort: Arc<Mutex<Option<Error>>>,
}
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(())
}

View File

@ -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)");

Some files were not shown because too many files have changed in this diff Show More