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, Error> {
+ let s = self.s;
+ if depth > MAX_TREE_DEPTH {
+ self.warn("layers nested deeper than 128 levels");
+ return Ok(None);
+ }
+ self.nodes += 1;
+ if self.nodes > MAX_NODES {
+ return Err(Error::Limit("too many layers"));
+ }
+ // A node that contains itself (a shared object linked back into its own subtree).
+ if !self.active.insert(id) {
+ self.warn("a layer that contains itself");
+ return Ok(None);
+ }
+ let local = s.floats::<6>(id, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
+ let world = local.then(parent);
+ let class = s.class(id).unwrap_or(Tag(0));
+ let name = s.str(id, b"Desc").unwrap_or_default().to_string();
+ let visible = s.bool(id, b"Visi").unwrap_or(true);
+ let locked = s.bool(id, b"Edtb").is_some_and(|e| !e);
+ let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0));
+ let blend = match s.enumeration(id, b"Blnd") {
+ None if s.bool(id, b"PasT") != Some(false) && is_container(class) => Blend::PassThrough,
+ None => Blend::Normal,
+ Some((i, v)) => Blend::from_enum(i, v).unwrap_or_else(|| {
+ self.warn("an unknown blend mode (imported as Normal)");
+ Blend::Normal
+ }),
+ };
+ let kind = if !world.is_finite() {
+ self.warn("a layer with an invalid transform");
+ Kind::Unsupported
+ } else {
+ self.kind(id, class, world, parent)?
+ };
+ // A compound's children are its operands, already merged into its outline.
+ let (children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? };
+ let (mask, attached_mask) = self.attached_masks(id, world)?;
+ let pixel_mask = attached_mask.or(child_mask);
+ self.active.remove(&id);
+ if s.objs(id, b"FiEf").iter().any(|e| s.bool(*e, b"Enab") != Some(false)) {
+ self.warn("layer effects (shadows, glows, outlines, bevels…)");
+ }
+ Ok(Some(Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, children }))
+ }
+
+ fn kind(&mut self, id: ObjId, class: Tag, world: Affine, parent: Affine) -> Result {
+ let s = self.s;
+ Ok(match &class.0.to_be_bytes() {
+ b"Scop" => Kind::Layer,
+ b"Grup" => Kind::Group,
+ b"PCrv" | b"ShpN" | b"Comp" | b"SNEN" | b"SNRR" | b"ShRN" => {
+ if class == Tag::of(b"ShpN") && s.bool(id, b"ABEn") == Some(true) {
+ return Ok(self.artboard(id, world));
+ }
+ let Some((local, even_odd)) = crate::geometry::outline(self, id, class, world) else {
+ self.warn(format!("a {} that could not be read", describe(class)));
+ return Ok(Kind::Unsupported);
+ };
+ let (fills, strokes) = paint::node_paint(self, id, world);
+ Kind::Shape { path: local.transformed(world), fills, strokes, even_odd }
+ }
+ b"TxtA" | b"TxtF" | b"TxtC" => match crate::text::read(self, id, world) {
+ Some(t) => Kind::Text(t),
+ None => {
+ self.warn("text whose story could not be read (master-page text, or an unknown layout)");
+ Kind::Unsupported
+ }
+ },
+ b"ImgN" | b"Rstr" => match crate::raster::node_image(self, id, world)? {
+ Some(img) => Kind::Image(img),
+ None => Kind::Unsupported,
+ },
+ b"EmbN" => match crate::raster::embedded(self, id, world)? {
+ Some(img) => {
+ self.warn("embedded documents and symbols (imported as pictures of them)");
+ Kind::Image(img)
+ }
+ None => {
+ self.warn("embedded documents and symbols without a cached picture");
+ Kind::Unsupported
+ }
+ },
+ b"FRst" => {
+ // A fill layer paints its whole bitmap area; its gradient lives in page space.
+ let b = s.obj(id, b"Bitm");
+ let size = |t: &[u8; 4]| b.and_then(|b| s.int(b, t)).map(|v| v as f64).filter(|v| *v > 0.0);
+ match (size(b"BmpW"), size(b"BmpH")) {
+ (Some(w), Some(h)) => {
+ let local = Path { subpaths: vec![crate::shapes::rectangle(0.0, 0.0, w, h)] };
+ let fills = paint::fills(self, id, parent, b"BFFl");
+ Kind::Shape { path: local.transformed(world), fills, strokes: Vec::new(), even_odd: false }
+ }
+ _ => {
+ self.warn("fill layers without an area");
+ Kind::Unsupported
+ }
+ }
+ }
+ _ => {
+ self.warn(format!("{} layers", describe(class)));
+ Kind::Unsupported
+ }
+ })
+ }
+
+ /// Vector shapes attached to a node (`AdCh`) mask it: it shows only inside their outlines;
+ /// an attached mask layer is a pixel mask. Adjustments attached the same way are reported.
+ fn attached_masks(&mut self, id: ObjId, world: Affine) -> Result<(Option, Option), Error> {
+ let s = self.s;
+ let attached = s.objs(id, b"AdCh");
+ let mut mask: Option = None;
+ let mut pixels: Option = None;
+ for a in attached {
+ let class = s.class(a).unwrap_or(Tag(0));
+ if s.bool(a, b"Visi") == Some(false) {
+ continue;
+ }
+ if class == Tag::of(b"PCrv") || class == Tag::of(b"ShpN") || class == Tag::of(b"Comp") {
+ let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world);
+ if let Some((p, _)) = crate::geometry::outline(self, a, class, local) {
+ mask.get_or_insert_with(Path::default).subpaths.extend(p.transformed(local).subpaths);
+ }
+ } else if class == Tag::of(b"MRst") {
+ let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world);
+ match crate::raster::mask(self, a, local)? {
+ Some(m) if pixels.is_none() => pixels = Some(m),
+ Some(_) => self.warn("several pixel masks on one layer (only the first is used)"),
+ None => {}
+ }
+ } else {
+ self.warn(format!("{} layers attached to other layers (imported without them)", describe(class)));
+ }
+ }
+ Ok((mask, pixels))
+ }
+
+ fn artboard(&mut self, id: ObjId, world: Affine) -> Kind {
+ let s = self.s;
+ let b = s.floats::<4>(id, b"ShpB").map(rect).unwrap_or(Rect { x0: 0.0, y0: 0.0, x1: 0.0, y1: 0.0 });
+ let corners =
+ [Point { x: b.x0, y: b.y0 }, Point { x: b.x1, y: b.y0 }, Point { x: b.x1, y: b.y1 }, Point { x: b.x0, y: b.y1 }].map(|p| world.apply(p));
+ let [a, b2, c, d, ..] = world.0;
+ if b2.abs() > 1e-9 || c.abs() > 1e-9 || a <= 0.0 || d <= 0.0 {
+ self.warn("a rotated or flipped artboard (imported at its bounding box)");
+ }
+ let xs = corners.map(|p| p.x);
+ let ys = corners.map(|p| p.y);
+ let rect = Rect {
+ x0: xs.iter().copied().fold(f64::INFINITY, f64::min),
+ y0: ys.iter().copied().fold(f64::INFINITY, f64::min),
+ x1: xs.iter().copied().fold(f64::NEG_INFINITY, f64::max),
+ y1: ys.iter().copied().fold(f64::NEG_INFINITY, f64::max),
+ };
+ let background = paint::fills(self, id, world, b"BFFl");
+ Kind::Artboard { rect, background }
+ }
+}
+
+/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC`
+/// equal pages (Publisher 1 facing spreads).
+fn pages(s: &Stream, spread: ObjId) -> Vec {
+ if let Some(md) = s.obj(spread, b"SpMd") {
+ let v: Vec =
+ s.objs(md, b"PagR").into_iter().filter_map(|p| s.floats::<4>(p, b"rctp").map(rect)).filter(|r| r.x1 > r.x0 && r.y1 > r.y0).collect();
+ if !v.is_empty() {
+ return v;
+ }
+ }
+ match (s.floats::<4>(spread, b"SprB").map(rect), s.int(spread, b"PagC")) {
+ (Some(b), Some(n @ 2..=16)) => {
+ let w = (b.x1 - b.x0) / n as f64;
+ (0..n).map(|i| Rect { x0: b.x0 + w * i as f64, x1: b.x0 + w * (i + 1) as f64, ..b }).collect()
+ }
+ _ => Vec::new(),
+ }
+}
+
+pub(crate) fn union(a: Rect, b: Rect) -> Rect {
+ Rect { x0: a.x0.min(b.x0), y0: a.y0.min(b.y0), x1: a.x1.max(b.x1), y1: a.y1.max(b.y1) }
+}
+
+fn is_container(class: Tag) -> bool {
+ class == Tag::of(b"Scop") || class == Tag::of(b"Grup")
+}
+
+pub(crate) fn rect(v: [f64; 4]) -> Rect {
+ Rect { x0: v[0], y0: v[1], x1: v[2], y1: v[3] }
+}
+
+/// A readable name for a layer class in warnings.
+pub(crate) fn describe(class: Tag) -> &'static str {
+ match &class.0.to_be_bytes() {
+ b"PCrv" => "curve",
+ b"ShpN" | b"SNEN" | b"SNRR" | b"ShRN" => "shape",
+ b"Comp" => "compound",
+ b"Rstr" => "pixel",
+ b"ImgN" => "image",
+ b"EmbN" => "embedded document",
+ b"MPIN" => "master page instance",
+ b"FRst" => "fill",
+ b"MRst" => "mask",
+ b"Slic" | b"SlcP" => "slice",
+ b"3DLA" => "3D",
+ [_, _, b'R', b'A'] => "adjustment",
+ _ => "unknown",
+ }
+}
diff --git a/crates/affinity/src/paint.rs b/crates/affinity/src/paint.rs
new file mode 100644
index 00000000..7f08fa51
--- /dev/null
+++ b/crates/affinity/src/paint.rs
@@ -0,0 +1,329 @@
+//! Fills, strokes and colours. Two schemas exist: Affinity 1 (`BFil` fill, `PFil` stroke paint,
+//! `LSty` stroke style) and Affinity 2/3 (`BFFl`, `LIFl` and `LILn` lists with a current index).
+
+use crate::model::{Affine, Reader};
+use crate::stream::{ObjId, Value};
+
+/// A colour as stored: RGB and grey values are encoded in the document's RGB space (sRGB unless
+/// the document embeds another profile), CMYK values go through its CMYK profile. Alpha is straight.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum Color {
+ Rgb {
+ r: f64,
+ g: f64,
+ b: f64,
+ a: f64,
+ },
+ Cmyk {
+ c: f64,
+ m: f64,
+ y: f64,
+ k: f64,
+ a: f64,
+ },
+ Gray {
+ v: f64,
+ a: f64,
+ },
+ /// CIE L*a*b* (D50).
+ Lab {
+ l: f64,
+ a: f64,
+ b: f64,
+ alpha: f64,
+ },
+}
+
+impl Color {
+ pub fn alpha(&self) -> f64 {
+ match self {
+ Color::Rgb { a, .. } | Color::Cmyk { a, .. } | Color::Gray { a, .. } => *a,
+ Color::Lab { alpha, .. } => *alpha,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum GradientKind {
+ Linear,
+ /// Elliptical and radial: distance from the centre in unit space.
+ Radial,
+ /// Angle around the centre.
+ Conical,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Stop {
+ pub offset: f64,
+ pub color: Color,
+ /// Affinity's midpoint bias for the segment from this stop to the next (0.5 = even blend).
+ pub bias: f64,
+}
+
+impl Stop {
+ /// Where, between this stop (0) and the next (1), the blend reaches half way: the bias curve
+ /// fitted to Affinity's renders is `t^k` (bias ≥ 0.5) or `1-(1-t)^k`, with `k = 1 + 8·|bias-0.5|`.
+ pub fn half_point(&self) -> f64 {
+ let k = 1.0 + 8.0 * (self.bias - 0.5).abs();
+ let h = 0.5f64.powf(1.0 / k);
+ if self.bias >= 0.5 { h } else { 1.0 - h }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct Gradient {
+ pub kind: GradientKind,
+ pub stops: Vec,
+ /// Unit space to document pixels: linear runs from unit (0,0) to (1,0); radial is the unit
+ /// circle; conical starts along unit +x and turns towards unit +y.
+ pub transform: Affine,
+}
+
+#[derive(Debug, Clone, PartialEq, Default)]
+pub enum Paint {
+ #[default]
+ None,
+ Solid(Color),
+ Gradient(Gradient),
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Cap {
+ Butt,
+ Round,
+ Square,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Join {
+ Miter,
+ Round,
+ Bevel,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Align {
+ Center,
+ Inside,
+ Outside,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct Stroke {
+ pub paint: Paint,
+ /// Width in document pixels.
+ pub width: f64,
+ pub cap: Cap,
+ pub join: Join,
+ pub miter_limit: f64,
+ pub align: Align,
+ /// Dash and gap lengths in document pixels, and the phase.
+ pub dash: Option<(Vec, f64)>,
+ /// Painted below the fill instead of above it.
+ pub behind: bool,
+}
+
+/// Gradient stops per gradient.
+const MAX_STOPS: usize = 1024;
+
+fn floats(v: &Value) -> Option> {
+ match v {
+ Value::Floats(f) => Some(f.clone()),
+ Value::Bytes(b) if b.len() % 4 == 0 => Some(b.chunks_exact(4).map(|c| f64::from(f32::from_le_bytes([c[0], c[1], c[2], c[3]]))).collect()),
+ _ => None,
+ }
+}
+
+/// A colour object (`RGBA`, `HSLA`, `CMYK`, `GRAY`, `LABA`, `Pant`).
+pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option {
+ let s = r.s;
+ let class = s.class(id)?;
+ let col = || -> Option> {
+ let v = floats(s.field(id, b"_col")?)?;
+ v.iter().all(|f| f.is_finite()).then_some(v)
+ };
+ Some(match &class.0.to_be_bytes() {
+ b"RGBA" => {
+ let v = col()?;
+ Color::Rgb { r: *v.first()?, g: *v.get(1)?, b: *v.get(2)?, a: v.get(3)?.clamp(0.0, 1.0) }
+ }
+ b"HSLA" => {
+ let v = col()?;
+ let (r2, g, b) = hsl(*v.first()?, *v.get(1)?, *v.get(2)?);
+ Color::Rgb { r: r2, g, b, a: v.get(3)?.clamp(0.0, 1.0) }
+ }
+ b"CMYK" => {
+ let v = col()?;
+ Color::Cmyk { c: *v.first()?, m: *v.get(1)?, y: *v.get(2)?, k: *v.get(3)?, a: v.get(4)?.clamp(0.0, 1.0) }
+ }
+ b"GRAY" => {
+ let v = col()?;
+ Color::Gray { v: *v.first()?, a: v.get(1)?.clamp(0.0, 1.0) }
+ }
+ b"LABA" => {
+ let b = s.bytes(id, b"_col")?;
+ let u = |i: usize| -> Option { Some(f64::from(u16::from_le_bytes([*b.get(2 * i)?, *b.get(2 * i + 1)?])) / 65535.0) };
+ Color::Lab { l: 100.0 * u(0)?, a: 255.0 * u(1)? - 128.0, b: 255.0 * u(2)? - 128.0, alpha: u(3)? }
+ }
+ b"Pant" => {
+ r.warn("spot colours (imported as their RGB equivalent)");
+ return s.obj(id, b"srgb").and_then(|c| color(r, c)).or_else(|| s.obj(id, b"base").and_then(|c| color(r, c)));
+ }
+ b"RegC" => Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0, a: 1.0 },
+ _ => {
+ r.warn("colours in an unknown model");
+ return None;
+ }
+ })
+}
+
+fn hsl(h: f64, s: f64, l: f64) -> (f64, f64, f64) {
+ let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
+ let hp = (h.rem_euclid(1.0)) * 6.0;
+ let x = c * (1.0 - (hp % 2.0 - 1.0).abs());
+ let (r, g, b) = match hp as u32 {
+ 0 => (c, x, 0.0),
+ 1 => (x, c, 0.0),
+ 2 => (0.0, c, x),
+ 3 => (0.0, x, c),
+ 4 => (x, 0.0, c),
+ _ => (c, 0.0, x),
+ };
+ let m = l - c / 2.0;
+ (r + m, g + m, b + m)
+}
+
+/// A fill descriptor (`FDsc`) or, in Affinity 1 files, a bare `FilS`; `local` maps the node's
+/// space to document pixels.
+pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option {
+ let s = r.s;
+ let (fill, unit) = if s.is(id, b"FDsc") {
+ let unit = s.floats::<6>(id, b"FDeX").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
+ (s.obj(id, b"FDeF")?, unit)
+ } else {
+ (id, Affine::IDENTITY)
+ };
+ match &s.class(fill)?.0.to_be_bytes() {
+ b"FilN" => Some(Paint::None),
+ b"FilS" => Some(Paint::Solid(color(r, s.obj(fill, b"Colr")?)?)),
+ b"FilG" => {
+ let kind = match s.enumeration(fill, b"Type").map(|(i, _)| i) {
+ Some(0) | None => GradientKind::Linear,
+ Some(1 | 2) => GradientKind::Radial,
+ Some(3) => GradientKind::Conical,
+ Some(_) => {
+ r.warn("gradients of an unknown kind (imported as linear)");
+ GradientKind::Linear
+ }
+ };
+ let grad = s.obj(fill, b"Grad")?;
+ let cols = s.objs(grad, b"Cols");
+ let posn: Vec> = match s.field(grad, b"Posn") {
+ Some(Value::Array(a)) => a.iter().filter_map(floats).collect(),
+ _ => Vec::new(),
+ };
+ let mut stops = Vec::new();
+ for (c, p) in cols.iter().zip(&posn).take(MAX_STOPS) {
+ let color = color(r, *c)?;
+ let offset = p.first().copied().filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0);
+ let bias = p.get(1).copied().filter(|v| v.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0);
+ stops.push(Stop { offset, color, bias });
+ }
+ if stops.is_empty() {
+ return Some(Paint::None);
+ }
+ Some(Paint::Gradient(Gradient { kind, stops, transform: unit.then(local) }))
+ }
+ b"FilB" => {
+ r.warn("bitmap and pattern fills (left empty)");
+ Some(Paint::None)
+ }
+ _ => {
+ r.warn("fills of an unknown kind (left empty)");
+ Some(Paint::None)
+ }
+ }
+}
+
+fn current(r: &Reader, id: ObjId, list: &[u8; 4], index: &[u8; 4]) -> Option {
+ let items = r.s.objs(id, list);
+ let i = r.s.int(id, index).and_then(|i| usize::try_from(i).ok()).unwrap_or(0);
+ items.get(i).or_else(|| items.first()).copied()
+}
+
+/// The fills of `list` (artboard backgrounds use `BFFl`).
+pub(crate) fn fills(r: &mut Reader, id: ObjId, local: Affine, list: &[u8; 4]) -> Vec {
+ let d = current(r, id, list, b"BFCr").or_else(|| r.s.obj(id, b"BFil"));
+ d.and_then(|d| descriptor(r, d, local)).filter(|p| *p != Paint::None).into_iter().collect()
+}
+
+/// A node's fill and stroke. `local` maps the node's space to document pixels.
+pub(crate) fn node_paint(r: &mut Reader, id: ObjId, local: Affine) -> (Vec, Vec) {
+ let s = r.s;
+ let fills = fills(r, id, local, b"BFFl");
+ if r.s.objs(id, b"BFFl").len() > 1 || r.s.objs(id, b"LIFl").len() > 1 {
+ r.warn("several fills or strokes on one object (only the active one is imported)");
+ }
+ if s.f64(id, b"FOpc").is_some_and(|o| o < 0.999) {
+ r.warn("fill opacity (imported at full fill opacity)");
+ }
+ if s.obj(id, b"Trns").is_some() {
+ r.warn("transparency gradients (imported without them)");
+ }
+ let (pen, style) = if s.field(id, b"LIFl").is_some() {
+ (current(r, id, b"LIFl", b"LICr"), current(r, id, b"LILn", b"LICr"))
+ } else {
+ (s.obj(id, b"PFil"), s.obj(id, b"LSty"))
+ };
+ let behind = matches!(s.field(id, b"DrwO"), Some(Value::Array(a)) if a.first() == Some(&Value::Enum { id: 1, version: 0 }));
+ let stroke = match (pen, style) {
+ (Some(pen), Some(style)) => stroke(r, pen, style, local, behind),
+ _ => None,
+ };
+ (fills, stroke.into_iter().collect())
+}
+
+fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) -> Option {
+ let s = r.s;
+ let paint = descriptor(r, pen, local)?;
+ if paint == Paint::None {
+ return None;
+ }
+ let lsty = if s.is(ldsc, b"LDsc") { s.obj(ldsc, b"LDeL")? } else { ldsc };
+ let weight = s.f64(lsty, b"Wght").filter(|w| *w > 0.0 && *w < 1e7)?;
+ // "Scale with object": the weight follows the object's transform; otherwise it is absolute.
+ let scales = s.bool(ldsc, b"LDSc").unwrap_or(true);
+ let width = if scales { weight * local.scale() } else { weight };
+ let data = s.bytes(lsty, b"Data").unwrap_or_default();
+ let miter_limit = data.get(0..8).and_then(|b| b.try_into().ok()).map(f64::from_le_bytes).filter(|m| m.is_finite() && *m >= 1.0).unwrap_or(4.0);
+ // Cap and join bytes are only partly understood: (0, 0) is butt/miter in every clean sample;
+ // (2, 2) ends plain lines square and brush lines round; joins import round, which never
+ // looks wrong.
+ let (cap, join) = match (data.get(8).copied(), data.get(9).copied(), data.get(10).copied()) {
+ (Some(0), Some(0), _) => (Cap::Butt, Join::Miter),
+ (Some(0), Some(_), _) => (Cap::Butt, Join::Round),
+ (Some(2), Some(2), Some(3)) => (Cap::Round, Join::Round),
+ (Some(2), Some(2), _) => (Cap::Square, Join::Round),
+ _ => (Cap::Round, Join::Round),
+ };
+ let align = match s.int(ldsc, b"LDSa") {
+ Some(1) => Align::Inside,
+ Some(2) => Align::Outside,
+ _ => Align::Center,
+ };
+ let dash = match s.field(lsty, b"Patn") {
+ Some(Value::Array(a)) if data.get(10) == Some(&2) || !a.is_empty() => {
+ let mut v: Vec = a.iter().filter_map(|x| if let Value::Float(f) = x { Some(*f) } else { None }).collect();
+ while v.len() >= 2 && v.iter().rev().take(2).all(|x| *x == 0.0) {
+ v.truncate(v.len() - 2);
+ }
+ let valid = !v.is_empty() && v.iter().all(|x| x.is_finite() && *x >= 0.0) && v.iter().any(|x| *x > 0.0);
+ valid.then(|| (v.iter().map(|x| x * width).collect(), s.f64(lsty, b"Phse").unwrap_or(0.0) * width))
+ }
+ _ => None,
+ };
+ if s.obj(lsty, b"Brus").is_some() || s.obj(ldsc, b"LDeP").is_some() {
+ r.warn("brush and pressure strokes (imported as plain strokes)");
+ }
+ Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind })
+}
diff --git a/crates/affinity/src/preview.rs b/crates/affinity/src/preview.rs
new file mode 100644
index 00000000..f50cb8c3
--- /dev/null
+++ b/crates/affinity/src/preview.rs
@@ -0,0 +1,127 @@
+//! The indexed PNG thumbnail Affinity stores next to the archive: Affinity's own render of the
+//! document, at most 512 pixels on the long side in real files, never the document itself.
+
+use crate::Error;
+use crate::container::header;
+
+pub const MAX_PREVIEW_BYTES: usize = 16 << 20;
+pub const MAX_PREVIEW_DIMENSION: u32 = 4096;
+
+/// Borrowed embedded thumbnail, at its own dimensions, never the native document dimensions.
+#[derive(Debug)]
+pub struct Preview<'a> {
+ pub png: &'a [u8],
+ pub width: u32,
+ pub height: u32,
+}
+
+fn bytes_at(bytes: &[u8], offset: usize, length: usize) -> Result<&[u8], Error> {
+ let end = offset.checked_add(length).ok_or(Error::Malformed("offset overflow"))?;
+ bytes.get(offset..end).ok_or(Error::Malformed("truncated record"))
+}
+
+fn le32(bytes: &[u8], offset: usize) -> Result {
+ let b = bytes_at(bytes, offset, 4)?;
+ Ok(u32::from_le_bytes(b.try_into().map_err(|_| Error::Malformed("integer"))?))
+}
+
+fn be32(bytes: &[u8], offset: usize) -> Result {
+ let b = bytes_at(bytes, offset, 4)?;
+ Ok(u32::from_be_bytes(b.try_into().map_err(|_| Error::Malformed("PNG integer"))?))
+}
+
+/// Read only the indexed `Thmb` record (identical in container versions 8 to 12), checking every PNG chunk CRC. Compressed
+/// pixels must still be validated by the consuming image decoder, with allocation limits.
+pub fn preview(bytes: &[u8]) -> Result, Error> {
+ let h = header(bytes)?;
+ if h.thumbnail_offset == 0 {
+ return Err(Error::Unsupported("no embedded preview"));
+ }
+ let offset = usize::try_from(h.thumbnail_offset).map_err(|_| Error::Malformed("thumbnail offset overflow"))?;
+ if offset < 72 {
+ return Err(Error::Malformed("thumbnail overlaps the file header"));
+ }
+ let record = bytes.get(offset..).ok_or(Error::Malformed("thumbnail offset outside file"))?;
+ if bytes_at(record, 0, 8)? != b"\xff\xff\xff\xffThmb" {
+ return Err(Error::Malformed("missing indexed thumbnail"));
+ }
+ if le32(record, 8)? != 1 || bytes_at(record, 28, 1)? != [1] {
+ return Err(Error::Unsupported("thumbnail record version or encoding"));
+ }
+ let size = usize::try_from(le32(record, 24)?).map_err(|_| Error::Limit("encoded size"))?;
+ if size > MAX_PREVIEW_BYTES {
+ return Err(Error::Limit("encoded preview exceeds 16 MiB"));
+ }
+ if le32(record, 16)? != 29 || le32(record, 20)? != 0 || u64::from(le32(record, 12)?) != size as u64 + 13 {
+ return Err(Error::Malformed("inconsistent thumbnail record lengths"));
+ }
+ let png = bytes_at(record, 29, size)?;
+ if bytes_at(png, 0, 8)? != b"\x89PNG\r\n\x1a\n" || be32(png, 8)? != 13 || bytes_at(png, 12, 4)? != b"IHDR" {
+ return Err(Error::Malformed("thumbnail is not a PNG"));
+ }
+ let width = be32(png, 16)?;
+ let height = be32(png, 20)?;
+ if width == 0 || height == 0 || width > MAX_PREVIEW_DIMENSION || height > MAX_PREVIEW_DIMENSION {
+ return Err(Error::Limit("preview dimensions must be 1..4096"));
+ }
+ // Bound work by bytes, not a file-supplied chunk count, and require exactly one complete PNG.
+ let mut pos = 8usize;
+ let (mut palette, mut image_data, mut image_data_ended) = (false, false, false);
+ loop {
+ let length = usize::try_from(be32(png, pos)?).map_err(|_| Error::Limit("PNG chunk length"))?;
+ let chunk_size = length.checked_add(12).ok_or(Error::Malformed("PNG chunk overflow"))?;
+ let chunk = bytes_at(png, pos, chunk_size)?;
+ let checksum_input = bytes_at(chunk, 4, chunk_size - 8)?;
+ if crc32fast::hash(checksum_input) != be32(chunk, chunk_size - 4)? {
+ return Err(Error::Malformed("PNG chunk checksum"));
+ }
+ let kind = bytes_at(chunk, 4, 4)?;
+ if !kind.iter().all(u8::is_ascii_alphabetic) {
+ return Err(Error::Malformed("PNG chunk type"));
+ }
+ match kind {
+ b"IHDR" if pos == 8 => {}
+ b"IHDR" => return Err(Error::Malformed("duplicate PNG header")),
+ b"PLTE" => {
+ if palette || image_data {
+ return Err(Error::Malformed("PNG palette order"));
+ }
+ palette = true;
+ }
+ b"IDAT" => {
+ if image_data_ended {
+ return Err(Error::Malformed("non-contiguous PNG image data"));
+ }
+ image_data = true;
+ }
+ b"IEND" => {
+ if !image_data {
+ return Err(Error::Malformed("PNG has no image data"));
+ }
+ }
+ _ if kind.first().is_some_and(u8::is_ascii_uppercase) => {
+ return Err(Error::Unsupported("unknown critical PNG chunk"));
+ }
+ _ => {}
+ }
+ if image_data && kind != b"IDAT" {
+ image_data_ended = true;
+ }
+ // Native sample previews are plain, single-frame PNGs. Pixel allocation limits do not
+ // necessarily bound aggregate compressed metadata; leave these layouts unsupported.
+ if matches!(kind, b"zTXt" | b"iTXt" | b"iCCP") {
+ return Err(Error::Unsupported("compressed PNG metadata in the preview"));
+ }
+ if matches!(kind, b"acTL" | b"fcTL" | b"fdAT") {
+ return Err(Error::Unsupported("animated PNG preview"));
+ }
+ pos = pos.checked_add(chunk_size).ok_or(Error::Malformed("PNG offset overflow"))?;
+ if kind == b"IEND" {
+ if length != 0 || pos != png.len() {
+ return Err(Error::Malformed("PNG end or trailing data"));
+ }
+ break;
+ }
+ }
+ Ok(Preview { png, width, height })
+}
diff --git a/crates/affinity/src/raster.rs b/crates/affinity/src/raster.rs
new file mode 100644
index 00000000..f89aa262
--- /dev/null
+++ b/crates/affinity/src/raster.rs
@@ -0,0 +1,243 @@
+//! Pixel layers (`Rstr`) and placed images (`ImgN`). A placed image keeps its original file
+//! (`DyBm.Bckg` → archive entry `c/N`), which is imported as is; other pixels are planar tiles of
+//! 256 bytes Ă— 256 rows per channel, each its own archive entry.
+
+use std::collections::HashMap;
+
+use crate::Error;
+use crate::model::{Affine, Image, Pixels, Reader};
+use crate::stream::{self, ObjId, Tag, Value};
+
+/// Pixels decoded per layer (the content's bounding box, not the canvas).
+const MAX_PIXELS: u64 = 64 << 20;
+const TILE: usize = 256;
+const TILE_BYTES: usize = TILE * TILE;
+
+pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result, Error> {
+ let s = r.s;
+ let Some(bitmap) = s.obj(id, b"Bitm") else {
+ r.warn("pixel layers without pixels");
+ return Ok(None);
+ };
+ if !s.is(bitmap, b"DyBm") {
+ r.warn("pixel layers stored in an unknown way");
+ return Ok(None);
+ }
+ let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
+ let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
+ if width == 0 || height == 0 {
+ return Ok(None);
+ }
+ // A placed image: the original file, byte for byte.
+ let has_tiles = s.field(bitmap, b"Sta1").is_some();
+ if let Some(name) = s.entry(bitmap, b"Bckg").filter(|n| !n.is_empty())
+ && (s.is(id, b"ImgN") || !has_tiles)
+ {
+ match original(r, name) {
+ Ok(bytes) => return Ok(Some(Image { width, height, pixels: Pixels::Encoded(bytes), transform: world })),
+ Err(Error::Limit(e)) => return Err(Error::Limit(e)),
+ Err(_) => r.warn("an embedded image file that could not be read (its cached pixels were used)"),
+ }
+ }
+ // The content's bounding box, so a mostly empty canvas-sized layer stays small.
+ let crop = s
+ .field(id, b"BitR")
+ .and_then(|v| if let Value::Ints(i) = v { Some(i.clone()) } else { None })
+ .filter(|v| v.len() == 4 && v[0] > i64::from(i32::MIN) + 1)
+ .map(|v| {
+ (
+ v[0].clamp(0, i64::from(width)) as u32,
+ v[1].clamp(0, i64::from(height)) as u32,
+ v[2].clamp(0, i64::from(width)) as u32,
+ v[3].clamp(0, i64::from(height)) as u32,
+ )
+ })
+ .unwrap_or((0, 0, width, height));
+ if s.field(bitmap, b"Bckg").is_some() && has_tiles && !s.is(id, b"ImgN") {
+ r.warn("edited placed images use their stored pixels");
+ }
+ decode(r, bitmap, crop, world)
+}
+
+/// A mask layer (`MRst`): one channel of coverage as grey pixels (white shows, black hides).
+pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine) -> Result , Error> {
+ let s = r.s;
+ let Some(bitmap) = s.obj(id, b"Bitm") else { return Ok(None) };
+ let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
+ let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
+ if width == 0 || height == 0 || !matches!(s.enumeration(bitmap, b"Frmt"), Some((6 | 7, _))) {
+ r.warn("pixel masks in an unknown format (imported without them)");
+ return Ok(None);
+ }
+ decode(r, bitmap, (0, 0, width, height), world)
+}
+
+/// An embedded document or symbol instance (`EmbN`), as the picture of it Affinity caches.
+pub(crate) fn embedded(r: &mut Reader, id: ObjId, world: Affine) -> Result , Error> {
+ let s = r.s;
+ let Some(cache) = s.obj(id, b"Bitm").and_then(|e| s.obj(e, b"Cach")) else { return Ok(None) };
+ let Some(bbox) = s.obj(id, b"Bitm").and_then(|e| s.floats::<4>(e, b"ChBB")) else { return Ok(None) };
+ let width = s.int(cache, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
+ let height = s.int(cache, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
+ if width == 0 || height == 0 {
+ return Ok(None);
+ }
+ let [x0, y0, x1, y1] = bbox;
+ // The instance's origin is the centre of the embedded content (checked against the
+ // thumbnails of documents with embedded documents and SVG files).
+ let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
+ let place = Affine([(x1 - x0) / f64::from(width), 0.0, 0.0, (y1 - y0) / f64::from(height), x0 - cx, y0 - cy]).then(world);
+ decode(r, cache, (0, 0, width, height), place)
+}
+
+/// Decode the tiles of a bitmap inside `crop` (pixels), placed by `world` (bitmap pixels to document).
+fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affine) -> Result , Error> {
+ let s = r.s;
+ let (x0, y0, x1, y1) = crop;
+ if x1 <= x0 || y1 <= y0 {
+ return Ok(None);
+ }
+ let (w, h) = (x1 - x0, y1 - y0);
+ if u64::from(w) * u64::from(h) > MAX_PIXELS {
+ r.warn("pixel layers larger than 64 megapixels");
+ return Ok(None);
+ }
+ let format = s.enumeration(bitmap, b"Frmt").map(|(f, _)| f);
+ let (channels, bps) = match format {
+ Some(0) => (4, 1),
+ Some(1) => (4, 2),
+ Some(6) => (1, 1),
+ Some(7) => (1, 2),
+ Some(4) => {
+ r.warn("CMYK pixel layers (converted to RGB without the document's colour profile)");
+ (5, 1)
+ }
+ _ => {
+ r.warn("pixel layers in grey, Lab or 32-bit formats");
+ return Ok(None);
+ }
+ };
+ let mut planes = Vec::with_capacity(channels);
+ let mut cache = HashMap::new();
+ for c in 1..=channels {
+ planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), &mut cache)?);
+ }
+ let n = (w as usize) * (h as usize);
+ let mut rgba = vec![0u8; n * 4];
+ let sample = |p: &Vec, i: usize| -> f64 {
+ if bps == 2 {
+ f64::from(u16::from_le_bytes([p.get(2 * i).copied().unwrap_or(0), p.get(2 * i + 1).copied().unwrap_or(0)])) / 65535.0
+ } else {
+ f64::from(p.get(i).copied().unwrap_or(0)) / 255.0
+ }
+ };
+ for (i, px) in rgba.chunks_exact_mut(4).enumerate() {
+ let v: Vec = planes.iter().map(|p| sample(p, i)).collect();
+ let (r2, g, b, a) = if channels == 1 {
+ (v[0], v[0], v[0], 1.0)
+ } else if channels == 5 {
+ let k = 1.0 - v[3];
+ ((1.0 - v[0]) * k, (1.0 - v[1]) * k, (1.0 - v[2]) * k, v[4])
+ } else {
+ (v[0], v[1], v[2], v[3])
+ };
+ px.copy_from_slice(&[r2, g, b, a].map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8));
+ }
+ let transform = Affine([1.0, 0.0, 0.0, 1.0, f64::from(x0), f64::from(y0)]).then(world);
+ Ok(Some(Image { width: w, height: h, pixels: Pixels::Rgba8(rgba), transform }))
+}
+
+/// The original image file: entry `c/N` is a stream whose root (`Blck`) holds it as `Data`.
+fn original(r: &mut Reader, name: &str) -> Result, Error> {
+ let bytes = r.archive.read(name)?;
+ let s = stream::parse(&bytes)?;
+ let root = s.object(s.root).ok_or(Error::Malformed("embedded image"))?;
+ match root.get(Tag::of(b"Data")) {
+ Some(Value::Blob(b)) if !b.is_empty() => Ok(b.clone()),
+ _ => Err(Error::Malformed("embedded image without data")),
+ }
+}
+
+fn tag(prefix: &[u8; 3], c: usize) -> [u8; 4] {
+ [prefix[0], prefix[1], prefix[2], b'0' + c as u8]
+}
+
+/// One channel of level 0, cropped to (x0, y0, w, h) pixels; `bps` bytes per sample.
+fn plane(
+ r: &mut Reader,
+ bitmap: ObjId,
+ c: usize,
+ bps: usize,
+ (x0, y0, w, h): (u32, u32, u32, u32),
+ cache: &mut HashMap>,
+) -> Result, Error> {
+ let s = r.s;
+ let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
+ let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
+ let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| (width * bps).div_ceil(TILE));
+ let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE));
+ let states: Vec = match s.field(bitmap, &tag(b"Sta", c)) {
+ Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
+ _ => Vec::new(),
+ };
+ let tiles = s.objs(bitmap, &tag(b"Idx", c));
+ let row = (w as usize) * bps;
+ let mut out = vec![0u8; row * h as usize];
+ let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps);
+ let (py0, py1) = (y0 as usize, (y0 + h) as usize);
+ let mut next = 0usize;
+ for (t, state) in states.iter().enumerate().take(tw.saturating_mul(th)) {
+ let (ty, tx) = (t / tw.max(1), t % tw.max(1));
+ let fill = match state {
+ 0 | 1 => None,
+ 2 => Some(0xFF),
+ 4 => {
+ let Some(blck) = tiles.get(next).copied() else { return Err(Error::Malformed("missing pixel tile")) };
+ next += 1;
+ let (ox, oy) = (tx * TILE, ty * TILE);
+ if ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0 {
+ continue;
+ }
+ let name = s.entry(blck, b"Data").ok_or(Error::Malformed("pixel tile without data"))?.to_string();
+ if !cache.contains_key(&name) {
+ let mut data = r.archive.read(&name)?;
+ // Older files wrap a tile in a one-field stream: header, then a 64 KiB blob.
+ if data.len() == TILE_BYTES + 22 && data.starts_with(b"\x00\xffKS") {
+ data = data.get(21..21 + TILE_BYTES).ok_or(Error::Malformed("pixel tile"))?.to_vec();
+ }
+ if data.len() != TILE_BYTES {
+ return Err(Error::Malformed("pixel tile size"));
+ }
+ cache.insert(name.clone(), data);
+ }
+ let tile = cache.get(&name).ok_or(Error::Malformed("pixel tile"))?;
+ copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0));
+ continue;
+ }
+ 3 => {
+ r.warn("32-bit pixel layers");
+ None
+ }
+ 5 => {
+ r.warn("pixel layers drawn from an embedded image (left empty)");
+ None
+ }
+ _ => return Err(Error::Malformed("unknown pixel tile state")),
+ };
+ if let Some(v) = fill {
+ copy(&mut out, row, (bx0, py0, bx1, py1), (tx * TILE, ty * TILE), |_, _| v);
+ }
+ }
+ Ok(out)
+}
+
+/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane.
+fn copy(out: &mut [u8], row: usize, (bx0, py0, bx1, py1): (usize, usize, usize, usize), (ox, oy): (usize, usize), at: impl Fn(usize, usize) -> u8) {
+ for y in oy.max(py0)..(oy + TILE).min(py1) {
+ for x in ox.max(bx0)..(ox + TILE).min(bx1) {
+ if let Some(b) = out.get_mut((y - py0) * row + (x - bx0)) {
+ *b = at(x - ox, y - oy);
+ }
+ }
+ }
+}
diff --git a/crates/affinity/src/shapes.rs b/crates/affinity/src/shapes.rs
new file mode 100644
index 00000000..7f845dfe
--- /dev/null
+++ b/crates/affinity/src/shapes.rs
@@ -0,0 +1,259 @@
+//! Parametric shapes: Affinity stores the shape's parameters and its box, never the outline.
+//! Formulas were fitted to Affinity's own renders (the document thumbnails) of public files.
+
+use std::f64::consts::{FRAC_PI_2, PI, TAU};
+
+use crate::model::{Affine, Path, Point, Reader, SubPath};
+use crate::stream::{ObjId, Tag, Value};
+
+/// Handle length of a quarter circle of radius 1.
+const KAPPA: f64 = 0.552_284_749_830_793_4;
+/// Vertices of a polygon or star.
+const MAX_SIDES: i64 = 10_000;
+
+/// The last value of a tag: a shape changed from one kind to another keeps a stale first block.
+fn last<'a>(r: &'a Reader, id: ObjId, tag: &[u8; 4]) -> Option<&'a Value> {
+ let t = Tag::of(tag);
+ r.s.object(id)?.fields.iter().rev().find(|(k, _)| *k == t).map(|(_, v)| v)
+}
+
+fn num(r: &Reader, id: ObjId, tag: &[u8; 4]) -> Option {
+ match last(r, id, tag)? {
+ Value::Float(f) if f.is_finite() => Some(*f),
+ Value::UInt(u) => Some(*u as f64),
+ Value::Int(i) => Some(*i as f64),
+ _ => None,
+ }
+}
+
+fn sides(r: &Reader, id: ObjId, tag: &[u8; 4], default: f64) -> usize {
+ let n = num(r, id, tag).unwrap_or(default).round();
+ n.clamp(3.0, MAX_SIDES as f64) as usize
+}
+
+fn pt(x: f64, y: f64) -> Point {
+ Point { x, y }
+}
+
+fn polygon(pts: impl IntoIterator- ) -> Option
{
+ let mut it = pts.into_iter();
+ let start = it.next()?;
+ let segments = it.map(|p| [p, p, p]).collect();
+ Some(SubPath { start, segments, closed: true })
+}
+
+/// Outline of `shape` (a `Shpe` object) in the node box `b` = (x0, y0, x1, y1).
+pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) -> Option {
+ let [x0, y0, x1, y1] = b;
+ let (w, h) = (x1 - x0, y1 - y0);
+ let (cx, cy, rx, ry) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, w / 2.0, h / 2.0);
+ let on_ellipse = |a: f64, f: f64| pt(cx + f * rx * a.cos(), cy + f * ry * a.sin());
+ let class = r.s.class(shape)?;
+ let sub = match &class.0.to_be_bytes() {
+ b"ShNR" | b"ShRR" => {
+ let rounded = matches!(last(r, shape, b"CTyp"), Some(Value::Array(a)) if !a.is_empty());
+ let radius = match last(r, shape, b"ShCR") {
+ Some(Value::Floats(v)) => v.first().copied().filter(|f| f.is_finite() && *f > 0.0),
+ _ => None,
+ };
+ if let (true, Some(f)) = (rounded, radius) {
+ if let Some(Value::Array(types)) = last(r, shape, b"CTyp")
+ && types.iter().any(|t| !matches!(t, Value::Enum { id: 0 | 4, .. }))
+ {
+ r.warn("rectangles with straight, concave or other corner types (imported with round corners)");
+ }
+ let (crx, cry) = if r.s.bool(shape, b"AbSz") == Some(true) {
+ // An absolute radius is in document pixels, whatever the node's scale.
+ let [a, b2, c, d, ..] = world.0;
+ let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt());
+ if sx > 0.0 && sy > 0.0 { (f / sx, f / sy) } else { (0.0, 0.0) }
+ } else {
+ (f * w.min(h), f * w.min(h))
+ };
+ rounded_rect(b, crx.min(w / 2.0), cry.min(h / 2.0))
+ } else {
+ polygon([pt(x0, y0), pt(x1, y0), pt(x1, y1), pt(x0, y1)])?
+ }
+ }
+ b"ShpE" => ellipse(cx, cy, rx, ry),
+ b"ShPy" => {
+ let n = sides(r, shape, b"Side", 5.0);
+ let a = |i: usize| -FRAC_PI_2 + TAU * i as f64 / n as f64;
+ if r.s.object(shape).is_some_and(|_| matches!(last(r, shape, b"Smth"), Some(Value::Bool(true)))) {
+ // Smoothed: tangent handles of length curv·4/3·tan(π/2n) along the inscribed ellipse.
+ let k = num(r, shape, b"Curv").unwrap_or(0.0) * 4.0 / 3.0 * (PI / (2.0 * n as f64)).tan();
+ let v = |i: usize| on_ellipse(a(i), 1.0);
+ let t = |i: usize| pt(-rx * a(i).sin(), ry * a(i).cos());
+ let segments = (0..n)
+ .map(|i| {
+ let (p, q, tp, tq) = (v(i), v(i + 1), t(i), t(i + 1));
+ [pt(p.x + k * tp.x, p.y + k * tp.y), pt(q.x - k * tq.x, q.y - k * tq.y), q]
+ })
+ .collect();
+ SubPath { start: v(0), segments, closed: true }
+ } else {
+ polygon((0..n).map(|i| on_ellipse(a(i), 1.0)))?
+ }
+ }
+ b"ShSt" => {
+ let n = sides(r, shape, b"Pnts", 5.0);
+ let q = num(r, shape, b"IRad").unwrap_or(0.382).clamp(0.0, 1.0);
+ if [b"CrcI", b"CrcO", b"CrvL", b"CrvR"].iter().any(|t| num(r, shape, t).is_some_and(|v| v.abs() > 1e-9)) {
+ r.warn("stars with rounded or curved points (imported with sharp points)");
+ }
+ polygon((0..2 * n).map(|i| on_ellipse(-FRAC_PI_2 + PI * i as f64 / n as f64, if i % 2 == 0 { 1.0 } else { q })))?
+ }
+ b"ShSS" => {
+ let n = sides(r, shape, b"Side", 5.0);
+ let ri = num(r, shape, b"COut").unwrap_or(0.5).clamp(0.0, 1.0);
+ let step = PI / n as f64;
+ let (l, hw, inner) = (step.cos(), ri * step.sin(), ri * step.cos());
+ polygon((0..n).flat_map(|k| {
+ let phi = -FRAC_PI_2 + step + TAU * k as f64 / n as f64;
+ let (c, s) = (phi.cos(), phi.sin());
+ [(inner, -hw), (l, -hw), (l, hw), (inner, hw)].map(|(u, v)| pt(cx + rx * (u * c - v * s), cy + ry * (u * s + v * c)))
+ }))?
+ }
+ b"ShPi" => {
+ let start = num(r, shape, b"AngS").unwrap_or(0.0);
+ let mut end = num(r, shape, b"AngE").unwrap_or(TAU);
+ while end < start {
+ end += TAU;
+ }
+ let inner = num(r, shape, b"IRad").unwrap_or(0.0).clamp(0.0, 1.0);
+ pie(cx, cy, rx, ry, start, end.min(start + TAU), inner)
+ }
+ b"ShpT" => {
+ let p = num(r, shape, b"Pos ").unwrap_or(0.5);
+ polygon([pt(x0 + p * w, y0), pt(x1, y1), pt(x0, y1)])?
+ }
+ b"ShTz" => {
+ let (l, rr) = (num(r, shape, b"PosL").unwrap_or(0.25), num(r, shape, b"PosR").unwrap_or(0.75));
+ polygon([pt(x0 + l * w, y0), pt(x0 + rr * w, y0), pt(x1, y1), pt(x0, y1)])?
+ }
+ _ => {
+ r.warn("cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse)");
+ ellipse(cx, cy, rx, ry)
+ }
+ };
+ Some(Path { subpaths: vec![sub] })
+}
+
+pub(crate) fn rectangle(x0: f64, y0: f64, x1: f64, y1: f64) -> SubPath {
+ SubPath { start: pt(x0, y0), segments: [pt(x1, y0), pt(x1, y1), pt(x0, y1)].map(|p| [p, p, p]).to_vec(), closed: true }
+}
+
+fn ellipse(cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath {
+ let (kx, ky) = (KAPPA * rx, KAPPA * ry);
+ SubPath {
+ start: pt(cx + rx, cy),
+ segments: vec![
+ [pt(cx + rx, cy + ky), pt(cx + kx, cy + ry), pt(cx, cy + ry)],
+ [pt(cx - kx, cy + ry), pt(cx - rx, cy + ky), pt(cx - rx, cy)],
+ [pt(cx - rx, cy - ky), pt(cx - kx, cy - ry), pt(cx, cy - ry)],
+ [pt(cx + kx, cy - ry), pt(cx + rx, cy - ky), pt(cx + rx, cy)],
+ ],
+ closed: true,
+ }
+}
+
+fn rounded_rect([x0, y0, x1, y1]: [f64; 4], rx: f64, ry: f64) -> SubPath {
+ let (kx, ky) = ((1.0 - KAPPA) * rx, (1.0 - KAPPA) * ry);
+ SubPath {
+ start: pt(x0 + rx, y0),
+ segments: vec![
+ [pt(x1 - rx, y0), pt(x1 - rx, y0), pt(x1 - rx, y0)],
+ [pt(x1 - kx, y0), pt(x1, y0 + ky), pt(x1, y0 + ry)],
+ [pt(x1, y1 - ry), pt(x1, y1 - ry), pt(x1, y1 - ry)],
+ [pt(x1, y1 - ky), pt(x1 - kx, y1), pt(x1 - rx, y1)],
+ [pt(x0 + rx, y1), pt(x0 + rx, y1), pt(x0 + rx, y1)],
+ [pt(x0 + kx, y1), pt(x0, y1 - ky), pt(x0, y1 - ry)],
+ [pt(x0, y0 + ry), pt(x0, y0 + ry), pt(x0, y0 + ry)],
+ [pt(x0, y0 + ky), pt(x0 + kx, y0), pt(x0 + rx, y0)],
+ ],
+ closed: true,
+ }
+}
+
+/// Elliptical arc from angle `a0` to `a1` (radians, measured y-up as Affinity's pie angles are),
+/// as cubic segments of at most a quarter turn.
+fn arc(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, a1: f64, out: &mut Vec<[Point; 3]>) {
+ let n = ((a1 - a0).abs() / FRAC_PI_2).ceil().clamp(1.0, 8.0) as usize;
+ let step = (a1 - a0) / n as f64;
+ let k = 4.0 / 3.0 * (step / 4.0).tan();
+ let p = |a: f64| pt(cx + rx * a.cos(), cy - ry * a.sin());
+ let d = |a: f64| pt(-rx * a.sin(), -ry * a.cos());
+ for i in 0..n {
+ let (s, e) = (a0 + step * i as f64, a0 + step * (i + 1) as f64);
+ let (ps, pe, ds, de) = (p(s), p(e), d(s), d(e));
+ out.push([pt(ps.x + k * ds.x, ps.y + k * ds.y), pt(pe.x - k * de.x, pe.y - k * de.y), pe]);
+ }
+}
+
+fn pie(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, a1: f64, inner: f64) -> SubPath {
+ let start = pt(cx + rx * a0.cos(), cy - ry * a0.sin());
+ let mut segments = Vec::new();
+ arc(cx, cy, rx, ry, a0, a1, &mut segments);
+ if inner > 0.0 {
+ let p = pt(cx + inner * rx * a1.cos(), cy - inner * ry * a1.sin());
+ segments.push([p, p, p]);
+ arc(cx, cy, inner * rx, inner * ry, a1, a0, &mut segments);
+ } else {
+ let c = pt(cx, cy);
+ segments.push([c, c, c]);
+ }
+ SubPath { start, segments, closed: true }
+}
+
+/// A polyline with circular corners of the given radii (Affinity's live corners on curves).
+pub(crate) fn fillet(pts: &[(Point, f64)], closed: bool) -> SubPath {
+ let n = pts.len();
+ let get = |i: isize| -> Option {
+ if closed { pts.get(i.rem_euclid(n as isize) as usize).map(|p| p.0) } else { usize::try_from(i).ok().and_then(|i| pts.get(i)).map(|p| p.0) }
+ };
+ let mut corners: Vec<(Point, Point, Point, Point)> = Vec::with_capacity(n);
+ for (i, (p, radius)) in pts.iter().enumerate() {
+ let (prev, next) = (get(i as isize - 1), get(i as isize + 1));
+ let (Some(a), Some(b)) = (prev, next) else {
+ corners.push((*p, *p, *p, *p));
+ continue;
+ };
+ let (u1, l1) = unit(*p, a);
+ let (u2, l2) = unit(*p, b);
+ let cos = (u1.x * u2.x + u1.y * u2.y).clamp(-1.0, 1.0);
+ let phi = cos.acos();
+ if *radius <= 0.0 || phi < 1e-6 || (PI - phi) < 1e-6 {
+ corners.push((*p, *p, *p, *p));
+ continue;
+ }
+ let t = (radius / (phi / 2.0).tan()).min(l1 / 2.0).min(l2 / 2.0);
+ let r = t * (phi / 2.0).tan();
+ let k = 4.0 / 3.0 * ((PI - phi) / 4.0).tan() * r;
+ let s = pt(p.x + u1.x * t, p.y + u1.y * t);
+ let e = pt(p.x + u2.x * t, p.y + u2.y * t);
+ corners.push((s, pt(s.x - u1.x * k, s.y - u1.y * k), pt(e.x - u2.x * k, e.y - u2.y * k), e));
+ }
+ let Some(first) = corners.first().copied() else { return SubPath { start: pt(0.0, 0.0), segments: vec![], closed } };
+ let mut segments = Vec::with_capacity(2 * n);
+ let mut cur = first.0;
+ let start = cur;
+ for (i, (s, c1, c2, e)) in corners.iter().enumerate() {
+ if i > 0 {
+ segments.push([cur, *s, *s]);
+ }
+ if s != e {
+ segments.push([*c1, *c2, *e]);
+ }
+ cur = *e;
+ }
+ if closed && cur != start {
+ segments.push([cur, start, start]);
+ }
+ SubPath { start, segments, closed }
+}
+
+fn unit(from: Point, to: Point) -> (Point, f64) {
+ let (dx, dy) = (to.x - from.x, to.y - from.y);
+ let l = (dx * dx + dy * dy).sqrt();
+ if l > 0.0 { (pt(dx / l, dy / l), l) } else { (pt(0.0, 0.0), 0.0) }
+}
diff --git a/crates/affinity/src/stream.rs b/crates/affinity/src/stream.rs
new file mode 100644
index 00000000..516776b2
--- /dev/null
+++ b/crates/affinity/src/stream.rs
@@ -0,0 +1,376 @@
+//! The tagged object stream inside `doc.dat`: a self-describing tree of objects whose fields carry
+//! a type code and a four-character tag. Parsing needs no schema; unknown fields are kept, so the
+//! document mapping can name what it could not import.
+
+use crate::Error;
+use crate::container::Cursor;
+
+/// A four-character tag in its readable order (`Desc`, `Chld`).
+#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
+pub struct Tag(pub u32);
+
+impl Tag {
+ pub const fn of(s: &[u8; 4]) -> Self {
+ Self(u32::from_be_bytes(*s))
+ }
+}
+
+impl std::fmt::Debug for Tag {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}", String::from_utf8_lossy(&self.0.to_be_bytes()))
+ }
+}
+
+impl std::fmt::Display for Tag {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ std::fmt::Debug::fmt(self, f)
+ }
+}
+
+/// Index of an object in [`Stream::objects`].
+pub type ObjId = usize;
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum Value {
+ Int(i64),
+ UInt(u64),
+ Float(f64),
+ Bool(bool),
+ Enum {
+ id: u16,
+ version: u16,
+ },
+ Str(String),
+ /// Fixed-length vectors of i32/f32/f64 (points, rectangles, transforms).
+ Ints(Vec),
+ Floats(Vec),
+ /// Fixed-size record (curve nodes, ids) or fixed-size struct (colour components).
+ Bytes(Vec),
+ Blob(Vec),
+ /// Name of another archive entry (raster tiles, embedded files).
+ Entry(String),
+ Flags(u64),
+ /// `None` is a null object.
+ Obj(Option),
+ Array(Vec),
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Kind {
+ Root,
+ /// Defined once and referenced by id elsewhere.
+ Shared,
+ Inline,
+ /// Untagged fields identified by position (curve data).
+ Positional,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct Object {
+ pub class: Tag,
+ pub kind: Kind,
+ /// Class chain, most derived first (`ShpN`, `VNod`, `Node`).
+ pub chain: Vec,
+ /// Fields in stream order; positional fields have tag 0.
+ pub fields: Vec<(Tag, Value)>,
+}
+
+impl Object {
+ pub fn get(&self, tag: Tag) -> Option<&Value> {
+ self.fields.iter().find(|(t, _)| *t == tag).map(|(_, v)| v)
+ }
+}
+
+#[derive(Debug)]
+pub struct Stream {
+ pub root: ObjId,
+ pub objects: Vec,
+}
+
+/// Deepest object nesting accepted; real documents stay well below (each layer level adds three).
+pub const MAX_DEPTH: usize = 384;
+
+const MAX_OBJECTS: usize = 8_000_000;
+
+struct Parser<'a> {
+ c: Cursor<'a>,
+ objects: Vec,
+ /// Shared ids, defined at first use and referenced backwards.
+ shared: std::collections::HashMap,
+ depth: usize,
+}
+
+/// Parse a stream (`00 FF 4B 53` header). Bytes after the root's terminator are ignored.
+pub fn parse(bytes: &[u8]) -> Result {
+ let mut c = Cursor::new(bytes, 0);
+ if c.take(4)? != b"\x00\xffKS" {
+ return Err(Error::Malformed("document stream signature"));
+ }
+ let version = c.u16()?;
+ if version > 2 {
+ return Err(Error::Unsupported("document stream version"));
+ }
+ let class = Tag(c.u32()?);
+ c.u16()?;
+ if version == 2 {
+ c.u32()?;
+ }
+ let mut p = Parser { c, objects: Vec::new(), shared: std::collections::HashMap::new(), depth: 0 };
+ let root = p.push(Object { class, kind: Kind::Root, chain: vec![class], fields: Vec::new() })?;
+ let fields = p.fields(true, class)?;
+ if let Some(o) = p.objects.get_mut(root) {
+ o.fields = fields;
+ }
+ Ok(Stream { root, objects: p.objects })
+}
+
+impl Parser<'_> {
+ fn push(&mut self, o: Object) -> Result {
+ if self.objects.len() >= MAX_OBJECTS {
+ return Err(Error::Limit("too many objects"));
+ }
+ self.objects.push(o);
+ Ok(self.objects.len() - 1)
+ }
+
+ /// Element count of an array: each element takes at least `min` bytes, so a count the
+ /// remaining input cannot hold is rejected before anything is allocated.
+ fn count(&mut self, min: usize) -> Result {
+ let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?;
+ if n.checked_mul(min.max(1)).is_none_or(|bytes| bytes > self.c.remaining()) {
+ return Err(Error::Malformed("array longer than its data"));
+ }
+ Ok(n)
+ }
+
+ fn fields(&mut self, tagged: bool, class: Tag) -> Result, Error> {
+ self.depth += 1;
+ if self.depth > MAX_DEPTH {
+ return Err(Error::Limit("objects nested too deeply"));
+ }
+ let mut out = Vec::new();
+ loop {
+ let t = self.c.u8()?;
+ let code = t & 0x7F;
+ if code == 0 {
+ break;
+ }
+ let tag = if tagged { Tag(self.c.u32()?) } else { Tag(0) };
+ let v = if t & 0x80 != 0 { self.array(code, class)? } else { self.scalar(code, tagged, class)? };
+ out.push((tag, v));
+ }
+ self.depth -= 1;
+ Ok(out)
+ }
+
+ fn string(&mut self) -> Result {
+ let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("string length"))?;
+ Ok(String::from_utf8_lossy(self.c.take(n)?).into_owned())
+ }
+
+ fn floats(&mut self, n: usize, wide: bool) -> Result {
+ let mut v = Vec::with_capacity(n);
+ for _ in 0..n {
+ v.push(if wide { f64::from_le_bytes(self.c.array()?) } else { f64::from(f32::from_le_bytes(self.c.array()?)) });
+ }
+ Ok(Value::Floats(v))
+ }
+
+ fn scalar(&mut self, code: u8, tagged: bool, class: Tag) -> Result {
+ let c = &mut self.c;
+ Ok(match code {
+ 0x01 => Value::UInt(c.u8()?.into()),
+ 0x02 => Value::UInt(c.u16()?.into()),
+ 0x03 | 0x2f | 0x34 => Value::UInt(c.u32()?.into()),
+ 0x04 => Value::UInt(c.u64()?),
+ 0x05 => Value::Int(i8::from_le_bytes(c.array()?).into()),
+ 0x06 => Value::Int(i16::from_le_bytes(c.array()?).into()),
+ 0x07 => Value::Int(i32::from_le_bytes(c.array()?).into()),
+ 0x08 => Value::Int(i64::from_le_bytes(c.array()?)),
+ 0x09 => Value::Float(f32::from_le_bytes(c.array()?).into()),
+ 0x0a => Value::Float(f64::from_le_bytes(c.array()?)),
+ 0x15..=0x19 => {
+ let n = usize::from(code - 0x13);
+ let mut v = Vec::with_capacity(n);
+ for _ in 0..n {
+ v.push(i32::from_le_bytes(c.array()?).into());
+ }
+ Value::Ints(v)
+ }
+ 0x1f..=0x23 => return self.floats(usize::from(code - 0x1d), false),
+ 0x24..=0x28 => return self.floats(usize::from(code - 0x22), true),
+ 0x29 => Value::Bool(c.u8()? != 0),
+ 0x2a => Value::Enum { id: c.u16()?, version: c.u16()? },
+ 0x2b | 0x2e => Value::Str(self.string()?),
+ 0x2c => {
+ let n = usize::from(c.u16()?);
+ Value::Bytes(c.take(n)?.to_vec())
+ }
+ 0x35..=0x74 => Value::Bytes(c.take(usize::from(code - 0x34))?.to_vec()),
+ 0x2d => {
+ let n = usize::try_from(c.u32()?).map_err(|_| Error::Malformed("blob length"))?;
+ Value::Blob(c.take(n)?.to_vec())
+ }
+ 0x33 => {
+ c.u32()?;
+ let n = usize::try_from(c.u32()?).map_err(|_| Error::Malformed("entry name length"))?;
+ Value::Entry(String::from_utf8_lossy(c.take(n)?).into_owned())
+ }
+ 0x75 => {
+ c.u16()?;
+ let n = usize::from(c.u8()?);
+ if n > 8 {
+ return Err(Error::Malformed("flags wider than 64 bits"));
+ }
+ let mut b = [0u8; 8];
+ b.get_mut(..n).ok_or(Error::Malformed("flags"))?.copy_from_slice(c.take(n)?);
+ Value::Flags(u64::from_le_bytes(b))
+ }
+ 0x30 => {
+ let fields = self.fields(false, class)?;
+ let id = self.push(Object { class, kind: Kind::Positional, chain: Vec::new(), fields })?;
+ Value::Obj(Some(id))
+ }
+ 0x31 | 0x32 if !tagged => return Err(Error::Malformed("object without a field tag")),
+ 0x31 => self.shared()?,
+ 0x32 => self.inline(None)?,
+ _ => return Err(Error::Unsupported("unknown field type in document stream")),
+ })
+ }
+
+ fn array(&mut self, code: u8, class: Tag) -> Result {
+ let min = match code {
+ 0x01 | 0x05 | 0x29 => 1,
+ 0x02 | 0x06 => 2,
+ 0x03 | 0x07 | 0x09 | 0x2f | 0x34 => 4,
+ 0x04 | 0x08 | 0x0a => 8,
+ 0x15..=0x19 => 4 * usize::from(code - 0x13),
+ 0x1f..=0x23 => 4 * usize::from(code - 0x1d),
+ 0x24..=0x28 => 8 * usize::from(code - 0x22),
+ 0x35..=0x74 => usize::from(code - 0x34),
+ _ => 1,
+ };
+ match code {
+ 0x29 => {
+ let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?;
+ let bits = self.c.take(n.div_ceil(8))?;
+ Ok(Value::Array((0..n).map(|i| Value::Bool(bits.get(i / 8).is_some_and(|b| b >> (i % 8) & 1 != 0))).collect()))
+ }
+ 0x2a => {
+ let n = self.count(2)?;
+ let version = self.c.u16()?;
+ let mut v = Vec::with_capacity(n);
+ for _ in 0..n {
+ v.push(Value::Enum { id: self.c.u16()?, version });
+ }
+ Ok(Value::Array(v))
+ }
+ 0x2b | 0x2e => {
+ self.c.u32()?;
+ let n = self.count(4)?;
+ let mut v = Vec::with_capacity(n);
+ for _ in 0..n {
+ v.push(Value::Str(self.string()?));
+ }
+ Ok(Value::Array(v))
+ }
+ 0x2c => {
+ let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?;
+ let size = usize::from(self.c.u16()?);
+ if n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) {
+ return Err(Error::Malformed("array longer than its data"));
+ }
+ let mut v = Vec::with_capacity(n);
+ for _ in 0..n {
+ v.push(Value::Bytes(self.c.take(size)?.to_vec()));
+ }
+ Ok(Value::Array(v))
+ }
+ 0x32 => {
+ let n = self.count(1)?;
+ let class = Tag(self.c.u32()?);
+ self.c.u16()?;
+ let mut v = Vec::with_capacity(n);
+ for _ in 0..n {
+ v.push(self.inline(Some(class))?);
+ }
+ Ok(Value::Array(v))
+ }
+ 0x2d | 0x33 | 0x75 => Err(Error::Malformed("array of an unarrayable type")),
+ _ => {
+ let n = self.count(min)?;
+ let mut v = Vec::with_capacity(n);
+ for _ in 0..n {
+ v.push(self.scalar(code, true, class)?);
+ }
+ Ok(Value::Array(v))
+ }
+ }
+ }
+
+ /// Inline object; `class` is given once for a whole array of them.
+ fn inline(&mut self, class: Option) -> Result {
+ match self.c.u8()? {
+ 0 => return Ok(Value::Obj(None)),
+ 1 => {}
+ _ => return Err(Error::Malformed("object marker")),
+ }
+ let class = match class {
+ Some(c) => c,
+ None => {
+ let c = Tag(self.c.u32()?);
+ self.c.u16()?;
+ c
+ }
+ };
+ let fields = self.fields(true, class)?;
+ let id = self.push(Object { class, kind: Kind::Inline, chain: vec![class], fields })?;
+ Ok(Value::Obj(Some(id)))
+ }
+
+ fn shared(&mut self) -> Result {
+ let flag = self.c.u8()?;
+ if flag == 0 {
+ return Ok(Value::Obj(None));
+ }
+ let key = self.c.u32()?;
+ match flag {
+ 2 => return self.shared.get(&key).map(|id| Value::Obj(Some(*id))).ok_or(Error::Malformed("reference to an undefined object")),
+ 1 => {}
+ _ => return Err(Error::Malformed("object marker")),
+ }
+ if self.shared.contains_key(&key) {
+ return Err(Error::Malformed("object defined twice"));
+ }
+ // Register before the fields: an object's own fields may refer back to it.
+ let id = self.push(Object { class: Tag(0), kind: Kind::Shared, chain: Vec::new(), fields: Vec::new() })?;
+ self.shared.insert(key, id);
+ let mut chain = Vec::new();
+ let mut fields = Vec::new();
+ loop {
+ match self.c.u8()? {
+ 0 => {
+ let tag = Tag(self.c.u32()?);
+ self.c.u16()?;
+ chain.push(tag);
+ fields.extend(self.fields(true, tag)?);
+ }
+ 1 => {
+ chain.push(Tag(self.c.u32()?));
+ break;
+ }
+ 2 => break,
+ _ => return Err(Error::Malformed("object class chain")),
+ }
+ if chain.len() > 64 {
+ return Err(Error::Limit("object class chain too long"));
+ }
+ }
+ let class = chain.first().copied().unwrap_or(Tag(0));
+ fields.extend(self.fields(true, class)?);
+ if let Some(o) = self.objects.get_mut(id) {
+ *o = Object { class, kind: Kind::Shared, chain, fields };
+ }
+ Ok(Value::Obj(Some(id)))
+ }
+}
diff --git a/crates/affinity/src/synth.rs b/crates/affinity/src/synth.rs
new file mode 100644
index 00000000..5b75ca0b
--- /dev/null
+++ b/crates/affinity/src/synth.rs
@@ -0,0 +1,259 @@
+//! Synthetic containers for tests and fuzz seeds (cargo feature `synth`).
+//!
+//! These builders write the layout the reader accepts, so tests can describe documents in a few
+//! lines. They are **not** an Affinity exporter: nothing written here has been opened in Affinity.
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+use crate::stream::Tag;
+
+/// A field value to encode.
+#[derive(Debug, Clone)]
+pub enum F {
+ U8(u8),
+ U32(u32),
+ I32(i32),
+ F32(f32),
+ F64(f64),
+ Bool(bool),
+ Enum(u16, u16),
+ Str(String),
+ /// f64 vector of 2..6 values (points, rectangles, transforms).
+ F64s(Vec),
+ /// Fixed-size struct (colour components), 1..64 bytes.
+ Struct(Vec),
+ /// Array of fixed-size records, all `size` bytes.
+ Records(u16, Vec>),
+ U8s(Vec),
+ Entry(String),
+ /// Shared object: `Def` defines id with class chain and fields, `Ref` links back.
+ Def(u32, Vec, Vec<(Tag, F)>),
+ Ref(u32),
+ Null,
+ /// Inline object.
+ Obj(Tag, Vec<(Tag, F)>),
+ /// Positional (untagged) object.
+ Pos(Vec),
+ /// Array of shared objects / links.
+ Shared(Vec),
+ /// Array of inline objects of one class.
+ Objs(Tag, Vec>),
+ /// Raw bytes appended verbatim (malformed-input tests).
+ Raw(Vec),
+}
+
+pub fn tag(s: &[u8; 4]) -> Tag {
+ Tag::of(s)
+}
+
+fn put_tag(out: &mut Vec, t: Tag) {
+ out.extend(t.0.to_le_bytes());
+}
+
+fn code(f: &F) -> u8 {
+ match f {
+ F::U8(_) => 0x01,
+ F::U32(_) => 0x03,
+ F::I32(_) => 0x07,
+ F::F32(_) => 0x09,
+ F::F64(_) => 0x0a,
+ F::Bool(_) => 0x29,
+ F::Enum(..) => 0x2a,
+ F::Str(_) => 0x2b,
+ F::F64s(v) => 0x22 + v.len() as u8,
+ F::Struct(b) => 0x34 + b.len() as u8,
+ F::Records(..) => 0x80 | 0x2c,
+ F::U8s(_) => 0x80 | 0x01,
+ F::Entry(_) => 0x33,
+ F::Def(..) | F::Ref(_) => 0x31,
+ F::Null => 0x31,
+ F::Obj(..) => 0x32,
+ F::Pos(_) => 0x30,
+ F::Shared(_) => 0x80 | 0x31,
+ F::Objs(..) => 0x80 | 0x32,
+ F::Raw(_) => 0,
+ }
+}
+
+fn payload(out: &mut Vec, f: &F) {
+ match f {
+ F::U8(v) => out.push(*v),
+ F::U32(v) => out.extend(v.to_le_bytes()),
+ F::I32(v) => out.extend(v.to_le_bytes()),
+ F::F32(v) => out.extend(v.to_le_bytes()),
+ F::F64(v) => out.extend(v.to_le_bytes()),
+ F::Bool(v) => out.push(u8::from(*v)),
+ F::Enum(id, ver) => {
+ out.extend(id.to_le_bytes());
+ out.extend(ver.to_le_bytes());
+ }
+ F::Str(s) => {
+ out.extend((s.len() as u32).to_le_bytes());
+ out.extend(s.as_bytes());
+ }
+ F::F64s(v) => v.iter().for_each(|x| out.extend(x.to_le_bytes())),
+ F::Struct(b) => out.extend(b),
+ F::Records(size, recs) => {
+ out.extend((recs.len() as u32).to_le_bytes());
+ out.extend(size.to_le_bytes());
+ recs.iter().for_each(|r| out.extend(r));
+ }
+ F::U8s(v) => {
+ out.extend((v.len() as u32).to_le_bytes());
+ out.extend(v);
+ }
+ F::Entry(name) => {
+ put_tag(out, tag(b"Data"));
+ out.extend((name.len() as u32).to_le_bytes());
+ out.extend(name.as_bytes());
+ }
+ F::Def(id, chain, fields) => {
+ out.push(1);
+ out.extend(id.to_le_bytes());
+ // Class chain: each level but the last carries no fields here; the last ends the chain.
+ let (last, rest) = chain.split_last().expect("class chain");
+ for t in rest {
+ out.push(0);
+ put_tag(out, *t);
+ out.extend(0u16.to_le_bytes());
+ out.push(0);
+ }
+ out.push(1);
+ put_tag(out, *last);
+ fields_into(out, fields, true);
+ }
+ F::Ref(id) => {
+ out.push(2);
+ out.extend(id.to_le_bytes());
+ }
+ F::Null => out.push(0),
+ F::Obj(class, fields) => {
+ out.push(1);
+ put_tag(out, *class);
+ out.extend(0u16.to_le_bytes());
+ fields_into(out, fields, true);
+ }
+ F::Pos(items) => {
+ for f in items {
+ out.push(code(f));
+ payload(out, f);
+ }
+ out.push(0);
+ }
+ F::Shared(items) => {
+ out.extend((items.len() as u32).to_le_bytes());
+ items.iter().for_each(|f| payload(out, f));
+ }
+ F::Objs(class, items) => {
+ out.extend((items.len() as u32).to_le_bytes());
+ put_tag(out, *class);
+ out.extend(0u16.to_le_bytes());
+ for fields in items {
+ out.push(1);
+ fields_into(out, fields, true);
+ }
+ }
+ F::Raw(b) => out.extend(b),
+ }
+}
+
+fn fields_into(out: &mut Vec, fields: &[(Tag, F)], tagged: bool) {
+ for (t, f) in fields {
+ if let F::Raw(b) = f {
+ out.extend(b);
+ continue;
+ }
+ out.push(code(f));
+ if tagged {
+ put_tag(out, *t);
+ }
+ payload(out, f);
+ }
+ out.push(0);
+}
+
+/// A `doc.dat` stream with root class `Pers`.
+pub fn stream(root: &[(Tag, F)]) -> Vec {
+ let mut out = b"\x00\xffKS".to_vec();
+ out.extend(2u16.to_le_bytes());
+ put_tag(&mut out, tag(b"Pers"));
+ out.extend(0u16.to_le_bytes());
+ out.extend(30u32.to_le_bytes());
+ fields_into(&mut out, root, true);
+ out
+}
+
+/// How an entry is stored.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Method {
+ Stored,
+ Zlib,
+ Zstd,
+}
+
+/// A version-12 container with one `#FT4` table holding `entries`, then an optional thumbnail record.
+pub fn container(entries: &[(&str, &[u8], Method)], thumbnail_png: Option<&[u8]>) -> Vec {
+ let mut out = vec![0u8; 72];
+ out[..4].copy_from_slice(crate::container::MAGIC);
+ out[4..6].copy_from_slice(&12u16.to_le_bytes());
+ out[6..8].copy_from_slice(&4u16.to_le_bytes());
+ out[8..12].copy_from_slice(b"nsrP");
+ out[12..16].copy_from_slice(b"#Inf");
+ out[64..68].copy_from_slice(b"Prot");
+ let mut records = Vec::new();
+ for (i, (name, data, method)) in entries.iter().enumerate() {
+ let packed = match method {
+ Method::Stored => data.to_vec(),
+ Method::Zlib => {
+ use std::io::Write;
+ let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
+ e.write_all(data).unwrap();
+ e.finish().unwrap()
+ }
+ Method::Zstd => ruzstd::encoding::compress_to_vec(*data, ruzstd::encoding::CompressionLevel::Fastest),
+ };
+ let offset = out.len() as u64;
+ out.extend(b"#Fil");
+ out.extend(&packed);
+ let mut r = Vec::new();
+ r.extend((i as u32 + 1).to_le_bytes());
+ r.push(0);
+ r.extend(offset.to_le_bytes());
+ r.extend((data.len() as u64).to_le_bytes());
+ r.extend((packed.len() as u64).to_le_bytes());
+ r.extend(crc32fast::hash(data).to_le_bytes());
+ r.push(match method {
+ Method::Stored => 0,
+ Method::Zlib => 1,
+ Method::Zstd => 2,
+ });
+ r.extend(0u32.to_le_bytes());
+ r.extend(0u32.to_le_bytes());
+ r.extend((name.len() as u16).to_le_bytes());
+ r.extend(name.as_bytes());
+ records.push(r);
+ }
+ let fat = out.len() as u64;
+ out.extend(b"#FT4");
+ out.extend(0u64.to_le_bytes());
+ out.extend(1_700_000_000u64.to_le_bytes());
+ out.extend([0u8; 24]);
+ out.extend((records.len() as u32).to_le_bytes());
+ out.extend([0u8; 8]);
+ out.extend(0u16.to_le_bytes());
+ out.push(0);
+ records.iter().for_each(|r| out.extend(r));
+ let thumb = out.len() as u64;
+ out[16..24].copy_from_slice(&fat.to_le_bytes());
+ if let Some(png) = thumbnail_png {
+ out[24..32].copy_from_slice(&thumb.to_le_bytes());
+ out.extend(b"\xff\xff\xff\xffThmb");
+ out.extend(1u32.to_le_bytes());
+ out.extend((png.len() as u32 + 13).to_le_bytes());
+ out.extend(29u32.to_le_bytes());
+ out.extend(0u32.to_le_bytes());
+ out.extend((png.len() as u32).to_le_bytes());
+ out.push(1);
+ out.extend(png);
+ }
+ out
+}
diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs
new file mode 100644
index 00000000..4c299dc3
--- /dev/null
+++ b/crates/affinity/src/tests.rs
@@ -0,0 +1,331 @@
+//! Unit tests on synthetic inputs; `tests/real_files.rs` covers public Affinity documents.
+
+mod preview {
+ use crate::container::header;
+ use crate::*;
+ use proptest::prelude::*;
+
+ fn fixture() -> Vec {
+ let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
+ png.extend(13u32.to_be_bytes());
+ png.extend(b"IHDR");
+ png.extend(2u32.to_be_bytes());
+ png.extend(1u32.to_be_bytes());
+ png.extend([8, 6, 0, 0, 0]);
+ png.extend(crc32fast::hash(&png[12..]).to_be_bytes());
+ png.extend([0; 4]);
+ png.extend(b"IDAT");
+ png.extend(crc32fast::hash(b"IDAT").to_be_bytes());
+ png.extend([0; 4]);
+ png.extend(b"IEND");
+ png.extend(crc32fast::hash(b"IEND").to_be_bytes());
+ let mut b = vec![0; 72];
+ b[..4].copy_from_slice(MAGIC);
+ b[4..6].copy_from_slice(&12u16.to_le_bytes());
+ b[8..12].copy_from_slice(b"nsrP");
+ b[12..16].copy_from_slice(b"#Inf");
+ b[24..32].copy_from_slice(&72u64.to_le_bytes());
+ b[64..68].copy_from_slice(b"Prot");
+ b.extend(b"\xff\xff\xff\xffThmb");
+ b.extend(1u32.to_le_bytes());
+ b.extend((png.len() as u32 + 13).to_le_bytes());
+ b.extend(29u32.to_le_bytes());
+ b.extend(0u32.to_le_bytes());
+ b.extend((png.len() as u32).to_le_bytes());
+ b.push(1);
+ b.extend(png);
+ b
+ }
+
+ #[test]
+ fn indexed_preview_and_header() {
+ let b = fixture();
+ assert_eq!(header(&b).unwrap().version, 12);
+ let p = preview(&b).unwrap();
+ assert_eq!((p.width, p.height), (2, 1));
+ assert_eq!(p.png, &b[101..]);
+ }
+
+ #[test]
+ fn every_truncation_is_rejected() {
+ let b = fixture();
+ for end in 0..b.len() {
+ assert!(preview(&b[..end]).is_err(), "{end}");
+ }
+ }
+
+ #[test]
+ fn rejects_assets_versions_offsets_lengths_and_bombs() {
+ let b = fixture();
+ for (offset, value) in [
+ (4, 13u64.to_le_bytes().to_vec()),
+ (8, b"urBR".to_vec()),
+ (24, u64::MAX.to_le_bytes().to_vec()),
+ (24, 1u64.to_le_bytes().to_vec()),
+ (24, 0u64.to_le_bytes().to_vec()),
+ (84, u32::MAX.to_le_bytes().to_vec()),
+ (88, 30u32.to_le_bytes().to_vec()),
+ (96, u32::MAX.to_le_bytes().to_vec()),
+ (117, 5000u32.to_be_bytes().to_vec()),
+ ] {
+ let mut broken = b.clone();
+ broken[offset..offset + value.len()].copy_from_slice(&value);
+ assert!(preview(&broken).is_err(), "offset {offset}");
+ }
+ // Versions 8 to 11 store the same record (checked on public Affinity 1 and 2 files).
+ for version in 8..12 {
+ let mut legacy = b.clone();
+ legacy[4..6].copy_from_slice(&(version as u16).to_le_bytes());
+ assert!(preview(&legacy).is_ok());
+ }
+ // Flags 4 and 8 occur in real files; the low two bits mark other container variants.
+ for (flags, ok) in [(4u16, true), (8, true), (1, false), (2, false)] {
+ let mut variant = b.clone();
+ variant[6..8].copy_from_slice(&flags.to_le_bytes());
+ assert_eq!(preview(&variant).is_ok(), ok, "flags {flags}");
+ }
+ let mut old = b.clone();
+ old[4..6].copy_from_slice(&7u16.to_le_bytes());
+ assert!(matches!(preview(&old), Err(Error::Unsupported(_))));
+ }
+
+ #[test]
+ fn an_unindexed_png_or_resource_is_never_used() {
+ let mut b = fixture();
+ b[24..32].copy_from_slice(&0u64.to_le_bytes());
+ assert!(preview(&b).is_err());
+ b[24..32].copy_from_slice(&72u64.to_le_bytes());
+ b[76..80].copy_from_slice(b"Meta");
+ assert!(preview(&b).is_err());
+ }
+
+ #[test]
+ fn every_chunk_checksum_including_the_end_is_checked() {
+ let b = fixture();
+ for at in [130, b.len() - 1] {
+ let mut damaged = b.clone();
+ damaged[at] ^= 1;
+ assert_eq!(preview(&damaged).unwrap_err(), Error::Malformed("PNG chunk checksum"));
+ }
+ let mut ancillary = vec![0; 4];
+ ancillary.extend(b"tEXt");
+ ancillary.extend(crc32fast::hash(b"tEXt").to_be_bytes());
+ let mut with_ancillary = b.clone();
+ let at = b.len() - 12;
+ with_ancillary.splice(at..at, ancillary);
+ let png_length = (with_ancillary.len() - 101) as u32;
+ with_ancillary[84..88].copy_from_slice(&(png_length + 13).to_le_bytes());
+ with_ancillary[96..100].copy_from_slice(&png_length.to_le_bytes());
+ assert!(preview(&with_ancillary).is_ok());
+ with_ancillary[at + 8] ^= 1;
+ assert_eq!(preview(&with_ancillary).unwrap_err(), Error::Malformed("PNG chunk checksum"));
+ }
+
+ #[test]
+ fn compressed_metadata_and_animation_are_not_preview_support() {
+ for kind in [b"zTXt", b"iTXt", b"iCCP", b"acTL", b"fcTL", b"fdAT"] {
+ let mut extra = vec![0; 4];
+ extra.extend(kind);
+ extra.extend(crc32fast::hash(kind).to_be_bytes());
+ let mut b = fixture();
+ let at = b.len() - 12;
+ b.splice(at..at, extra);
+ let length = (b.len() - 101) as u32;
+ b[84..88].copy_from_slice(&(length + 13).to_le_bytes());
+ b[96..100].copy_from_slice(&length.to_le_bytes());
+ assert!(matches!(preview(&b), Err(Error::Unsupported(_))));
+ }
+ }
+
+ #[test]
+ fn critical_chunks_follow_a_single_complete_png_layout() {
+ fn with_chunks(kinds: &[&[u8; 4]]) -> Vec {
+ let original = fixture();
+ let mut b = original[..134].to_vec(); // File, record and complete first IHDR.
+ for kind in kinds {
+ b.extend([0; 4]);
+ b.extend(*kind);
+ b.extend(crc32fast::hash(*kind).to_be_bytes());
+ }
+ let length = (b.len() - 101) as u32;
+ b[84..88].copy_from_slice(&(length + 13).to_le_bytes());
+ b[96..100].copy_from_slice(&length.to_le_bytes());
+ b
+ }
+ assert!(preview(&with_chunks(&[b"IDAT", b"IDAT", b"IEND"])).is_ok());
+ // PNG readers must not reject an otherwise valid unknown ancillary chunk just
+ // because its reserved (third-letter) bit is set; future versions can define it.
+ assert!(preview(&with_chunks(&[b"abct", b"IDAT", b"IEND"])).is_ok());
+ for kinds in [
+ vec![b"IEND"],
+ vec![b"IDAT", b"IHDR", b"IEND"],
+ vec![b"PLTE", b"PLTE", b"IDAT", b"IEND"],
+ vec![b"IDAT", b"PLTE", b"IEND"],
+ vec![b"IDAT", b"tEXt", b"IDAT", b"IEND"],
+ vec![b"ABCD", b"IDAT", b"IEND"],
+ vec![b"IDAT", b"IEND", b"tEXt"],
+ ] {
+ assert!(preview(&with_chunks(&kinds)).is_err(), "{kinds:?}");
+ }
+ }
+
+ proptest! {
+ #![proptest_config(ProptestConfig::with_cases(256))]
+ #[test]
+ fn arbitrary_input_never_panics(b in prop::collection::vec(any::(), 0..2048)) {
+ let _ = header(&b);
+ let _ = preview(&b);
+ }
+ #[test]
+ fn mutations_never_panic(edits in prop::collection::vec((0usize..200, any::()), 0..32)) {
+ let mut b = fixture();
+ for (i, value) in edits { if let Some(v) = b.get_mut(i) { *v = value; } }
+ let _ = preview(&b);
+ }
+ }
+}
+
+mod archive {
+ use crate::Error;
+ use crate::container::{Archive, Limits};
+ use crate::stream::{self, Kind, Tag, Value};
+ use crate::synth::{self, F, Method, tag};
+ use proptest::prelude::*;
+
+ fn doc() -> Vec {
+ synth::stream(&[
+ (tag(b"Desc"), F::Str("Document".into())),
+ (tag(b"Visi"), F::Bool(true)),
+ (tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 5.0, 0.0, 1.0, -2.0])),
+ (tag(b"Colr"), F::Def(7, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct([0u8; 16].to_vec()))])),
+ (tag(b"Same"), F::Ref(7)),
+ (tag(b"Null"), F::Null),
+ (tag(b"Kids"), F::Shared(vec![F::Def(8, vec![tag(b"Grup"), tag(b"Node")], vec![(tag(b"Desc"), F::Str("g".into()))]), F::Ref(8)])),
+ (
+ tag(b"Crvs"),
+ F::Obj(tag(b"PCvD"), vec![(tag(b"Data"), F::Pos(vec![F::U8(0), F::U32(1), F::Bool(true), F::Records(18, vec![vec![0; 18]; 4])]))]),
+ ),
+ (tag(b"Enum"), F::Enum(2, 1)),
+ (tag(b"Tile"), F::Entry("d/3".into())),
+ (tag(b"Stat"), F::U8s(vec![4, 0, 2])),
+ ])
+ }
+
+ #[test]
+ fn every_method_round_trips_with_checksums() {
+ let d = doc();
+ for method in [Method::Stored, Method::Zlib, Method::Zstd] {
+ let file = synth::container(&[("doc.dat", &d, method), ("d/3", b"tile", method)], None);
+ let mut a = Archive::open(&file, Limits::default()).unwrap();
+ assert_eq!(a.header.version, 12);
+ assert_eq!(a.read("doc.dat").unwrap(), d);
+ assert_eq!(a.read("d/3").unwrap(), b"tile");
+ assert_eq!(a.extracted(), d.len() + 4);
+ assert!(a.read("missing").is_err());
+ }
+ }
+
+ #[test]
+ fn the_stream_parses_into_shared_objects_and_links() {
+ let s = stream::parse(&doc()).unwrap();
+ let root = &s.objects[s.root];
+ assert_eq!(root.class, Tag::of(b"Pers"));
+ assert_eq!(root.get(Tag::of(b"Desc")), Some(&Value::Str("Document".into())));
+ assert_eq!(root.get(Tag::of(b"Xfrm")), Some(&Value::Floats(vec![1.0, 0.0, 5.0, 0.0, 1.0, -2.0])));
+ let (Some(Value::Obj(Some(a))), Some(Value::Obj(Some(b)))) = (root.get(Tag::of(b"Colr")), root.get(Tag::of(b"Same"))) else { panic!() };
+ assert_eq!(a, b);
+ assert_eq!(s.objects[*a].class, Tag::of(b"RGBA"));
+ assert_eq!(s.objects[*a].kind, Kind::Shared);
+ let Some(Value::Array(kids)) = root.get(Tag::of(b"Kids")) else { panic!() };
+ assert_eq!(kids[0], kids[1]);
+ let Value::Obj(Some(g)) = kids[0] else { panic!() };
+ assert_eq!(s.objects[g].chain, vec![Tag::of(b"Grup"), Tag::of(b"Node")]);
+ assert_eq!(root.get(Tag::of(b"Enum")), Some(&Value::Enum { id: 2, version: 1 }));
+ assert_eq!(root.get(Tag::of(b"Tile")), Some(&Value::Entry("d/3".into())));
+ assert_eq!(root.get(Tag::of(b"Null")), Some(&Value::Obj(None)));
+ }
+
+ #[test]
+ fn checksums_sizes_and_budgets_are_enforced() {
+ let d = doc();
+ let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None);
+ // Flip a byte inside the compressed payload, past the "#Fil" tag at offset 72.
+ let mut broken = file.clone();
+ broken[90] ^= 0x55;
+ let mut a = Archive::open(&broken, Limits::default()).unwrap();
+ assert!(a.read("doc.dat").is_err());
+ let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX }).unwrap();
+ assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
+ let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1 }).unwrap();
+ a.read("doc.dat").unwrap();
+ assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
+ }
+
+ #[test]
+ fn a_declared_size_smaller_than_the_data_is_rejected_not_truncated() {
+ let big = vec![7u8; 1 << 20];
+ let mut file = synth::container(&[("doc.dat", &big, Method::Zlib)], None);
+ // Rewrite the declared size in the table to 1 KiB: decoding must stop and fail.
+ let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()) as usize;
+ let size_at = fat + 59 + 5 + 8;
+ file[size_at..size_at + 8].copy_from_slice(&1024u64.to_le_bytes());
+ let mut a = Archive::open(&file, Limits::default()).unwrap();
+ assert_eq!(a.read("doc.dat").unwrap_err(), Error::Malformed("archive entry size mismatch"));
+ }
+
+ #[test]
+ fn libraries_and_cyclic_tables_are_rejected() {
+ let d = doc();
+ let mut file = synth::container(&[("doc.dat", &d, Method::Stored)], None);
+ let mut library = file.clone();
+ library[8..12].copy_from_slice(b"rAsA");
+ assert!(matches!(Archive::open(&library, Limits::default()), Err(Error::Unsupported(_))));
+ let fat = u64::from_le_bytes(file[16..24].try_into().unwrap());
+ let at = fat as usize + 4;
+ file[at..at + 8].copy_from_slice(&fat.to_le_bytes());
+ assert_eq!(Archive::open(&file, Limits::default()).unwrap_err(), Error::Malformed("allocation tables form a cycle"));
+ }
+
+ #[test]
+ fn hostile_streams_fail_cleanly() {
+ let deep = (0..2000).fold(Vec::new(), |inner, _| vec![(tag(b"Chld"), F::Obj(tag(b"Grup"), inner))]);
+ assert_eq!(stream::parse(&synth::stream(&deep)).unwrap_err(), Error::Limit("objects nested too deeply"));
+ // An array claiming four billion elements in a few bytes.
+ let huge = synth::stream(&[(tag(b"Big_"), F::Raw(vec![0x81, b'_', b'g', b'i', b'B', 0xff, 0xff, 0xff, 0xff, 0]))]);
+ assert_eq!(stream::parse(&huge).unwrap_err(), Error::Malformed("array longer than its data"));
+ let forward = synth::stream(&[(tag(b"Link"), F::Ref(99))]);
+ assert!(stream::parse(&forward).is_err());
+ let twice = synth::stream(&[(tag(b"A___"), F::Def(1, vec![tag(b"RGBA")], vec![])), (tag(b"B___"), F::Def(1, vec![tag(b"RGBA")], vec![]))]);
+ assert!(stream::parse(&twice).is_err());
+ let unknown = synth::stream(&[(tag(b"X___"), F::Raw(vec![0x0b, 1, 2, 3, 4]))]);
+ assert!(matches!(stream::parse(&unknown), Err(Error::Unsupported(_))));
+ let d = doc();
+ for end in 0..d.len() {
+ assert!(stream::parse(&d[..end]).is_err(), "{end}");
+ }
+ }
+
+ proptest! {
+ #![proptest_config(ProptestConfig::with_cases(512))]
+ #[test]
+ fn mutated_containers_and_streams_never_panic(edits in prop::collection::vec((0usize..4096, any::()), 1..24)) {
+ let d = doc();
+ for method in [Method::Stored, Method::Zlib, Method::Zstd] {
+ let mut file = synth::container(&[("doc.dat", &d, method)], None);
+ for (i, v) in &edits {
+ let n = file.len();
+ file[i % n] = *v;
+ }
+ if let Ok(bytes) = Archive::open(&file, Limits::default()).and_then(|mut a| a.read("doc.dat")) {
+ let _ = stream::parse(&bytes);
+ }
+ }
+ let mut s = d.clone();
+ for (i, v) in &edits {
+ let n = s.len();
+ s[i % n] = *v;
+ }
+ let _ = stream::parse(&s);
+ }
+ }
+}
diff --git a/crates/affinity/src/text.rs b/crates/affinity/src/text.rs
new file mode 100644
index 00000000..1b6e3dd7
--- /dev/null
+++ b/crates/affinity/src/text.rs
@@ -0,0 +1,151 @@
+//! Artistic and frame text: the story's characters with their font, size, colour, tracking and
+//! leading, the paragraph alignment and the first baseline. Layout (line breaks in frames, kerning,
+//! OpenType features) is left to the importing application.
+
+use crate::model::{Affine, Align, Point, Reader, Text, TextRun, rect};
+use crate::paint::{self, Paint};
+use crate::stream::{ObjId, Value};
+
+/// Characters read per text node (stories are split into blocks; this caps hostile ones).
+const MAX_CHARS: usize = 1 << 20;
+
+pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option {
+ let s = r.s;
+ let story = s.obj(id, b"StSt")?;
+ let frame = s.obj(id, b"TxtH")?;
+ let artistic = s.is(frame, b"ArFr");
+ let bounds = s.floats::<4>(frame, b"FrmB").map(rect)?;
+ let mut runs = Vec::new();
+ let mut align = None;
+ let mut total = 0usize;
+ let mut placeholders = false;
+ for block in s.objs(story, b"Blok") {
+ let glyphs = s.obj(block, b"Glyp")?;
+ let chars: Vec = match s.str(glyphs, b"Utf8") {
+ Some(t) => t.chars().collect(),
+ None => {
+ // Inline objects (page numbers, anchors) each take one index before the segment text.
+ let mut v = Vec::new();
+ for seg in s.objs(glyphs, b"Mixd") {
+ let inline = s.objs(seg, b"Glys").len();
+ v.extend(std::iter::repeat_n('\u{FFFC}', inline));
+ placeholders |= inline > 0;
+ v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars());
+ }
+ v
+ }
+ };
+ total = total.checked_add(chars.len())?;
+ if total > MAX_CHARS {
+ r.warn("text longer than a million characters (truncated)");
+ break;
+ }
+ let mut start = 0usize;
+ for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() {
+ let end = usize::try_from(s.int(run, b"Indx")?).ok()?.min(chars.len());
+ if end <= start {
+ continue;
+ }
+ let text: String = chars.get(start..end)?.iter().filter(|c| **c != '\0' && **c != '\u{FFFC}').collect();
+ start = end;
+ let Some(attrs) = s.obj(run, b"Item") else { continue };
+ if !text.is_empty() {
+ runs.push(run_attrs(r, attrs, text, world));
+ }
+ }
+ if align.is_none() {
+ align = s
+ .obj(block, b"PAtt")
+ .and_then(|p| s.objs(p, b"Runs").first().copied())
+ .and_then(|run| s.obj(run, b"Item"))
+ .map(|p| paragraph_align(r, p));
+ }
+ }
+ if placeholders {
+ r.warn("text fields such as page numbers (left out of the text)");
+ }
+ let align = align.unwrap_or(Align::Left);
+ let first_baseline = bounds.y0 + s.f64(frame, b"ArtV").unwrap_or(0.0);
+ let anchor_x = match align {
+ Align::Center => (bounds.x0 + bounds.x1) / 2.0,
+ Align::Right => bounds.x1,
+ _ => bounds.x0,
+ };
+ if !artistic {
+ if s.objs(frame, b"ColW").len() > 1 || matches!(s.field(frame, b"ColW"), Some(Value::Array(a)) if a.len() > 1) {
+ r.warn("text frames with several columns (imported as one column)");
+ }
+ if s.class(id) == Some(crate::stream::Tag::of(b"TxtC")) {
+ r.warn("text frames with a curved outline (imported as rectangular frames)");
+ }
+ }
+ Some(Text { runs, align, anchor: Point { x: anchor_x, y: first_baseline }, frame: if artistic { None } else { Some(bounds) }, transform: world })
+}
+
+fn slot(v: Option<&Value>, i: usize, f: impl Fn(&Value) -> Option) -> Option {
+ match v? {
+ Value::Array(a) => a.get(i).and_then(f),
+ _ => None,
+ }
+}
+
+fn float(v: &Value) -> Option {
+ match v {
+ Value::Float(f) if f.is_finite() => Some(*f),
+ _ => None,
+ }
+}
+
+fn int(v: &Value) -> Option {
+ match v {
+ Value::Int(i) => Some(*i),
+ Value::UInt(u) => i64::try_from(*u).ok(),
+ _ => None,
+ }
+}
+
+fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine) -> TextRun {
+ let s = r.s;
+ let doubles = s.field(attrs, b"Doub");
+ let ints = s.field(attrs, b"Ints");
+ let size = slot(doubles, 0, float).filter(|v| *v > 0.0 && *v < 1e6).unwrap_or(12.0 * r.dpi / 72.0);
+ let tracking = slot(doubles, 4, float).unwrap_or(0.0);
+ let leading = (slot(ints, 4, int) == Some(1)).then(|| slot(doubles, 13, float)).flatten().filter(|v| *v > 0.0);
+ let objects = match s.field(attrs, b"Objs") {
+ Some(Value::Array(a)) => a.iter().map(|v| if let Value::Obj(o) = v { *o } else { None }).collect(),
+ _ => Vec::new(),
+ };
+ let fill = objects.first().copied().flatten().and_then(|f| paint::descriptor(r, f, world)).unwrap_or(Paint::None);
+ if objects.get(1).copied().flatten().and_then(|f| paint::descriptor(r, f, world)).is_some_and(|p| p != Paint::None) {
+ r.warn("outlined (stroked) text");
+ }
+ if slot(doubles, 10, float).is_some_and(|h| (h - 1.0).abs() > 1e-3) {
+ r.warn("horizontally scaled text");
+ }
+ let font = s.obj(attrs, b"DFnt");
+ let get = |tag: &[u8; 4]| font.and_then(|f| s.str(f, tag)).unwrap_or_default().to_string();
+ TextRun {
+ text,
+ postscript: get(b"Post"),
+ family: get(b"Famy"),
+ weight: font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400),
+ italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false),
+ size,
+ tracking,
+ leading,
+ fill,
+ }
+}
+
+fn paragraph_align(r: &mut Reader, p: ObjId) -> Align {
+ match slot(r.s.field(p, b"Ints"), 0, int) {
+ Some(1) => Align::Center,
+ Some(2) => Align::Right,
+ Some(3) => Align::Justify,
+ Some(0) | None => Align::Left,
+ Some(_) => {
+ r.warn("an unknown paragraph alignment (imported as left)");
+ Align::Left
+ }
+ }
+}
diff --git a/crates/affinity/tests/real_files.rs b/crates/affinity/tests/real_files.rs
new file mode 100644
index 00000000..302c75ab
--- /dev/null
+++ b/crates/affinity/tests/real_files.rs
@@ -0,0 +1,83 @@
+//! Public Affinity documents: the pinned set `cargo xtask corpus --affinity` fetches into
+//! `corpus/affinity`, or any folder of `.af`/`.afdesign`/`.afphoto`/`.afpub` files named by
+//! `AFFINITY_SAMPLES` (searched recursively). Without either, these tests do nothing.
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+use std::path::{Path, PathBuf};
+
+use photocraft_affinity::{Archive, Limits, stream};
+
+fn files(dir: &Path, out: &mut Vec) {
+ for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
+ let p = e.path();
+ if p.is_dir() {
+ files(&p, out);
+ } else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["af", "afdesign", "afphoto", "afpub", "aftemplate"].contains(&e))
+ && std::fs::read(&p).is_ok_and(|b| b.starts_with(photocraft_affinity::MAGIC))
+ {
+ out.push(p);
+ }
+ }
+}
+
+fn samples() -> Option {
+ let pinned = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/affinity");
+ std::env::var_os("AFFINITY_SAMPLES").map(PathBuf::from).or_else(|| pinned.join("SOURCES.md").exists().then_some(pinned))
+}
+
+#[test]
+fn public_documents_parse_completely() {
+ let Some(dir) = samples() else { return };
+ let mut paths = Vec::new();
+ files(&dir, &mut paths);
+ assert!(!paths.is_empty(), "no Affinity documents under {dir:?}");
+ let mut failures = Vec::new();
+ for p in &paths {
+ let bytes = std::fs::read(p).unwrap();
+ let result = Archive::open(&bytes, Limits::default()).and_then(|mut a| {
+ let doc = a.read("doc.dat")?;
+ stream::parse(&doc).map(|s| s.objects.len())
+ });
+ match result {
+ Ok(n) => assert!(n > 0),
+ Err(e) => failures.push(format!("{}: {e}", p.display())),
+ }
+ }
+ assert!(failures.is_empty(), "{} of {} failed:\n{}", failures.len(), paths.len(), failures.join("\n"));
+ eprintln!("{} Affinity documents parsed", paths.len());
+}
+
+#[test]
+fn public_documents_map_to_the_model() {
+ let Some(dir) = samples() else { return };
+ let mut paths = Vec::new();
+ files(&dir, &mut paths);
+ let mut warnings = std::collections::BTreeMap::::new();
+ let mut failures = Vec::new();
+ let (mut nodes, mut docs) = (0usize, 0usize);
+ fn count(n: &[photocraft_affinity::model::Node]) -> usize {
+ n.iter().map(|n| 1 + count(&n.children)).sum()
+ }
+ for p in &paths {
+ let bytes = std::fs::read(p).unwrap();
+ if !bytes.starts_with(photocraft_affinity::MAGIC) {
+ continue;
+ }
+ match photocraft_affinity::read(&bytes, Limits::default()) {
+ Ok(d) => {
+ docs += 1;
+ nodes += d.spreads.iter().map(|s| count(&s.nodes)).sum::();
+ for w in d.warnings {
+ let key = w.split(" (").next().unwrap_or(&w).to_string();
+ *warnings.entry(key).or_default() += 1;
+ }
+ }
+ Err(e) => failures.push(format!("{}: {e}", p.display())),
+ }
+ }
+ eprintln!("{docs} documents, {nodes} nodes");
+ for (w, n) in &warnings {
+ eprintln!("{n:4} documents: {w}");
+ }
+ assert!(failures.is_empty(), "{}", failures.join("\n"));
+}
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
index d85fb05f..a24b7f0f 100644
--- a/crates/engine/Cargo.toml
+++ b/crates/engine/Cargo.toml
@@ -36,6 +36,7 @@ photocraft-codecs = { workspace = true }
flate2 = { workspace = true }
[dev-dependencies]
+photocraft-affinity = { workspace = true, features = ["synth"] }
# Synthetic camera raw files for the open tests.
photocraft-raw = { workspace = true, features = ["testgen"] }
# WebAssembly text -> binary for plug-in test modules (Apache-2.0 WITH LLVM-exception).
diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs
index 05b0db1c..dc6d9332 100644
--- a/crates/engine/src/file_cmds.rs
+++ b/crates/engine/src/file_cmds.rs
@@ -181,9 +181,9 @@ pub(crate) fn import(name: &str, bytes: &[u8]) -> Result {
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 {
+ if r.preview_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"
+ "{name}: only this Affinity file's embedded preview could be read; open it with File › Open to see the warning, or export PSD or PNG from Affinity before using it here"
)));
}
Ok(r.document)
diff --git a/crates/engine/tests/affinity.rs b/crates/engine/tests/affinity.rs
index c4e343e3..87108467 100644
--- a/crates/engine/tests/affinity.rs
+++ b/crates/engine/tests/affinity.rs
@@ -1,4 +1,5 @@
-//! A preview is a new document, never a save target for the Affinity source.
+//! An Affinity document is never a save target for its source; a native one can be placed, a
+//! preview fallback cannot.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::sync::Arc;
@@ -66,7 +67,7 @@ fn background_preview_retains_the_source_protection_and_warning() {
Started::Job(id) => s.wait_job(id).unwrap(),
Started::Done(v) => v,
};
- assert!(result["warnings"].to_string().contains("Native layers"));
+ assert!(result["warnings"].to_string().contains("no layers"));
assert!(s.active().unwrap().source_read_only);
assert!(s.active().unwrap().path.is_none());
}
@@ -78,7 +79,45 @@ fn warningless_auxiliary_paths_cannot_silently_use_a_thumbnail() {
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!(error.contains("embedded preview could be read"), "{error}");
assert_eq!(s.active().unwrap().doc.layers.len(), layers);
assert!(photocraft_engine::smart_cmds::decode_source("source.af", &bytes).unwrap_err().to_string().contains("export PSD or PNG"));
}
+
+/// A minimal native document: one 10Ă—10 px red square at (5, 5) on a 40Ă—20 page.
+fn native() -> Vec {
+ use photocraft_affinity::synth::{self, F, Method, tag};
+ let node = |x: f64, y: f64| {
+ let mut r = x.to_le_bytes().to_vec();
+ r.extend(y.to_le_bytes());
+ r.extend([1, 0]);
+ r
+ };
+ let red = F::Struct([1.0f32, 0.0, 0.0, 1.0].iter().flat_map(|v| v.to_le_bytes()).collect());
+ let square = F::Def(
+ 3,
+ vec![tag(b"PCrv")],
+ vec![
+ (tag(b"Crvs"), F::Obj(tag(b"PCvD"), vec![(tag(b"Data"), F::Pos(vec![F::U8(0), F::U32(1), F::Bool(true), F::Records(18, vec![node(5.0, 5.0), node(15.0, 5.0), node(15.0, 15.0), node(5.0, 15.0), node(5.0, 5.0)])]))])),
+ (tag(b"BFFl"), F::Shared(vec![F::Def(4, vec![tag(b"FDsc")], vec![(tag(b"FDeF"), F::Def(5, vec![tag(b"FilS")], vec![(tag(b"Colr"), F::Def(6, vec![tag(b"RGBA")], vec![(tag(b"_col"), red)]))]))])])),
+ ],
+ );
+ let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 40.0, 20.0])), (tag(b"SprT"), F::Bool(true)), (tag(b"Chld"), F::Shared(vec![square]))]);
+ let doc = synth::stream(&[(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
+ synth::container(&[("doc.dat", &doc, Method::Zstd)], None)
+}
+
+#[test]
+fn native_documents_open_read_only_and_can_be_placed() {
+ let bytes = native();
+ let mut s = Session::new();
+ let r = open_bytes_as(&mut s, "art.af", &bytes, None, Some("/source/art.af".into())).unwrap();
+ assert!(!r["warnings"].to_string().contains("preview"), "{r}");
+ let st = s.active().unwrap();
+ assert!(st.path.is_none() && st.source_read_only);
+ assert_eq!((st.doc.size.width, st.doc.size.height), (40, 20));
+ s.execute("file.new", json!({"width": 64, "height": 64})).unwrap();
+ let before = s.active().unwrap().doc.layers.len();
+ photocraft_engine::file_cmds::place_bytes(&mut s, "art.af", bytes, None, &json!({})).unwrap();
+ assert_eq!(s.active().unwrap().doc.layers.len(), before + 1);
+}
diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml
index 9db74a13..f0f9aab0 100644
--- a/crates/io/Cargo.toml
+++ b/crates/io/Cargo.toml
@@ -37,6 +37,7 @@ blake3 = { version = "1.8", default-features = false, features = ["pure"] }
[dev-dependencies]
crc32fast = "1.5"
+photocraft-affinity = { workspace = true, features = ["synth"] }
flate2 = { workspace = true }
photocraft-psd = { workspace = true, features = ["testgen"] }
photocraft-raw = { workspace = true, features = ["testgen"] }
diff --git a/crates/io/src/affinity.rs b/crates/io/src/affinity.rs
index 24cdf4de..d34c4755 100644
--- a/crates/io/src/affinity.rs
+++ b/crates/io/src/affinity.rs
@@ -1,18 +1,55 @@
-//! Affinity documents currently open as their embedded preview, with an explicit warning.
+//! Affinity documents (`.af`, `.afdesign`, `.afphoto`, `.afpub`): the native layers, curves,
+//! shapes, artboards, text, images and masks `photocraft-affinity` reads, mapped to PhotoCraft
+//! layers (groups, shape layers, type layers, embedded smart objects, pixel layers, layer and
+//! vector masks). What could not be mapped is listed in the warnings. A file whose native data
+//! can't be read opens as its embedded preview, with a warning saying why.
+
+use std::sync::Arc;
+
+use photocraft_affinity::model::{self as af, Align, Blend, Kind, Pixels};
+use photocraft_affinity::paint::{self as ap, Cap, GradientKind, Join};
+use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType};
+use photocraft_doc::text::{CharStyle, ParagraphRun, ParagraphStyle, TextAlign, TextRun, TextShape};
+use photocraft_doc::vector::{Knot, LineCap, LineJoin, Path, ShapeLayer, ShapeStroke, StrokeAlign, Subpath, VectorMask};
+use photocraft_doc::{
+ Artboard, ArtboardBackground, Document, Fill, GradientStyle, Group, Layer, LayerContent, LayerMask, Locks, SmartObject, SmartSource, TextLayer,
+};
+use photocraft_geom::{Affine, Point, Rect, Size};
+use photocraft_raster::Surface;
use crate::{ImportResult, IoError};
pub use photocraft_affinity::is_affinity;
-/// Extensions recognized as Affinity documents (legacy containers get an actionable error).
+/// Extensions recognized as Affinity documents.
pub const EXTENSIONS: &[&str] = &["af", "afphoto", "afdesign", "afpub"];
+/// Pixels an image or mask is resampled into, after cropping to the canvas.
+const MAX_PIXELS: u64 = 64 << 20;
+/// Canvas side in pixels.
+const MAX_SIDE: f64 = 300_000.0;
+
pub fn has_extension(name: &str) -> bool {
name.rsplit(['/', '\\']).next().and_then(|n| n.rsplit_once('.')).is_some_and(|(_, ext)| EXTENSIONS.iter().any(|e| ext.eq_ignore_ascii_case(e)))
}
pub(crate) fn import(name: &str, bytes: &[u8]) -> Result {
- let p = photocraft_affinity::preview(bytes).map_err(|e| IoError::Unsupported(e.to_string()))?;
+ match photocraft_affinity::read(bytes, photocraft_affinity::Limits::default()) {
+ Ok(d) => {
+ let mut b = Builder::new(name, &d)?;
+ b.document(&d)?;
+ let mut warnings: Vec = d.warnings.iter().map(|w| format!("Affinity: not imported or approximated: {w}.")).collect();
+ warnings.extend(b.warnings.iter().map(|w| format!("Affinity: {w}.")));
+ // Save never writes back over the Affinity file.
+ Ok(ImportResult { document: b.doc, warnings, source_read_only: true, preview_only: false })
+ }
+ Err(native) => preview(name, bytes, &native),
+ }
+}
+
+/// The embedded preview, for files whose native document can't be read.
+fn preview(name: &str, bytes: &[u8], native: &photocraft_affinity::Error) -> Result {
+ let p = photocraft_affinity::preview(bytes).map_err(|e| IoError::Unsupported(format!("{native}; its embedded preview can't be read either ({e})")))?;
let opts = photocraft_codecs::DecodeOptions {
limits: photocraft_codecs::Limits { max_width: 4096, max_height: 4096, max_pixels: 4096 * 4096, max_alloc: 128 << 20 },
..Default::default()
@@ -24,8 +61,559 @@ pub(crate) fn import(name: &str, bytes: &[u8]) -> Result
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
- ));
+ r.preview_only = true;
+ r.warnings.insert(
+ 0,
+ format!(
+ "The native Affinity document could not be read ({native}). Opened only its embedded {}Ă—{} PNG preview, which may be smaller than the document: no layers, vectors, text or pages. Save a new copy; the Affinity source cannot be saved back. Export PSD or PNG from Affinity for full-resolution artwork.",
+ p.width, p.height
+ ),
+ );
Ok(r)
}
+
+struct Builder {
+ doc: Document,
+ warnings: Vec,
+ /// Document pixels (in the canvas) per Affinity document pixel: always 1; spreads are offset.
+ format: PixelFormat,
+ canvas: Rect,
+}
+
+fn affine(a: af::Affine) -> Affine {
+ Affine { m: a.0 }
+}
+
+fn point(p: af::Point) -> Point {
+ Point::new(p.x, p.y)
+}
+
+impl Builder {
+ fn new(name: &str, d: &af::Document) -> Result {
+ // Spreads (Publisher pages) side by side, 64 px apart.
+ let mut w: f64 = 0.0;
+ let mut h: f64 = 0.0;
+ for (i, s) in d.spreads.iter().enumerate() {
+ w += (s.bounds.x1 - s.bounds.x0).max(1.0) + if i > 0 { 64.0 } else { 0.0 };
+ h = h.max((s.bounds.y1 - s.bounds.y0).max(1.0));
+ }
+ if !(w.is_finite() && h.is_finite()) || w > MAX_SIDE || h > MAX_SIDE {
+ return Err(IoError::Unsupported("the Affinity document is larger than 300 000 pixels on a side".into()));
+ }
+ let size = Size::new(w.ceil().max(1.0) as u32, h.ceil().max(1.0) as u32);
+ let mut doc = Document::new(name, size, ColorMode::Rgb, SampleType::U8);
+ doc.resolution_dpi = d.dpi as f32;
+ let canvas = Rect::new(0, 0, size.width as i32, size.height as i32);
+ Ok(Self { doc, warnings: Vec::new(), format: PixelFormat::RGBA8, canvas })
+ }
+
+ fn warn(&mut self, w: &str) {
+ if !self.warnings.iter().any(|x| x == w) {
+ self.warnings.push(w.to_string());
+ }
+ }
+
+ fn document(&mut self, d: &af::Document) -> Result<(), IoError> {
+ let pages = d.spreads.len() > 1 || d.spreads.iter().any(|s| s.pages.len() > 1);
+ let mut x = 0.0;
+ let mut layers = Vec::new();
+ for (i, spread) in d.spreads.iter().enumerate() {
+ let to_doc = Affine::translate(x - spread.bounds.x0, -spread.bounds.y0);
+ let mut children: Vec = spread.nodes.iter().filter_map(|n| self.layer(n, to_doc, true)).collect();
+ if pages {
+ // Each page becomes an artboard holding the art drawn on it.
+ let rects: Vec = if spread.pages.is_empty() { vec![spread.bounds] } else { spread.pages.clone() };
+ let page = rects.first().copied().unwrap_or(spread.bounds);
+ let r = self.bbox(to_doc, page);
+ let mut g = Layer::new(
+ format!("Page {}", i + 1),
+ LayerContent::Group(Group { children: std::mem::take(&mut children), expanded: true, artboard: None }),
+ );
+ if let LayerContent::Group(gr) = &mut g.content {
+ gr.artboard = Some(Artboard { rect: r, background: ArtboardBackground::White, preset: String::new() });
+ }
+ if rects.len() > 1 {
+ self.warn("facing pages import as one artboard per spread");
+ }
+ layers.push(g);
+ } else {
+ layers.append(&mut children);
+ }
+ x += (spread.bounds.x1 - spread.bounds.x0).max(1.0) + 64.0;
+ }
+ if d.spreads.first().is_some_and(|s| !s.transparent) && !self.doc_has_artboards(&layers) {
+ // An opaque page: a white background below the art.
+ let mut bg = Surface::with_default(self.format, &[1.0, 1.0, 1.0, 1.0]);
+ bg.prune();
+ let mut l = Layer::new("Background", LayerContent::Raster(bg));
+ l.locks.transparency = true;
+ layers.insert(0, l);
+ }
+ self.doc.layers = layers;
+ Ok(())
+ }
+
+ fn doc_has_artboards(&self, layers: &[Layer]) -> bool {
+ layers.iter().any(|l| matches!(&l.content, LayerContent::Group(g) if g.artboard.is_some()))
+ }
+
+ /// Integer bounding box of `r` mapped by `m`, clipped to the canvas.
+ fn bbox(&self, m: Affine, r: af::Rect) -> Rect {
+ let pts = [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| m.apply(Point::new(x, y)));
+ let fold = |f: fn(f64, f64) -> f64, init: f64, g: fn(&Point) -> f64| pts.iter().map(g).fold(init, f);
+ let x0 = fold(f64::min, f64::INFINITY, |p| p.x).floor();
+ let y0 = fold(f64::min, f64::INFINITY, |p| p.y).floor();
+ let x1 = fold(f64::max, f64::NEG_INFINITY, |p| p.x).ceil();
+ let y1 = fold(f64::max, f64::NEG_INFINITY, |p| p.y).ceil();
+ let c = |v: f64, lo: i32, hi: i32| (v.clamp(f64::from(lo), f64::from(hi))) as i32;
+ Rect::new(
+ c(x0, self.canvas.x0, self.canvas.x1),
+ c(y0, self.canvas.y0, self.canvas.y1),
+ c(x1, self.canvas.x0, self.canvas.x1),
+ c(y1, self.canvas.y0, self.canvas.y1),
+ )
+ }
+
+ fn blend(&mut self, b: Blend) -> BlendMode {
+ match b {
+ Blend::Normal => BlendMode::Normal,
+ Blend::PassThrough => BlendMode::PassThrough,
+ Blend::Darken => BlendMode::Darken,
+ Blend::DarkerColor => BlendMode::DarkerColor,
+ Blend::Multiply => BlendMode::Multiply,
+ Blend::ColorBurn => BlendMode::ColorBurn,
+ Blend::Lighten => BlendMode::Lighten,
+ Blend::LighterColor => BlendMode::LighterColor,
+ Blend::Screen => BlendMode::Screen,
+ Blend::ColorDodge => BlendMode::ColorDodge,
+ Blend::Add => BlendMode::LinearDodge,
+ Blend::Overlay => BlendMode::Overlay,
+ Blend::SoftLight => BlendMode::SoftLight,
+ Blend::HardLight => BlendMode::HardLight,
+ Blend::VividLight => BlendMode::VividLight,
+ Blend::PinLight => BlendMode::PinLight,
+ Blend::LinearLight => BlendMode::LinearLight,
+ Blend::HardMix => BlendMode::HardMix,
+ Blend::Difference => BlendMode::Difference,
+ Blend::Exclusion => BlendMode::Exclusion,
+ Blend::Subtract => BlendMode::Subtract,
+ Blend::Hue => BlendMode::Hue,
+ Blend::Saturation => BlendMode::Saturation,
+ Blend::Luminosity => BlendMode::Luminosity,
+ Blend::Color => BlendMode::Color,
+ Blend::Average | Blend::Negation | Blend::Reflect | Blend::Glow | Blend::Erase => {
+ self.warn("the Average, Negation, Reflect, Glow and Erase blend modes import as Normal");
+ BlendMode::Normal
+ }
+ }
+ }
+
+ fn base(&mut self, n: &af::Node, content: LayerContent) -> Layer {
+ let mut l = Layer::new(if n.name.is_empty() { default_name(&n.kind) } else { n.name.clone() }, content);
+ l.visible = n.visible;
+ l.opacity = n.opacity as f32;
+ l.blend = self.blend(n.blend);
+ if n.locked {
+ l.locks = Locks { all: true, ..Locks::default() };
+ }
+ l
+ }
+
+ fn group(&mut self, n: &af::Node, children: Vec) -> Layer {
+ // Pass-through and Normal (isolated) groups map one to one.
+ self.base(n, LayerContent::Group(Group { children, expanded: false, artboard: None }))
+ }
+
+ /// One Affinity node and its subtree; `to_doc` maps Affinity document pixels to the canvas.
+ fn layer(&mut self, n: &af::Node, to_doc: Affine, top: bool) -> Option {
+ let children: Vec = n.children.iter().filter_map(|c| self.layer(c, to_doc, false)).collect();
+ let mut l = match &n.kind {
+ Kind::Layer | Kind::Group => self.group(n, children),
+ Kind::Unsupported if children.is_empty() => return None,
+ Kind::Unsupported => self.group(n, children),
+ Kind::Shape { path, fills, strokes, even_odd } => {
+ let path = self.path(path, to_doc, *even_odd);
+ let shape = self.shape(path.clone(), fills, strokes, to_doc);
+ if children.is_empty() {
+ self.base(n, LayerContent::Shape(shape))
+ } else {
+ // A shape clips its children: a group masked by the outline, the shape at its bottom.
+ let mut own = Layer::new(if n.name.is_empty() { "Shape".to_string() } else { n.name.clone() }, LayerContent::Shape(shape));
+ own.opacity = 1.0;
+ let mut kids = vec![own];
+ kids.extend(children);
+ let mut g = self.group(n, kids);
+ g.vector_mask = Some(VectorMask { path, enabled: true, linked: true, density: 1.0, feather: 0.0 });
+ if !strokes.is_empty() {
+ self.warn("strokes of shapes that clip other layers are clipped to the shape");
+ }
+ g
+ }
+ }
+ Kind::Artboard { rect, background } => {
+ let r = self.bbox(to_doc, *rect);
+ let bg = match background.first() {
+ Some(ap::Paint::Solid(c)) => {
+ let c = color(*c);
+ if c.alpha <= 0.0 { ArtboardBackground::Transparent } else { ArtboardBackground::Custom(c) }
+ }
+ Some(_) => {
+ self.warn("artboard backgrounds other than a solid colour import as transparent");
+ ArtboardBackground::Transparent
+ }
+ None => ArtboardBackground::Transparent,
+ };
+ let mut g = self.group(n, children);
+ if top {
+ if let LayerContent::Group(gr) = &mut g.content {
+ gr.artboard = Some(Artboard { rect: r, background: bg, preset: String::new() });
+ }
+ } else {
+ // PhotoCraft artboards are top-level groups: a nested one clips by a vector mask.
+ let p = Path::new(vec![Subpath::polygon(&[
+ (f64::from(r.x0), f64::from(r.y0)),
+ (f64::from(r.x1), f64::from(r.y0)),
+ (f64::from(r.x1), f64::from(r.y1)),
+ (f64::from(r.x0), f64::from(r.y1)),
+ ])]);
+ g.vector_mask = Some(VectorMask { path: p, enabled: true, linked: true, density: 1.0, feather: 0.0 });
+ self.warn("artboards inside groups import as masked groups");
+ }
+ g
+ }
+ Kind::Text(t) => {
+ let layer = self.text(t, to_doc);
+ let l = self.base(n, LayerContent::Text(layer));
+ if children.is_empty() {
+ l
+ } else {
+ let mut kids = vec![l];
+ kids.extend(children);
+ self.group(n, kids)
+ }
+ }
+ Kind::Image(img) => match self.image(img, to_doc) {
+ Some(content) => self.base(n, content),
+ None if children.is_empty() => return None,
+ None => self.group(n, children),
+ },
+ };
+ if let Some(mask) = &n.mask {
+ l.vector_mask = Some(VectorMask { path: self.path(mask, to_doc, false), enabled: true, linked: true, density: 1.0, feather: 0.0 });
+ }
+ if let Some(mask) = &n.pixel_mask
+ && let Some(surface) = self.mask(mask, to_doc)
+ {
+ l.mask = Some(LayerMask { surface, enabled: true, linked: true, density: 1.0, feather: 0.0 });
+ }
+ Some(l)
+ }
+
+ fn path(&self, p: &af::Path, to_doc: Affine, even_odd: bool) -> Path {
+ let m = to_doc;
+ let subpaths = p
+ .subpaths
+ .iter()
+ .map(|s| {
+ // Knots: each anchor with the control points of the segments arriving and leaving.
+ let mut knots: Vec = Vec::with_capacity(s.segments.len() + 1);
+ let start = m.apply(point(s.start));
+ knots.push(Knot::corner(start.x, start.y));
+ for [c1, c2, e] in &s.segments {
+ if let Some(prev) = knots.last_mut() {
+ prev.out_ctrl = m.apply(point(*c1));
+ }
+ let e = m.apply(point(*e));
+ let mut k = Knot::corner(e.x, e.y);
+ k.in_ctrl = m.apply(point(*c2));
+ knots.push(k);
+ }
+ // A closed subpath that ends where it starts: the last knot is the first.
+ if s.closed
+ && knots.len() > 1
+ && let (Some(f), Some(l)) = (knots.first().copied(), knots.last().copied())
+ && (f.anchor.x - l.anchor.x).abs() < 1e-9
+ && (f.anchor.y - l.anchor.y).abs() < 1e-9
+ {
+ knots.pop();
+ if let Some(first) = knots.first_mut() {
+ first.in_ctrl = l.in_ctrl;
+ }
+ }
+ Subpath { closed: s.closed, knots, op: Default::default() }
+ })
+ .collect();
+ let mut path = Path::new(subpaths);
+ if even_odd {
+ // One component filled even-odd, so inner subpaths cut holes as in Affinity.
+ path.fill_rule = photocraft_doc::vector::FillRule::EvenOdd;
+ for s in path.subpaths.iter_mut().skip(1) {
+ s.op = photocraft_doc::vector::PathOp::Join;
+ }
+ }
+ path
+ }
+
+ fn fill(&mut self, p: &ap::Paint, frame: Rect, to_doc: Affine) -> Option {
+ match p {
+ ap::Paint::None => None,
+ ap::Paint::Solid(c) => Some(Fill::Solid(color(*c))),
+ ap::Paint::Gradient(g) => {
+ let style = match g.kind {
+ GradientKind::Linear => GradientStyle::Linear,
+ GradientKind::Radial => GradientStyle::Radial,
+ GradientKind::Conical => GradientStyle::Angle,
+ };
+ let m = to_doc.mul(&affine(g.transform));
+ let (o, e, v) = (m.apply(Point::new(0.0, 0.0)), m.apply(Point::new(1.0, 0.0)), m.apply(Point::new(0.0, 1.0)));
+ let (u, w) = ((e.x - o.x).hypot(e.y - o.y), (v.x - o.x).hypot(v.y - o.y));
+ if style == GradientStyle::Radial && u > 0.0 && (w / u - 1.0).abs() > 0.01 {
+ self.warn("elliptical gradients import as circular ones");
+ }
+ let (angle, scale, offset) =
+ photocraft_compose::gradient_fill::from_handles(style, [o.x as f32, o.y as f32], [e.x as f32, e.y as f32], frame, 0.0);
+ let stops = g.stops.iter().map(|s| (s.offset as f32, Color { alpha: 1.0, ..color(s.color) })).collect();
+ let opacity_stops = g.stops.iter().map(|s| (s.offset as f32, s.color.alpha() as f32)).collect();
+ let midpoints = g.stops.iter().take(g.stops.len().saturating_sub(1)).map(|s| s.half_point() as f32).collect();
+ Some(Fill::Gradient { stops, angle, scale, style, reverse: false, opacity_stops, midpoints, offset, dither: false, align: true })
+ }
+ }
+ }
+
+ fn shape(&mut self, path: Path, fills: &[ap::Paint], strokes: &[ap::Stroke], to_doc: Affine) -> ShapeLayer {
+ let frame = path
+ .control_bounds()
+ .map(|(x0, y0, x1, y1)| Rect::new(x0.floor() as i32, y0.floor() as i32, x1.ceil() as i32, y1.ceil() as i32))
+ .unwrap_or(self.canvas);
+ if fills.len() > 1 || strokes.len() > 1 {
+ self.warn("only one fill and one stroke per shape import");
+ }
+ let fill = fills.first().and_then(|f| self.fill(f, frame, to_doc));
+ let stroke = strokes.first().and_then(|s| {
+ let paint = self.fill(&s.paint, frame, to_doc)?;
+ if s.behind {
+ self.warn("strokes drawn behind their fill import in front of it");
+ }
+ Some(ShapeStroke {
+ width: s.width as f32,
+ paint,
+ opacity: 1.0,
+ align: match s.align {
+ ap::Align::Center => StrokeAlign::Center,
+ ap::Align::Inside => StrokeAlign::Inside,
+ ap::Align::Outside => StrokeAlign::Outside,
+ },
+ cap: match s.cap {
+ Cap::Butt => LineCap::Butt,
+ Cap::Round => LineCap::Round,
+ Cap::Square => LineCap::Square,
+ },
+ join: match s.join {
+ Join::Miter => LineJoin::Miter,
+ Join::Round => LineJoin::Round,
+ Join::Bevel => LineJoin::Bevel,
+ },
+ miter_limit: s.miter_limit as f32,
+ // In multiples of the width, as Photoshop stores them.
+ dashes: s.dash.as_ref().map(|(d, _)| d.iter().map(|v| (v / s.width.max(1e-9)) as f32).collect()).unwrap_or_default(),
+ dash_offset: s.dash.as_ref().map_or(0.0, |(_, o)| (o / s.width.max(1e-9)) as f32),
+ })
+ });
+ let mut sh = ShapeLayer { path, fill, stroke, live: None, cache: None, psd_raw: None };
+ sh.cache = Some(photocraft_vector::render_shape(&sh, self.format, self.canvas));
+ sh
+ }
+
+ fn text(&mut self, t: &af::Text, to_doc: Affine) -> TextLayer {
+ let origin = match t.frame {
+ Some(f) => Point::new(f.x0, f.y0),
+ None => point(t.anchor),
+ };
+ let m = to_doc.mul(&affine(t.transform)).mul(&Affine::translate(origin.x, origin.y));
+ let k = m.determinant().abs().sqrt().max(1e-9);
+ let transform = m.mul(&Affine::scale(1.0 / k));
+ let dpi = self.doc.resolution_dpi.max(1.0) as f64;
+ let mut text = String::new();
+ let mut runs = Vec::new();
+ for r in &t.runs {
+ // Affinity separates paragraphs with U+2029 and lines with U+2028.
+ let s = r.text.replace(['\u{2029}', '\u{2028}'], "\r");
+ let style = CharStyle {
+ font_family: r.family.clone(),
+ postscript_name: (!r.postscript.is_empty()).then(|| r.postscript.clone()),
+ weight: r.weight.clamp(100, 900) as u16,
+ italic: r.italic,
+ size_pt: (r.size * k * 72.0 / dpi) as f32,
+ color: match &r.fill {
+ ap::Paint::Solid(c) => color(*c),
+ _ => Color::BLACK,
+ },
+ tracking: (r.tracking * 1000.0) as f32,
+ leading_pt: r.leading.map(|l| (l * k * 72.0 / dpi) as f32),
+ ..CharStyle::default()
+ };
+ text.push_str(&s);
+ runs.push(TextRun { len: s.len(), style });
+ }
+ let align = match t.align {
+ Align::Left => TextAlign::Left,
+ Align::Center => TextAlign::Center,
+ Align::Right => TextAlign::Right,
+ Align::Justify => TextAlign::JustifyLeft,
+ };
+ let mut layer = TextLayer {
+ text: text.clone(),
+ font_family: runs.first().map(|r| r.style.font_family.clone()).unwrap_or_default(),
+ size_pt: runs.first().map_or(12.0, |r| r.style.size_pt),
+ color: runs.first().map_or(Color::BLACK, |r| r.style.color),
+ transform,
+ runs,
+ paragraphs: vec![ParagraphRun { len: text.len(), style: ParagraphStyle { align, ..ParagraphStyle::default() } }],
+ ..TextLayer::default()
+ };
+ if let Some(f) = t.frame {
+ layer.shape = TextShape::Box { x: 0.0, y: 0.0, width: ((f.x1 - f.x0) * k) as f32, height: ((f.y1 - f.y0) * k) as f32 };
+ }
+ let engine = photocraft_text::shared();
+ let mut e = engine.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
+ e.render_layer(&mut layer, self.doc.resolution_dpi, self.format);
+ layer
+ }
+
+ /// Straight RGBA8 pixels of an image (decoding an embedded file), with its size.
+ fn pixels(&mut self, img: &af::Image) -> Option<(u32, u32, Vec)> {
+ match &img.pixels {
+ Pixels::Rgba8(v) => Some((img.width, img.height, v.clone())),
+ Pixels::Encoded(bytes) => {
+ let opts = photocraft_codecs::DecodeOptions {
+ limits: photocraft_codecs::Limits { max_width: 65_535, max_height: 65_535, max_pixels: MAX_PIXELS, max_alloc: 512 << 20 },
+ ..Default::default()
+ };
+ match photocraft_codecs::decode_with(bytes, &opts) {
+ Ok(d) => {
+ let (w, h) = d.dimensions();
+ Some((w, h, d.to_rgba8()))
+ }
+ Err(_) => {
+ self.warn("an embedded image that could not be decoded was left out");
+ None
+ }
+ }
+ }
+ }
+ }
+
+ fn image(&mut self, img: &af::Image, to_doc: Affine) -> Option {
+ let (w, h, rgba) = self.pixels(img)?;
+ // The pixel grid maps onto the Affinity image's own size, whatever the stored file's.
+ let m = to_doc
+ .mul(&affine(img.transform))
+ .mul(&Affine { m: [f64::from(img.width) / f64::from(w.max(1)), 0.0, 0.0, f64::from(img.height) / f64::from(h.max(1)), 0.0, 0.0] });
+ let surface = self.resample(w, h, &rgba, m, 4)?;
+ Some(match &img.pixels {
+ // A placed image keeps its file, as a smart object placed by the same transform.
+ Pixels::Encoded(bytes) => LayerContent::Smart(SmartObject::new(
+ SmartSource::Embedded { file_name: "Affinity image".into(), bytes: Arc::new(bytes.clone()) },
+ m,
+ Some(surface),
+ )),
+ Pixels::Rgba8(_) => LayerContent::Raster(surface),
+ })
+ }
+
+ fn mask(&mut self, img: &af::Image, to_doc: Affine) -> Option {
+ let (w, h, rgba) = self.pixels(img)?;
+ let grey: Vec = rgba.chunks_exact(4).map(|p| p[0]).collect();
+ let m = to_doc.mul(&affine(img.transform));
+ self.resample(w, h, &grey, m, 1)
+ }
+
+ /// Bilinear resampling of `src` (`w`Ă—`h`, `channels` 4 = straight RGBA8 or 1 = grey) mapped
+ /// by `m` (source pixels to canvas pixels) into a surface; exact copy when `m` is an integer
+ /// translation. Pixels outside the source are transparent (grey: 0, hidden).
+ fn resample(&mut self, w: u32, h: u32, src: &[u8], m: Affine, channels: usize) -> Option {
+ let format = if channels == 1 { PixelFormat::GRAY8 } else { self.format };
+ let mut out = if channels == 1 { Surface::with_default(format, &[0.0]) } else { Surface::new(format) };
+ let r = self.bbox(m, af::Rect { x0: 0.0, y0: 0.0, x1: f64::from(w), y1: f64::from(h) });
+ if r.width() <= 0 || r.height() <= 0 {
+ return Some(out);
+ }
+ if u64::from(r.width() as u32) * u64::from(r.height() as u32) > MAX_PIXELS {
+ self.warn("images larger than 64 megapixels on the canvas were left out");
+ return None;
+ }
+ let [a, b, c, d, e, f] = m.m;
+ let exact = a == 1.0 && d == 1.0 && b == 0.0 && c == 0.0 && e.fract() == 0.0 && f.fract() == 0.0;
+ let inv = m.inverse()?;
+ let (rw, rh) = (r.width() as usize, r.height() as usize);
+ let mut buf = vec![0u8; rw * rh * channels];
+ let at = |x: i64, y: i64, ch: usize| -> f64 {
+ if x < 0 || y < 0 || x >= i64::from(w) || y >= i64::from(h) {
+ return 0.0;
+ }
+ f64::from(src.get((y as usize * w as usize + x as usize) * channels + ch).copied().unwrap_or(0))
+ };
+ for (row, out_row) in buf.chunks_exact_mut(rw * channels).enumerate() {
+ for (col, px) in out_row.chunks_exact_mut(channels).enumerate() {
+ let p = inv.apply(Point::new(f64::from(r.x0) + col as f64 + 0.5, f64::from(r.y0) + row as f64 + 0.5));
+ if exact {
+ let (x, y) = (p.x.floor() as i64, p.y.floor() as i64);
+ for (ch, v) in px.iter_mut().enumerate() {
+ *v = at(x, y, ch) as u8;
+ }
+ continue;
+ }
+ let (fx, fy) = (p.x - 0.5, p.y - 0.5);
+ let (x0, y0) = (fx.floor() as i64, fy.floor() as i64);
+ let (tx, ty) = (fx - x0 as f64, fy - y0 as f64);
+ let weights =
+ [((x0, y0), (1.0 - tx) * (1.0 - ty)), ((x0 + 1, y0), tx * (1.0 - ty)), ((x0, y0 + 1), (1.0 - tx) * ty), ((x0 + 1, y0 + 1), tx * ty)];
+ if channels == 1 {
+ px[0] = weights.iter().map(|((x, y), wgt)| at(*x, *y, 0) * wgt).sum::().round().clamp(0.0, 255.0) as u8;
+ } else {
+ // Interpolate premultiplied colour so transparent pixels don't bleed.
+ let alpha: f64 = weights.iter().map(|((x, y), wgt)| at(*x, *y, 3) * wgt).sum();
+ for (ch, v) in px.iter_mut().enumerate().take(3) {
+ let pm: f64 = weights.iter().map(|((x, y), wgt)| at(*x, *y, ch) * at(*x, *y, 3) / 255.0 * wgt).sum();
+ *v = if alpha > 0.0 { (pm * 255.0 / alpha).round().clamp(0.0, 255.0) as u8 } else { 0 };
+ }
+ px[3] = alpha.round().clamp(0.0, 255.0) as u8;
+ }
+ }
+ }
+ out.write_interleaved(r, &buf);
+ out.prune();
+ Some(out)
+ }
+}
+
+fn default_name(k: &Kind) -> String {
+ match k {
+ Kind::Layer => "Layer",
+ Kind::Group => "Group",
+ Kind::Artboard { .. } => "Artboard",
+ Kind::Shape { .. } => "Shape",
+ Kind::Text(_) => "Text",
+ Kind::Image(_) => "Image",
+ Kind::Unsupported => "Group",
+ }
+ .into()
+}
+
+fn color(c: ap::Color) -> Color {
+ let f = |v: f64| v.clamp(0.0, 1.0) as f32;
+ match c {
+ ap::Color::Rgb { r, g, b, a } => Color::rgba(f(r), f(g), f(b), f(a)),
+ ap::Color::Cmyk { c, m, y, k, a } => {
+ let cmyk = Color { mode: ColorMode::Cmyk, c: [f(c), f(m), f(y), f(k)], alpha: f(a) };
+ let [r, g, b] = cmyk.to_rgb();
+ Color::rgba(r, g, b, f(a))
+ }
+ ap::Color::Gray { v, a } => Color::rgba(f(v), f(v), f(v), f(a)),
+ ap::Color::Lab { l, a, b, alpha } => {
+ let lab = Color { mode: ColorMode::Lab, c: [f(l / 100.0), f((a + 128.0) / 255.0), f((b + 128.0) / 255.0), 0.0], alpha: f(alpha) };
+ let [r, g, bl] = lab.to_rgb();
+ Color::rgba(r, g, bl, f(alpha))
+ }
+ }
+}
diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs
index 7356a6d2..e931d012 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.
+ /// Save must not write back to the source (an Affinity document: PhotoCraft can't write it).
pub source_read_only: bool,
+ /// Only a stand-in picture of the file (an Affinity document whose native data couldn't be
+ /// read): Open shows it with its warning; Place and other auxiliary imports refuse it.
+ pub preview_only: bool,
}
/// Result of [`export`].
@@ -163,7 +166,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(), source_read_only: false });
+ return Ok(ImportResult { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new(), source_read_only: false, preview_only: false });
}
if is_psd(bytes) {
let file = PsdFile::from_bytes(bytes)?;
@@ -176,7 +179,7 @@ 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, source_read_only: false });
+ return Ok(ImportResult { document, warnings, source_read_only: false, preview_only: false });
}
if affinity::is_affinity(bytes) || affinity::has_extension(name) {
return affinity::import(name, bytes);
diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs
index 1a621c21..2f82a133 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 F {
+ F::Struct([r, g, b, a].iter().flat_map(|v| v.to_le_bytes()).collect())
+ }
+
+ fn solid(id: u32, c: F) -> F {
+ F::Def(
+ id,
+ vec![tag(b"FDsc")],
+ vec![(tag(b"FDeF"), F::Def(id + 1, vec![tag(b"FilS")], vec![(tag(b"Colr"), F::Def(id + 2, vec![tag(b"RGBA")], vec![(tag(b"_col"), c)]))]))],
+ )
+ }
+
+ fn node(x: f64, y: f64) -> Vec {
+ let mut r = x.to_le_bytes().to_vec();
+ r.extend(y.to_le_bytes());
+ r.extend([1, 0]);
+ r
+ }
+
+ /// A version-12 document at 144 ppi, 200Ă—100: an artboard "Board" moved by (20, 10) holding
+ /// a red ring (two subpaths, filled even-odd) with a 4 px blue stroke.
+ pub(super) fn document(method: Method) -> Vec {
+ let square = |s: f64, o: f64| vec![node(o, o), node(o + s, o), node(o + s, o + s), node(o, o + s), node(o, o)];
+ let ring = F::Def(
+ 20,
+ vec![tag(b"PCrv")],
+ vec![
+ (tag(b"Desc"), F::Str("Ring".into())),
+ (
+ tag(b"Crvs"),
+ F::Obj(
+ tag(b"PCvD"),
+ vec![(
+ tag(b"Data"),
+ F::Pos(vec![
+ F::U8(0),
+ F::U32(2),
+ F::Bool(true),
+ F::Records(18, square(60.0, 0.0)),
+ F::Bool(true),
+ F::Records(18, square(20.0, 20.0)),
+ ]),
+ )],
+ ),
+ ),
+ (tag(b"BFFl"), F::Shared(vec![solid(21, rgba(1.0, 0.0, 0.0, 1.0))])),
+ (tag(b"LIFl"), F::Shared(vec![solid(24, rgba(0.0, 0.0, 1.0, 1.0))])),
+ (
+ tag(b"LILn"),
+ F::Shared(vec![F::Def(27, vec![tag(b"LDsc")], vec![(tag(b"LDeL"), F::Def(28, vec![tag(b"LSty")], vec![(tag(b"Wght"), F::F64(4.0))]))])]),
+ ),
+ ],
+ );
+ let board = F::Def(
+ 10,
+ vec![tag(b"ShpN")],
+ vec![
+ (tag(b"Desc"), F::Str("Board".into())),
+ (tag(b"ABEn"), F::Bool(true)),
+ (tag(b"Shpe"), F::Def(11, vec![tag(b"ShNR")], vec![])),
+ (tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 120.0, 80.0])),
+ (tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 20.0, 0.0, 1.0, 10.0])),
+ (tag(b"BFFl"), F::Shared(vec![solid(12, rgba(1.0, 1.0, 1.0, 1.0))])),
+ (tag(b"Chld"), F::Shared(vec![ring])),
+ ],
+ );
+ let spread = F::Def(
+ 2,
+ vec![tag(b"Sprd")],
+ vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 200.0, 100.0])), (tag(b"SprT"), F::Bool(true)), (tag(b"Chld"), F::Shared(vec![board]))],
+ );
+ let doc = synth::stream(&[
+ (tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(144.0))])),
+ (tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))])),
+ ]);
+ synth::container(&[("doc.dat", &doc, method)], Some(&png()))
+ }
+
+ #[test]
+ fn artboards_shapes_and_paints_import_as_editable_layers() {
+ for method in [Method::Stored, Method::Zlib, Method::Zstd] {
+ let r = import("native.af", &document(method)).unwrap();
+ assert!(r.source_read_only && !r.preview_only, "{:?}", r.warnings);
+ let d = &r.document;
+ assert_eq!((d.size.width, d.size.height, d.resolution_dpi), (200, 100, 144.0));
+ let board = &d.layers[0];
+ assert_eq!(board.name, "Board");
+ let LayerContent::Group(g) = &board.content else { panic!("{:?}", board.content) };
+ let ab = g.artboard.as_ref().unwrap();
+ assert_eq!((ab.rect.x0, ab.rect.y0, ab.rect.x1, ab.rect.y1), (20, 10, 140, 90));
+ let ring = &g.children[0];
+ assert_eq!(ring.name, "Ring");
+ let LayerContent::Shape(sh) = &ring.content else { panic!() };
+ assert_eq!(sh.path.subpaths.len(), 2);
+ assert_eq!(sh.path.subpaths[0].knots.len(), 4, "the closing duplicate knot is merged");
+ assert_eq!(sh.path.subpaths[0].knots[0].anchor, photocraft_geom::Point::new(20.0, 10.0));
+ assert_eq!(sh.stroke.as_ref().unwrap().width, 4.0);
+ // The cache shows the ring: red on it, empty in its hole.
+ let cache = sh.cache.as_ref().unwrap();
+ let px = |x: i32, y: i32| cache.to_interleaved(photocraft_geom::Rect::new(x, y, x + 1, y + 1));
+ assert_eq!(px(30, 20), [255, 0, 0, 255]);
+ assert_eq!(px(50, 40)[3], 0, "even-odd hole");
+ // Saved as a native PhotoCraft document and opened again.
+ let saved = export(d, "copy.pcraft", &ExportOptions::default()).unwrap();
+ let back = import("copy.pcraft", &saved.bytes).unwrap();
+ assert_eq!(back.document.layers.len(), d.layers.len());
+ assert!(export(d, "copy.psd", &ExportOptions::default()).is_ok());
+ assert!(export(d, "copy.af", &ExportOptions::default()).is_err());
+ }
+ }
+
+ #[test]
+ fn a_damaged_native_document_falls_back_to_its_preview_with_the_reason() {
+ let mut bytes = document(Method::Zlib);
+ bytes[80] ^= 0xFF;
+ let r = import("broken.af", &bytes).unwrap();
+ assert!(r.preview_only);
+ assert!(r.warnings[0].contains("could not be read (damaged Affinity file"), "{}", r.warnings[0]);
+ }
+
+ proptest! {
+ #![proptest_config(ProptestConfig::with_cases(256))]
+ #[test]
+ fn mutated_native_documents_never_panic(edits in prop::collection::vec((0usize..2048, any::()), 1..16), stored in any::()) {
+ let mut b = document(if stored { Method::Stored } else { Method::Zstd });
+ let n = b.len();
+ for (i, v) in edits { b[i % n] = v; }
+ let _ = import("mutated.af", &b);
}
}
}
diff --git a/crates/io/tests/affinity_corpus.rs b/crates/io/tests/affinity_corpus.rs
new file mode 100644
index 00000000..e4cb2921
--- /dev/null
+++ b/crates/io/tests/affinity_corpus.rs
@@ -0,0 +1,107 @@
+//! Public Affinity documents (`cargo xtask corpus --affinity`, pinned and sha256-verified) open
+//! natively and look like Affinity's own picture of them: every document embeds a thumbnail
+//! Affinity rendered when saving it. Each file's mean difference from that thumbnail (0–255 per
+//! channel, both flattened on white, at the smaller of the two sizes) must stay under its ceiling,
+//! set just above the difference measured when the importer landed.
+#![cfg(feature = "corpus")]
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+use std::path::PathBuf;
+
+use photocraft_io::{ExportOptions, export, import};
+
+/// (file, ceiling). Measured differences when the importer landed are in the comments.
+const FILES: &[(&str, f64)] = &[
+ ("vector-art/mexican-guy.af", 3e+00), // 1.92: brush strokes as plain strokes
+ ("vector-art/mexican-man.af", 4e+00), // 2.74
+ ("vector-art/mexican-woman.af", 4e+00), // 2.24
+ ("vector-art/playing-cards.af", 4e+00), // 3.04: layer effects
+ ("vector-art/beer.afdesign", 4e+00), // 2.91
+ ("vector-art/cactus.afdesign", 2e+00), // 0.57
+ ("vector-art/car.afdesign", 2e+00), // 0.39
+ ("vector-art/flowers.afdesign", 3e+00), // 2.09
+ ("vector-art/lion-track.afdesign", 6e+00), // 4.41
+ ("afdesignload/color.afdesign", 1e+00), // 0.00
+ ("afdesignload/layer_mode.afdesign", 1e+00), // 0.00
+ ("afdesignload/layer_test.afdesign", 1e+00), // 0.00
+ ("afdesignload/margins.afdesign", 1e+00), // 0.00
+ ("afdesignload/raster_test.afdesign", 2e+00), // 0.82: pixel layer
+ ("afdesignload/revision_test.afdesign", 1e+00), // 0.01
+ ("afdesignload/shape_test.afdesign", 6e+00), // 4.60: cloud and heart shapes as ellipses
+ ("afdesignload/slice_test.afdesign", 1e+00), // 0.02
+ ("afdesignload/test_path.afdesign", 2e+00), // 1.37
+ ("jac21/SimpleLogo.afdesign", 2e+00), // 0.78
+ ("jac21/SimpleLogoBanner.afdesign", 4e+00), // 2.72: placed images in a Display P3 document
+ ("asset-store-template/AssetStore.aftemplate", 1e+00), // 0.00: artboards
+];
+
+fn corpus(rel: &str) -> Vec {
+ let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/affinity").join(rel);
+ std::fs::read(&p).unwrap_or_else(|e| panic!("{}: {e}: run `cargo xtask corpus --affinity`", p.display()))
+}
+
+/// Straight RGBA8 pixels flattened on white.
+fn on_white(px: &[u8]) -> Vec {
+ px.chunks_exact(4)
+ .flat_map(|p| {
+ let a = f64::from(p[3]) / 255.0;
+ [0, 1, 2].map(|i| f64::from(p[i]) * a + 255.0 * (1.0 - a))
+ })
+ .collect()
+}
+
+/// Resample `src` (`sw`Ă—`sh`, 3 channels) to `dw`Ă—`dh`: each target pixel averages the source
+/// pixels it covers (at least one, so small documents scale up too).
+fn shrink(src: &[f64], sw: usize, sh: usize, dw: usize, dh: usize) -> Vec {
+ let span = |d: usize, s: usize, n: usize| {
+ let a = (d * s / n).min(s - 1);
+ (a, ((d + 1) * s).div_ceil(n).clamp(a + 1, s))
+ };
+ let mut out = Vec::with_capacity(dw * dh * 3);
+ for ty in 0..dh {
+ let (y0, y1) = span(ty, sh, dh);
+ for tx in 0..dw {
+ let (x0, x1) = span(tx, sw, dw);
+ let mut sum = [0.0f64; 3];
+ for y in y0..y1 {
+ for x in x0..x1 {
+ for (c, s) in sum.iter_mut().enumerate() {
+ *s += src[(y * sw + x) * 3 + c];
+ }
+ }
+ }
+ let n = ((y1 - y0) * (x1 - x0)) as f64;
+ out.extend(sum.map(|s| s / n));
+ }
+ }
+ out
+}
+
+#[test]
+fn public_affinity_documents_look_like_their_affinity_thumbnails() {
+ let mut failures = Vec::new();
+ for (name, ceiling) in FILES {
+ let bytes = corpus(name);
+ let r = import(name, &bytes).unwrap();
+ assert!(!r.preview_only, "{name} opened as its preview: {:?}", r.warnings);
+ let thumb = photocraft_codecs::decode(photocraft_affinity::preview(&bytes).unwrap().png).unwrap();
+ let (tw, th) = thumb.dimensions();
+ let png = export(&r.document, "x.png", &ExportOptions::default()).unwrap().bytes;
+ let flat = photocraft_codecs::decode(&png).unwrap();
+ let (fw, fh) = flat.dimensions();
+ // Several pages sit side by side; the thumbnail shows the first.
+ let fw_first = ((f64::from(fh) * f64::from(tw) / f64::from(th)).round() as u32).min(fw);
+ let rgba = flat.to_rgba8();
+ let cropped: Vec = rgba.chunks_exact(fw as usize * 4).flat_map(|row| row[..fw_first as usize * 4].to_vec()).collect();
+ // Compare at the smaller of the two sizes, so neither picture is enlarged.
+ let (cw, ch) = if fh < th { (fw_first, fh) } else { (tw, th) };
+ let got = shrink(&on_white(&cropped), fw_first as usize, fh as usize, cw as usize, ch as usize);
+ let want = shrink(&on_white(&thumb.to_rgba8()), tw as usize, th as usize, cw as usize, ch as usize);
+ let diff = got.iter().zip(&want).map(|(a, b)| (a - b).abs()).sum::() / want.len() as f64;
+ eprintln!("{name}: {diff:.2} (ceiling {ceiling})");
+ if diff > *ceiling {
+ failures.push(format!("{name}: {diff:.2} > {ceiling}"));
+ }
+ }
+ assert!(failures.is_empty(), "{}", failures.join("\n"));
+}
diff --git a/docs/architecture.md b/docs/architecture.md
index 1f4ca326..000c116a 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -99,7 +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)
+│ ├─ affinity/ photocraft-affinity bounded native Affinity reader: archive, object stream, layers, vectors, text, pixels (standalone; read only)
│ ├─ raw/ photocraft-raw clean-room camera RAW decode (DNG, CR2, TIFF/EP) + develop pipeline (standalone, like psd)
│ ├─ format/ photocraft-format native document format (.pcraft bundle): manifest + content-addressed tiles
│ ├─ io/ photocraft-io import/export orchestration; doc ⇄ PSD mapping; PDF/SVG import (features)
diff --git a/docs/development.md b/docs/development.md
index f34da4e9..729d8337 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -330,6 +330,7 @@ against committed **sha256 manifests**. All pins are in one place:
| `corpus/psd/` | 170 small psd-tools and ag-psd files, the mix most PSD tests use | psd-tools and ag-psd upstreams (MIT) | `xtask/psd-corpus.sha256` |
| `corpus/psd-tools/` | the complete psd-tools test set (309 files) | psd-tools upstream (MIT) | `xtask/psd-tools-corpus.sha256` |
| `corpus/heif/` | 9 small HEIC/HEIF files (checkerboards, RGB strips, a grid-tiled photo with EXIF/XMP, each with Apple's decode as `.ref.png`; a 10-bit RGBA file with its source PNG), for the `heif` feature | heic-rs (MIT OR Apache-2.0) and pillow-heif (BSD-3-Clause) upstreams | `xtask/heif-corpus.sha256` |
+| `corpus/affinity/` | 21 public Affinity documents (four Affinity 3 `.af` files, `.afdesign` vector art, layer/shape/raster test files, a template with artboards), each holding Affinity's own render as its thumbnail | vector-art (CC0), AFDesignLoad, Jac21/Branding and AssetStoreTemplate (MIT) upstreams | `xtask/affinity-corpus.sha256` |
| `corpus/pngsuite/` | PngSuite | schaik.com release archive (public domain) | (fixed archive) |
```sh
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 42851802..c85efe6f 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -28,11 +28,16 @@ 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.
+2026-10-08: Affinity `.af`, `.afdesign`, `.afphoto` and `.afpub` documents (container versions
+8–12) open **natively** ([format notes](../crates/affinity/README.md)): pages and artboards, layers
+and groups, curves and geometric shapes with fills, gradients and strokes, text as type layers,
+placed images as smart objects, pixel layers and masks, as an 8-bit RGB document without a source
+save path. Measured against the thumbnail Affinity embeds in each file, 21 pinned public documents
+(`cargo xtask corpus --affinity`) differ by 0–4.6 of 255 on average. Layer effects, adjustments,
+live filters, brush strokes, special shapes, master pages and CMYK/Lab/16-bit document colour are
+approximated or left out, each with a warning; damaged or unknown files fall back to the embedded
+preview. Affinity writing is not implemented: no Affinity installation was available to check
+written files, so `.af` export stays unsupported.
## Honest parity assessment (2026-10-05)
diff --git a/xtask/affinity-corpus.sha256 b/xtask/affinity-corpus.sha256
new file mode 100644
index 00000000..933e4dcf
--- /dev/null
+++ b/xtask/affinity-corpus.sha256
@@ -0,0 +1,21 @@
+cc8ca0db0d8fc545eb29153be900f153fa6b7b7eac068d05e54a502aba1264fe afdesignload/color.afdesign
+9a17f500d09fb3b1a70d24aabdc2882955ec3eac3700a474549e6100be3a8484 afdesignload/layer_mode.afdesign
+77ae60c02749224fe2c52691c951568138eaed5f8584ebc5da83715fae0234dd afdesignload/layer_test.afdesign
+04944918ff8e29c5c1b466b7e43c232f7ab80db15fb934080bbcd7baaf093e22 afdesignload/margins.afdesign
+35afa31b89f2b9970eb3701ee64e2a19476383637e8b07689334d6ffe556b593 afdesignload/raster_test.afdesign
+e56c35f876277b101790ff4384cfde103a093ed6c81790a2d62176de21da339f afdesignload/revision_test.afdesign
+08946c60c0da1c8aeea92d65acfb244721eeb3b480ed358609f4a48bbecfc5b9 afdesignload/shape_test.afdesign
+7306ab4fcbdc225d816f32a0d2aca15ac887053f60170459ac947e70f4ef343d afdesignload/slice_test.afdesign
+e5d04bac0235af9386aea251cdd6903ed7810d335fdb6194d0483ea30b14e24a afdesignload/test_path.afdesign
+e1b121d7c3d02356baa5dea512f5aa1fe77249c28d7b227e29cab2bc67ec323a asset-store-template/AssetStore.aftemplate
+fbb196e918e302c16f8de9f2e6dffaef70ab3c317b32a9f44607fd4b2ead0206 jac21/SimpleLogo.afdesign
+9d874245936483de3ec04cca48b383028d33666801dff1814246a44e695e29b8 jac21/SimpleLogoBanner.afdesign
+87a7bd93de16c404f6c6dd9cdebff96ec47832a3b342d2ce0af73d9a87e209e0 vector-art/beer.afdesign
+09ba790c428e8e09dc0b9ddebb4f3db6daefaa2af1da492ee34e73fba1b8af69 vector-art/cactus.afdesign
+c3bcd0395b407493bc9d2877d424029b70f0e4c817a56e893f7ac74973df52e5 vector-art/car.afdesign
+112562bc91e7cccf003d34364430544208373c4fb4bb4ab9acdaf27cf0125047 vector-art/flowers.afdesign
+023531b8216f2d4da24ab715e3b313506712e50d5f5bf7da927f434def1d0ea2 vector-art/lion-track.afdesign
+c499cf3ca73cf7cd3e2e65bed048d1b8ecf03e7167b74d54f0ae6db31868cab8 vector-art/mexican-guy.af
+730e3fae51d9044ada58f51c7b5f9327d99e8d62376453bc251d0d26cda47743 vector-art/mexican-man.af
+5f5fccc0bd8be2bb3b2a07461ec77a012a61eef1883485d7c80f72d4383231ce vector-art/mexican-woman.af
+0a52bb923c949c566a4339b996bce1a67b683f61def1a73dfefb910effaa450d vector-art/playing-cards.af
diff --git a/xtask/src/corpus.rs b/xtask/src/corpus.rs
index 8e2574db..080fdb87 100644
--- a/xtask/src/corpus.rs
+++ b/xtask/src/corpus.rs
@@ -17,7 +17,7 @@ const HEIF_CRATES: &[&str] = &["photocraft-codecs", "photocraft-io"];
/// Paths whose changes make `test-corpus --changed` run (the file-format and rendering crates).
const CRITICAL: &[&str] = &["crates/psd/", "crates/io/", "crates/codecs/", "crates/heif/", "crates/compose/", "crates/gpu/", "crates/text/", "crates/format/"];
-/// `cargo xtask corpus [--all | --pngsuite | --download | --psd | --psd-tools | --heif | --photoshop [--local]] [--update-manifest]`
+/// `cargo xtask corpus [--all | --pngsuite | --download | --psd | --psd-tools | --heif | --affinity | --photoshop [--local]] [--update-manifest]`
pub fn cmd(args: &[&str]) -> Result<(), String> {
let update = args.contains(&"--update-manifest");
let mut did = false;
@@ -40,6 +40,10 @@ pub fn cmd(args: &[&str]) -> Result<(), String> {
corpus_pins::HEIF.fetch(update)?;
did = true;
}
+ if args.contains(&"--affinity") {
+ corpus_pins::AFFINITY.fetch(update)?;
+ did = true;
+ }
if args.contains(&"--photoshop") {
if args.contains(&"--local") {
corpus_pins::PHOTOSHOP.fetch_local(&local_clone()?, update)?;
@@ -105,6 +109,8 @@ fn list() {
manifest xtask/psd-tools-corpus.sha256. Fetch: --psd-tools
corpus/heif/ [{}] a few HEIC/HEIF files from heic-rs@{} (MIT OR Apache-2.0) and
pillow-heif@{} (BSD-3-Clause), manifest xtask/heif-corpus.sha256. Fetch: --heif
+ corpus/affinity/ [{}] 20 public Affinity 1–3 documents (CC0, MIT): vector-art, AFDesignLoad,
+ Jac21/Branding, AssetStoreTemplate; manifest xtask/affinity-corpus.sha256. Fetch: --affinity
corpus/pngsuite/ [{}] PngSuite (public domain), {PNGSUITE_URL}. Fetch: --pngsuite
corpus/tiff/, corpus/exr/, corpus/raw/ optional, copied in by hand
@@ -118,6 +124,7 @@ Pins: xtask/src/corpus_pins.rs. Moving one: change it, then -- --update-ma
status(corpus_pins::HEIF.is_current()),
&HEIC_RS_COMMIT[..12],
&PILLOW_HEIF_COMMIT[..12],
+ status(corpus_pins::AFFINITY.is_current()),
status(png_ok),
);
}
diff --git a/xtask/src/corpus_pins.rs b/xtask/src/corpus_pins.rs
index 3ac2bd2e..6578a4f8 100644
--- a/xtask/src/corpus_pins.rs
+++ b/xtask/src/corpus_pins.rs
@@ -23,6 +23,15 @@ pub const AG_PSD_COMMIT: &str = "387049670cb89b88fb8fe1b7c01aeacf98dd2e3b";
pub const HEIC_RS_COMMIT: &str = "4f0d4df474c773dfc3b14fa80e7219be6898866e";
/// https://github.com/bigcat88/pillow_heif (BSD-3-Clause): `tests/images` (master, 2026-09-25).
pub const PILLOW_HEIF_COMMIT: &str = "b16be1196dfa465a342d68894696e685ca3655cb";
+/// https://github.com/samuel-etver/vector-art (CC0-1.0): `simple/`, four Affinity 3 `.af` files and
+/// Designer `.afdesign` files (main, 2026-04-22).
+pub const VECTOR_ART_COMMIT: &str = "255f8add3c8f0740196e22bd59502b811b532f0b";
+/// https://github.com/NickBeeuwsaert/AFDesignLoad (MIT): `testDesigns/`, purpose-made Designer files.
+pub const AFDESIGNLOAD_COMMIT: &str = "a18dd50a7079fb861a28835eeefa0d015f03f4a1";
+/// https://github.com/Jac21/Branding (MIT): Designer logos with artboards and placed images.
+pub const JAC21_BRANDING_COMMIT: &str = "57ae3f45bf4637f6927c0b07de25b922c55f944f";
+/// https://github.com/eviltwo/AssetStoreTemplate (MIT): an Affinity template with artboards.
+pub const ASSET_STORE_TEMPLATE_COMMIT: &str = "f671981ee89d15526285b3cc87b4393d3defe644";
/// PngSuite (public domain), a fixed release archive.
pub const PNGSUITE_URL: &str = "http://www.schaik.com/pngsuite/PngSuite-2017jul19.tgz";
@@ -134,5 +143,52 @@ pub const HEIF: PinnedCorpus = PinnedCorpus {
},
};
+/// `corpus/affinity`: public Affinity documents saved by Affinity 1.x to 3.x, chosen for having no
+/// personal paths in their metadata. Each embeds Affinity's own render of itself (its thumbnail),
+/// the oracle `photocraft-io`'s corpus test compares the imported document against. The manifest
+/// selects the files.
+pub const AFFINITY: PinnedCorpus = PinnedCorpus {
+ name: "affinity",
+ dest: "affinity",
+ upstreams: &[
+ Upstream { prefix: "vector-art/", repo: "samuel-etver/vector-art", commit: VECTOR_ART_COMMIT, subdir: "simple", extras: &[("LICENSE", "vector-art/LICENSE")] },
+ Upstream {
+ prefix: "afdesignload/",
+ repo: "NickBeeuwsaert/AFDesignLoad",
+ commit: AFDESIGNLOAD_COMMIT,
+ subdir: "testDesigns",
+ extras: &[("LICENSE", "afdesignload/LICENSE")],
+ },
+ Upstream {
+ prefix: "jac21/",
+ repo: "Jac21/Branding",
+ commit: JAC21_BRANDING_COMMIT,
+ subdir: "Logos/JC/DesignerFiles/Affinity",
+ extras: &[("LICENSE.md", "jac21/LICENSE.md")],
+ },
+ Upstream {
+ prefix: "asset-store-template/",
+ repo: "eviltwo/AssetStoreTemplate",
+ commit: ASSET_STORE_TEMPLATE_COMMIT,
+ subdir: "AssetStoreTemplate",
+ extras: &[("LICENSE", "asset-store-template/LICENSE")],
+ },
+ ],
+ manifest: "xtask/affinity-corpus.sha256",
+ exts: &["af", "afdesign", "aftemplate"],
+ subset: true,
+ sources_md: |c| {
+ format!(
+ "# Affinity test corpus\n\nPublic Affinity documents, unmodified, with their licences next to them: \
+ https://github.com/samuel-etver/vector-art/tree/{VECTOR_ART_COMMIT}/simple (`vector-art/`, CC0-1.0), \
+ https://github.com/NickBeeuwsaert/AFDesignLoad/tree/{AFDESIGNLOAD_COMMIT}/testDesigns (`afdesignload/`, MIT, Copyright (c) 2015 Nick Beeuwsaert), \
+ https://github.com/Jac21/Branding/tree/{JAC21_BRANDING_COMMIT}/Logos/JC/DesignerFiles/Affinity (`jac21/`, MIT, Copyright (c) 2018 Jeremy Cantu) and \
+ https://github.com/eviltwo/AssetStoreTemplate/tree/{ASSET_STORE_TEMPLATE_COMMIT}/AssetStoreTemplate (`asset-store-template/`, MIT). \
+ Selected by and sha256-verified against `{}` by `cargo xtask corpus --affinity`. Gitignored; never commit these files.\n",
+ c.manifest
+ )
+ },
+};
+
/// Every pinned corpus, in fetch order.
-pub const ALL: &[&PinnedCorpus] = &[&PSD_MIXED, &PSD_TOOLS, &PHOTOSHOP, &HEIF];
+pub const ALL: &[&PinnedCorpus] = &[&PSD_MIXED, &PSD_TOOLS, &PHOTOSHOP, &HEIF, &AFFINITY];