- PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and AVIF, with symmetric read and write at 8, 16 and 32 bits, HEIC photos from iPhone and Mac (read; in official builds, an optional --features heif build feature), and the native .pcraft format.
+ PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and AVIF, with symmetric read and write at 8, 16 and 32 bits, HEIC photos from iPhone and Mac (read; in official builds, an optional --features heif build feature), and the native .pcraft format. Some current Affinity .af files open as their embedded PNG preview, with a warning: native layers and full-resolution artwork are not imported, previewless and legacy files are unsupported, and Affinity export is unavailable.
🪄 The everyday essentials
diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs
index 65e1a98b..9fb616a4 100644
--- a/apps/photocraft-cli/src/lib.rs
+++ b/apps/photocraft-cli/src/lib.rs
@@ -288,7 +288,7 @@ 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") || photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read);
p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase()))
}
diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs
index 95e31faa..a7190234 100644
--- a/apps/photocraft-web/src/web.rs
+++ b/apps/photocraft-web/src/web.rs
@@ -11,11 +11,11 @@ use wasm_bindgen::JsCast as _;
type Inbox = Arc)>>>;
-/// Everything File › Open reads: PhotoCraft and Photoshop documents, flat images, and Photoshop
+/// Everything File › Open reads: PhotoCraft, Photoshop and Affinity preview documents, flat images, and Photoshop
/// brushes (.abr) and gradients (.grd), 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",
+ "pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "af",
];
const CANVAS_ID: &str = "photocraft_canvas";
diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs
index e7faf24f..95626922 100644
--- a/apps/photocraft/src/services.rs
+++ b/apps/photocraft/src/services.rs
@@ -11,11 +11,11 @@ use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::Arc;
-/// Everything File › Open reads: PhotoCraft and Photoshop documents, flat images, and Photoshop
+/// Everything File › Open reads: PhotoCraft, Photoshop and Affinity preview documents, flat images, and Photoshop
/// brushes (.abr) and gradients (.grd), 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",
+ "pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "af",
];
/// File › Save As formats: (filter name, extensions). The filter matching the suggested name's
diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md
index d3ef25c8..e819bfbd 100644
--- a/book/src/SUMMARY.md
+++ b/book/src/SUMMARY.md
@@ -21,6 +21,7 @@
- [Overview](formats/overview.md)
- [PSD and PSB](formats/psd-psb.md)
- [Raster formats](formats/raster-formats.md)
+- [Affinity previews](formats/affinity.md)
- [The `.pcraft` format](formats/pcraft.md)
# Automation
diff --git a/book/src/architecture/crates.md b/book/src/architecture/crates.md
index c423cf2e..34df8b60 100644
--- a/book/src/architecture/crates.md
+++ b/book/src/architecture/crates.md
@@ -5,7 +5,7 @@ The layer numbers below describe allowed dependency direction, not runtime privi
| Layer | Crates | Responsibility |
|---|---|---|
| L0 | `geom`, `cms`, `color`, `raster` | Geometry, ICC processing, color/blend math, pixel formats, and copy-on-write tiles |
-| L0 standalone | `psd`, `codecs` | PSD/PSB and flat raster format parsing/encoding |
+| L0 standalone | `psd`, `codecs`, `affinity` | PSD/PSB and flat raster format parsing/encoding; bounded Affinity preview extraction |
| L1 | `doc` | Pure-data documents, layers, masks, effects, adjustments, text, vectors, and smart objects |
| L2 | `ops`, `paint`, `algo`, `text`, `vector` | History operations, painting, imaging algorithms, type, paths, and shapes |
| L3 | `compose`, `gpu`, `format` | CPU composition, wgpu composition, and `.pcraft` persistence |
diff --git a/book/src/formats/affinity.md b/book/src/formats/affinity.md
new file mode 100644
index 00000000..70e32c0b
--- /dev/null
+++ b/book/src/formats/affinity.md
@@ -0,0 +1,28 @@
+# Affinity previews
+
+PhotoCraft can open the **embedded PNG preview** in some current Affinity `.af` files. The
+document contains one editable raster layer named “Affinity preview”, at the preview's actual
+dimensions. The preview may be smaller than the source document. Native layers, text, vectors,
+masks, effects, pages and document colour settings are not imported; the warning on open
+explains this limitation.
+
+This is preview import, not editable Affinity document support. Files without an indexed PNG
+preview fail with an actionable error. Legacy `.afphoto`, `.afdesign` and `.afpub` containers
+are recognized, but their thumbnail layouts are unverified and rejected. Export PSD or PNG
+from Affinity for full-resolution artwork.
+
+An opened preview starts without a save path, including when the source was renamed to another
+extension. Save asks for a new copy. Writing `.af`, `.afphoto`, `.afdesign` or `.afpub` is
+unsupported. Saving `.pcraft`, PSD or PNG saves the imported preview, not the native content.
+Place, smart-object content decoding and auxiliary imports that cannot display the warning
+reject the preview; export PSD or PNG in Affinity before using those paths.
+
+The standalone `photocraft-affinity` crate inspects the container and reads only the declared
+version-12 thumbnail offset. It never scans for PNGs or decompresses the object graph. The PNG
+decoder validates the compressed preview with limits of 4096 pixels per dimension and 128 MiB
+of decoded allocation; encoded PNGs are limited to 16 MiB.
+Every PNG chunk CRC is checked. Compressed PNG metadata and animated PNG previews are rejected
+before decoding; these have not been observed in the native oracle files.
+
+See [the crate's format notes](https://github.com/storytold/photocraft/blob/main/crates/affinity/README.md)
+for observed layouts, provenance, tests and the remaining native-import work.
diff --git a/book/src/formats/overview.md b/book/src/formats/overview.md
index 7c36352a..41937cef 100644
--- a/book/src/formats/overview.md
+++ b/book/src/formats/overview.md
@@ -1,13 +1,14 @@
# Format overview
-External bytes enter PhotoCraft through three main paths:
+External bytes enter PhotoCraft through four main paths:
- `photocraft-psd` parses and writes PSD and PSB structures.
- `photocraft-codecs` decodes and encodes flat raster formats.
- `photocraft-format` loads and saves the native `.pcraft` bundle.
+- `photocraft-affinity` inspects Affinity containers and extracts an indexed PNG preview in some current `.af` files; it does not decode native layers.
`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), 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), [Affinity previews](affinity.md), and [The `.pcraft` format](pcraft.md). Cross-format hardening guidance is in [Parser hardening](../security/parser-hardening.md).
diff --git a/crates/affinity/Cargo.toml b/crates/affinity/Cargo.toml
new file mode 100644
index 00000000..7a4df2cb
--- /dev/null
+++ b/crates/affinity/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "photocraft-affinity"
+description = "Bounded Affinity container inspection and embedded PNG preview extraction"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+rust-version.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+crc32fast = "1.5"
+
+[dev-dependencies]
+proptest = { workspace = true }
diff --git a/crates/affinity/README.md b/crates/affinity/README.md
new file mode 100644
index 00000000..e53b001e
--- /dev/null
+++ b/crates/affinity/README.md
@@ -0,0 +1,86 @@
+# photocraft-affinity
+
+Standalone Rust inspection of Affinity containers and extraction of an indexed
+PNG preview. It does **not** decode the native document graph or write Affinity files. The
+`photocraft-io` adapter opens the preview as one raster layer and reports the fidelity limitation.
+
+`inspect(bytes)` recognizes document-class containers with versions 7–12. `preview(bytes)`
+accepts only the version-12 thumbnail record observed in newly generated Affinity 3.3.0.4850
+documents. Versions 7–11, files without an indexed thumbnail, other encodings, and unknown
+record layouts return an actionable error. Not every current `.af` file contains a preview.
+Recognizing a filename extension or the container magic does not establish editable support.
+
+The preview must be decoded by a bounded PNG decoder: this crate checks the PNG signature,
+IHDR dimensions, every chunk CRC, chunk boundaries and exact IEND termination; it does not validate
+compressed pixels or the native colour settings. Limits are 16 MiB of encoded PNG and 4096
+pixels in either dimension. The I/O adapter additionally limits decoded allocation to 128 MiB.
+Offsets and lengths are checked before indexing. No compressed object graph is decompressed.
+The only runtime dependency is the already-used, permissively licensed `crc32fast` checksum crate.
+Compressed PNG text/profile chunks (`zTXt`, `iTXt`, `iCCP`) and APNG records are unsupported
+and rejected before decoding. The observed native previews contain only IHDR, pHYs, IDAT and
+IEND; relaxing this policy needs a total metadata allocation bound and native-file oracles.
+IHDR must be first and unique, IDAT chunks must be present and contiguous, and an optional
+PLTE must be unique and precede IDAT. Unknown critical chunks are rejected. This protects
+against trailing format errors that the image decoder may otherwise treat as partial output.
+
+## Observed record layout
+
+All container integers below are little-endian. The header's offset 24 points to the record;
+signature scanning is deliberately unsupported because a resource or obsolete revision may
+contain another PNG.
+
+| 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 |
+
+This is a narrow, observed layout rather than a vendor specification. Expanding it needs
+independent, synthetic native-file oracles and deliberate version dispatch.
+
+## Provenance and verification
+
+The public MIT-licensed [afread reader at `04b6723`](https://github.com/VMDevCpp/afread/tree/04b672334a43e3e37ded6b5ffc57af231d589774) (Vladimir Mamonov, 2020–2021) was consulted for
+the legacy header layout, `Prsn` document class and versions 7–11. This crate's Rust code was
+written independently; it does not incorporate C++, GPL/AGPL readers or proprietary source.
+The version-12 thumbnail layout was observed through files authored from scratch with the
+public Affinity scripting SDK. No proprietary application bundle or private user files were read.
+
+Unit tests use generated record envelopes, every truncation, bad offsets, dimensions and
+mutations. Integration tests exercise the bounded PNG decoder, alpha, copy export and save
+protection, including 8-bit RGBA and 16-bit Gray/alpha previews. Warningless auxiliary imports
+(Place, smart-object decoding, stack/batch, displacement maps, variables, notes and video)
+reject an Affinity derivative preview so it cannot be silently treated as native content.
+A local ignored `native_affinity_oracle` in `crates/io/tests/affinity.rs` compares
+generated Affinity files against their PNG exports. Binary oracles belong in the shared
+[photocraft-corpus](https://github.com/storytold/photocraft-corpus), with committed generators,
+scrubbed personal metadata and a pinned hash manifest; they are never committed here.
+
+```sh
+cargo test -p photocraft-affinity
+cargo test -p photocraft-io --test affinity
+AFFINITY_ORACLE_DIR=/path/to/generated/files cargo test -p photocraft-io --test affinity native_affinity_oracle -- --ignored
+cd crates/affinity/fuzz
+cargo +nightly fuzz run preview -- -max_total_time=60 -max_len=20000 -timeout=5
+cargo +nightly fuzz run import_preview --features io -- -max_total_time=60 -max_len=20000 -timeout=5
+```
+
+Native editable import remains a separate project:
+
+- Validate the allocation table and revision chain and choose the current `doc.dat`, including
+ incremental saves, deletions, object checksums and resource bounds.
+- Decode zstd/deflate and predictors with independent output, ratio and work limits.
+- Build a typed tagged intermediate model, resolving object references with cycle, count and
+ depth limits before mapping data into the document model.
+- Preserve RGB/Gray/CMYK/Lab, 8/16/32f depth and ICC profiles; map raster tiles, layer order,
+ groups, transforms, masks and blends; issue explicit warnings for unsupported effects,
+ text, vectors, pages and other records rather than silently flattening or dropping them.
+- Verify native-file oracles across application/platform versions and malformed inputs.
+ Native writing and round trips need a later design for carrying unknown records safely.
+
+Preview import must never be presented as completing those tasks or preserving the original artwork.
diff --git a/crates/affinity/fuzz/.gitignore b/crates/affinity/fuzz/.gitignore
new file mode 100644
index 00000000..ab0eaa1a
--- /dev/null
+++ b/crates/affinity/fuzz/.gitignore
@@ -0,0 +1,5 @@
+target/
+corpus/
+artifacts/
+coverage/
+Cargo.lock
diff --git a/crates/affinity/fuzz/Cargo.toml b/crates/affinity/fuzz/Cargo.toml
new file mode 100644
index 00000000..288d89e1
--- /dev/null
+++ b/crates/affinity/fuzz/Cargo.toml
@@ -0,0 +1,34 @@
+[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 = ".." }
+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
diff --git a/crates/affinity/fuzz/fuzz_targets/import_preview.rs b/crates/affinity/fuzz/fuzz_targets/import_preview.rs
new file mode 100644
index 00000000..13e4bd79
--- /dev/null
+++ b/crates/affinity/fuzz/fuzz_targets/import_preview.rs
@@ -0,0 +1,10 @@
+#![no_main]
+
+use libfuzzer_sys::fuzz_target;
+
+fuzz_target!(|data: &[u8]| {
+ // Exercise the bounded preview PNG decoder and document mapping, not unrelated formats.
+ if photocraft_affinity::is_affinity(data) {
+ let _ = photocraft_io::import("fuzz.af", data);
+ }
+});
diff --git a/crates/affinity/fuzz/fuzz_targets/preview.rs b/crates/affinity/fuzz/fuzz_targets/preview.rs
new file mode 100644
index 00000000..60ab2dfd
--- /dev/null
+++ b/crates/affinity/fuzz/fuzz_targets/preview.rs
@@ -0,0 +1,8 @@
+#![no_main]
+
+use libfuzzer_sys::fuzz_target;
+
+fuzz_target!(|data: &[u8]| {
+ let _ = photocraft_affinity::inspect(data);
+ let _ = photocraft_affinity::preview(data);
+});
diff --git a/crates/affinity/src/lib.rs b/crates/affinity/src/lib.rs
new file mode 100644
index 00000000..95f6e87e
--- /dev/null
+++ b/crates/affinity/src/lib.rs
@@ -0,0 +1,394 @@
+//! Affinity container inspection, without interpreting the compressed document graph.
+//!
+//! The header layout was researched with MIT-licensed afread (see README). The version-12
+//! thumbnail record was observed in synthetic documents saved by Affinity 3.3. Do not scan for
+//! PNG signatures: an unrelated resource or an obsolete revision is not a document preview.
+#![forbid(unsafe_code)]
+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+
+use std::fmt;
+
+pub const MAGIC: &[u8; 4] = b"\x00\xffKA";
+pub const MAX_PREVIEW_BYTES: usize = 16 << 20;
+pub const MAX_PREVIEW_DIMENSION: u32 = 4096;
+
+/// Container failure. The compressed graph is deliberately never decompressed here.
+#[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 {
+ let (kind, reason) = match self {
+ Self::Malformed(s) => ("damaged Affinity file", s),
+ Self::Unsupported(s) => ("unsupported Affinity file", s),
+ Self::Limit(s) => ("Affinity preview limit exceeded", s),
+ };
+ write!(f, "{kind}: {reason}; export PNG, PSD or SVG from Affinity for full-resolution artwork")
+ }
+}
+
+impl std::error::Error for Error {}
+
+/// Header identity; offsets remain private so callers cannot bypass validation.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Header {
+ pub version: u16,
+ thumbnail_offset: u64,
+}
+
+/// 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,
+}
+
+pub fn is_affinity(bytes: &[u8]) -> bool {
+ bytes.starts_with(MAGIC)
+}
+
+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 le16(bytes: &[u8], offset: usize) -> Result {
+ let b = bytes_at(bytes, offset, 2)?;
+ Ok(u16::from_le_bytes(b.try_into().map_err(|_| Error::Malformed("integer"))?))
+}
+
+fn le32(bytes: &[u8], offset: usize) -> Result {
+ let b = bytes_at(bytes, offset, 4)?;
+ Ok(u32::from_le_bytes(b.try_into().map_err(|_| Error::Malformed("integer"))?))
+}
+
+fn le64(bytes: &[u8], offset: usize) -> Result {
+ let b = bytes_at(bytes, offset, 8)?;
+ Ok(u64::from_le_bytes(b.try_into().map_err(|_| Error::Malformed("integer"))?))
+}
+
+fn be32(bytes: &[u8], offset: usize) -> Result {
+ let b = bytes_at(bytes, offset, 4)?;
+ Ok(u32::from_be_bytes(b.try_into().map_err(|_| Error::Malformed("PNG integer"))?))
+}
+
+/// Recognize documents, rejecting add-ons with the same magic and unknown container versions.
+pub fn inspect(bytes: &[u8]) -> Result {
+ if !is_affinity(bytes) {
+ return Err(Error::Malformed("missing container signature"));
+ }
+ let version = le16(bytes, 4)?;
+ if !(7..=12).contains(&version) {
+ return Err(Error::Unsupported("unverified container version"));
+ }
+ if le16(bytes, 6)? != 0 {
+ return Err(Error::Unsupported("container flags"));
+ }
+ // afread's Prsn document class, stored little-endian. Brushes, macros and palettes
+ // share the magic but must not be opened as documents.
+ if bytes_at(bytes, 8, 4)? != b"nsrP" {
+ return Err(Error::Unsupported("this container is not an Affinity document"));
+ }
+ if bytes_at(bytes, 12, 4)? != b"#Inf" {
+ return Err(Error::Malformed("missing information header"));
+ }
+ bytes_at(bytes, 0, if version == 7 { 64 } else { 72 })?;
+ if version > 7 && bytes_at(bytes, 64, 4)? != b"Prot" {
+ return Err(Error::Malformed("missing protocol header"));
+ }
+ Ok(Header { version, thumbnail_offset: le64(bytes, 24)? })
+}
+
+/// Read only the indexed version-12 `Thmb` record and validate every PNG chunk CRC.
+/// Compressed pixels must still be validated by the consuming image decoder, with allocation limits.
+pub fn preview(bytes: &[u8]) -> Result, Error> {
+ let h = inspect(bytes)?;
+ if h.version != 12 {
+ return Err(Error::Unsupported("legacy preview records have not been verified"));
+ }
+ if h.thumbnail_offset == 0 {
+ return Err(Error::Unsupported("no embedded preview; native layer decoding is not implemented"));
+ }
+ 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 seen_ihdr = false;
+ let mut seen_plte = false;
+ let mut seen_idat = false;
+ let mut idat_ended = 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 crc_offset = chunk_size.checked_sub(4).ok_or(Error::Malformed("PNG CRC offset"))?;
+ let protected = chunk.get(4..crc_offset).ok_or(Error::Malformed("PNG CRC span"))?;
+ if crc32fast::hash(protected) != be32(chunk, crc_offset)? {
+ return Err(Error::Malformed("PNG chunk CRC mismatch"));
+ }
+ let kind = bytes_at(chunk, 4, 4)?;
+ if !kind.iter().all(u8::is_ascii_alphabetic) {
+ return Err(Error::Malformed("invalid PNG chunk name"));
+ }
+ // Native oracles have no compressed metadata or animation. Reject before any decoder
+ // can inflate many individually capped text/profile chunks or silently pick a frame.
+ if matches!(kind, b"zTXt" | b"iTXt" | b"iCCP") {
+ return Err(Error::Unsupported("compressed PNG metadata in preview"));
+ }
+ if matches!(kind, b"acTL" | b"fcTL" | b"fdAT") {
+ return Err(Error::Unsupported("animated PNG preview"));
+ }
+ match kind {
+ b"IHDR" => {
+ if seen_ihdr || pos != 8 {
+ return Err(Error::Malformed("PNG IHDR must be first and unique"));
+ }
+ seen_ihdr = true;
+ }
+ b"PLTE" => {
+ if seen_plte || seen_idat {
+ return Err(Error::Malformed("PNG PLTE must be unique and precede IDAT"));
+ }
+ seen_plte = true;
+ }
+ b"IDAT" => {
+ if idat_ended {
+ return Err(Error::Malformed("PNG IDAT chunks must be contiguous"));
+ }
+ seen_idat = true;
+ }
+ b"IEND" => {
+ if !seen_idat {
+ return Err(Error::Malformed("PNG has no image data"));
+ }
+ }
+ _ => {
+ if kind.first().is_some_and(|b| b.is_ascii_uppercase()) {
+ return Err(Error::Unsupported("unknown critical PNG chunk"));
+ }
+ }
+ }
+ if seen_idat && kind != b"IDAT" {
+ idat_ended = true;
+ }
+ 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 })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use proptest::prelude::*;
+
+ fn fixture() -> Vec {
+ let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
+ png.extend(13u32.to_be_bytes());
+ png.extend(b"IHDR");
+ png.extend(2u32.to_be_bytes());
+ png.extend(1u32.to_be_bytes());
+ png.extend([8, 6, 0, 0, 0]);
+ let ihdr_crc = crc32fast::hash(&png[12..]);
+ png.extend(ihdr_crc.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());
+ // Valid CRCs, but no compressed pixels: consuming decoders still validate those.
+ file(&png)
+ }
+
+ fn file(png: &[u8]) -> Vec {
+ 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
+ }
+
+ fn chunk(kind: &[u8; 4], data: &[u8]) -> Vec {
+ let mut tagged = kind.to_vec();
+ tagged.extend(data);
+ let mut result = (data.len() as u32).to_be_bytes().to_vec();
+ result.extend(&tagged);
+ result.extend(crc32fast::hash(&tagged).to_be_bytes());
+ result
+ }
+
+ #[test]
+ fn indexed_preview_and_header() {
+ let b = fixture();
+ assert_eq!(inspect(&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}");
+ }
+ for version in 7..12 {
+ let mut legacy = b.clone();
+ legacy[4..6].copy_from_slice(&(version as u16).to_le_bytes());
+ assert!(inspect(&legacy).is_ok());
+ assert!(matches!(preview(&legacy), 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_crc_including_iend_and_ancillary_is_checked() {
+ let mut bad_iend = fixture();
+ *bad_iend.last_mut().unwrap() ^= 1;
+ assert_eq!(preview(&bad_iend).unwrap_err(), Error::Malformed("PNG chunk CRC mismatch"));
+
+ let b = fixture();
+ let mut png = preview(&b).unwrap().png.to_vec();
+ 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 = png.len() - 12;
+ png.splice(end..end, chunk.clone());
+ assert!(preview(&file(&png)).is_ok());
+ png[end + chunk.len() - 1] ^= 1;
+ assert_eq!(preview(&file(&png)).unwrap_err(), Error::Malformed("PNG chunk CRC mismatch"));
+ }
+
+ #[test]
+ fn compressed_metadata_and_animation_are_rejected_before_pixel_decoding() {
+ let b = fixture();
+ for kind in [b"zTXt", b"iTXt", b"iCCP", b"acTL", b"fcTL", b"fdAT"] {
+ let mut png = preview(&b).unwrap().png.to_vec();
+ // The chunk claims compressed metadata without making the parser inflate it.
+ 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 = png.len() - 12;
+ png.splice(end..end, chunk);
+ assert!(matches!(preview(&file(&png)), Err(Error::Unsupported(_))), "{kind:?}");
+ }
+ }
+
+ #[test]
+ fn critical_chunk_order_is_checked_even_after_image_data() {
+ let b = fixture();
+ let original = preview(&b).unwrap().png.to_vec();
+ for trailing in [chunk(b"IHDR", &original[16..29]), chunk(b"PLTE", &[0, 0, 0]), chunk(b"ABCD", &[])] {
+ let mut png = original.clone();
+ let end = png.len() - 12;
+ png.splice(end..end, trailing);
+ assert!(preview(&file(&png)).is_err());
+ }
+ let mut no_image = original.clone();
+ no_image.drain(33..45);
+ assert!(preview(&file(&no_image)).is_err());
+ let mut duplicate_palette = original.clone();
+ duplicate_palette.splice(33..33, chunk(b"PLTE", &[0, 0, 0]).repeat(2));
+ assert!(preview(&file(&duplicate_palette)).is_err());
+ let mut split_image = original;
+ let end = split_image.len() - 12;
+ let mut interrupted = chunk(b"tEXt", b"note\0preview");
+ interrupted.extend(chunk(b"IDAT", &[]));
+ split_image.splice(end..end, interrupted);
+ assert!(preview(&file(&split_image)).is_err());
+ }
+
+ proptest! {
+ #![proptest_config(ProptestConfig::with_cases(256))]
+ #[test]
+ fn arbitrary_input_never_panics(b in prop::collection::vec(any::(), 0..2048)) {
+ let _ = inspect(&b);
+ let _ = preview(&b);
+ }
+ #[test]
+ fn mutations_never_panic(edits in prop::collection::vec((0usize..200, any::()), 0..32)) {
+ let mut b = fixture();
+ for (i, value) in edits { if let Some(v) = b.get_mut(i) { *v = value; } }
+ let _ = preview(&b);
+ }
+ }
+}
diff --git a/crates/automation/src/files.rs b/crates/automation/src/files.rs
index 09513c23..004b806e 100644
--- a/crates/automation/src/files.rs
+++ b/crates/automation/src/files.rs
@@ -13,22 +13,23 @@ use crate::AutomationError;
pub struct Opened {
pub document: Document,
pub warnings: Vec,
+ pub source_read_only: bool,
}
/// Decode a document that was read through a filesystem capability.
pub fn open_bytes(name: &str, bytes: &[u8]) -> Result {
if Path::new(name).extension().is_some_and(|extension| extension.eq_ignore_ascii_case(photocraft_format::EXTENSION)) {
- return Ok(Opened { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new() });
+ return Ok(Opened { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new(), source_read_only: false });
}
let r = photocraft_io::import(name, bytes)?;
- Ok(Opened { document: r.document, warnings: r.warnings })
+ Ok(Opened { document: r.document, warnings: r.warnings, source_read_only: r.source_read_only })
}
/// Open a document from disk. Directory bundles and `.pcraft` ZIPs load
/// natively; everything else goes through `photocraft-io` (PSD, PNG, …).
pub fn open(path: &Path) -> Result {
if path.is_dir() {
- return Ok(Opened { document: photocraft_format::load_path(path)?, warnings: Vec::new() });
+ return Ok(Opened { document: photocraft_format::load_path(path)?, warnings: Vec::new(), source_read_only: false });
}
let bytes = photocraft_format::read_file(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
diff --git a/crates/automation/src/headless.rs b/crates/automation/src/headless.rs
index e3bd8c74..79d84248 100644
--- a/crates/automation/src/headless.rs
+++ b/crates/automation/src/headless.rs
@@ -91,7 +91,10 @@ impl Headless {
}
None => Some(requested.to_string()),
};
- let index = self.session.add_document(o.document, path);
+ let index = self.session.add_document(o.document, path.filter(|_| !o.source_read_only));
+ if let Some(st) = self.session.active_mut() {
+ st.source_read_only = o.source_read_only;
+ }
let d = &self.session.documents()[index];
Ok(json!({
"index": index,
diff --git a/crates/automation/tests/mcp.rs b/crates/automation/tests/mcp.rs
index cf745dc7..40fb341f 100644
--- a/crates/automation/tests/mcp.rs
+++ b/crates/automation/tests/mcp.rs
@@ -316,6 +316,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("Native layers"));
+ 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() {
diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs
index 4b4d6269..05b0db1c 100644
--- a/crates/engine/src/file_cmds.rs
+++ b/crates/engine/src/file_cmds.rs
@@ -117,7 +117,7 @@ pub(crate) fn list_images(dir: &str) -> Result> {
/// Extensions the batch commands pick up from a folder.
const OPENABLE: &[&str] = &[
"psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi", "ico", "pnm", "ppm", "pgm", "heic", "heif",
- "hif", "dng", "cr2", "nef", "nrw", "arw", "pef",
+ "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "af",
];
pub(crate) fn file_name(path: &str) -> String {
@@ -178,7 +178,15 @@ pub(crate) fn sanitize(name: &str) -> String {
}
pub(crate) fn import(name: &str, bytes: &[u8]) -> Result {
- photocraft_io::import(name, bytes).map(|r| r.document).map_err(|e| EngineError::Other(format!("{name}: {e}")))
+ let r = photocraft_io::import(name, bytes).map_err(|e| EngineError::Other(format!("{name}: {e}")))?;
+ // Auxiliary imports return only a document and cannot surface the preview's fidelity warning.
+ // Open has its own warning-preserving path; never silently place or process a thumbnail.
+ if r.source_read_only {
+ return Err(EngineError::Other(format!(
+ "{name}: only an Affinity preview is available; 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.
@@ -341,7 +349,10 @@ pub fn open_bytes_as(s: &mut Session, name: &str, bytes: &[u8], as_ext: Option<&
}
};
// Color Settings policies (preserve / convert / discard the embedded profile).
- let (i, color) = s.open_document(doc, path);
+ let (i, color) = s.open_document(doc, path.filter(|_| !r.source_read_only));
+ if let Some(st) = s.active_mut() {
+ st.source_read_only = r.source_read_only;
+ }
// Import notes (e.g. how a camera raw was developed, or that only its preview opened).
Ok(json!({"document": i, "color": color, "warnings": r.warnings}))
}
diff --git a/crates/engine/src/filters_ext.rs b/crates/engine/src/filters_ext.rs
index 1f25aff5..8c93a409 100644
--- a/crates/engine/src/filters_ext.rs
+++ b/crates/engine/src/filters_ext.rs
@@ -427,8 +427,8 @@ pub(crate) fn resolve(s: &Session, fp: &mut FilterParams, p: &Value) -> Result<(
let layer = p.get("mapLayer").and_then(Value::as_u64);
let img = if let Some(path) = p.get("mapPath").and_then(Value::as_str).filter(|p| !p.is_empty()) {
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("cannot read displacement map `{path}`: {e}")))?;
- let imported = photocraft_io::import(path, &bytes).map_err(|e| EngineError::Other(format!("cannot open displacement map `{path}`: {e}")))?;
- map_from_document(&imported.document, layer)
+ let imported = crate::file_cmds::import(path, &bytes).map_err(|e| EngineError::Other(format!("cannot open displacement map `{path}`: {e}")))?;
+ map_from_document(&imported, layer)
} else if let Some(d) = p.get("mapDocument") {
let docs = s.documents();
let found = match d {
diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs
index 0bbacbec..f473f3ad 100644
--- a/crates/engine/src/jobs.rs
+++ b/crates/engine/src/jobs.rs
@@ -427,6 +427,9 @@ impl Session {
},
move |s, r: photocraft_io::ImportResult| {
let (index, color) = s.open_document(r.document, None);
+ if let Some(st) = s.active_mut() {
+ st.source_read_only = r.source_read_only;
+ }
Ok(json!({"document": index, "name": name_a, "warnings": r.warnings, "color": color}))
},
)
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 48aad051..624536df 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -155,6 +155,8 @@ pub struct DocState {
/// Anchor of ⇧-click range selection (the last plainly or ⌘-clicked layer).
pub layer_anchor: Option,
pub path: Option,
+ /// An Affinity 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,
@@ -191,6 +193,7 @@ impl DocState {
selected_layers: active_layer.into_iter().collect(),
layer_anchor: active_layer,
path,
+ source_read_only: false,
revision: 1,
saved_revision: 1,
last_damage: None,
diff --git a/crates/engine/src/notes_cmds.rs b/crates/engine/src/notes_cmds.rs
index aac8efe3..54bbd60b 100644
--- a/crates/engine/src/notes_cmds.rs
+++ b/crates/engine/src/notes_cmds.rs
@@ -165,7 +165,7 @@ fn import(s: &mut Session, p: &Value) -> Result {
/// File › Import › Notes… from file bytes (what a file picker returns).
pub fn import_notes_from(s: &mut Session, name: &str, bytes: &[u8]) -> Result {
- let src = photocraft_io::import(name, bytes).map_err(|e| EngineError::Other(format!("{name}: {e}")))?.document;
+ let src = crate::file_cmds::import(name, bytes)?;
if src.notes.is_empty() {
return Err(EngineError::Other(format!("{name} has no notes")));
}
diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs
index 2578e7f0..2e5647fd 100644
--- a/crates/engine/src/smart_cmds.rs
+++ b/crates/engine/src/smart_cmds.rs
@@ -64,7 +64,7 @@ pub fn encode_source(doc: &Document) -> Result> {
/// Decodes smart-object contents (a `.pcraft` bundle, PSD/PSB, or any flat image format).
pub fn decode_source(file_name: &str, bytes: &[u8]) -> Result {
- photocraft_io::import(file_name, bytes).map(|r| r.document).map_err(|e| other(format!("can't read smart object contents \"{file_name}\": {e}")))
+ crate::file_cmds::import(file_name, bytes).map_err(|e| other(format!("can't read smart object contents \"{file_name}\": {e}")))
}
fn base_name(path: &str) -> String {
diff --git a/crates/engine/src/variables_cmds.rs b/crates/engine/src/variables_cmds.rs
index 2cf30e7d..65e3ffee 100644
--- a/crates/engine/src/variables_cmds.rs
+++ b/crates/engine/src/variables_cmds.rs
@@ -190,7 +190,7 @@ fn replace_pixels(doc: &mut Document, layer: LayerId, path: &str, method: PixelM
}
let fmt = doc.pixel_format();
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("read `{path}`: {e}")))?;
- let src = photocraft_io::import(path, &bytes).map_err(|e| EngineError::Other(format!("`{path}`: {e}")))?.document;
+ let src = crate::file_cmds::import(path, &bytes)?;
let img = crate::file_cmds::flattened(&src, fmt);
let (iw, ih) = (src.size.width.max(1) as f64, src.size.height.max(1) as f64);
let (tw, th) = (target.width() as f64, target.height() as f64);
diff --git a/crates/engine/src/video_cmds.rs b/crates/engine/src/video_cmds.rs
index cefe37ca..744f8c20 100644
--- a/crates/engine/src/video_cmds.rs
+++ b/crates/engine/src/video_cmds.rs
@@ -183,7 +183,7 @@ fn load_frames(path: &str, fmt: photocraft_color::PixelFormat) -> Result Vec {
+ let mut src = Session::new();
+ src.execute("file.new", json!({"width": 2, "height": 1, "background": "#dc283c"})).unwrap();
+ let png = photocraft_io::export(&src.active().unwrap().doc, "preview.png", &Default::default()).unwrap().bytes;
+ // Observed v12 preview envelope, without a native object graph.
+ let mut bytes = vec![0; 72];
+ bytes[..4].copy_from_slice(b"\x00\xffKA");
+ bytes[4..6].copy_from_slice(&12u16.to_le_bytes());
+ bytes[8..12].copy_from_slice(b"nsrP");
+ bytes[12..16].copy_from_slice(b"#Inf");
+ bytes[24..32].copy_from_slice(&72u64.to_le_bytes());
+ bytes[64..68].copy_from_slice(b"Prot");
+ bytes.extend(b"\xff\xff\xff\xffThmb");
+ bytes.extend(1u32.to_le_bytes());
+ bytes.extend((png.len() as u32 + 13).to_le_bytes());
+ bytes.extend(29u32.to_le_bytes());
+ bytes.extend(0u32.to_le_bytes());
+ bytes.extend((png.len() as u32).to_le_bytes());
+ bytes.push(1);
+ bytes.extend(png);
+ bytes
+}
+
+#[test]
+fn preview_never_saves_back_to_its_native_or_renamed_source() {
+ let dir = std::env::temp_dir().join(format!("photocraft-affinity-source-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let bytes = fixture();
+ for name in ["source.af", "renamed.psd"] {
+ let path = dir.join(name).to_string_lossy().into_owned();
+ std::fs::write(&path, &bytes).unwrap();
+ let mut s = Session::new();
+ let r = open_bytes_as(&mut s, &path, &bytes, None, Some(path.clone())).unwrap();
+ assert!(r["warnings"].to_string().contains("embedded"));
+ assert!(s.active().unwrap().path.is_none(), "{name}");
+ assert!(s.active().unwrap().source_read_only);
+ assert!(!s.is_enabled("file.revert"));
+ assert_eq!(std::fs::read(&path).unwrap(), bytes);
+ // Explicit native export is refused before any file is written.
+ let native = dir.join("refused.af");
+ assert!(s.execute("file.saveACopy", json!({"path": native})).is_err());
+ assert!(!native.exists());
+ let copy = dir.join(format!("{name}.pcraft"));
+ s.execute("file.saveACopy", json!({"path": copy})).unwrap();
+ assert!(photocraft_io::import("copy.pcraft", &std::fs::read(copy).unwrap()).is_ok());
+ assert_eq!(std::fs::read(path).unwrap(), bytes);
+ }
+ std::fs::remove_dir_all(dir).unwrap();
+}
+
+#[test]
+fn background_preview_retains_the_source_protection_and_warning() {
+ let mut s = Session::new();
+ let started = s.start_open("source.af", OpenSource::Bytes(Arc::new(fixture()))).unwrap();
+ let result = match started {
+ Started::Job(id) => s.wait_job(id).unwrap(),
+ Started::Done(v) => v,
+ };
+ assert!(result["warnings"].to_string().contains("Native 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("only an Affinity preview"), "{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"));
+}
diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml
index eb8bc833..9db74a13 100644
--- a/crates/io/Cargo.toml
+++ b/crates/io/Cargo.toml
@@ -26,6 +26,7 @@ photocraft-algo = { workspace = true }
photocraft-psd = { workspace = true }
photocraft-codecs = { workspace = true }
photocraft-raw = { workspace = true }
+photocraft-affinity = { workspace = true }
photocraft-text = { workspace = true }
photocraft-paint = { workspace = true }
photocraft-vector = { workspace = true }
@@ -35,6 +36,8 @@ serde_json = { workspace = true }
blake3 = { version = "1.8", default-features = false, features = ["pure"] }
[dev-dependencies]
+crc32fast = "1.5"
+flate2 = { workspace = true }
photocraft-psd = { workspace = true, features = ["testgen"] }
photocraft-raw = { workspace = true, features = ["testgen"] }
proptest = { workspace = true }
diff --git a/crates/io/src/affinity.rs b/crates/io/src/affinity.rs
new file mode 100644
index 00000000..24cdf4de
--- /dev/null
+++ b/crates/io/src/affinity.rs
@@ -0,0 +1,31 @@
+//! Affinity documents currently open as their embedded preview, with an explicit warning.
+
+use crate::{ImportResult, IoError};
+
+pub use photocraft_affinity::is_affinity;
+
+/// Extensions recognized as Affinity documents (legacy containers get an actionable error).
+pub const EXTENSIONS: &[&str] = &["af", "afphoto", "afdesign", "afpub"];
+
+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 {
+ let p = photocraft_affinity::preview(bytes).map_err(|e| IoError::Unsupported(e.to_string()))?;
+ 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.warnings.insert(0, format!(
+ "Opened only Affinity's embedded {}×{} PNG preview, which may be smaller than the document. Native layers, vectors, text, masks, effects, pages and the document's colour settings are not imported. 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)
+}
diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs
index 9ccaf408..7356a6d2 100644
--- a/crates/io/src/flat.rs
+++ b/crates/io/src/flat.rs
@@ -102,7 +102,7 @@ pub(crate) fn image_to_document(name: &str, img: &Image) -> Result,
+ /// An Affinity preview must not retain a Save path to its native source.
+ pub source_read_only: bool,
}
/// Result of [`export`].
@@ -139,7 +144,7 @@ pub fn is_psd(bytes: &[u8]) -> bool {
bytes.starts_with(b"8BPS")
}
-/// Imports a file. PSD/PSB and camera raws are detected by magic; everything
+/// Imports a file. PSD/PSB, Affinity containers and camera raws are detected by magic; everything
/// else is decoded with `photocraft-codecs`.
pub fn import(name: &str, bytes: &[u8]) -> Result {
import_with(name, bytes, &photocraft_raster::Interrupt::NONE)
@@ -158,7 +163,7 @@ pub fn import_with(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt)
fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt) -> Result {
// A declared native extension must reach its loader so malformed bundles retain format errors.
if has_extension(name, photocraft_format::EXTENSION) || photocraft_format::is_pcraft(bytes) {
- return Ok(ImportResult { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new() });
+ return Ok(ImportResult { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new(), source_read_only: false });
}
if is_psd(bytes) {
let file = PsdFile::from_bytes(bytes)?;
@@ -171,7 +176,10 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt) -
ctl.progress(0.05);
let (mut document, warnings) = psd_import::psd_to_document_with(&file, ctl).ok_or(IoError::Cancelled)?;
document.name = name.to_string();
- return Ok(ImportResult { document, warnings });
+ return Ok(ImportResult { document, warnings, source_read_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);
@@ -191,6 +199,9 @@ fn has_extension(name: &str, expected: &str) -> bool {
/// bare extension).
pub fn export(doc: &Document, name_or_ext: &str, opts: &ExportOptions) -> Result {
let ext = extension(name_or_ext);
+ if affinity::EXTENSIONS.contains(&ext.as_str()) {
+ return Err(IoError::Unsupported("Affinity export is not implemented; save a new PSD, PNG or .pcraft copy".into()));
+ }
if ext == photocraft_format::EXTENSION {
let previews = photocraft_format::SaveOptions {
thumbnail: Some(photocraft_compose::thumbnail(doc, 256)),
diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs
index 6ad712c1..1a621c21 100644
--- a/crates/io/src/tiff_layers.rs
+++ b/crates/io/src/tiff_layers.rs
@@ -112,7 +112,7 @@ pub(crate) fn import_layered(name: &str, img: &Image, layers: &[u8]) -> Result Vec {
+ let img = Image::from_raw(2, 1, ChannelLayout::Rgba, SampleType::U8, vec![220, 40, 60, 255, 0, 0, 0, 0]).unwrap();
+ photocraft_codecs::encode(&img, Format::Png, &EncodeOptions::default()).unwrap()
+}
+
+fn chunk(kind: &[u8; 4], data: &[u8]) -> Vec {
+ let mut tagged = kind.to_vec();
+ tagged.extend(data);
+ let mut result = (data.len() as u32).to_be_bytes().to_vec();
+ result.extend(&tagged);
+ result.extend(crc32fast::hash(&tagged).to_be_bytes());
+ result
+}
+
+/// A synthetic record envelope, not an Affinity document writer (there is no object graph).
+fn file(png: &[u8]) -> Vec {
+ let mut b = vec![0; 72];
+ b[..4].copy_from_slice(photocraft_affinity::MAGIC);
+ b[4..6].copy_from_slice(&12u16.to_le_bytes());
+ b[8..12].copy_from_slice(b"nsrP");
+ b[12..16].copy_from_slice(b"#Inf");
+ b[24..32].copy_from_slice(&72u64.to_le_bytes());
+ b[64..68].copy_from_slice(b"Prot");
+ b.extend(b"\xff\xff\xff\xffThmb");
+ b.extend(1u32.to_le_bytes());
+ b.extend((png.len() as u32 + 13).to_le_bytes());
+ b.extend(29u32.to_le_bytes());
+ b.extend(0u32.to_le_bytes());
+ b.extend((png.len() as u32).to_le_bytes());
+ b.push(1);
+ b.extend(png);
+ b
+}
+
+#[test]
+fn preview_is_named_and_warns_at_its_actual_size() {
+ let r = import("art.af", &file(&png())).unwrap();
+ assert_eq!((r.document.size.width, r.document.size.height), (2, 1));
+ assert_eq!(r.document.layers.len(), 1);
+ assert_eq!(r.document.layers[0].name, "Affinity preview");
+ assert!(r.source_read_only);
+ assert!(r.warnings[0].contains("2×1"));
+ assert!(r.warnings[0].contains("Native layers"));
+ 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);
+ 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());
+ let mut legacy = file(&png());
+ legacy[4..6].copy_from_slice(&10u16.to_le_bytes());
+ assert!(import("legacy.afphoto", &legacy).unwrap_err().to_string().contains("legacy preview"));
+ assert!(import("legacy.af", &legacy).is_err());
+}
+
+#[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}");
+ assert!(error.contains("export PNG, PSD or SVG"), "{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 CRC"));
+
+ 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 CRC"));
+}
+
+#[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("IDAT chunks must be contiguous"));
+}
+
+proptest! {
+ #![proptest_config(ProptestConfig::with_cases(128))]
+ #[test]
+ fn preview_and_decoder_survive_mutations(edits in prop::collection::vec((0usize..300, any::()), 0..16)) {
+ let mut b = file(&png());
+ for (i, v) in edits { if let Some(byte) = b.get_mut(i) { *byte = v; } }
+ let _ = import("mutated.af", &b);
+ }
+}
+
+/// Optional local oracle; authored by Affinity 3.3, never committed as a binary fixture.
+#[test]
+#[ignore = "set AFFINITY_ORACLE_DIR to synthetic Affinity .af and exported PNG files"]
+fn native_affinity_oracle() {
+ let dir = std::env::var("AFFINITY_ORACLE_DIR").unwrap();
+ for (name, size) in [("synthetic-rectangle", (64, 48)), ("synthetic-large", (1024, 768))] {
+ let bytes = std::fs::read(format!("{dir}/{name}.af")).unwrap();
+ let r = import(&format!("{name}.af"), &bytes).unwrap();
+ assert_eq!((r.document.size.width, r.document.size.height), size);
+ let preview = photocraft_affinity::preview(&bytes).unwrap();
+ let source = photocraft_codecs::decode(&std::fs::read(format!("{dir}/{name}.png")).unwrap()).unwrap();
+ let decoded = photocraft_codecs::decode(preview.png).unwrap();
+ assert_eq!(decoded.data().len(), size.0 as usize * size.1 as usize * 4);
+ if name == "synthetic-rectangle" {
+ assert_eq!(decoded.data(), source.data());
+ }
+ }
+}
diff --git a/crates/ui-egui/src/file_open.rs b/crates/ui-egui/src/file_open.rs
index 6339ac77..8c5f81c4 100644
--- a/crates/ui-egui/src/file_open.rs
+++ b/crates/ui-egui/src/file_open.rs
@@ -45,7 +45,7 @@ pub fn display_name(path: &str) -> String {
impl PhotocraftApp {
/// Open `bytes` read from the file at `path`: the document is named after the file, keeps
- /// `path` for File › Save, and `path` goes to the top of Open Recent. Returns the import
+ /// `path` for File › Save unless it is a derivative preview, and `path` goes to the top of Open Recent. Returns the import
/// warnings (also shown to the user).
pub fn open_file(&mut self, path: &str, bytes: &[u8]) -> Result, String> {
// Brushes/gradients go to the preset libraries (no document, no Open Recent entry).
@@ -63,10 +63,11 @@ impl PhotocraftApp {
}
/// Record that the active document was just opened from `path`: File › Save writes back to it
- /// (unless it's a template) and it goes to the top of Open Recent.
+ /// (unless it's a template or derivative preview) and it goes to the top of Open Recent.
pub(crate) fn opened_from(&mut self, path: &str) {
if !is_template(path)
&& let Some(st) = self.session.active_mut()
+ && !st.source_read_only
{
st.path = Some(path.to_string());
}
diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs
index 87573d10..cbe22155 100644
--- a/crates/ui-egui/src/file_open/tests.rs
+++ b/crates/ui-egui/src/file_open/tests.rs
@@ -61,6 +61,27 @@ fn open_file_sets_name_path_and_recent() {
assert!(app.ui.notices.is_empty());
}
+#[test]
+fn affinity_preview_does_not_acquire_the_source_path_even_when_renamed() {
+ for automation in [false, true] {
+ for path in ["/pics/source.af", "/pics/renamed.psd"] {
+ let (mut app, written) = app_with(None, None);
+ // The fake importer supplies the preview; its source signature controls path policy.
+ if automation {
+ app.open_automation_bytes(&display_name(path), b"\x00\xffKA").unwrap();
+ app.opened_from(path);
+ } else {
+ app.open_file(path, b"\x00\xffKA").unwrap();
+ }
+ assert!(app.session.active().unwrap().source_read_only);
+ assert!(app.session.active().unwrap().path.is_none());
+ assert_eq!(app.ui.recent_files.first().map(String::as_str), Some(path));
+ assert_eq!(menus::invoke(&mut app, &egui::Context::default(), "file.save", json!({})).unwrap_err(), "cancelled");
+ assert!(written.borrow().is_empty());
+ }
+ }
+}
+
#[test]
fn file_open_dialog_sets_path_so_save_writes_in_place() {
// The dialog returns a full path: the document is named after the file, not the path.
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index c6edf19a..b2901a63 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -757,6 +757,9 @@ impl PhotocraftApp {
// Edit › Color Settings policies apply on open; mismatches can ask what to do.
// No path yet: a bare name isn't a location to save back to (`open_file` sets the path).
let (_, color) = self.session.open_document(doc, None);
+ if let Some(st) = self.session.active_mut() {
+ st.source_read_only = photocraft_io::affinity::is_affinity(bytes);
+ }
self.sync_views();
self.ui.status = format!("Opened {name}");
self.ui.status_error = false;
@@ -797,6 +800,9 @@ impl PhotocraftApp {
let (doc, warnings) = import(name, bytes)?;
// The caller records the path it read from.
self.session.add_document(doc, None);
+ if let Some(st) = self.session.active_mut() {
+ st.source_read_only = photocraft_io::affinity::is_affinity(bytes);
+ }
self.sync_views();
self.ui.status = format!("Opened {name}");
self.ui.status_error = false;
diff --git a/docs/architecture.md b/docs/architecture.md
index 63a3bc59..1f4ca326 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -99,6 +99,7 @@ photocraft/
│ ├─ adobe-assets/ photocraft-adobe-assets .abr .asl .aco/.ase .grd .pat .csh .atn .cube/.3dl, ACR .xmp presets (standalone, like psd)
│ ├─ codecs/ photocraft-codecs png/jpeg/tiff/webp/gif/bmp/avif decode/encode, heif/heic decode (feature `heif`)
│ ├─ heif/ photocraft-heif optional HEIF/HEIC decoder (heic-rs, pure Rust); used only by codecs behind its `heif` feature (standalone)
+│ ├─ affinity/ photocraft-affinity bounded container inspection and indexed PNG preview extraction (standalone; no native graph decoder)
│ ├─ raw/ photocraft-raw clean-room camera RAW decode (DNG, CR2, TIFF/EP) + develop pipeline (standalone, like psd)
│ ├─ format/ photocraft-format native document format (.pcraft bundle): manifest + content-addressed tiles
│ ├─ io/ photocraft-io import/export orchestration; doc ⇄ PSD mapping; PDF/SVG import (features)
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 50b400f4..42851802 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -28,6 +28,12 @@ readiness; treat it as an upper bound.
**Menu parity: 532 / 625 (85.1%)** on 2026-10-01, up from 224 (35.8%) the day before. See [`parity.md`](parity.md).
+2026-10-08: Affinity `.af` import has an initial, bounded **embedded-preview-only** path
+([format notes](../crates/affinity/README.md)). Some version-12 documents open at the preview's
+dimensions with an explicit warning and without a source save path. Previewless files and
+legacy containers remain unsupported; native layers, document colour settings, full-resolution
+content and Affinity writing are still missing. This does not raise editable-format parity.
+
## Honest parity assessment (2026-10-05)
This is the reference answer to "how close are we to Photoshop parity, really". Agents: read it
@@ -149,4 +155,3 @@ Each milestone has a **definition of done (DoD)** and must leave `main` green on
| **M10** | Smart features | `ml` (ort native / ort-web on the web), Select Subject/Object/Sky, Remove BG, Remove tool, content-aware fill, healing, AI denoise, RAW develop | Quality benchmarks on a public dataset; timing budgets |
| **M11** | Automation + formats | MCP server, batch, scripting, remaining formats (JP2, DICOM, DPX…), C2PA | An agent completes 10 scripted edit tasks via MCP |
| **M12** | Pro parity | CMYK/Lab UI, print, HDR display, photomerge/HDR merge, timeline, layer comps, artboards, symmetry, neural filters | `xtask parity` ≥ 90% of Photoshop menu checklist |
-
diff --git a/docs/scorecard.md b/docs/scorecard.md
index f903a510..fa4fcec7 100644
--- a/docs/scorecard.md
+++ b/docs/scorecard.md
@@ -12,7 +12,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
| [Workspace and UI](#workspace-and-ui) | 0 | 5 | 18 | settings that do nothing: 57 of 135 (target 0) |
| [Type](#type) | 0 | 3 | 6 | |
| [Automation](#automation) | 3 | 2 | 6 | 627 of 627 menu items live (100.0%) |
-| [Reliability](#reliability) | 4 | 1 | 2 | never-crash lints on 27 of 27 crates; panic_hunt present |
+| [Reliability](#reliability) | 4 | 1 | 2 | never-crash lints on 28 of 28 crates; panic_hunt present |
| [Distribution](#distribution) | 4 | 4 | 3 | |
| [Open bugs](#open-bugs) | 12 | 4 | 16 | |
@@ -218,7 +218,7 @@ Menu wiring (`docs/parity.md`): 627 of 627 menu items live (100.0%). This counts
## Reliability
-Never-crash attribute on 27 of 27 crate roots.
+Never-crash attribute on 28 of 28 crate roots.
| Id | Target | Status | Issue | Note |
|---|---|---|---|---|
diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs
index 7a97c94f..7d567d4b 100644
--- a/xtask/src/layers.rs
+++ b/xtask/src/layers.rs
@@ -42,6 +42,7 @@ pub const TABLE: &[(&str, Class)] = &[
// Optional HEIF/HEIC decoder (heic-rs), used only by `codecs` behind its `heif` feature.
("heif", Class::Standalone),
("raw", Class::Standalone),
+ ("affinity", Class::Standalone),
("adobe-assets", Class::Standalone),
// Pen tablet input (the one isolated `unsafe` helper: AppKit interop on macOS).
("tablet", Class::Standalone),
From f94bb1dc9b3fb48ca5f3c6d78f9dce516fb80c48 Mon Sep 17 00:00:00 2001
From: callmeBalloch <12576795+callmeBalloch@users.noreply.github.com>
Date: Thu, 8 Oct 2026 17:10:10 +0000
Subject: [PATCH 02/71] AF.1: refresh the lockfile for the 0.5.0 workspace
version
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
---
Cargo.lock | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Cargo.lock b/Cargo.lock
index 0c5c7037..e2bd067a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3492,7 +3492,7 @@ dependencies = [
[[package]]
name = "photocraft-affinity"
-version = "0.4.1"
+version = "0.5.0"
dependencies = [
"crc32fast",
"proptest",
From ae0313d3255669e45ac858c93e97db9f086acbee Mon Sep 17 00:00:00 2001
From: callmeBalloch <12576795+callmeBalloch@users.noreply.github.com>
Date: Thu, 8 Oct 2026 18:46:22 +0000
Subject: [PATCH 03/71] AF.3: native Affinity import with artboards, vectors,
text and images
photocraft-affinity now reads native Affinity documents (container
versions 8-12): the FAT and revision chain, stored/zlib/zstd entries
with predictors and CRC checks, the tagged object stream in doc.dat,
and the document model (pages, artboards, layers, groups, curves,
geometric and compound shapes, fills, gradients, strokes, text, placed
images, pixel layers, masks and embedded-document caches). Every size
is bounded and malformed input returns an error.
photocraft-io maps it to an RGB8 document with artboards, groups, shape
layers, type layers, embedded smart objects, pixel layers and
vector/layer masks; what can't be mapped is listed in the warnings. The
embedded preview is now only a fallback when the native data can't be
read, and only that preview is refused by Place and warningless imports.
`cargo xtask corpus --affinity` pins 21 public CC0/MIT documents,
sha256-verified; the io corpus test compares each import with the
thumbnail Affinity saved in it (mean difference 0-4.6 of 255).
Affinity export is not implemented: no Affinity installation was
available to validate written files.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
---
Cargo.lock | 3 +
README.md | 2 +-
book/src/SUMMARY.md | 2 +-
book/src/formats/affinity.md | 49 +-
book/src/formats/overview.md | 4 +-
crates/affinity/Cargo.toml | 8 +-
crates/affinity/README.md | 158 +++--
crates/affinity/fuzz/Cargo.toml | 9 +-
.../fuzz/fuzz_targets/import_preview.rs | 2 +-
crates/affinity/fuzz/fuzz_targets/preview.rs | 4 +-
crates/affinity/fuzz/fuzz_targets/stream.rs | 11 +
crates/affinity/src/access.rs | 105 +++
crates/affinity/src/container.rs | 371 +++++++++++
crates/affinity/src/geometry.rs | 175 +++++
crates/affinity/src/lib.rs | 401 +-----------
crates/affinity/src/model.rs | 594 +++++++++++++++++
crates/affinity/src/paint.rs | 329 ++++++++++
crates/affinity/src/preview.rs | 127 ++++
crates/affinity/src/raster.rs | 243 +++++++
crates/affinity/src/shapes.rs | 259 ++++++++
crates/affinity/src/stream.rs | 376 +++++++++++
crates/affinity/src/synth.rs | 259 ++++++++
crates/affinity/src/tests.rs | 331 ++++++++++
crates/affinity/src/text.rs | 151 +++++
crates/affinity/tests/real_files.rs | 83 +++
crates/engine/Cargo.toml | 1 +
crates/engine/src/file_cmds.rs | 4 +-
crates/engine/tests/affinity.rs | 45 +-
crates/io/Cargo.toml | 1 +
crates/io/src/affinity.rs | 600 +++++++++++++++++-
crates/io/src/flat.rs | 2 +-
crates/io/src/lib.rs | 9 +-
crates/io/src/tiff_layers.rs | 2 +-
crates/io/tests/affinity.rs | 170 ++++-
crates/io/tests/affinity_corpus.rs | 107 ++++
docs/architecture.md | 2 +-
docs/development.md | 1 +
docs/roadmap.md | 15 +-
xtask/affinity-corpus.sha256 | 21 +
xtask/src/corpus.rs | 9 +-
xtask/src/corpus_pins.rs | 58 +-
41 files changed, 4590 insertions(+), 513 deletions(-)
create mode 100644 crates/affinity/fuzz/fuzz_targets/stream.rs
create mode 100644 crates/affinity/src/access.rs
create mode 100644 crates/affinity/src/container.rs
create mode 100644 crates/affinity/src/geometry.rs
create mode 100644 crates/affinity/src/model.rs
create mode 100644 crates/affinity/src/paint.rs
create mode 100644 crates/affinity/src/preview.rs
create mode 100644 crates/affinity/src/raster.rs
create mode 100644 crates/affinity/src/shapes.rs
create mode 100644 crates/affinity/src/stream.rs
create mode 100644 crates/affinity/src/synth.rs
create mode 100644 crates/affinity/src/tests.rs
create mode 100644 crates/affinity/src/text.rs
create mode 100644 crates/affinity/tests/real_files.rs
create mode 100644 crates/io/tests/affinity_corpus.rs
create mode 100644 xtask/affinity-corpus.sha256
diff --git a/Cargo.lock b/Cargo.lock
index e2bd067a..aefe2afb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3495,7 +3495,9 @@ name = "photocraft-affinity"
version = "0.5.0"
dependencies = [
"crc32fast",
+ "flate2",
"proptest",
+ "ruzstd",
]
[[package]]
@@ -3613,6 +3615,7 @@ version = "0.5.0"
dependencies = [
"blake3",
"flate2",
+ "photocraft-affinity",
"photocraft-algo",
"photocraft-cms",
"photocraft-codecs",
diff --git a/README.md b/README.md
index 1624da3b..ed752391 100644
--- a/README.md
+++ b/README.md
@@ -199,7 +199,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
🗂️ Formats
- PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and AVIF, with symmetric read and write at 8, 16 and 32 bits, HEIC photos from iPhone and Mac (read; in official builds, an optional --features heif build feature), and the native .pcraft format. Some current Affinity .af files open as their embedded PNG preview, with a warning: native layers and full-resolution artwork are not imported, previewless and legacy files are unsupported, and Affinity export is unavailable.
+ PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and AVIF, with symmetric read and write at 8, 16 and 32 bits, HEIC photos from iPhone and Mac (read; in official builds, an optional --features heif build feature), and the native .pcraft format. Affinity documents (.af, .afdesign, .afphoto, .afpub) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
🪄 The everyday essentials
diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md
index e819bfbd..e99430f2 100644
--- a/book/src/SUMMARY.md
+++ b/book/src/SUMMARY.md
@@ -21,7 +21,7 @@
- [Overview](formats/overview.md)
- [PSD and PSB](formats/psd-psb.md)
- [Raster formats](formats/raster-formats.md)
-- [Affinity previews](formats/affinity.md)
+- [Affinity documents](formats/affinity.md)
- [The `.pcraft` format](formats/pcraft.md)
# Automation
diff --git a/book/src/formats/affinity.md b/book/src/formats/affinity.md
index 70e32c0b..36544c7f 100644
--- a/book/src/formats/affinity.md
+++ b/book/src/formats/affinity.md
@@ -1,28 +1,31 @@
-# Affinity previews
+# Affinity documents
-PhotoCraft can open the **embedded PNG preview** in some current Affinity `.af` files. The
-document contains one editable raster layer named “Affinity preview”, at the preview's actual
-dimensions. The preview may be smaller than the source document. Native layers, text, vectors,
-masks, effects, pages and document colour settings are not imported; the warning on open
-explains this limitation.
+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:
-This is preview import, not editable Affinity document support. Files without an indexed PNG
-preview fail with an actionable error. Legacy `.afphoto`, `.afdesign` and `.afpub` containers
-are recognized, but their thumbnail layouts are unverified and rejected. Export PSD or PNG
-from Affinity for full-resolution artwork.
+- 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.
-An opened preview starts without a save path, including when the source was renamed to another
-extension. Save asks for a new copy. Writing `.af`, `.afphoto`, `.afdesign` or `.afpub` is
-unsupported. Saving `.pcraft`, PSD or PNG saves the imported preview, not the native content.
-Place, smart-object content decoding and auxiliary imports that cannot display the warning
-reject the preview; export PSD or PNG in Affinity before using those paths.
+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.
-The standalone `photocraft-affinity` crate inspects the container and reads only the declared
-version-12 thumbnail offset. It never scans for PNGs or decompresses the object graph. The PNG
-decoder validates the compressed preview with limits of 4096 pixels per dimension and 128 MiB
-of decoded allocation; encoded PNGs are limited to 16 MiB.
-Every PNG chunk CRC is checked. Compressed PNG metadata and animated PNG previews are rejected
-before decoding; these have not been observed in the native oracle files.
+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.
-See [the crate's format notes](https://github.com/storytold/photocraft/blob/main/crates/affinity/README.md)
-for observed layouts, provenance, tests and the remaining native-import work.
+If the native data can't be read (a damaged file, or a layout PhotoCraft doesn't know), PhotoCraft
+opens the small PNG preview Affinity stores in the file instead, as a single pixel layer named
+“Affinity preview”, and the warning says why. That preview can't be placed or used by File ▸ Place,
+stacks, batch processing or other imports that can't show the warning. Export PSD or PNG from
+Affinity in that case.
+
+See [the crate's notes](https://github.com/storytold/photocraft/blob/main/crates/affinity/README.md)
+for the full list of what is imported, how the reader was written and validated, and its safety
+limits.
diff --git a/book/src/formats/overview.md b/book/src/formats/overview.md
index 41937cef..b2aabb2f 100644
--- a/book/src/formats/overview.md
+++ b/book/src/formats/overview.md
@@ -5,10 +5,10 @@ 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` inspects Affinity containers and extracts an indexed PNG preview in some current `.af` files; it does not decode native layers.
+- `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), [Affinity previews](affinity.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), [Affinity documents](affinity.md), and [The `.pcraft` format](pcraft.md). Cross-format hardening guidance is in [Parser hardening](../security/parser-hardening.md).
diff --git a/crates/affinity/Cargo.toml b/crates/affinity/Cargo.toml
index 7a4df2cb..e686e737 100644
--- a/crates/affinity/Cargo.toml
+++ b/crates/affinity/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "photocraft-affinity"
-description = "Bounded Affinity container inspection and embedded PNG preview extraction"
+description = "Bounded reader for native Affinity documents: archive, object stream and document model"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -9,8 +9,14 @@ rust-version.workspace = true
[lints]
workspace = true
+[features]
+# Synthetic-container builders for tests and fuzz seeds; not an Affinity exporter.
+synth = []
+
[dependencies]
crc32fast = "1.5"
+flate2 = { workspace = true }
+ruzstd = "0.9"
[dev-dependencies]
proptest = { workspace = true }
diff --git a/crates/affinity/README.md b/crates/affinity/README.md
index e53b001e..f07f84c0 100644
--- a/crates/affinity/README.md
+++ b/crates/affinity/README.md
@@ -1,33 +1,56 @@
# photocraft-affinity
-Standalone Rust inspection of Affinity containers and extraction of an indexed
-PNG preview. It does **not** decode the native document graph or write Affinity files. The
-`photocraft-io` adapter opens the preview as one raster layer and reports the fidelity limitation.
+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.
-`inspect(bytes)` recognizes document-class containers with versions 7–12. `preview(bytes)`
-accepts only the version-12 thumbnail record observed in newly generated Affinity 3.3.0.4850
-documents. Versions 7–11, files without an indexed thumbnail, other encodings, and unknown
-record layouts return an actionable error. Not every current `.af` file contains a preview.
-Recognizing a filename extension or the container magic does not establish editable support.
+## What is imported
-The preview must be decoded by a bounded PNG decoder: this crate checks the PNG signature,
-IHDR dimensions, every chunk CRC, chunk boundaries and exact IEND termination; it does not validate
-compressed pixels or the native colour settings. Limits are 16 MiB of encoded PNG and 4096
-pixels in either dimension. The I/O adapter additionally limits decoded allocation to 128 MiB.
-Offsets and lengths are checked before indexing. No compressed object graph is decompressed.
-The only runtime dependency is the already-used, permissively licensed `crc32fast` checksum crate.
-Compressed PNG text/profile chunks (`zTXt`, `iTXt`, `iCCP`) and APNG records are unsupported
-and rejected before decoding. The observed native previews contain only IHDR, pHYs, IDAT and
-IEND; relaxing this policy needs a total metadata allocation bound and native-file oracles.
-IHDR must be first and unique, IDAT chunks must be present and contiguous, and an optional
-PLTE must be unique and precede IDAT. Unknown critical chunks are rejected. This protects
-against trailing format errors that the image decoder may otherwise treat as partial output.
+`photocraft-io` maps the model to an 8-bit RGB document at the Affinity document's resolution:
-## Observed record layout
+| 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) |
-All container integers below are little-endian. The header's offset 24 points to the record;
-signature scanning is deliberately unsupported because a resource or obsolete revision may
-contain another PNG.
+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 |
|---|---:|---|
@@ -40,47 +63,60 @@ contain another PNG.
| 28 | 1 | PNG encoding 1 |
| 29 | declared length | Exactly one PNG |
-This is a narrow, observed layout rather than a vendor specification. Expanding it needs
-independent, synthetic native-file oracles and deliberate version dispatch.
+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 verification
+## Provenance
-The public MIT-licensed [afread reader at `04b6723`](https://github.com/VMDevCpp/afread/tree/04b672334a43e3e37ded6b5ffc57af231d589774) (Vladimir Mamonov, 2020–2021) was consulted for
-the legacy header layout, `Prsn` document class and versions 7–11. This crate's Rust code was
-written independently; it does not incorporate C++, GPL/AGPL readers or proprietary source.
-The version-12 thumbnail layout was observed through files authored from scratch with the
-public Affinity scripting SDK. No proprietary application bundle or private user files were read.
+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. Its
+meaning was then worked out from public documents only, without running Affinity: the semantics of
+shapes, paints, text and pixel data were fitted to the thumbnail every Affinity document embeds
+(Affinity's own render of it) on 176 public documents saved by Affinity 1.x, 2.x and 3.0/3.1 on
+Windows, macOS and iPad. No GPL/AGPL code (such as Inkscape's Affinity extension) was read, and no
+Affinity application code, asset or document is part of this repository. The same reader is
+shared, as an independent copy, with VectorCraft's `vectorcraft-affinity`.
-Unit tests use generated record envelopes, every truncation, bad offsets, dimensions and
-mutations. Integration tests exercise the bounded PNG decoder, alpha, copy export and save
-protection, including 8-bit RGBA and 16-bit Gray/alpha previews. Warningless auxiliary imports
-(Place, smart-object decoding, stack/batch, displacement maps, variables, notes and video)
-reject an Affinity derivative preview so it cannot be silently treated as native content.
-A local ignored `native_affinity_oracle` in `crates/io/tests/affinity.rs` compares
-generated Affinity files against their PNG exports. Binary oracles belong in the shared
-[photocraft-corpus](https://github.com/storytold/photocraft-corpus), with committed generators,
-scrubbed personal metadata and a pinned hash manifest; they are never committed here.
+## Validation
-```sh
-cargo test -p photocraft-affinity
-cargo test -p photocraft-io --test affinity
-AFFINITY_ORACLE_DIR=/path/to/generated/files cargo test -p photocraft-io --test affinity native_affinity_oracle -- --ignored
-cd crates/affinity/fuzz
-cargo +nightly fuzz run preview -- -max_total_time=60 -max_len=20000 -timeout=5
-cargo +nightly fuzz run import_preview --features io -- -max_total_time=60 -max_len=20000 -timeout=5
-```
+* 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`):
-Native editable import remains a separate project:
+ ```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
+ ```
-- Validate the allocation table and revision chain and choose the current `doc.dat`, including
- incremental saves, deletions, object checksums and resource bounds.
-- Decode zstd/deflate and predictors with independent output, ratio and work limits.
-- Build a typed tagged intermediate model, resolving object references with cycle, count and
- depth limits before mapping data into the document model.
-- Preserve RGB/Gray/CMYK/Lab, 8/16/32f depth and ICC profiles; map raster tiles, layer order,
- groups, transforms, masks and blends; issue explicit warnings for unsupported effects,
- text, vectors, pages and other records rather than silently flattening or dropping them.
-- Verify native-file oracles across application/platform versions and malformed inputs.
- Native writing and round trips need a later design for carrying unknown records safely.
+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.
-Preview import must never be presented as completing those tasks or preserving the original artwork.
+## 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, 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve
+nodes per curve, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient
+stops; `photocraft-io` adds 300 000 pixels a side per document and 64 megapixels per image on the
+canvas. Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the
+crate has no panics outside tests.
+
+## Privacy
+
+Affinity documents can hold the folder they were saved in, user names, original image paths and
+XMP metadata. This reader never imports those fields.
diff --git a/crates/affinity/fuzz/Cargo.toml b/crates/affinity/fuzz/Cargo.toml
index 288d89e1..6c61c87b 100644
--- a/crates/affinity/fuzz/Cargo.toml
+++ b/crates/affinity/fuzz/Cargo.toml
@@ -15,7 +15,7 @@ io = ["dep:photocraft-io"]
[dependencies]
libfuzzer-sys = "0.4"
-photocraft-affinity = { path = ".." }
+photocraft-affinity = { path = "..", features = ["synth"] }
photocraft-io = { path = "../../io", optional = true }
[[bin]]
@@ -32,3 +32,10 @@ required-features = ["io"]
test = false
doc = false
bench = false
+
+[[bin]]
+name = "stream"
+path = "fuzz_targets/stream.rs"
+test = false
+doc = false
+bench = false
diff --git a/crates/affinity/fuzz/fuzz_targets/import_preview.rs b/crates/affinity/fuzz/fuzz_targets/import_preview.rs
index 13e4bd79..50177197 100644
--- a/crates/affinity/fuzz/fuzz_targets/import_preview.rs
+++ b/crates/affinity/fuzz/fuzz_targets/import_preview.rs
@@ -3,7 +3,7 @@
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
- // Exercise the bounded preview PNG decoder and document mapping, not unrelated formats.
+ // The complete import: native document mapping, or the preview fallback.
if photocraft_affinity::is_affinity(data) {
let _ = photocraft_io::import("fuzz.af", data);
}
diff --git a/crates/affinity/fuzz/fuzz_targets/preview.rs b/crates/affinity/fuzz/fuzz_targets/preview.rs
index 60ab2dfd..31e2bf97 100644
--- a/crates/affinity/fuzz/fuzz_targets/preview.rs
+++ b/crates/affinity/fuzz/fuzz_targets/preview.rs
@@ -2,7 +2,9 @@
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::inspect(data);
+ let _ = photocraft_affinity::container::header(data);
let _ = photocraft_affinity::preview(data);
+ let _ = photocraft_affinity::read(data, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 });
});
diff --git a/crates/affinity/fuzz/fuzz_targets/stream.rs b/crates/affinity/fuzz/fuzz_targets/stream.rs
new file mode 100644
index 00000000..fd6128a6
--- /dev/null
+++ b/crates/affinity/fuzz/fuzz_targets/stream.rs
@@ -0,0 +1,11 @@
+#![no_main]
+
+use libfuzzer_sys::fuzz_target;
+
+// The doc.dat object stream and the model built from it, wrapped in a stored archive entry so
+// mutations reach the tagged grammar instead of the archive's checksum.
+fuzz_target!(|stream: &[u8]| {
+ let _ = photocraft_affinity::stream::parse(stream);
+ let file = photocraft_affinity::synth::container(&[("doc.dat", stream, photocraft_affinity::synth::Method::Stored)], None);
+ let _ = photocraft_affinity::read(&file, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 });
+});
diff --git a/crates/affinity/src/access.rs b/crates/affinity/src/access.rs
new file mode 100644
index 00000000..0b60ed7d
--- /dev/null
+++ b/crates/affinity/src/access.rs
@@ -0,0 +1,105 @@
+//! Typed reads of [`Stream`] objects. Every accessor returns `None` when a field is missing or has
+//! another type, so the document mapping treats odd data as absent rather than failing.
+
+use crate::stream::{ObjId, Object, Stream, Tag, Value};
+
+impl Stream {
+ pub fn object(&self, id: ObjId) -> Option<&Object> {
+ self.objects.get(id)
+ }
+
+ pub fn field(&self, id: ObjId, tag: &[u8; 4]) -> Option<&Value> {
+ self.object(id)?.get(Tag::of(tag))
+ }
+
+ pub fn class(&self, id: ObjId) -> Option {
+ self.object(id).map(|o| o.class)
+ }
+
+ pub fn is(&self, id: ObjId, class: &[u8; 4]) -> bool {
+ self.class(id) == Some(Tag::of(class))
+ }
+
+ /// A child object (shared, linked or inline); `None` for null or missing.
+ pub fn obj(&self, id: ObjId, tag: &[u8; 4]) -> Option {
+ match self.field(id, tag)? {
+ Value::Obj(o) => *o,
+ _ => None,
+ }
+ }
+
+ /// An array of objects (nulls skipped).
+ pub fn objs(&self, id: ObjId, tag: &[u8; 4]) -> Vec {
+ match self.field(id, tag) {
+ Some(Value::Array(a)) => a.iter().filter_map(|v| if let Value::Obj(Some(o)) = v { Some(*o) } else { None }).collect(),
+ _ => Vec::new(),
+ }
+ }
+
+ pub fn f64(&self, id: ObjId, tag: &[u8; 4]) -> Option {
+ match self.field(id, tag)? {
+ Value::Float(f) if f.is_finite() => Some(*f),
+ _ => None,
+ }
+ }
+
+ pub fn int(&self, id: ObjId, tag: &[u8; 4]) -> Option {
+ match self.field(id, tag)? {
+ Value::Int(i) => Some(*i),
+ Value::UInt(u) => i64::try_from(*u).ok(),
+ _ => None,
+ }
+ }
+
+ pub fn bool(&self, id: ObjId, tag: &[u8; 4]) -> Option {
+ match self.field(id, tag)? {
+ Value::Bool(b) => Some(*b),
+ _ => None,
+ }
+ }
+
+ pub fn str(&self, id: ObjId, tag: &[u8; 4]) -> Option<&str> {
+ match self.field(id, tag)? {
+ Value::Str(s) => Some(s),
+ _ => None,
+ }
+ }
+
+ pub fn enumeration(&self, id: ObjId, tag: &[u8; 4]) -> Option<(u16, u16)> {
+ match self.field(id, tag)? {
+ Value::Enum { id, version } => Some((*id, *version)),
+ _ => None,
+ }
+ }
+
+ /// A fixed vector of `N` finite floats (points, rectangles, transforms).
+ pub fn floats(&self, id: ObjId, tag: &[u8; 4]) -> Option<[f64; N]> {
+ match self.field(id, tag)? {
+ Value::Floats(v) if v.len() == N && v.iter().all(|f| f.is_finite()) => {
+ let mut a = [0.0; N];
+ a.copy_from_slice(v);
+ Some(a)
+ }
+ _ => None,
+ }
+ }
+
+ pub fn bytes(&self, id: ObjId, tag: &[u8; 4]) -> Option<&[u8]> {
+ match self.field(id, tag)? {
+ Value::Bytes(b) | Value::Blob(b) => Some(b),
+ _ => None,
+ }
+ }
+
+ pub fn entry(&self, id: ObjId, tag: &[u8; 4]) -> Option<&str> {
+ match self.field(id, tag)? {
+ Value::Entry(s) => Some(s),
+ _ => None,
+ }
+ }
+
+ /// Untagged fields of a positional object, in order.
+ pub fn positional(&self, id: ObjId) -> impl Iterator {
+ self.object(id).into_iter().flat_map(|o| o.fields.iter().map(|(_, v)| v))
+ }
+}
diff --git a/crates/affinity/src/container.rs b/crates/affinity/src/container.rs
new file mode 100644
index 00000000..0829f858
--- /dev/null
+++ b/crates/affinity/src/container.rs
@@ -0,0 +1,371 @@
+//! The Affinity archive: header, the chain of file allocation tables (one per save) and
+//! bounded, CRC-checked extraction of named entries such as `doc.dat` and raster tiles.
+
+use std::collections::{HashMap, HashSet};
+use std::io::Read;
+
+use crate::Error;
+
+pub const MAGIC: &[u8; 4] = b"\x00\xffKA";
+/// Oldest and newest container versions seen in real documents (Affinity 1.x writes 8,
+/// Affinity 3 writes 12). Version 7 is described by afread but no sample exists.
+pub const VERSIONS: std::ops::RangeInclusive = 8..=12;
+/// `Prsn` (stored byte-reversed): documents. Asset, brush and style libraries share the magic.
+const DOCUMENT_KIND: &[u8; 4] = b"nsrP";
+
+/// A save appends one table; real files have one to a few hundred.
+const MAX_TABLES: usize = 4096;
+/// zstd window ceiling: real documents use a few MiB; this stops a header from reserving gigabytes.
+const MAX_ZSTD_WINDOW: u64 = 64 << 20;
+
+/// Decompression budget for one import. Every entry declares its size, which is checked against
+/// these limits before anything is allocated, and the decoded length must match it exactly.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Limits {
+ /// Largest single entry (`doc.dat` or one tile).
+ pub max_entry: usize,
+ /// Total bytes extracted over the whole import.
+ pub max_total: usize,
+}
+
+impl Default for Limits {
+ fn default() -> Self {
+ Self { max_entry: 256 << 20, max_total: 1 << 30 }
+ }
+}
+
+/// The container header, all integers little-endian.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Header {
+ pub version: u16,
+ /// Seen as 0, 4 or 8 in real files; bits 0 and 1 mark container variants that are rejected.
+ pub flags: u16,
+ pub(crate) fat_offset: u64,
+ pub(crate) thumbnail_offset: u64,
+ /// Save time in Unix seconds, as written by the application.
+ pub saved: u32,
+}
+
+/// One FAT record, as of the newest table that mentions its id.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Entry {
+ pub id: u32,
+ pub name: String,
+ offset: u64,
+ size: u64,
+ compressed: u64,
+ crc: u32,
+ compression: u8,
+}
+
+impl Entry {
+ /// Decompressed size declared by the table.
+ pub fn size(&self) -> u64 {
+ self.size
+ }
+}
+
+#[derive(Debug)]
+pub struct Archive<'a> {
+ bytes: &'a [u8],
+ pub header: Header,
+ /// Head revision of every named, non-deleted entry.
+ entries: HashMap,
+ /// Number of tables (saves) in the chain.
+ pub revisions: usize,
+ extracted: usize,
+ limits: Limits,
+}
+
+pub(crate) struct Cursor<'a> {
+ bytes: &'a [u8],
+ pub(crate) pos: usize,
+}
+
+impl<'a> Cursor<'a> {
+ pub(crate) fn new(bytes: &'a [u8], pos: usize) -> Self {
+ Self { bytes, pos }
+ }
+ pub(crate) fn remaining(&self) -> usize {
+ self.bytes.len().saturating_sub(self.pos)
+ }
+ pub(crate) fn take(&mut self, n: usize) -> Result<&'a [u8], Error> {
+ let end = self.pos.checked_add(n).ok_or(Error::Malformed("length overflow"))?;
+ let s = self.bytes.get(self.pos..end).ok_or(Error::Malformed("truncated data"))?;
+ self.pos = end;
+ Ok(s)
+ }
+ pub(crate) fn array(&mut self) -> Result<[u8; N], Error> {
+ let s = self.take(N)?;
+ let mut a = [0u8; N];
+ a.copy_from_slice(s);
+ Ok(a)
+ }
+ pub(crate) fn u8(&mut self) -> Result {
+ Ok(self.array::<1>()?[0])
+ }
+ pub(crate) fn u16(&mut self) -> Result {
+ Ok(u16::from_le_bytes(self.array()?))
+ }
+ pub(crate) fn u32(&mut self) -> Result {
+ Ok(u32::from_le_bytes(self.array()?))
+ }
+ pub(crate) fn u64(&mut self) -> Result {
+ Ok(u64::from_le_bytes(self.array()?))
+ }
+}
+
+fn offset(v: u64) -> Result {
+ usize::try_from(v).map_err(|_| Error::Malformed("offset beyond addressable memory"))
+}
+
+/// True when `bytes` start with the Affinity container signature (documents and libraries).
+pub fn is_affinity(bytes: &[u8]) -> bool {
+ bytes.starts_with(MAGIC)
+}
+
+/// Check the signature, version and document kind.
+pub fn header(bytes: &[u8]) -> Result {
+ if !is_affinity(bytes) {
+ return Err(Error::Malformed("missing container signature"));
+ }
+ let mut c = Cursor::new(bytes, 4);
+ let version = c.u16()?;
+ let flags = c.u16()?;
+ if !VERSIONS.contains(&version) {
+ return Err(Error::Unsupported("unknown container version"));
+ }
+ if flags & 3 != 0 {
+ return Err(Error::Unsupported("container variant"));
+ }
+ if c.take(4)? != DOCUMENT_KIND {
+ return Err(Error::Unsupported("this Affinity file is a library (assets, brushes or styles), not a document"));
+ }
+ if c.take(4)? != b"#Inf" {
+ return Err(Error::Malformed("missing information header"));
+ }
+ let fat_offset = c.u64()?;
+ let thumbnail_offset = c.u64()?;
+ c.take(16)?;
+ let saved = u32::try_from(c.u64()?).unwrap_or(0);
+ c.take(8)?;
+ if c.take(4)? != b"Prot" {
+ return Err(Error::Malformed("missing protocol header"));
+ }
+ Ok(Header { version, flags, fat_offset, thumbnail_offset, saved })
+}
+
+impl<'a> Archive<'a> {
+ pub fn open(bytes: &'a [u8], limits: Limits) -> Result {
+ let header = header(bytes)?;
+ // Newest record per id: (table date, table index, record).
+ let mut latest: HashMap)> = HashMap::new();
+ let mut names: HashMap = HashMap::new();
+ let mut seen = HashSet::new();
+ let mut next = header.fat_offset;
+ let mut tables = 0usize;
+ while next != 0 {
+ if !seen.insert(next) {
+ return Err(Error::Malformed("allocation tables form a cycle"));
+ }
+ tables += 1;
+ if tables > MAX_TABLES {
+ return Err(Error::Limit("too many saved revisions"));
+ }
+ let mut c = Cursor::new(bytes, offset(next)?);
+ let tag = c.array::<4>()?;
+ let level = match &tag {
+ b"#FAT" => 1,
+ b"#FT2" => 2,
+ b"#FT3" => 3,
+ b"#FT4" => 4,
+ _ => return Err(Error::Malformed("missing allocation table")),
+ };
+ next = c.u64()?;
+ let date = c.u64()?;
+ c.take(24)?;
+ let files = c.u32()?;
+ c.take(8)?;
+ let dirs = c.u16()?;
+ c.take(1)?;
+ for _ in 0..files {
+ let id = c.u32()?;
+ let flag = c.u8()?;
+ let mut record = None;
+ match flag {
+ 0 | 1 => {
+ let offset = c.u64()?;
+ let size = c.u64()?;
+ let compressed = c.u64()?;
+ let crc = c.u32()?;
+ let stored = c.u8()?;
+ if level >= 2 {
+ c.take(4)?;
+ }
+ if level >= 4 {
+ c.take(4)?;
+ }
+ // Old tables number the methods; newer ones store the method byte itself.
+ let compression = if level <= 2 {
+ match stored {
+ 1 => 0x01,
+ 2 => 0x41,
+ 3 => 0x81,
+ 4 => 0xC1,
+ _ => 0,
+ }
+ } else {
+ stored
+ };
+ record = Some(Entry { id, name: String::new(), offset, size, compressed, crc, compression });
+ }
+ 2 => {}
+ _ => return Err(Error::Malformed("unknown allocation record")),
+ }
+ if flag == 0 {
+ let len = usize::from(c.u16()?);
+ let name = String::from_utf8_lossy(c.take(len)?).into_owned();
+ names.entry(id).or_insert(name);
+ }
+ // Equal dates: the first table in the chain (the newest) wins, as in afread.
+ let replace = latest.get(&id).is_none_or(|(d, _, _)| date > *d);
+ if replace {
+ latest.insert(id, (date, tables, record));
+ }
+ }
+ for _ in 0..dirs {
+ let len = usize::from(c.u16()?);
+ c.take(10)?;
+ c.take(len)?;
+ }
+ }
+ let mut entries = HashMap::new();
+ for (id, (_, _, record)) in latest {
+ if let (Some(mut e), Some(name)) = (record, names.get(&id)) {
+ e.name = name.clone();
+ entries.insert(e.name.clone(), e);
+ }
+ }
+ Ok(Self { bytes, header, entries, revisions: tables, extracted: 0, limits })
+ }
+
+ pub fn entry(&self, name: &str) -> Option<&Entry> {
+ self.entries.get(name)
+ }
+
+ pub fn entry_names(&self) -> impl Iterator {
+ self.entries.keys().map(String::as_str)
+ }
+
+ /// Bytes extracted so far, counted against [`Limits::max_total`].
+ pub fn extracted(&self) -> usize {
+ self.extracted
+ }
+
+ /// Extract an entry by name: decompress, undo the byte predictor and verify the CRC.
+ pub fn read(&mut self, name: &str) -> Result, Error> {
+ let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?.clone();
+ let size = usize::try_from(e.size).map_err(|_| Error::Limit("archive entry too large"))?;
+ if size > self.limits.max_entry {
+ return Err(Error::Limit("archive entry too large"));
+ }
+ let total = self.extracted.checked_add(size).ok_or(Error::Limit("document too large"))?;
+ if total > self.limits.max_total {
+ return Err(Error::Limit("document too large"));
+ }
+ let mut c = Cursor::new(self.bytes, offset(e.offset)?);
+ if c.take(4)? != b"#Fil" {
+ return Err(Error::Malformed("archive entry has no data block"));
+ }
+ let method = e.compression & 3;
+ let stored_len = if matches!(method, 1 | 2) { e.compressed } else { e.size };
+ let data = c.take(offset(stored_len)?)?;
+ let mut out = match method {
+ 1 => inflate(data, size)?,
+ 2 => unzstd(data, size)?,
+ _ => data.to_vec(),
+ };
+ if out.len() != size {
+ return Err(Error::Malformed("archive entry size mismatch"));
+ }
+ unpredict(&mut out, e.compression)?;
+ if crc32fast::hash(&out) != e.crc {
+ return Err(Error::Malformed("archive entry checksum mismatch"));
+ }
+ self.extracted = total;
+ Ok(out)
+ }
+}
+
+/// Read at most `size + 1` bytes so a stream that decodes past its declared size is caught
+/// without unbounded allocation.
+fn read_bounded(mut r: impl Read, size: usize) -> Result, Error> {
+ let mut out = Vec::with_capacity(size.min(64 << 20));
+ let cap = u64::try_from(size).unwrap_or(u64::MAX).saturating_add(1);
+ r.by_ref().take(cap).read_to_end(&mut out).map_err(|_| Error::Malformed("corrupt compressed entry"))?;
+ Ok(out)
+}
+
+fn inflate(data: &[u8], size: usize) -> Result, Error> {
+ read_bounded(flate2::read::ZlibDecoder::new(data), size)
+}
+
+fn unzstd(data: &[u8], size: usize) -> Result, Error> {
+ let mut src = data;
+ let mut out = Vec::new();
+ // Entries may hold several concatenated frames.
+ while !src.is_empty() {
+ let left = size.checked_sub(out.len()).ok_or(Error::Malformed("archive entry size mismatch"))?;
+ let decoder = ruzstd::decoding::StreamingDecoder::new_with_max_window_size(&mut src, MAX_ZSTD_WINDOW)
+ .map_err(|_| Error::Malformed("corrupt compressed entry"))?;
+ let frame = read_bounded(decoder, left)?;
+ if frame.is_empty() {
+ return Err(Error::Malformed("corrupt compressed entry"));
+ }
+ out.extend_from_slice(&frame);
+ if out.len() > size {
+ return Err(Error::Malformed("archive entry size mismatch"));
+ }
+ }
+ Ok(out)
+}
+
+/// Undo the byte predictor selected by the high bits of the compression byte.
+fn unpredict(b: &mut [u8], compression: u8) -> Result<(), Error> {
+ match (compression & 0xC0, compression & 0x20 != 0) {
+ (0x00, _) => {}
+ (0x40, _) => byte_delta(b),
+ (0x80, false) => {
+ let mut prev = 0u16;
+ for pair in b.chunks_exact_mut(2) {
+ let v = u16::from_le_bytes([pair[0], pair[1]]).wrapping_add(prev);
+ pair.copy_from_slice(&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.chunks_exact_mut(4).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.copy_from_slice(&[l[0], h[0], l[1], h[1]]);
+ }
+ }
+ }
+ _ => return Err(Error::Unsupported("archive entry predictor")),
+ }
+ Ok(())
+}
+
+fn byte_delta(b: &mut [u8]) {
+ let mut prev = 0u8;
+ for v in b {
+ *v = v.wrapping_add(prev);
+ prev = *v;
+ }
+}
diff --git a/crates/affinity/src/geometry.rs b/crates/affinity/src/geometry.rs
new file mode 100644
index 00000000..6a63190c
--- /dev/null
+++ b/crates/affinity/src/geometry.rs
@@ -0,0 +1,175 @@
+//! Outlines of curves (`PCvD` node lists), parametric shapes (`Shpe` objects) and compounds, in
+//! node space.
+
+use crate::model::{Affine, Path, Point, Reader, SubPath};
+use crate::stream::{ObjId, Tag, Value};
+
+/// Nodes per curve object.
+const MAX_NODES: usize = 4_000_000;
+
+/// The node-space outline of a curve, shape or compound node, and whether it fills even-odd.
+/// Instances (master-page or symbol copies) take the geometry of their master.
+pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> {
+ let s = r.s;
+ match &class.0.to_be_bytes() {
+ b"PCrv" | b"TxtC" => {
+ let crvs = s.obj(id, b"Crvs").or_else(|| s.obj(id, b"MCrM").and_then(|m| s.obj(m, b"Crvs")))?;
+ let path = curves(r, crvs)?;
+ let multi = path.subpaths.len() > 1;
+ Some((path, multi))
+ }
+ b"Comp" => {
+ // One path of all the operands' contours in the compound's space, filled even-odd: this
+ // matches add and subtract operands in every public sample; a true union of overlapping
+ // added operands would differ.
+ let mut path = Path::default();
+ for c in s.objs(id, b"Chld") {
+ let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
+ let class = s.class(c)?;
+ if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) {
+ r.warn("compound shapes that intersect or exclude (imported as add)");
+ }
+ if let Some((p, _)) = outline(r, c, class, local.then(world)) {
+ path.subpaths.extend(p.transformed(local).subpaths);
+ }
+ }
+ Some((path, true))
+ }
+ _ => {
+ let (shape, box_owner) = match s.obj(id, b"Shpe") {
+ Some(sh) => (sh, id),
+ None => {
+ let master = s.obj(id, b"ShpM")?;
+ (s.obj(master, b"Shpe")?, master)
+ }
+ };
+ let b = s.floats::<4>(box_owner, b"ShpB")?;
+ crate::shapes::shape(r, shape, b, world).map(|p| (p, false))
+ }
+ }
+}
+
+/// `PCvD.Data`: positional `[u8, u32 count, then per subpath: bool closed, 18-byte records]`.
+/// A record is (f64 x, f64 y, u8 kind, u8 role): role 0 is an anchor, 1 the out-handle of the
+/// anchor before it, 2 the in-handle of the anchor after it. A closed subpath repeats its first
+/// anchor at the end. `CnrD` live corners are rounded on the stored polyline.
+pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option {
+ let s = r.s;
+ let data = s.obj(crvs, b"Data")?;
+ let mut fields = s.positional(data);
+ fields.next()?;
+ let count = match fields.next()? {
+ Value::UInt(n) => usize::try_from(*n).ok()?,
+ _ => return None,
+ };
+ let corners = s.obj(crvs, b"CnrD").and_then(|c| corner_radii(s.positional(c).collect()));
+ let mut path = Path::default();
+ let mut seen = 0usize;
+ for index in 0..count {
+ let closed = match fields.next()? {
+ Value::Bool(b) => *b,
+ _ => return None,
+ };
+ let Value::Array(records) = fields.next()? else { return None };
+ seen = seen.checked_add(records.len())?;
+ if seen > MAX_NODES {
+ r.warn("curves with more than four million nodes");
+ return None;
+ }
+ let anchors = anchors(records)?;
+ let radii = if index == 0 { corners.as_ref() } else { None };
+ if let Some(sp) = subpath(&anchors, closed, radii) {
+ path.subpaths.push(sp);
+ }
+ }
+ if corners.is_some() && count > 1 {
+ r.warn("rounded corners on curves with several subpaths (only the first is rounded)");
+ }
+ Some(path)
+}
+
+struct Anchor {
+ p: Point,
+ inh: Option,
+ out: Option,
+ /// Index of the anchor's record (live corners refer to records).
+ record: usize,
+}
+
+fn record(v: &Value) -> Option<(Point, u8)> {
+ let Value::Bytes(b) = v else { return None };
+ if b.len() != 18 {
+ return None;
+ }
+ let x = f64::from_le_bytes(b.get(0..8)?.try_into().ok()?);
+ let y = f64::from_le_bytes(b.get(8..16)?.try_into().ok()?);
+ if !x.is_finite() || !y.is_finite() {
+ return None;
+ }
+ Some((Point { x, y }, *b.get(17)?))
+}
+
+fn anchors(records: &[Value]) -> Option> {
+ let mut out: Vec = Vec::with_capacity(records.len() / 3 + 1);
+ let mut pending_in = None;
+ for (i, v) in records.iter().enumerate() {
+ let (p, role) = record(v)?;
+ match role {
+ 0 => {
+ out.push(Anchor { p, inh: pending_in.take(), out: None, record: i });
+ }
+ 1 => {
+ // A leading out-handle without an anchor is edit state only.
+ if let Some(a) = out.last_mut() {
+ a.out = Some(p);
+ }
+ }
+ 2 => pending_in = Some(p),
+ _ => return None,
+ }
+ }
+ Some(out)
+}
+
+fn subpath(anchors: &[Anchor], closed: bool, radii: Option<&Vec<(usize, f64)>>) -> Option {
+ let first = anchors.first()?;
+ if let Some(radii) = radii.filter(|r| !r.is_empty()) {
+ let corner = |a: &Anchor| radii.iter().filter(|(rec, _)| *rec == a.record).map(|(_, r)| *r).fold(0.0, f64::max);
+ let polyline = anchors.iter().all(|a| a.inh.is_none_or(|h| h == a.p) && a.out.is_none_or(|h| h == a.p));
+ if polyline {
+ let mut pts: Vec<(Point, f64)> = anchors.iter().map(|a| (a.p, corner(a))).collect();
+ // A closed subpath repeats its first anchor; its radius may be on either copy.
+ if closed
+ && pts.len() > 1
+ && pts.first().map(|p| p.0) == pts.last().map(|p| p.0)
+ && let Some((_, r)) = pts.pop()
+ && let Some(f) = pts.first_mut()
+ {
+ f.1 = f.1.max(r);
+ }
+ return Some(crate::shapes::fillet(&pts, closed));
+ }
+ }
+ let segments = anchors
+ .windows(2)
+ .filter_map(|w| {
+ let [a, b] = w else { return None };
+ Some([a.out.unwrap_or(a.p), b.inh.unwrap_or(b.p), b.p])
+ })
+ .collect();
+ Some(SubPath { start: first.p, segments, closed })
+}
+
+/// `CnrD`: positional `[u8, i32[] record indices, f64[] radii, u8[] types]`.
+fn corner_radii(fields: Vec<&Value>) -> Option> {
+ let (Some(Value::Array(idx)), Some(Value::Array(rad))) = (fields.get(1), fields.get(2)) else { return None };
+ Some(
+ idx.iter()
+ .zip(rad)
+ .filter_map(|(i, r)| match (i, r) {
+ (Value::Int(i), Value::Float(r)) if r.is_finite() && *r > 0.0 => Some((usize::try_from(*i).ok()?, *r)),
+ _ => None,
+ })
+ .collect(),
+ )
+}
diff --git a/crates/affinity/src/lib.rs b/crates/affinity/src/lib.rs
index 95f6e87e..9520fa2a 100644
--- a/crates/affinity/src/lib.rs
+++ b/crates/affinity/src/lib.rs
@@ -1,18 +1,33 @@
-//! Affinity container inspection, without interpreting the compressed document graph.
+//! 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.
//!
-//! The header layout was researched with MIT-licensed afread (see README). The version-12
-//! thumbnail record was observed in synthetic documents saved by Affinity 3.3. Do not scan for
-//! PNG signatures: an unrelated resource or an obsolete revision is not a document preview.
+//! 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;
-pub const MAGIC: &[u8; 4] = b"\x00\xffKA";
-pub const MAX_PREVIEW_BYTES: usize = 16 << 20;
-pub const MAX_PREVIEW_DIMENSION: u32 = 4096;
+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;
-/// Container failure. The compressed graph is deliberately never decompressed here.
+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),
@@ -22,373 +37,15 @@ pub enum Error {
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- let (kind, reason) = match self {
- Self::Malformed(s) => ("damaged Affinity file", s),
- Self::Unsupported(s) => ("unsupported Affinity file", s),
- Self::Limit(s) => ("Affinity preview limit exceeded", s),
- };
- write!(f, "{kind}: {reason}; export PNG, PSD or SVG from Affinity for full-resolution artwork")
+ 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 {}
-/// Header identity; offsets remain private so callers cannot bypass validation.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub struct Header {
- pub version: u16,
- thumbnail_offset: u64,
-}
-
-/// 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,
-}
-
-pub fn is_affinity(bytes: &[u8]) -> bool {
- bytes.starts_with(MAGIC)
-}
-
-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 le16(bytes: &[u8], offset: usize) -> Result {
- let b = bytes_at(bytes, offset, 2)?;
- Ok(u16::from_le_bytes(b.try_into().map_err(|_| Error::Malformed("integer"))?))
-}
-
-fn le32(bytes: &[u8], offset: usize) -> Result {
- let b = bytes_at(bytes, offset, 4)?;
- Ok(u32::from_le_bytes(b.try_into().map_err(|_| Error::Malformed("integer"))?))
-}
-
-fn le64(bytes: &[u8], offset: usize) -> Result {
- let b = bytes_at(bytes, offset, 8)?;
- Ok(u64::from_le_bytes(b.try_into().map_err(|_| Error::Malformed("integer"))?))
-}
-
-fn be32(bytes: &[u8], offset: usize) -> Result {
- let b = bytes_at(bytes, offset, 4)?;
- Ok(u32::from_be_bytes(b.try_into().map_err(|_| Error::Malformed("PNG integer"))?))
-}
-
-/// Recognize documents, rejecting add-ons with the same magic and unknown container versions.
-pub fn inspect(bytes: &[u8]) -> Result {
- if !is_affinity(bytes) {
- return Err(Error::Malformed("missing container signature"));
- }
- let version = le16(bytes, 4)?;
- if !(7..=12).contains(&version) {
- return Err(Error::Unsupported("unverified container version"));
- }
- if le16(bytes, 6)? != 0 {
- return Err(Error::Unsupported("container flags"));
- }
- // afread's Prsn document class, stored little-endian. Brushes, macros and palettes
- // share the magic but must not be opened as documents.
- if bytes_at(bytes, 8, 4)? != b"nsrP" {
- return Err(Error::Unsupported("this container is not an Affinity document"));
- }
- if bytes_at(bytes, 12, 4)? != b"#Inf" {
- return Err(Error::Malformed("missing information header"));
- }
- bytes_at(bytes, 0, if version == 7 { 64 } else { 72 })?;
- if version > 7 && bytes_at(bytes, 64, 4)? != b"Prot" {
- return Err(Error::Malformed("missing protocol header"));
- }
- Ok(Header { version, thumbnail_offset: le64(bytes, 24)? })
-}
-
-/// Read only the indexed version-12 `Thmb` record and validate every PNG chunk CRC.
-/// Compressed pixels must still be validated by the consuming image decoder, with allocation limits.
-pub fn preview(bytes: &[u8]) -> Result, Error> {
- let h = inspect(bytes)?;
- if h.version != 12 {
- return Err(Error::Unsupported("legacy preview records have not been verified"));
- }
- if h.thumbnail_offset == 0 {
- return Err(Error::Unsupported("no embedded preview; native layer decoding is not implemented"));
- }
- 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 seen_ihdr = false;
- let mut seen_plte = false;
- let mut seen_idat = false;
- let mut idat_ended = 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 crc_offset = chunk_size.checked_sub(4).ok_or(Error::Malformed("PNG CRC offset"))?;
- let protected = chunk.get(4..crc_offset).ok_or(Error::Malformed("PNG CRC span"))?;
- if crc32fast::hash(protected) != be32(chunk, crc_offset)? {
- return Err(Error::Malformed("PNG chunk CRC mismatch"));
- }
- let kind = bytes_at(chunk, 4, 4)?;
- if !kind.iter().all(u8::is_ascii_alphabetic) {
- return Err(Error::Malformed("invalid PNG chunk name"));
- }
- // Native oracles have no compressed metadata or animation. Reject before any decoder
- // can inflate many individually capped text/profile chunks or silently pick a frame.
- if matches!(kind, b"zTXt" | b"iTXt" | b"iCCP") {
- return Err(Error::Unsupported("compressed PNG metadata in preview"));
- }
- if matches!(kind, b"acTL" | b"fcTL" | b"fdAT") {
- return Err(Error::Unsupported("animated PNG preview"));
- }
- match kind {
- b"IHDR" => {
- if seen_ihdr || pos != 8 {
- return Err(Error::Malformed("PNG IHDR must be first and unique"));
- }
- seen_ihdr = true;
- }
- b"PLTE" => {
- if seen_plte || seen_idat {
- return Err(Error::Malformed("PNG PLTE must be unique and precede IDAT"));
- }
- seen_plte = true;
- }
- b"IDAT" => {
- if idat_ended {
- return Err(Error::Malformed("PNG IDAT chunks must be contiguous"));
- }
- seen_idat = true;
- }
- b"IEND" => {
- if !seen_idat {
- return Err(Error::Malformed("PNG has no image data"));
- }
- }
- _ => {
- if kind.first().is_some_and(|b| b.is_ascii_uppercase()) {
- return Err(Error::Unsupported("unknown critical PNG chunk"));
- }
- }
- }
- if seen_idat && kind != b"IDAT" {
- idat_ended = true;
- }
- 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 })
-}
-
#[cfg(test)]
-mod tests {
- use super::*;
- use proptest::prelude::*;
-
- fn fixture() -> Vec {
- let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
- png.extend(13u32.to_be_bytes());
- png.extend(b"IHDR");
- png.extend(2u32.to_be_bytes());
- png.extend(1u32.to_be_bytes());
- png.extend([8, 6, 0, 0, 0]);
- let ihdr_crc = crc32fast::hash(&png[12..]);
- png.extend(ihdr_crc.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());
- // Valid CRCs, but no compressed pixels: consuming decoders still validate those.
- file(&png)
- }
-
- fn file(png: &[u8]) -> Vec {
- 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
- }
-
- fn chunk(kind: &[u8; 4], data: &[u8]) -> Vec {
- let mut tagged = kind.to_vec();
- tagged.extend(data);
- let mut result = (data.len() as u32).to_be_bytes().to_vec();
- result.extend(&tagged);
- result.extend(crc32fast::hash(&tagged).to_be_bytes());
- result
- }
-
- #[test]
- fn indexed_preview_and_header() {
- let b = fixture();
- assert_eq!(inspect(&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}");
- }
- for version in 7..12 {
- let mut legacy = b.clone();
- legacy[4..6].copy_from_slice(&(version as u16).to_le_bytes());
- assert!(inspect(&legacy).is_ok());
- assert!(matches!(preview(&legacy), 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_crc_including_iend_and_ancillary_is_checked() {
- let mut bad_iend = fixture();
- *bad_iend.last_mut().unwrap() ^= 1;
- assert_eq!(preview(&bad_iend).unwrap_err(), Error::Malformed("PNG chunk CRC mismatch"));
-
- let b = fixture();
- let mut png = preview(&b).unwrap().png.to_vec();
- 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 = png.len() - 12;
- png.splice(end..end, chunk.clone());
- assert!(preview(&file(&png)).is_ok());
- png[end + chunk.len() - 1] ^= 1;
- assert_eq!(preview(&file(&png)).unwrap_err(), Error::Malformed("PNG chunk CRC mismatch"));
- }
-
- #[test]
- fn compressed_metadata_and_animation_are_rejected_before_pixel_decoding() {
- let b = fixture();
- for kind in [b"zTXt", b"iTXt", b"iCCP", b"acTL", b"fcTL", b"fdAT"] {
- let mut png = preview(&b).unwrap().png.to_vec();
- // The chunk claims compressed metadata without making the parser inflate it.
- 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 = png.len() - 12;
- png.splice(end..end, chunk);
- assert!(matches!(preview(&file(&png)), Err(Error::Unsupported(_))), "{kind:?}");
- }
- }
-
- #[test]
- fn critical_chunk_order_is_checked_even_after_image_data() {
- let b = fixture();
- let original = preview(&b).unwrap().png.to_vec();
- for trailing in [chunk(b"IHDR", &original[16..29]), chunk(b"PLTE", &[0, 0, 0]), chunk(b"ABCD", &[])] {
- let mut png = original.clone();
- let end = png.len() - 12;
- png.splice(end..end, trailing);
- assert!(preview(&file(&png)).is_err());
- }
- let mut no_image = original.clone();
- no_image.drain(33..45);
- assert!(preview(&file(&no_image)).is_err());
- let mut duplicate_palette = original.clone();
- duplicate_palette.splice(33..33, chunk(b"PLTE", &[0, 0, 0]).repeat(2));
- assert!(preview(&file(&duplicate_palette)).is_err());
- let mut split_image = original;
- let end = split_image.len() - 12;
- let mut interrupted = chunk(b"tEXt", b"note\0preview");
- interrupted.extend(chunk(b"IDAT", &[]));
- split_image.splice(end..end, interrupted);
- assert!(preview(&file(&split_image)).is_err());
- }
-
- proptest! {
- #![proptest_config(ProptestConfig::with_cases(256))]
- #[test]
- fn arbitrary_input_never_panics(b in prop::collection::vec(any::(), 0..2048)) {
- let _ = inspect(&b);
- let _ = preview(&b);
- }
- #[test]
- fn mutations_never_panic(edits in prop::collection::vec((0usize..200, any::()), 0..32)) {
- let mut b = fixture();
- for (i, value) in edits { if let Some(v) = b.get_mut(i) { *v = value; } }
- let _ = preview(&b);
- }
- }
-}
+mod tests;
diff --git a/crates/affinity/src/model.rs b/crates/affinity/src/model.rs
new file mode 100644
index 00000000..166cba07
--- /dev/null
+++ b/crates/affinity/src/model.rs
@@ -0,0 +1,594 @@
+//! The document model PhotoCraft imports: spreads, artboards, layers, groups, curves, shapes,
+//! text and images, with transforms composed into document space. Anything else is reported
+//! by [`Document::warnings`] instead of being dropped silently.
+
+use std::collections::{BTreeMap, HashSet};
+
+use crate::paint::{self, Paint, Stroke};
+use crate::stream::{ObjId, Stream, Tag};
+use crate::{Archive, Error, Limits, stream};
+
+/// Affine map `x' = a·x + c·y + e`, `y' = b·x + d·y + f` (kurbo's coefficient order).
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Affine(pub [f64; 6]);
+
+impl Affine {
+ pub const IDENTITY: Self = Self([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
+
+ /// Affinity's `Xfrm`, stored row by row: (a, c, e, b, d, f).
+ pub(crate) fn from_xfrm(m: [f64; 6]) -> Self {
+ Self([m[0], m[3], m[1], m[4], m[2], m[5]])
+ }
+
+ pub fn then(self, outer: Self) -> Self {
+ let [a, b, c, d, e, f] = self.0;
+ let [oa, ob, oc, od, oe, of] = outer.0;
+ Self([oa * a + oc * b, ob * a + od * b, oa * c + oc * d, ob * c + od * d, oa * e + oc * f + oe, ob * e + od * f + of])
+ }
+
+ pub fn apply(&self, p: Point) -> Point {
+ let [a, b, c, d, e, f] = self.0;
+ Point { x: a * p.x + c * p.y + e, y: b * p.x + d * p.y + f }
+ }
+
+ /// Geometric mean of the axis scales: how much a stroke width or font size grows.
+ pub fn scale(&self) -> f64 {
+ let [a, b, c, d, ..] = self.0;
+ (a * d - b * c).abs().sqrt()
+ }
+
+ pub fn is_finite(&self) -> bool {
+ self.0.iter().all(|v| v.is_finite())
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Point {
+ pub x: f64,
+ pub y: f64,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Rect {
+ pub x0: f64,
+ pub y0: f64,
+ pub x1: f64,
+ pub y1: f64,
+}
+
+/// One cubic Bézier subpath, in document pixels.
+#[derive(Debug, Clone, PartialEq)]
+pub struct SubPath {
+ pub start: Point,
+ /// (control 1, control 2, end) per segment.
+ pub segments: Vec<[Point; 3]>,
+ pub closed: bool,
+}
+
+/// An outline in document pixels.
+#[derive(Debug, Clone, Default, PartialEq)]
+pub struct Path {
+ pub subpaths: Vec,
+}
+
+impl Path {
+ pub fn transformed(&self, m: Affine) -> Self {
+ Self {
+ subpaths: self
+ .subpaths
+ .iter()
+ .map(|s| SubPath {
+ start: m.apply(s.start),
+ segments: s.segments.iter().map(|seg| seg.map(|p| m.apply(p))).collect(),
+ closed: s.closed,
+ })
+ .collect(),
+ }
+ }
+}
+
+/// Affinity's blend modes, numbered by the `Blnd` enum.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum Blend {
+ #[default]
+ Normal,
+ PassThrough,
+ Darken,
+ DarkerColor,
+ Multiply,
+ ColorBurn,
+ Lighten,
+ LighterColor,
+ Screen,
+ ColorDodge,
+ Add,
+ Overlay,
+ SoftLight,
+ HardLight,
+ VividLight,
+ PinLight,
+ LinearLight,
+ HardMix,
+ Difference,
+ Exclusion,
+ Subtract,
+ Hue,
+ Saturation,
+ Luminosity,
+ Color,
+ Average,
+ Negation,
+ Reflect,
+ Glow,
+ Erase,
+}
+
+impl Blend {
+ /// The enum (id, version) pairs observed in a document whose layers are named after their mode.
+ fn from_enum(id: u16, version: u16) -> Option {
+ use Blend::*;
+ Some(match (id, version) {
+ (0, _) => Normal,
+ (1, 0) => Darken,
+ (2, 1) => DarkerColor,
+ (2, 0) => Multiply,
+ (3, 0) => ColorBurn,
+ (4, 0) => Lighten,
+ (6, 1) => LighterColor,
+ (5, 0) => Screen,
+ (6, 0) => ColorDodge,
+ (7, 0) => Add,
+ (8, 0) => Overlay,
+ (9, 0) => SoftLight,
+ (10, 0) => HardLight,
+ (11, 0) => VividLight,
+ (12, 0) => PinLight,
+ (15, 1) => LinearLight,
+ (13, 0) => HardMix,
+ (14, 0) => Difference,
+ (15, 0) => Exclusion,
+ (16, 0) => Subtract,
+ (17, 0) => Hue,
+ (18, 0) => Saturation,
+ (19, 0) => Luminosity,
+ (20, 0) => Color,
+ (21, 0) => Average,
+ (22, 0) => Negation,
+ (23, 0) => Reflect,
+ (24, 0) => Glow,
+ (25, 0) => Erase,
+ _ => return None,
+ })
+ }
+}
+
+/// A run of text with one set of character attributes.
+#[derive(Debug, Clone, PartialEq)]
+pub struct TextRun {
+ pub text: String,
+ /// PostScript name and family of the chosen font.
+ pub postscript: String,
+ pub family: String,
+ /// CSS weight (400 regular, 700 bold).
+ pub weight: i64,
+ pub italic: bool,
+ /// Font size in document pixels, before the node's transform.
+ pub size: f64,
+ /// Tracking in em.
+ pub tracking: f64,
+ /// Fixed line pitch in document pixels, when the run overrides automatic leading.
+ pub leading: Option,
+ pub fill: Paint,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Align {
+ Left,
+ Center,
+ Right,
+ Justify,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct Text {
+ pub runs: Vec,
+ pub align: Align,
+ /// Artistic text: the first baseline's anchor point in node space (start, centre or end of the
+ /// line according to `align`). Frame text: the frame box in node space.
+ pub anchor: Point,
+ pub frame: Option,
+ /// Node space to document pixels.
+ pub transform: Affine,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum Pixels {
+ /// Straight-alpha RGBA, 8 bits per channel, row by row.
+ Rgba8(Vec),
+ /// The original file a placed image was made from (PNG, JPEG…), as embedded.
+ Encoded(Vec),
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct Image {
+ pub width: u32,
+ pub height: u32,
+ pub pixels: Pixels,
+ /// Pixel space (0..w, 0..h) to document pixels.
+ pub transform: Affine,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum Kind {
+ /// Layer container (`Scop`).
+ Layer,
+ Group,
+ /// Artboard: a rectangle that clips its children; `background` paints behind them.
+ Artboard {
+ rect: Rect,
+ background: Vec,
+ },
+ /// Curve, parametric shape or compound shape: an outline with its fills and strokes.
+ /// Children of a shape are clipped by its outline (painted over its fills, under its strokes).
+ Shape {
+ path: Path,
+ fills: Vec,
+ strokes: Vec,
+ even_odd: bool,
+ },
+ Text(Text),
+ Image(Image),
+ /// Something this reader does not import; its children are still imported.
+ Unsupported,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct Node {
+ pub class: Tag,
+ pub name: String,
+ pub visible: bool,
+ pub locked: bool,
+ pub opacity: f64,
+ pub blend: Blend,
+ pub kind: Kind,
+ /// Vector mask in document pixels: the node shows only inside it (nonzero fill).
+ pub mask: Option,
+ /// Pixel mask: grey levels (white shows the node, black hides it); outside it the node is hidden.
+ pub pixel_mask: Option,
+ /// Bottom to top.
+ pub children: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct Spread {
+ /// Spread bounds in document pixels (the canvas, or the union of the artboards).
+ pub bounds: Rect,
+ /// Pages of a Publisher-style spread (one, or two facing pages), in document pixels.
+ pub pages: Vec,
+ /// Transparent background; otherwise the page is white.
+ pub transparent: bool,
+ /// Bottom to top.
+ pub nodes: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct Document {
+ /// Pixels per inch: all geometry is in document pixels at this resolution.
+ pub dpi: f64,
+ pub spreads: Vec,
+ /// Application that last saved the file, as stored (`Affinity 3.0.2`), when present.
+ pub saved_by: Option,
+ /// Content that was not imported or was approximated, one line per kind, deduplicated.
+ pub warnings: Vec,
+}
+
+/// Deepest layer nesting followed; deeper content is reported, not imported.
+const MAX_TREE_DEPTH: usize = 128;
+/// Total layer nodes imported.
+const MAX_NODES: usize = 500_000;
+
+/// Read a document: archive, `doc.dat` and the model.
+pub fn read(bytes: &[u8], limits: Limits) -> Result {
+ let mut archive = Archive::open(bytes, limits)?;
+ let doc = archive.read("doc.dat")?;
+ let s = stream::parse(&doc)?;
+ Reader::new(&s, &mut archive).document()
+}
+
+pub(crate) struct Reader<'s, 'a, 'b> {
+ pub(crate) s: &'s Stream,
+ pub(crate) archive: &'b mut Archive<'a>,
+ pub(crate) dpi: f64,
+ warnings: BTreeMap,
+ nodes: usize,
+ active: HashSet,
+}
+
+impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
+ fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self {
+ Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new() }
+ }
+
+ pub(crate) fn warn(&mut self, what: impl Into) {
+ *self.warnings.entry(what.into()).or_default() += 1;
+ }
+
+ fn document(mut self) -> Result {
+ let s = self.s;
+ let root = s.root;
+ self.dpi = s.obj(root, b"UVCn").and_then(|u| s.f64(u, b"UPPI")).filter(|d| *d > 0.0 && *d < 100_000.0).unwrap_or(72.0);
+ let saved_by = s.obj(root, b"NVer").map(|v| {
+ let product = s.str(v, b"Prod").unwrap_or("Affinity");
+ match (s.int(v, b"Majr"), s.int(v, b"Minr"), s.int(v, b"Bild")) {
+ (Some(a), Some(b), Some(c)) => format!("{product} {a}.{b}.{c}"),
+ _ => product.to_string(),
+ }
+ });
+ let doc = s.obj(root, b"DocR").ok_or(Error::Malformed("document has no root node"))?;
+ let mut spreads = Vec::new();
+ for spread in s.objs(doc, b"Chld") {
+ if !s.is(spread, b"Sprd") {
+ self.warn("an unknown kind of page");
+ continue;
+ }
+ let pages = pages(s, spread);
+ let default_size =
+ s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h });
+ let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect {
+ x0: 0.0,
+ y0: 0.0,
+ x1: 0.0,
+ y1: 0.0,
+ });
+ let transparent = s.bool(spread, b"SprT").unwrap_or(false);
+ let (nodes, mask) = self.children(spread, Affine::IDENTITY, 0)?;
+ if mask.is_some() {
+ self.warn("mask layers directly on a page (imported without them)");
+ }
+ spreads.push(Spread { bounds, pages, transparent, nodes });
+ }
+ if spreads.is_empty() {
+ return Err(Error::Malformed("document has no pages"));
+ }
+ if !s.objs(doc, b"MpCh").is_empty() {
+ self.warn("master pages (their content is not placed on the pages that use them)");
+ }
+ let warnings = self.warnings.into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect();
+ Ok(Document { dpi: self.dpi, spreads, saved_by, warnings })
+ }
+
+ /// The children of `parent`, and the pixel mask a mask layer among them puts on the parent.
+ fn children(&mut self, parent: ObjId, world: Affine, depth: usize) -> Result<(Vec, Option), Error> {
+ let s = self.s;
+ let mut out = Vec::new();
+ let mut mask = None;
+ for c in s.objs(parent, b"Chld") {
+ if s.is(c, b"MRst") {
+ // A mask layer inside a group or layer masks that container.
+ if s.bool(c, b"Visi") == Some(false) {
+ continue;
+ }
+ let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
+ match crate::raster::mask(self, c, local.then(world))? {
+ Some(m) if mask.is_none() => mask = Some(m),
+ Some(_) => self.warn("several pixel masks on one layer (only the first is used)"),
+ None => {}
+ }
+ continue;
+ }
+ if let Some(n) = self.node(c, world, depth + 1)? {
+ out.push(n);
+ }
+ }
+ Ok((out, mask))
+ }
+
+ fn node(&mut self, id: ObjId, parent: Affine, depth: usize) -> Result