AF.3: native Affinity import with artboards, vectors, text and images

photocraft-affinity now reads native Affinity documents (container
versions 8-12): the FAT and revision chain, stored/zlib/zstd entries
with predictors and CRC checks, the tagged object stream in doc.dat,
and the document model (pages, artboards, layers, groups, curves,
geometric and compound shapes, fills, gradients, strokes, text, placed
images, pixel layers, masks and embedded-document caches). Every size
is bounded and malformed input returns an error.

photocraft-io maps it to an RGB8 document with artboards, groups, shape
layers, type layers, embedded smart objects, pixel layers and
vector/layer masks; what can't be mapped is listed in the warnings. The
embedded preview is now only a fallback when the native data can't be
read, and only that preview is refused by Place and warningless imports.

`cargo xtask corpus --affinity` pins 21 public CC0/MIT documents,
sha256-verified; the io corpus test compares each import with the
thumbnail Affinity saved in it (mean difference 0-4.6 of 255).
Affinity export is not implemented: no Affinity installation was
available to validate written files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
This commit is contained in:
callmeBalloch 2026-10-08 18:46:22 +00:00 committed by Claude
parent f94bb1dc9b
commit ae0313d325
No known key found for this signature in database
41 changed files with 4590 additions and 513 deletions

3
Cargo.lock generated
View File

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

View File

@ -199,7 +199,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
</td>
<td width="33%" valign="top">
<h4>🗂️ Formats</h4>
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and AVIF, with symmetric read and write at 8, 16 and 32 bits, HEIC photos from iPhone and Mac (read; in official builds, an optional <code>--features heif</code> build feature), and the native <code>.pcraft</code> format. Some current Affinity <code>.af</code> 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 <code>--features heif</code> build feature), and the native <code>.pcraft</code> format. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
</td>
<td width="33%" valign="top">
<h4>🪄 The everyday essentials</h4>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,11 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
// The doc.dat object stream and the model built from it, wrapped in a stored archive entry so
// mutations reach the tagged grammar instead of the archive's checksum.
fuzz_target!(|stream: &[u8]| {
let _ = photocraft_affinity::stream::parse(stream);
let file = photocraft_affinity::synth::container(&[("doc.dat", stream, photocraft_affinity::synth::Method::Stored)], None);
let _ = photocraft_affinity::read(&file, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 });
});

View File

@ -0,0 +1,105 @@
//! Typed reads of [`Stream`] objects. Every accessor returns `None` when a field is missing or has
//! another type, so the document mapping treats odd data as absent rather than failing.
use crate::stream::{ObjId, Object, Stream, Tag, Value};
impl Stream {
pub fn object(&self, id: ObjId) -> Option<&Object> {
self.objects.get(id)
}
pub fn field(&self, id: ObjId, tag: &[u8; 4]) -> Option<&Value> {
self.object(id)?.get(Tag::of(tag))
}
pub fn class(&self, id: ObjId) -> Option<Tag> {
self.object(id).map(|o| o.class)
}
pub fn is(&self, id: ObjId, class: &[u8; 4]) -> bool {
self.class(id) == Some(Tag::of(class))
}
/// A child object (shared, linked or inline); `None` for null or missing.
pub fn obj(&self, id: ObjId, tag: &[u8; 4]) -> Option<ObjId> {
match self.field(id, tag)? {
Value::Obj(o) => *o,
_ => None,
}
}
/// An array of objects (nulls skipped).
pub fn objs(&self, id: ObjId, tag: &[u8; 4]) -> Vec<ObjId> {
match self.field(id, tag) {
Some(Value::Array(a)) => a.iter().filter_map(|v| if let Value::Obj(Some(o)) = v { Some(*o) } else { None }).collect(),
_ => Vec::new(),
}
}
pub fn f64(&self, id: ObjId, tag: &[u8; 4]) -> Option<f64> {
match self.field(id, tag)? {
Value::Float(f) if f.is_finite() => Some(*f),
_ => None,
}
}
pub fn int(&self, id: ObjId, tag: &[u8; 4]) -> Option<i64> {
match self.field(id, tag)? {
Value::Int(i) => Some(*i),
Value::UInt(u) => i64::try_from(*u).ok(),
_ => None,
}
}
pub fn bool(&self, id: ObjId, tag: &[u8; 4]) -> Option<bool> {
match self.field(id, tag)? {
Value::Bool(b) => Some(*b),
_ => None,
}
}
pub fn str(&self, id: ObjId, tag: &[u8; 4]) -> Option<&str> {
match self.field(id, tag)? {
Value::Str(s) => Some(s),
_ => None,
}
}
pub fn enumeration(&self, id: ObjId, tag: &[u8; 4]) -> Option<(u16, u16)> {
match self.field(id, tag)? {
Value::Enum { id, version } => Some((*id, *version)),
_ => None,
}
}
/// A fixed vector of `N` finite floats (points, rectangles, transforms).
pub fn floats<const N: usize>(&self, id: ObjId, tag: &[u8; 4]) -> Option<[f64; N]> {
match self.field(id, tag)? {
Value::Floats(v) if v.len() == N && v.iter().all(|f| f.is_finite()) => {
let mut a = [0.0; N];
a.copy_from_slice(v);
Some(a)
}
_ => None,
}
}
pub fn bytes(&self, id: ObjId, tag: &[u8; 4]) -> Option<&[u8]> {
match self.field(id, tag)? {
Value::Bytes(b) | Value::Blob(b) => Some(b),
_ => None,
}
}
pub fn entry(&self, id: ObjId, tag: &[u8; 4]) -> Option<&str> {
match self.field(id, tag)? {
Value::Entry(s) => Some(s),
_ => None,
}
}
/// Untagged fields of a positional object, in order.
pub fn positional(&self, id: ObjId) -> impl Iterator<Item = &Value> {
self.object(id).into_iter().flat_map(|o| o.fields.iter().map(|(_, v)| v))
}
}

View File

@ -0,0 +1,371 @@
//! The Affinity archive: header, the chain of file allocation tables (one per save) and
//! bounded, CRC-checked extraction of named entries such as `doc.dat` and raster tiles.
use std::collections::{HashMap, HashSet};
use std::io::Read;
use crate::Error;
pub const MAGIC: &[u8; 4] = b"\x00\xffKA";
/// Oldest and newest container versions seen in real documents (Affinity 1.x writes 8,
/// Affinity 3 writes 12). Version 7 is described by afread but no sample exists.
pub const VERSIONS: std::ops::RangeInclusive<u16> = 8..=12;
/// `Prsn` (stored byte-reversed): documents. Asset, brush and style libraries share the magic.
const DOCUMENT_KIND: &[u8; 4] = b"nsrP";
/// A save appends one table; real files have one to a few hundred.
const MAX_TABLES: usize = 4096;
/// zstd window ceiling: real documents use a few MiB; this stops a header from reserving gigabytes.
const MAX_ZSTD_WINDOW: u64 = 64 << 20;
/// Decompression budget for one import. Every entry declares its size, which is checked against
/// these limits before anything is allocated, and the decoded length must match it exactly.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Limits {
/// Largest single entry (`doc.dat` or one tile).
pub max_entry: usize,
/// Total bytes extracted over the whole import.
pub max_total: usize,
}
impl Default for Limits {
fn default() -> Self {
Self { max_entry: 256 << 20, max_total: 1 << 30 }
}
}
/// The container header, all integers little-endian.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Header {
pub version: u16,
/// Seen as 0, 4 or 8 in real files; bits 0 and 1 mark container variants that are rejected.
pub flags: u16,
pub(crate) fat_offset: u64,
pub(crate) thumbnail_offset: u64,
/// Save time in Unix seconds, as written by the application.
pub saved: u32,
}
/// One FAT record, as of the newest table that mentions its id.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub id: u32,
pub name: String,
offset: u64,
size: u64,
compressed: u64,
crc: u32,
compression: u8,
}
impl Entry {
/// Decompressed size declared by the table.
pub fn size(&self) -> u64 {
self.size
}
}
#[derive(Debug)]
pub struct Archive<'a> {
bytes: &'a [u8],
pub header: Header,
/// Head revision of every named, non-deleted entry.
entries: HashMap<String, Entry>,
/// Number of tables (saves) in the chain.
pub revisions: usize,
extracted: usize,
limits: Limits,
}
pub(crate) struct Cursor<'a> {
bytes: &'a [u8],
pub(crate) pos: usize,
}
impl<'a> Cursor<'a> {
pub(crate) fn new(bytes: &'a [u8], pos: usize) -> Self {
Self { bytes, pos }
}
pub(crate) fn remaining(&self) -> usize {
self.bytes.len().saturating_sub(self.pos)
}
pub(crate) fn take(&mut self, n: usize) -> Result<&'a [u8], Error> {
let end = self.pos.checked_add(n).ok_or(Error::Malformed("length overflow"))?;
let s = self.bytes.get(self.pos..end).ok_or(Error::Malformed("truncated data"))?;
self.pos = end;
Ok(s)
}
pub(crate) fn array<const N: usize>(&mut self) -> Result<[u8; N], Error> {
let s = self.take(N)?;
let mut a = [0u8; N];
a.copy_from_slice(s);
Ok(a)
}
pub(crate) fn u8(&mut self) -> Result<u8, Error> {
Ok(self.array::<1>()?[0])
}
pub(crate) fn u16(&mut self) -> Result<u16, Error> {
Ok(u16::from_le_bytes(self.array()?))
}
pub(crate) fn u32(&mut self) -> Result<u32, Error> {
Ok(u32::from_le_bytes(self.array()?))
}
pub(crate) fn u64(&mut self) -> Result<u64, Error> {
Ok(u64::from_le_bytes(self.array()?))
}
}
fn offset(v: u64) -> Result<usize, Error> {
usize::try_from(v).map_err(|_| Error::Malformed("offset beyond addressable memory"))
}
/// True when `bytes` start with the Affinity container signature (documents and libraries).
pub fn is_affinity(bytes: &[u8]) -> bool {
bytes.starts_with(MAGIC)
}
/// Check the signature, version and document kind.
pub fn header(bytes: &[u8]) -> Result<Header, Error> {
if !is_affinity(bytes) {
return Err(Error::Malformed("missing container signature"));
}
let mut c = Cursor::new(bytes, 4);
let version = c.u16()?;
let flags = c.u16()?;
if !VERSIONS.contains(&version) {
return Err(Error::Unsupported("unknown container version"));
}
if flags & 3 != 0 {
return Err(Error::Unsupported("container variant"));
}
if c.take(4)? != DOCUMENT_KIND {
return Err(Error::Unsupported("this Affinity file is a library (assets, brushes or styles), not a document"));
}
if c.take(4)? != b"#Inf" {
return Err(Error::Malformed("missing information header"));
}
let fat_offset = c.u64()?;
let thumbnail_offset = c.u64()?;
c.take(16)?;
let saved = u32::try_from(c.u64()?).unwrap_or(0);
c.take(8)?;
if c.take(4)? != b"Prot" {
return Err(Error::Malformed("missing protocol header"));
}
Ok(Header { version, flags, fat_offset, thumbnail_offset, saved })
}
impl<'a> Archive<'a> {
pub fn open(bytes: &'a [u8], limits: Limits) -> Result<Self, Error> {
let header = header(bytes)?;
// Newest record per id: (table date, table index, record).
let mut latest: HashMap<u32, (u64, usize, Option<Entry>)> = HashMap::new();
let mut names: HashMap<u32, String> = HashMap::new();
let mut seen = HashSet::new();
let mut next = header.fat_offset;
let mut tables = 0usize;
while next != 0 {
if !seen.insert(next) {
return Err(Error::Malformed("allocation tables form a cycle"));
}
tables += 1;
if tables > MAX_TABLES {
return Err(Error::Limit("too many saved revisions"));
}
let mut c = Cursor::new(bytes, offset(next)?);
let tag = c.array::<4>()?;
let level = match &tag {
b"#FAT" => 1,
b"#FT2" => 2,
b"#FT3" => 3,
b"#FT4" => 4,
_ => return Err(Error::Malformed("missing allocation table")),
};
next = c.u64()?;
let date = c.u64()?;
c.take(24)?;
let files = c.u32()?;
c.take(8)?;
let dirs = c.u16()?;
c.take(1)?;
for _ in 0..files {
let id = c.u32()?;
let flag = c.u8()?;
let mut record = None;
match flag {
0 | 1 => {
let offset = c.u64()?;
let size = c.u64()?;
let compressed = c.u64()?;
let crc = c.u32()?;
let stored = c.u8()?;
if level >= 2 {
c.take(4)?;
}
if level >= 4 {
c.take(4)?;
}
// Old tables number the methods; newer ones store the method byte itself.
let compression = if level <= 2 {
match stored {
1 => 0x01,
2 => 0x41,
3 => 0x81,
4 => 0xC1,
_ => 0,
}
} else {
stored
};
record = Some(Entry { id, name: String::new(), offset, size, compressed, crc, compression });
}
2 => {}
_ => return Err(Error::Malformed("unknown allocation record")),
}
if flag == 0 {
let len = usize::from(c.u16()?);
let name = String::from_utf8_lossy(c.take(len)?).into_owned();
names.entry(id).or_insert(name);
}
// Equal dates: the first table in the chain (the newest) wins, as in afread.
let replace = latest.get(&id).is_none_or(|(d, _, _)| date > *d);
if replace {
latest.insert(id, (date, tables, record));
}
}
for _ in 0..dirs {
let len = usize::from(c.u16()?);
c.take(10)?;
c.take(len)?;
}
}
let mut entries = HashMap::new();
for (id, (_, _, record)) in latest {
if let (Some(mut e), Some(name)) = (record, names.get(&id)) {
e.name = name.clone();
entries.insert(e.name.clone(), e);
}
}
Ok(Self { bytes, header, entries, revisions: tables, extracted: 0, limits })
}
pub fn entry(&self, name: &str) -> Option<&Entry> {
self.entries.get(name)
}
pub fn entry_names(&self) -> impl Iterator<Item = &str> {
self.entries.keys().map(String::as_str)
}
/// Bytes extracted so far, counted against [`Limits::max_total`].
pub fn extracted(&self) -> usize {
self.extracted
}
/// Extract an entry by name: decompress, undo the byte predictor and verify the CRC.
pub fn read(&mut self, name: &str) -> Result<Vec<u8>, Error> {
let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?.clone();
let size = usize::try_from(e.size).map_err(|_| Error::Limit("archive entry too large"))?;
if size > self.limits.max_entry {
return Err(Error::Limit("archive entry too large"));
}
let total = self.extracted.checked_add(size).ok_or(Error::Limit("document too large"))?;
if total > self.limits.max_total {
return Err(Error::Limit("document too large"));
}
let mut c = Cursor::new(self.bytes, offset(e.offset)?);
if c.take(4)? != b"#Fil" {
return Err(Error::Malformed("archive entry has no data block"));
}
let method = e.compression & 3;
let stored_len = if matches!(method, 1 | 2) { e.compressed } else { e.size };
let data = c.take(offset(stored_len)?)?;
let mut out = match method {
1 => inflate(data, size)?,
2 => unzstd(data, size)?,
_ => data.to_vec(),
};
if out.len() != size {
return Err(Error::Malformed("archive entry size mismatch"));
}
unpredict(&mut out, e.compression)?;
if crc32fast::hash(&out) != e.crc {
return Err(Error::Malformed("archive entry checksum mismatch"));
}
self.extracted = total;
Ok(out)
}
}
/// Read at most `size + 1` bytes so a stream that decodes past its declared size is caught
/// without unbounded allocation.
fn read_bounded(mut r: impl Read, size: usize) -> Result<Vec<u8>, Error> {
let mut out = Vec::with_capacity(size.min(64 << 20));
let cap = u64::try_from(size).unwrap_or(u64::MAX).saturating_add(1);
r.by_ref().take(cap).read_to_end(&mut out).map_err(|_| Error::Malformed("corrupt compressed entry"))?;
Ok(out)
}
fn inflate(data: &[u8], size: usize) -> Result<Vec<u8>, Error> {
read_bounded(flate2::read::ZlibDecoder::new(data), size)
}
fn unzstd(data: &[u8], size: usize) -> Result<Vec<u8>, Error> {
let mut src = data;
let mut out = Vec::new();
// Entries may hold several concatenated frames.
while !src.is_empty() {
let left = size.checked_sub(out.len()).ok_or(Error::Malformed("archive entry size mismatch"))?;
let decoder = ruzstd::decoding::StreamingDecoder::new_with_max_window_size(&mut src, MAX_ZSTD_WINDOW)
.map_err(|_| Error::Malformed("corrupt compressed entry"))?;
let frame = read_bounded(decoder, left)?;
if frame.is_empty() {
return Err(Error::Malformed("corrupt compressed entry"));
}
out.extend_from_slice(&frame);
if out.len() > size {
return Err(Error::Malformed("archive entry size mismatch"));
}
}
Ok(out)
}
/// Undo the byte predictor selected by the high bits of the compression byte.
fn unpredict(b: &mut [u8], compression: u8) -> Result<(), Error> {
match (compression & 0xC0, compression & 0x20 != 0) {
(0x00, _) => {}
(0x40, _) => byte_delta(b),
(0x80, false) => {
let mut prev = 0u16;
for pair in b.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;
}
}

View File

@ -0,0 +1,175 @@
//! Outlines of curves (`PCvD` node lists), parametric shapes (`Shpe` objects) and compounds, in
//! node space.
use crate::model::{Affine, Path, Point, Reader, SubPath};
use crate::stream::{ObjId, Tag, Value};
/// Nodes per curve object.
const MAX_NODES: usize = 4_000_000;
/// The node-space outline of a curve, shape or compound node, and whether it fills even-odd.
/// Instances (master-page or symbol copies) take the geometry of their master.
pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> {
let s = r.s;
match &class.0.to_be_bytes() {
b"PCrv" | b"TxtC" => {
let crvs = s.obj(id, b"Crvs").or_else(|| s.obj(id, b"MCrM").and_then(|m| s.obj(m, b"Crvs")))?;
let path = curves(r, crvs)?;
let multi = path.subpaths.len() > 1;
Some((path, multi))
}
b"Comp" => {
// One path of all the operands' contours in the compound's space, filled even-odd: this
// matches add and subtract operands in every public sample; a true union of overlapping
// added operands would differ.
let mut path = Path::default();
for c in s.objs(id, b"Chld") {
let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
let class = s.class(c)?;
if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) {
r.warn("compound shapes that intersect or exclude (imported as add)");
}
if let Some((p, _)) = outline(r, c, class, local.then(world)) {
path.subpaths.extend(p.transformed(local).subpaths);
}
}
Some((path, true))
}
_ => {
let (shape, box_owner) = match s.obj(id, b"Shpe") {
Some(sh) => (sh, id),
None => {
let master = s.obj(id, b"ShpM")?;
(s.obj(master, b"Shpe")?, master)
}
};
let b = s.floats::<4>(box_owner, b"ShpB")?;
crate::shapes::shape(r, shape, b, world).map(|p| (p, false))
}
}
}
/// `PCvD.Data`: positional `[u8, u32 count, then per subpath: bool closed, 18-byte records]`.
/// A record is (f64 x, f64 y, u8 kind, u8 role): role 0 is an anchor, 1 the out-handle of the
/// anchor before it, 2 the in-handle of the anchor after it. A closed subpath repeats its first
/// anchor at the end. `CnrD` live corners are rounded on the stored polyline.
pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<Path> {
let s = r.s;
let data = s.obj(crvs, b"Data")?;
let mut fields = s.positional(data);
fields.next()?;
let count = match fields.next()? {
Value::UInt(n) => usize::try_from(*n).ok()?,
_ => return None,
};
let corners = s.obj(crvs, b"CnrD").and_then(|c| corner_radii(s.positional(c).collect()));
let mut path = Path::default();
let mut seen = 0usize;
for index in 0..count {
let closed = match fields.next()? {
Value::Bool(b) => *b,
_ => return None,
};
let Value::Array(records) = fields.next()? else { return None };
seen = seen.checked_add(records.len())?;
if seen > MAX_NODES {
r.warn("curves with more than four million nodes");
return None;
}
let anchors = anchors(records)?;
let radii = if index == 0 { corners.as_ref() } else { None };
if let Some(sp) = subpath(&anchors, closed, radii) {
path.subpaths.push(sp);
}
}
if corners.is_some() && count > 1 {
r.warn("rounded corners on curves with several subpaths (only the first is rounded)");
}
Some(path)
}
struct Anchor {
p: Point,
inh: Option<Point>,
out: Option<Point>,
/// Index of the anchor's record (live corners refer to records).
record: usize,
}
fn record(v: &Value) -> Option<(Point, u8)> {
let Value::Bytes(b) = v else { return None };
if b.len() != 18 {
return None;
}
let x = f64::from_le_bytes(b.get(0..8)?.try_into().ok()?);
let y = f64::from_le_bytes(b.get(8..16)?.try_into().ok()?);
if !x.is_finite() || !y.is_finite() {
return None;
}
Some((Point { x, y }, *b.get(17)?))
}
fn anchors(records: &[Value]) -> Option<Vec<Anchor>> {
let mut out: Vec<Anchor> = Vec::with_capacity(records.len() / 3 + 1);
let mut pending_in = None;
for (i, v) in records.iter().enumerate() {
let (p, role) = record(v)?;
match role {
0 => {
out.push(Anchor { p, inh: pending_in.take(), out: None, record: i });
}
1 => {
// A leading out-handle without an anchor is edit state only.
if let Some(a) = out.last_mut() {
a.out = Some(p);
}
}
2 => pending_in = Some(p),
_ => return None,
}
}
Some(out)
}
fn subpath(anchors: &[Anchor], closed: bool, radii: Option<&Vec<(usize, f64)>>) -> Option<SubPath> {
let first = anchors.first()?;
if let Some(radii) = radii.filter(|r| !r.is_empty()) {
let corner = |a: &Anchor| radii.iter().filter(|(rec, _)| *rec == a.record).map(|(_, r)| *r).fold(0.0, f64::max);
let polyline = anchors.iter().all(|a| a.inh.is_none_or(|h| h == a.p) && a.out.is_none_or(|h| h == a.p));
if polyline {
let mut pts: Vec<(Point, f64)> = anchors.iter().map(|a| (a.p, corner(a))).collect();
// A closed subpath repeats its first anchor; its radius may be on either copy.
if closed
&& pts.len() > 1
&& pts.first().map(|p| p.0) == pts.last().map(|p| p.0)
&& let Some((_, r)) = pts.pop()
&& let Some(f) = pts.first_mut()
{
f.1 = f.1.max(r);
}
return Some(crate::shapes::fillet(&pts, closed));
}
}
let segments = anchors
.windows(2)
.filter_map(|w| {
let [a, b] = w else { return None };
Some([a.out.unwrap_or(a.p), b.inh.unwrap_or(b.p), b.p])
})
.collect();
Some(SubPath { start: first.p, segments, closed })
}
/// `CnrD`: positional `[u8, i32[] record indices, f64[] radii, u8[] types]`.
fn corner_radii(fields: Vec<&Value>) -> Option<Vec<(usize, f64)>> {
let (Some(Value::Array(idx)), Some(Value::Array(rad))) = (fields.get(1), fields.get(2)) else { return None };
Some(
idx.iter()
.zip(rad)
.filter_map(|(i, r)| match (i, r) {
(Value::Int(i), Value::Float(r)) if r.is_finite() && *r > 0.0 => Some((usize::try_from(*i).ok()?, *r)),
_ => None,
})
.collect(),
)
}

View File

@ -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<u16, Error> {
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<u32, Error> {
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<u64, Error> {
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<u32, Error> {
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<Header, Error> {
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<Preview<'_>, 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<u8> {
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
png.extend(13u32.to_be_bytes());
png.extend(b"IHDR");
png.extend(2u32.to_be_bytes());
png.extend(1u32.to_be_bytes());
png.extend([8, 6, 0, 0, 0]);
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<u8> {
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<u8> {
let mut tagged = kind.to_vec();
tagged.extend(data);
let mut result = (data.len() as u32).to_be_bytes().to_vec();
result.extend(&tagged);
result.extend(crc32fast::hash(&tagged).to_be_bytes());
result
}
#[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::<u8>(), 0..2048)) {
let _ = inspect(&b);
let _ = preview(&b);
}
#[test]
fn mutations_never_panic(edits in prop::collection::vec((0usize..200, any::<u8>()), 0..32)) {
let mut b = fixture();
for (i, value) in edits { if let Some(v) = b.get_mut(i) { *v = value; } }
let _ = preview(&b);
}
}
}
mod tests;

View File

@ -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<SubPath>,
}
impl Path {
pub fn transformed(&self, m: Affine) -> Self {
Self {
subpaths: self
.subpaths
.iter()
.map(|s| SubPath {
start: m.apply(s.start),
segments: s.segments.iter().map(|seg| seg.map(|p| m.apply(p))).collect(),
closed: s.closed,
})
.collect(),
}
}
}
/// Affinity's blend modes, numbered by the `Blnd` enum.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Blend {
#[default]
Normal,
PassThrough,
Darken,
DarkerColor,
Multiply,
ColorBurn,
Lighten,
LighterColor,
Screen,
ColorDodge,
Add,
Overlay,
SoftLight,
HardLight,
VividLight,
PinLight,
LinearLight,
HardMix,
Difference,
Exclusion,
Subtract,
Hue,
Saturation,
Luminosity,
Color,
Average,
Negation,
Reflect,
Glow,
Erase,
}
impl Blend {
/// The enum (id, version) pairs observed in a document whose layers are named after their mode.
fn from_enum(id: u16, version: u16) -> Option<Self> {
use Blend::*;
Some(match (id, version) {
(0, _) => Normal,
(1, 0) => Darken,
(2, 1) => DarkerColor,
(2, 0) => Multiply,
(3, 0) => ColorBurn,
(4, 0) => Lighten,
(6, 1) => LighterColor,
(5, 0) => Screen,
(6, 0) => ColorDodge,
(7, 0) => Add,
(8, 0) => Overlay,
(9, 0) => SoftLight,
(10, 0) => HardLight,
(11, 0) => VividLight,
(12, 0) => PinLight,
(15, 1) => LinearLight,
(13, 0) => HardMix,
(14, 0) => Difference,
(15, 0) => Exclusion,
(16, 0) => Subtract,
(17, 0) => Hue,
(18, 0) => Saturation,
(19, 0) => Luminosity,
(20, 0) => Color,
(21, 0) => Average,
(22, 0) => Negation,
(23, 0) => Reflect,
(24, 0) => Glow,
(25, 0) => Erase,
_ => return None,
})
}
}
/// A run of text with one set of character attributes.
#[derive(Debug, Clone, PartialEq)]
pub struct TextRun {
pub text: String,
/// PostScript name and family of the chosen font.
pub postscript: String,
pub family: String,
/// CSS weight (400 regular, 700 bold).
pub weight: i64,
pub italic: bool,
/// Font size in document pixels, before the node's transform.
pub size: f64,
/// Tracking in em.
pub tracking: f64,
/// Fixed line pitch in document pixels, when the run overrides automatic leading.
pub leading: Option<f64>,
pub fill: Paint,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Align {
Left,
Center,
Right,
Justify,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Text {
pub runs: Vec<TextRun>,
pub align: Align,
/// Artistic text: the first baseline's anchor point in node space (start, centre or end of the
/// line according to `align`). Frame text: the frame box in node space.
pub anchor: Point,
pub frame: Option<Rect>,
/// Node space to document pixels.
pub transform: Affine,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Pixels {
/// Straight-alpha RGBA, 8 bits per channel, row by row.
Rgba8(Vec<u8>),
/// The original file a placed image was made from (PNG, JPEG…), as embedded.
Encoded(Vec<u8>),
}
#[derive(Debug, Clone, PartialEq)]
pub struct Image {
pub width: u32,
pub height: u32,
pub pixels: Pixels,
/// Pixel space (0..w, 0..h) to document pixels.
pub transform: Affine,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Kind {
/// Layer container (`Scop`).
Layer,
Group,
/// Artboard: a rectangle that clips its children; `background` paints behind them.
Artboard {
rect: Rect,
background: Vec<Paint>,
},
/// Curve, parametric shape or compound shape: an outline with its fills and strokes.
/// Children of a shape are clipped by its outline (painted over its fills, under its strokes).
Shape {
path: Path,
fills: Vec<Paint>,
strokes: Vec<Stroke>,
even_odd: bool,
},
Text(Text),
Image(Image),
/// Something this reader does not import; its children are still imported.
Unsupported,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Node {
pub class: Tag,
pub name: String,
pub visible: bool,
pub locked: bool,
pub opacity: f64,
pub blend: Blend,
pub kind: Kind,
/// Vector mask in document pixels: the node shows only inside it (nonzero fill).
pub mask: Option<Path>,
/// Pixel mask: grey levels (white shows the node, black hides it); outside it the node is hidden.
pub pixel_mask: Option<Image>,
/// Bottom to top.
pub children: Vec<Node>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Spread {
/// Spread bounds in document pixels (the canvas, or the union of the artboards).
pub bounds: Rect,
/// Pages of a Publisher-style spread (one, or two facing pages), in document pixels.
pub pages: Vec<Rect>,
/// Transparent background; otherwise the page is white.
pub transparent: bool,
/// Bottom to top.
pub nodes: Vec<Node>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Document {
/// Pixels per inch: all geometry is in document pixels at this resolution.
pub dpi: f64,
pub spreads: Vec<Spread>,
/// Application that last saved the file, as stored (`Affinity 3.0.2`), when present.
pub saved_by: Option<String>,
/// Content that was not imported or was approximated, one line per kind, deduplicated.
pub warnings: Vec<String>,
}
/// Deepest layer nesting followed; deeper content is reported, not imported.
const MAX_TREE_DEPTH: usize = 128;
/// Total layer nodes imported.
const MAX_NODES: usize = 500_000;
/// Read a document: archive, `doc.dat` and the model.
pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
let mut archive = Archive::open(bytes, limits)?;
let doc = archive.read("doc.dat")?;
let s = stream::parse(&doc)?;
Reader::new(&s, &mut archive).document()
}
pub(crate) struct Reader<'s, 'a, 'b> {
pub(crate) s: &'s Stream,
pub(crate) archive: &'b mut Archive<'a>,
pub(crate) dpi: f64,
warnings: BTreeMap<String, usize>,
nodes: usize,
active: HashSet<ObjId>,
}
impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self {
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new() }
}
pub(crate) fn warn(&mut self, what: impl Into<String>) {
*self.warnings.entry(what.into()).or_default() += 1;
}
fn document(mut self) -> Result<Document, Error> {
let s = self.s;
let root = s.root;
self.dpi = s.obj(root, b"UVCn").and_then(|u| s.f64(u, b"UPPI")).filter(|d| *d > 0.0 && *d < 100_000.0).unwrap_or(72.0);
let saved_by = s.obj(root, b"NVer").map(|v| {
let product = s.str(v, b"Prod").unwrap_or("Affinity");
match (s.int(v, b"Majr"), s.int(v, b"Minr"), s.int(v, b"Bild")) {
(Some(a), Some(b), Some(c)) => format!("{product} {a}.{b}.{c}"),
_ => product.to_string(),
}
});
let doc = s.obj(root, b"DocR").ok_or(Error::Malformed("document has no root node"))?;
let mut spreads = Vec::new();
for spread in s.objs(doc, b"Chld") {
if !s.is(spread, b"Sprd") {
self.warn("an unknown kind of page");
continue;
}
let pages = pages(s, spread);
let default_size =
s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h });
let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect {
x0: 0.0,
y0: 0.0,
x1: 0.0,
y1: 0.0,
});
let transparent = s.bool(spread, b"SprT").unwrap_or(false);
let (nodes, mask) = self.children(spread, Affine::IDENTITY, 0)?;
if mask.is_some() {
self.warn("mask layers directly on a page (imported without them)");
}
spreads.push(Spread { bounds, pages, transparent, nodes });
}
if spreads.is_empty() {
return Err(Error::Malformed("document has no pages"));
}
if !s.objs(doc, b"MpCh").is_empty() {
self.warn("master pages (their content is not placed on the pages that use them)");
}
let warnings = self.warnings.into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect();
Ok(Document { dpi: self.dpi, spreads, saved_by, warnings })
}
/// The children of `parent`, and the pixel mask a mask layer among them puts on the parent.
fn children(&mut self, parent: ObjId, world: Affine, depth: usize) -> Result<(Vec<Node>, Option<Image>), Error> {
let s = self.s;
let mut out = Vec::new();
let mut mask = None;
for c in s.objs(parent, b"Chld") {
if s.is(c, b"MRst") {
// A mask layer inside a group or layer masks that container.
if s.bool(c, b"Visi") == Some(false) {
continue;
}
let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
match crate::raster::mask(self, c, local.then(world))? {
Some(m) if mask.is_none() => mask = Some(m),
Some(_) => self.warn("several pixel masks on one layer (only the first is used)"),
None => {}
}
continue;
}
if let Some(n) = self.node(c, world, depth + 1)? {
out.push(n);
}
}
Ok((out, mask))
}
fn node(&mut self, id: ObjId, parent: Affine, depth: usize) -> Result<Option<Node>, Error> {
let s = self.s;
if depth > MAX_TREE_DEPTH {
self.warn("layers nested deeper than 128 levels");
return Ok(None);
}
self.nodes += 1;
if self.nodes > MAX_NODES {
return Err(Error::Limit("too many layers"));
}
// A node that contains itself (a shared object linked back into its own subtree).
if !self.active.insert(id) {
self.warn("a layer that contains itself");
return Ok(None);
}
let local = s.floats::<6>(id, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
let world = local.then(parent);
let class = s.class(id).unwrap_or(Tag(0));
let name = s.str(id, b"Desc").unwrap_or_default().to_string();
let visible = s.bool(id, b"Visi").unwrap_or(true);
let locked = s.bool(id, b"Edtb").is_some_and(|e| !e);
let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0));
let blend = match s.enumeration(id, b"Blnd") {
None if s.bool(id, b"PasT") != Some(false) && is_container(class) => Blend::PassThrough,
None => Blend::Normal,
Some((i, v)) => Blend::from_enum(i, v).unwrap_or_else(|| {
self.warn("an unknown blend mode (imported as Normal)");
Blend::Normal
}),
};
let kind = if !world.is_finite() {
self.warn("a layer with an invalid transform");
Kind::Unsupported
} else {
self.kind(id, class, world, parent)?
};
// A compound's children are its operands, already merged into its outline.
let (children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? };
let (mask, attached_mask) = self.attached_masks(id, world)?;
let pixel_mask = attached_mask.or(child_mask);
self.active.remove(&id);
if s.objs(id, b"FiEf").iter().any(|e| s.bool(*e, b"Enab") != Some(false)) {
self.warn("layer effects (shadows, glows, outlines, bevels…)");
}
Ok(Some(Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, children }))
}
fn kind(&mut self, id: ObjId, class: Tag, world: Affine, parent: Affine) -> Result<Kind, Error> {
let s = self.s;
Ok(match &class.0.to_be_bytes() {
b"Scop" => Kind::Layer,
b"Grup" => Kind::Group,
b"PCrv" | b"ShpN" | b"Comp" | b"SNEN" | b"SNRR" | b"ShRN" => {
if class == Tag::of(b"ShpN") && s.bool(id, b"ABEn") == Some(true) {
return Ok(self.artboard(id, world));
}
let Some((local, even_odd)) = crate::geometry::outline(self, id, class, world) else {
self.warn(format!("a {} that could not be read", describe(class)));
return Ok(Kind::Unsupported);
};
let (fills, strokes) = paint::node_paint(self, id, world);
Kind::Shape { path: local.transformed(world), fills, strokes, even_odd }
}
b"TxtA" | b"TxtF" | b"TxtC" => match crate::text::read(self, id, world) {
Some(t) => Kind::Text(t),
None => {
self.warn("text whose story could not be read (master-page text, or an unknown layout)");
Kind::Unsupported
}
},
b"ImgN" | b"Rstr" => match crate::raster::node_image(self, id, world)? {
Some(img) => Kind::Image(img),
None => Kind::Unsupported,
},
b"EmbN" => match crate::raster::embedded(self, id, world)? {
Some(img) => {
self.warn("embedded documents and symbols (imported as pictures of them)");
Kind::Image(img)
}
None => {
self.warn("embedded documents and symbols without a cached picture");
Kind::Unsupported
}
},
b"FRst" => {
// A fill layer paints its whole bitmap area; its gradient lives in page space.
let b = s.obj(id, b"Bitm");
let size = |t: &[u8; 4]| b.and_then(|b| s.int(b, t)).map(|v| v as f64).filter(|v| *v > 0.0);
match (size(b"BmpW"), size(b"BmpH")) {
(Some(w), Some(h)) => {
let local = Path { subpaths: vec![crate::shapes::rectangle(0.0, 0.0, w, h)] };
let fills = paint::fills(self, id, parent, b"BFFl");
Kind::Shape { path: local.transformed(world), fills, strokes: Vec::new(), even_odd: false }
}
_ => {
self.warn("fill layers without an area");
Kind::Unsupported
}
}
}
_ => {
self.warn(format!("{} layers", describe(class)));
Kind::Unsupported
}
})
}
/// Vector shapes attached to a node (`AdCh`) mask it: it shows only inside their outlines;
/// an attached mask layer is a pixel mask. Adjustments attached the same way are reported.
fn attached_masks(&mut self, id: ObjId, world: Affine) -> Result<(Option<Path>, Option<Image>), Error> {
let s = self.s;
let attached = s.objs(id, b"AdCh");
let mut mask: Option<Path> = None;
let mut pixels: Option<Image> = None;
for a in attached {
let class = s.class(a).unwrap_or(Tag(0));
if s.bool(a, b"Visi") == Some(false) {
continue;
}
if class == Tag::of(b"PCrv") || class == Tag::of(b"ShpN") || class == Tag::of(b"Comp") {
let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world);
if let Some((p, _)) = crate::geometry::outline(self, a, class, local) {
mask.get_or_insert_with(Path::default).subpaths.extend(p.transformed(local).subpaths);
}
} else if class == Tag::of(b"MRst") {
let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world);
match crate::raster::mask(self, a, local)? {
Some(m) if pixels.is_none() => pixels = Some(m),
Some(_) => self.warn("several pixel masks on one layer (only the first is used)"),
None => {}
}
} else {
self.warn(format!("{} layers attached to other layers (imported without them)", describe(class)));
}
}
Ok((mask, pixels))
}
fn artboard(&mut self, id: ObjId, world: Affine) -> Kind {
let s = self.s;
let b = s.floats::<4>(id, b"ShpB").map(rect).unwrap_or(Rect { x0: 0.0, y0: 0.0, x1: 0.0, y1: 0.0 });
let corners =
[Point { x: b.x0, y: b.y0 }, Point { x: b.x1, y: b.y0 }, Point { x: b.x1, y: b.y1 }, Point { x: b.x0, y: b.y1 }].map(|p| world.apply(p));
let [a, b2, c, d, ..] = world.0;
if b2.abs() > 1e-9 || c.abs() > 1e-9 || a <= 0.0 || d <= 0.0 {
self.warn("a rotated or flipped artboard (imported at its bounding box)");
}
let xs = corners.map(|p| p.x);
let ys = corners.map(|p| p.y);
let rect = Rect {
x0: xs.iter().copied().fold(f64::INFINITY, f64::min),
y0: ys.iter().copied().fold(f64::INFINITY, f64::min),
x1: xs.iter().copied().fold(f64::NEG_INFINITY, f64::max),
y1: ys.iter().copied().fold(f64::NEG_INFINITY, f64::max),
};
let background = paint::fills(self, id, world, b"BFFl");
Kind::Artboard { rect, background }
}
}
/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC`
/// equal pages (Publisher 1 facing spreads).
fn pages(s: &Stream, spread: ObjId) -> Vec<Rect> {
if let Some(md) = s.obj(spread, b"SpMd") {
let v: Vec<Rect> =
s.objs(md, b"PagR").into_iter().filter_map(|p| s.floats::<4>(p, b"rctp").map(rect)).filter(|r| r.x1 > r.x0 && r.y1 > r.y0).collect();
if !v.is_empty() {
return v;
}
}
match (s.floats::<4>(spread, b"SprB").map(rect), s.int(spread, b"PagC")) {
(Some(b), Some(n @ 2..=16)) => {
let w = (b.x1 - b.x0) / n as f64;
(0..n).map(|i| Rect { x0: b.x0 + w * i as f64, x1: b.x0 + w * (i + 1) as f64, ..b }).collect()
}
_ => Vec::new(),
}
}
pub(crate) fn union(a: Rect, b: Rect) -> Rect {
Rect { x0: a.x0.min(b.x0), y0: a.y0.min(b.y0), x1: a.x1.max(b.x1), y1: a.y1.max(b.y1) }
}
fn is_container(class: Tag) -> bool {
class == Tag::of(b"Scop") || class == Tag::of(b"Grup")
}
pub(crate) fn rect(v: [f64; 4]) -> Rect {
Rect { x0: v[0], y0: v[1], x1: v[2], y1: v[3] }
}
/// A readable name for a layer class in warnings.
pub(crate) fn describe(class: Tag) -> &'static str {
match &class.0.to_be_bytes() {
b"PCrv" => "curve",
b"ShpN" | b"SNEN" | b"SNRR" | b"ShRN" => "shape",
b"Comp" => "compound",
b"Rstr" => "pixel",
b"ImgN" => "image",
b"EmbN" => "embedded document",
b"MPIN" => "master page instance",
b"FRst" => "fill",
b"MRst" => "mask",
b"Slic" | b"SlcP" => "slice",
b"3DLA" => "3D",
[_, _, b'R', b'A'] => "adjustment",
_ => "unknown",
}
}

View File

@ -0,0 +1,329 @@
//! Fills, strokes and colours. Two schemas exist: Affinity 1 (`BFil` fill, `PFil` stroke paint,
//! `LSty` stroke style) and Affinity 2/3 (`BFFl`, `LIFl` and `LILn` lists with a current index).
use crate::model::{Affine, Reader};
use crate::stream::{ObjId, Value};
/// A colour as stored: RGB and grey values are encoded in the document's RGB space (sRGB unless
/// the document embeds another profile), CMYK values go through its CMYK profile. Alpha is straight.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Color {
Rgb {
r: f64,
g: f64,
b: f64,
a: f64,
},
Cmyk {
c: f64,
m: f64,
y: f64,
k: f64,
a: f64,
},
Gray {
v: f64,
a: f64,
},
/// CIE L*a*b* (D50).
Lab {
l: f64,
a: f64,
b: f64,
alpha: f64,
},
}
impl Color {
pub fn alpha(&self) -> f64 {
match self {
Color::Rgb { a, .. } | Color::Cmyk { a, .. } | Color::Gray { a, .. } => *a,
Color::Lab { alpha, .. } => *alpha,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GradientKind {
Linear,
/// Elliptical and radial: distance from the centre in unit space.
Radial,
/// Angle around the centre.
Conical,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Stop {
pub offset: f64,
pub color: Color,
/// Affinity's midpoint bias for the segment from this stop to the next (0.5 = even blend).
pub bias: f64,
}
impl Stop {
/// Where, between this stop (0) and the next (1), the blend reaches half way: the bias curve
/// fitted to Affinity's renders is `t^k` (bias ≥ 0.5) or `1-(1-t)^k`, with `k = 1 + 8·|bias-0.5|`.
pub fn half_point(&self) -> f64 {
let k = 1.0 + 8.0 * (self.bias - 0.5).abs();
let h = 0.5f64.powf(1.0 / k);
if self.bias >= 0.5 { h } else { 1.0 - h }
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Gradient {
pub kind: GradientKind,
pub stops: Vec<Stop>,
/// Unit space to document pixels: linear runs from unit (0,0) to (1,0); radial is the unit
/// circle; conical starts along unit +x and turns towards unit +y.
pub transform: Affine,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub enum Paint {
#[default]
None,
Solid(Color),
Gradient(Gradient),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Cap {
Butt,
Round,
Square,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Join {
Miter,
Round,
Bevel,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Align {
Center,
Inside,
Outside,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Stroke {
pub paint: Paint,
/// Width in document pixels.
pub width: f64,
pub cap: Cap,
pub join: Join,
pub miter_limit: f64,
pub align: Align,
/// Dash and gap lengths in document pixels, and the phase.
pub dash: Option<(Vec<f64>, f64)>,
/// Painted below the fill instead of above it.
pub behind: bool,
}
/// Gradient stops per gradient.
const MAX_STOPS: usize = 1024;
fn floats(v: &Value) -> Option<Vec<f64>> {
match v {
Value::Floats(f) => Some(f.clone()),
Value::Bytes(b) if b.len() % 4 == 0 => Some(b.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<Color> {
let s = r.s;
let class = s.class(id)?;
let col = || -> Option<Vec<f64>> {
let v = floats(s.field(id, b"_col")?)?;
v.iter().all(|f| f.is_finite()).then_some(v)
};
Some(match &class.0.to_be_bytes() {
b"RGBA" => {
let v = col()?;
Color::Rgb { r: *v.first()?, g: *v.get(1)?, b: *v.get(2)?, a: v.get(3)?.clamp(0.0, 1.0) }
}
b"HSLA" => {
let v = col()?;
let (r2, g, b) = hsl(*v.first()?, *v.get(1)?, *v.get(2)?);
Color::Rgb { r: r2, g, b, a: v.get(3)?.clamp(0.0, 1.0) }
}
b"CMYK" => {
let v = col()?;
Color::Cmyk { c: *v.first()?, m: *v.get(1)?, y: *v.get(2)?, k: *v.get(3)?, a: v.get(4)?.clamp(0.0, 1.0) }
}
b"GRAY" => {
let v = col()?;
Color::Gray { v: *v.first()?, a: v.get(1)?.clamp(0.0, 1.0) }
}
b"LABA" => {
let b = s.bytes(id, b"_col")?;
let u = |i: usize| -> Option<f64> { Some(f64::from(u16::from_le_bytes([*b.get(2 * i)?, *b.get(2 * i + 1)?])) / 65535.0) };
Color::Lab { l: 100.0 * u(0)?, a: 255.0 * u(1)? - 128.0, b: 255.0 * u(2)? - 128.0, alpha: u(3)? }
}
b"Pant" => {
r.warn("spot colours (imported as their RGB equivalent)");
return s.obj(id, b"srgb").and_then(|c| color(r, c)).or_else(|| s.obj(id, b"base").and_then(|c| color(r, c)));
}
b"RegC" => Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0, a: 1.0 },
_ => {
r.warn("colours in an unknown model");
return None;
}
})
}
fn hsl(h: f64, s: f64, l: f64) -> (f64, f64, f64) {
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let hp = (h.rem_euclid(1.0)) * 6.0;
let x = c * (1.0 - (hp % 2.0 - 1.0).abs());
let (r, g, b) = match hp as u32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = l - c / 2.0;
(r + m, g + m, b + m)
}
/// A fill descriptor (`FDsc`) or, in Affinity 1 files, a bare `FilS`; `local` maps the node's
/// space to document pixels.
pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option<Paint> {
let s = r.s;
let (fill, unit) = if s.is(id, b"FDsc") {
let unit = s.floats::<6>(id, b"FDeX").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
(s.obj(id, b"FDeF")?, unit)
} else {
(id, Affine::IDENTITY)
};
match &s.class(fill)?.0.to_be_bytes() {
b"FilN" => Some(Paint::None),
b"FilS" => Some(Paint::Solid(color(r, s.obj(fill, b"Colr")?)?)),
b"FilG" => {
let kind = match s.enumeration(fill, b"Type").map(|(i, _)| i) {
Some(0) | None => GradientKind::Linear,
Some(1 | 2) => GradientKind::Radial,
Some(3) => GradientKind::Conical,
Some(_) => {
r.warn("gradients of an unknown kind (imported as linear)");
GradientKind::Linear
}
};
let grad = s.obj(fill, b"Grad")?;
let cols = s.objs(grad, b"Cols");
let posn: Vec<Vec<f64>> = match s.field(grad, b"Posn") {
Some(Value::Array(a)) => a.iter().filter_map(floats).collect(),
_ => Vec::new(),
};
let mut stops = Vec::new();
for (c, p) in cols.iter().zip(&posn).take(MAX_STOPS) {
let color = color(r, *c)?;
let offset = p.first().copied().filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0);
let bias = p.get(1).copied().filter(|v| v.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0);
stops.push(Stop { offset, color, bias });
}
if stops.is_empty() {
return Some(Paint::None);
}
Some(Paint::Gradient(Gradient { kind, stops, transform: unit.then(local) }))
}
b"FilB" => {
r.warn("bitmap and pattern fills (left empty)");
Some(Paint::None)
}
_ => {
r.warn("fills of an unknown kind (left empty)");
Some(Paint::None)
}
}
}
fn current(r: &Reader, id: ObjId, list: &[u8; 4], index: &[u8; 4]) -> Option<ObjId> {
let items = r.s.objs(id, list);
let i = r.s.int(id, index).and_then(|i| usize::try_from(i).ok()).unwrap_or(0);
items.get(i).or_else(|| items.first()).copied()
}
/// The fills of `list` (artboard backgrounds use `BFFl`).
pub(crate) fn fills(r: &mut Reader, id: ObjId, local: Affine, list: &[u8; 4]) -> Vec<crate::paint::Paint> {
let d = current(r, id, list, b"BFCr").or_else(|| r.s.obj(id, b"BFil"));
d.and_then(|d| descriptor(r, d, local)).filter(|p| *p != Paint::None).into_iter().collect()
}
/// A node's fill and stroke. `local` maps the node's space to document pixels.
pub(crate) fn node_paint(r: &mut Reader, id: ObjId, local: Affine) -> (Vec<Paint>, Vec<Stroke>) {
let s = r.s;
let fills = fills(r, id, local, b"BFFl");
if r.s.objs(id, b"BFFl").len() > 1 || r.s.objs(id, b"LIFl").len() > 1 {
r.warn("several fills or strokes on one object (only the active one is imported)");
}
if s.f64(id, b"FOpc").is_some_and(|o| o < 0.999) {
r.warn("fill opacity (imported at full fill opacity)");
}
if s.obj(id, b"Trns").is_some() {
r.warn("transparency gradients (imported without them)");
}
let (pen, style) = if s.field(id, b"LIFl").is_some() {
(current(r, id, b"LIFl", b"LICr"), current(r, id, b"LILn", b"LICr"))
} else {
(s.obj(id, b"PFil"), s.obj(id, b"LSty"))
};
let behind = matches!(s.field(id, b"DrwO"), Some(Value::Array(a)) if a.first() == Some(&Value::Enum { id: 1, version: 0 }));
let stroke = match (pen, style) {
(Some(pen), Some(style)) => stroke(r, pen, style, local, behind),
_ => None,
};
(fills, stroke.into_iter().collect())
}
fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) -> Option<Stroke> {
let s = r.s;
let paint = descriptor(r, pen, local)?;
if paint == Paint::None {
return None;
}
let lsty = if s.is(ldsc, b"LDsc") { s.obj(ldsc, b"LDeL")? } else { ldsc };
let weight = s.f64(lsty, b"Wght").filter(|w| *w > 0.0 && *w < 1e7)?;
// "Scale with object": the weight follows the object's transform; otherwise it is absolute.
let scales = s.bool(ldsc, b"LDSc").unwrap_or(true);
let width = if scales { weight * local.scale() } else { weight };
let data = s.bytes(lsty, b"Data").unwrap_or_default();
let miter_limit = data.get(0..8).and_then(|b| b.try_into().ok()).map(f64::from_le_bytes).filter(|m| m.is_finite() && *m >= 1.0).unwrap_or(4.0);
// Cap and join bytes are only partly understood: (0, 0) is butt/miter in every clean sample;
// (2, 2) ends plain lines square and brush lines round; joins import round, which never
// looks wrong.
let (cap, join) = match (data.get(8).copied(), data.get(9).copied(), data.get(10).copied()) {
(Some(0), Some(0), _) => (Cap::Butt, Join::Miter),
(Some(0), Some(_), _) => (Cap::Butt, Join::Round),
(Some(2), Some(2), Some(3)) => (Cap::Round, Join::Round),
(Some(2), Some(2), _) => (Cap::Square, Join::Round),
_ => (Cap::Round, Join::Round),
};
let align = match s.int(ldsc, b"LDSa") {
Some(1) => Align::Inside,
Some(2) => Align::Outside,
_ => Align::Center,
};
let dash = match s.field(lsty, b"Patn") {
Some(Value::Array(a)) if data.get(10) == Some(&2) || !a.is_empty() => {
let mut v: Vec<f64> = a.iter().filter_map(|x| if let Value::Float(f) = x { Some(*f) } else { None }).collect();
while v.len() >= 2 && v.iter().rev().take(2).all(|x| *x == 0.0) {
v.truncate(v.len() - 2);
}
let valid = !v.is_empty() && v.iter().all(|x| x.is_finite() && *x >= 0.0) && v.iter().any(|x| *x > 0.0);
valid.then(|| (v.iter().map(|x| x * width).collect(), s.f64(lsty, b"Phse").unwrap_or(0.0) * width))
}
_ => None,
};
if s.obj(lsty, b"Brus").is_some() || s.obj(ldsc, b"LDeP").is_some() {
r.warn("brush and pressure strokes (imported as plain strokes)");
}
Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind })
}

View File

@ -0,0 +1,127 @@
//! The indexed PNG thumbnail Affinity stores next to the archive: Affinity's own render of the
//! document, at most 512 pixels on the long side in real files, never the document itself.
use crate::Error;
use crate::container::header;
pub const MAX_PREVIEW_BYTES: usize = 16 << 20;
pub const MAX_PREVIEW_DIMENSION: u32 = 4096;
/// Borrowed embedded thumbnail, at its own dimensions, never the native document dimensions.
#[derive(Debug)]
pub struct Preview<'a> {
pub png: &'a [u8],
pub width: u32,
pub height: u32,
}
fn bytes_at(bytes: &[u8], offset: usize, length: usize) -> Result<&[u8], Error> {
let end = offset.checked_add(length).ok_or(Error::Malformed("offset overflow"))?;
bytes.get(offset..end).ok_or(Error::Malformed("truncated record"))
}
fn le32(bytes: &[u8], offset: usize) -> Result<u32, Error> {
let b = bytes_at(bytes, offset, 4)?;
Ok(u32::from_le_bytes(b.try_into().map_err(|_| Error::Malformed("integer"))?))
}
fn be32(bytes: &[u8], offset: usize) -> Result<u32, Error> {
let b = bytes_at(bytes, offset, 4)?;
Ok(u32::from_be_bytes(b.try_into().map_err(|_| Error::Malformed("PNG integer"))?))
}
/// Read only the indexed `Thmb` record (identical in container versions 8 to 12), checking every PNG chunk CRC. Compressed
/// pixels must still be validated by the consuming image decoder, with allocation limits.
pub fn preview(bytes: &[u8]) -> Result<Preview<'_>, Error> {
let h = header(bytes)?;
if h.thumbnail_offset == 0 {
return Err(Error::Unsupported("no embedded preview"));
}
let offset = usize::try_from(h.thumbnail_offset).map_err(|_| Error::Malformed("thumbnail offset overflow"))?;
if offset < 72 {
return Err(Error::Malformed("thumbnail overlaps the file header"));
}
let record = bytes.get(offset..).ok_or(Error::Malformed("thumbnail offset outside file"))?;
if bytes_at(record, 0, 8)? != b"\xff\xff\xff\xffThmb" {
return Err(Error::Malformed("missing indexed thumbnail"));
}
if le32(record, 8)? != 1 || bytes_at(record, 28, 1)? != [1] {
return Err(Error::Unsupported("thumbnail record version or encoding"));
}
let size = usize::try_from(le32(record, 24)?).map_err(|_| Error::Limit("encoded size"))?;
if size > MAX_PREVIEW_BYTES {
return Err(Error::Limit("encoded preview exceeds 16 MiB"));
}
if le32(record, 16)? != 29 || le32(record, 20)? != 0 || u64::from(le32(record, 12)?) != size as u64 + 13 {
return Err(Error::Malformed("inconsistent thumbnail record lengths"));
}
let png = bytes_at(record, 29, size)?;
if bytes_at(png, 0, 8)? != b"\x89PNG\r\n\x1a\n" || be32(png, 8)? != 13 || bytes_at(png, 12, 4)? != b"IHDR" {
return Err(Error::Malformed("thumbnail is not a PNG"));
}
let width = be32(png, 16)?;
let height = be32(png, 20)?;
if width == 0 || height == 0 || width > MAX_PREVIEW_DIMENSION || height > MAX_PREVIEW_DIMENSION {
return Err(Error::Limit("preview dimensions must be 1..4096"));
}
// Bound work by bytes, not a file-supplied chunk count, and require exactly one complete PNG.
let mut pos = 8usize;
let (mut palette, mut image_data, mut image_data_ended) = (false, false, false);
loop {
let length = usize::try_from(be32(png, pos)?).map_err(|_| Error::Limit("PNG chunk length"))?;
let chunk_size = length.checked_add(12).ok_or(Error::Malformed("PNG chunk overflow"))?;
let chunk = bytes_at(png, pos, chunk_size)?;
let checksum_input = bytes_at(chunk, 4, chunk_size - 8)?;
if crc32fast::hash(checksum_input) != be32(chunk, chunk_size - 4)? {
return Err(Error::Malformed("PNG chunk checksum"));
}
let kind = bytes_at(chunk, 4, 4)?;
if !kind.iter().all(u8::is_ascii_alphabetic) {
return Err(Error::Malformed("PNG chunk type"));
}
match kind {
b"IHDR" if pos == 8 => {}
b"IHDR" => return Err(Error::Malformed("duplicate PNG header")),
b"PLTE" => {
if palette || image_data {
return Err(Error::Malformed("PNG palette order"));
}
palette = true;
}
b"IDAT" => {
if image_data_ended {
return Err(Error::Malformed("non-contiguous PNG image data"));
}
image_data = true;
}
b"IEND" => {
if !image_data {
return Err(Error::Malformed("PNG has no image data"));
}
}
_ if kind.first().is_some_and(u8::is_ascii_uppercase) => {
return Err(Error::Unsupported("unknown critical PNG chunk"));
}
_ => {}
}
if image_data && kind != b"IDAT" {
image_data_ended = true;
}
// Native sample previews are plain, single-frame PNGs. Pixel allocation limits do not
// necessarily bound aggregate compressed metadata; leave these layouts unsupported.
if matches!(kind, b"zTXt" | b"iTXt" | b"iCCP") {
return Err(Error::Unsupported("compressed PNG metadata in the preview"));
}
if matches!(kind, b"acTL" | b"fcTL" | b"fdAT") {
return Err(Error::Unsupported("animated PNG preview"));
}
pos = pos.checked_add(chunk_size).ok_or(Error::Malformed("PNG offset overflow"))?;
if kind == b"IEND" {
if length != 0 || pos != png.len() {
return Err(Error::Malformed("PNG end or trailing data"));
}
break;
}
}
Ok(Preview { png, width, height })
}

View File

@ -0,0 +1,243 @@
//! Pixel layers (`Rstr`) and placed images (`ImgN`). A placed image keeps its original file
//! (`DyBm.Bckg` → archive entry `c/N`), which is imported as is; other pixels are planar tiles of
//! 256 bytes × 256 rows per channel, each its own archive entry.
use std::collections::HashMap;
use crate::Error;
use crate::model::{Affine, Image, Pixels, Reader};
use crate::stream::{self, ObjId, Tag, Value};
/// Pixels decoded per layer (the content's bounding box, not the canvas).
const MAX_PIXELS: u64 = 64 << 20;
const TILE: usize = 256;
const TILE_BYTES: usize = TILE * TILE;
pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Image>, Error> {
let s = r.s;
let Some(bitmap) = s.obj(id, b"Bitm") else {
r.warn("pixel layers without pixels");
return Ok(None);
};
if !s.is(bitmap, b"DyBm") {
r.warn("pixel layers stored in an unknown way");
return Ok(None);
}
let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
if width == 0 || height == 0 {
return Ok(None);
}
// A placed image: the original file, byte for byte.
let has_tiles = s.field(bitmap, b"Sta1").is_some();
if let Some(name) = s.entry(bitmap, b"Bckg").filter(|n| !n.is_empty())
&& (s.is(id, b"ImgN") || !has_tiles)
{
match original(r, name) {
Ok(bytes) => return Ok(Some(Image { width, height, pixels: Pixels::Encoded(bytes), transform: world })),
Err(Error::Limit(e)) => return Err(Error::Limit(e)),
Err(_) => r.warn("an embedded image file that could not be read (its cached pixels were used)"),
}
}
// The content's bounding box, so a mostly empty canvas-sized layer stays small.
let crop = s
.field(id, b"BitR")
.and_then(|v| if let Value::Ints(i) = v { Some(i.clone()) } else { None })
.filter(|v| v.len() == 4 && v[0] > i64::from(i32::MIN) + 1)
.map(|v| {
(
v[0].clamp(0, i64::from(width)) as u32,
v[1].clamp(0, i64::from(height)) as u32,
v[2].clamp(0, i64::from(width)) as u32,
v[3].clamp(0, i64::from(height)) as u32,
)
})
.unwrap_or((0, 0, width, height));
if s.field(bitmap, b"Bckg").is_some() && has_tiles && !s.is(id, b"ImgN") {
r.warn("edited placed images use their stored pixels");
}
decode(r, bitmap, crop, world)
}
/// A mask layer (`MRst`): one channel of coverage as grey pixels (white shows, black hides).
pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Image>, Error> {
let s = r.s;
let Some(bitmap) = s.obj(id, b"Bitm") else { return Ok(None) };
let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
if width == 0 || height == 0 || !matches!(s.enumeration(bitmap, b"Frmt"), Some((6 | 7, _))) {
r.warn("pixel masks in an unknown format (imported without them)");
return Ok(None);
}
decode(r, bitmap, (0, 0, width, height), world)
}
/// An embedded document or symbol instance (`EmbN`), as the picture of it Affinity caches.
pub(crate) fn embedded(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Image>, Error> {
let s = r.s;
let Some(cache) = s.obj(id, b"Bitm").and_then(|e| s.obj(e, b"Cach")) else { return Ok(None) };
let Some(bbox) = s.obj(id, b"Bitm").and_then(|e| s.floats::<4>(e, b"ChBB")) else { return Ok(None) };
let width = s.int(cache, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
let height = s.int(cache, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
if width == 0 || height == 0 {
return Ok(None);
}
let [x0, y0, x1, y1] = bbox;
// The instance's origin is the centre of the embedded content (checked against the
// thumbnails of documents with embedded documents and SVG files).
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
let place = Affine([(x1 - x0) / f64::from(width), 0.0, 0.0, (y1 - y0) / f64::from(height), x0 - cx, y0 - cy]).then(world);
decode(r, cache, (0, 0, width, height), place)
}
/// Decode the tiles of a bitmap inside `crop` (pixels), placed by `world` (bitmap pixels to document).
fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affine) -> Result<Option<Image>, Error> {
let s = r.s;
let (x0, y0, x1, y1) = crop;
if x1 <= x0 || y1 <= y0 {
return Ok(None);
}
let (w, h) = (x1 - x0, y1 - y0);
if u64::from(w) * u64::from(h) > MAX_PIXELS {
r.warn("pixel layers larger than 64 megapixels");
return Ok(None);
}
let format = s.enumeration(bitmap, b"Frmt").map(|(f, _)| f);
let (channels, bps) = match format {
Some(0) => (4, 1),
Some(1) => (4, 2),
Some(6) => (1, 1),
Some(7) => (1, 2),
Some(4) => {
r.warn("CMYK pixel layers (converted to RGB without the document's colour profile)");
(5, 1)
}
_ => {
r.warn("pixel layers in grey, Lab or 32-bit formats");
return Ok(None);
}
};
let mut planes = Vec::with_capacity(channels);
let mut cache = HashMap::new();
for c in 1..=channels {
planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), &mut cache)?);
}
let n = (w as usize) * (h as usize);
let mut rgba = vec![0u8; n * 4];
let sample = |p: &Vec<u8>, i: usize| -> f64 {
if bps == 2 {
f64::from(u16::from_le_bytes([p.get(2 * i).copied().unwrap_or(0), p.get(2 * i + 1).copied().unwrap_or(0)])) / 65535.0
} else {
f64::from(p.get(i).copied().unwrap_or(0)) / 255.0
}
};
for (i, px) in rgba.chunks_exact_mut(4).enumerate() {
let v: Vec<f64> = planes.iter().map(|p| sample(p, i)).collect();
let (r2, g, b, a) = if channels == 1 {
(v[0], v[0], v[0], 1.0)
} else if channels == 5 {
let k = 1.0 - v[3];
((1.0 - v[0]) * k, (1.0 - v[1]) * k, (1.0 - v[2]) * k, v[4])
} else {
(v[0], v[1], v[2], v[3])
};
px.copy_from_slice(&[r2, g, b, a].map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8));
}
let transform = Affine([1.0, 0.0, 0.0, 1.0, f64::from(x0), f64::from(y0)]).then(world);
Ok(Some(Image { width: w, height: h, pixels: Pixels::Rgba8(rgba), transform }))
}
/// The original image file: entry `c/N` is a stream whose root (`Blck`) holds it as `Data`.
fn original(r: &mut Reader, name: &str) -> Result<Vec<u8>, Error> {
let bytes = r.archive.read(name)?;
let s = stream::parse(&bytes)?;
let root = s.object(s.root).ok_or(Error::Malformed("embedded image"))?;
match root.get(Tag::of(b"Data")) {
Some(Value::Blob(b)) if !b.is_empty() => Ok(b.clone()),
_ => Err(Error::Malformed("embedded image without data")),
}
}
fn tag(prefix: &[u8; 3], c: usize) -> [u8; 4] {
[prefix[0], prefix[1], prefix[2], b'0' + c as u8]
}
/// One channel of level 0, cropped to (x0, y0, w, h) pixels; `bps` bytes per sample.
fn plane(
r: &mut Reader,
bitmap: ObjId,
c: usize,
bps: usize,
(x0, y0, w, h): (u32, u32, u32, u32),
cache: &mut HashMap<String, Vec<u8>>,
) -> Result<Vec<u8>, Error> {
let s = r.s;
let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| (width * bps).div_ceil(TILE));
let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE));
let states: Vec<u8> = match s.field(bitmap, &tag(b"Sta", c)) {
Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
_ => Vec::new(),
};
let tiles = s.objs(bitmap, &tag(b"Idx", c));
let row = (w as usize) * bps;
let mut out = vec![0u8; row * h as usize];
let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps);
let (py0, py1) = (y0 as usize, (y0 + h) as usize);
let mut next = 0usize;
for (t, state) in states.iter().enumerate().take(tw.saturating_mul(th)) {
let (ty, tx) = (t / tw.max(1), t % tw.max(1));
let fill = match state {
0 | 1 => None,
2 => Some(0xFF),
4 => {
let Some(blck) = tiles.get(next).copied() else { return Err(Error::Malformed("missing pixel tile")) };
next += 1;
let (ox, oy) = (tx * TILE, ty * TILE);
if ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0 {
continue;
}
let name = s.entry(blck, b"Data").ok_or(Error::Malformed("pixel tile without data"))?.to_string();
if !cache.contains_key(&name) {
let mut data = r.archive.read(&name)?;
// Older files wrap a tile in a one-field stream: header, then a 64 KiB blob.
if data.len() == TILE_BYTES + 22 && data.starts_with(b"\x00\xffKS") {
data = data.get(21..21 + TILE_BYTES).ok_or(Error::Malformed("pixel tile"))?.to_vec();
}
if data.len() != TILE_BYTES {
return Err(Error::Malformed("pixel tile size"));
}
cache.insert(name.clone(), data);
}
let tile = cache.get(&name).ok_or(Error::Malformed("pixel tile"))?;
copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0));
continue;
}
3 => {
r.warn("32-bit pixel layers");
None
}
5 => {
r.warn("pixel layers drawn from an embedded image (left empty)");
None
}
_ => return Err(Error::Malformed("unknown pixel tile state")),
};
if let Some(v) = fill {
copy(&mut out, row, (bx0, py0, bx1, py1), (tx * TILE, ty * TILE), |_, _| v);
}
}
Ok(out)
}
/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane.
fn copy(out: &mut [u8], row: usize, (bx0, py0, bx1, py1): (usize, usize, usize, usize), (ox, oy): (usize, usize), at: impl Fn(usize, usize) -> u8) {
for y in oy.max(py0)..(oy + TILE).min(py1) {
for x in ox.max(bx0)..(ox + TILE).min(bx1) {
if let Some(b) = out.get_mut((y - py0) * row + (x - bx0)) {
*b = at(x - ox, y - oy);
}
}
}
}

View File

@ -0,0 +1,259 @@
//! Parametric shapes: Affinity stores the shape's parameters and its box, never the outline.
//! Formulas were fitted to Affinity's own renders (the document thumbnails) of public files.
use std::f64::consts::{FRAC_PI_2, PI, TAU};
use crate::model::{Affine, Path, Point, Reader, SubPath};
use crate::stream::{ObjId, Tag, Value};
/// Handle length of a quarter circle of radius 1.
const KAPPA: f64 = 0.552_284_749_830_793_4;
/// Vertices of a polygon or star.
const MAX_SIDES: i64 = 10_000;
/// The last value of a tag: a shape changed from one kind to another keeps a stale first block.
fn last<'a>(r: &'a Reader, id: ObjId, tag: &[u8; 4]) -> Option<&'a Value> {
let t = Tag::of(tag);
r.s.object(id)?.fields.iter().rev().find(|(k, _)| *k == t).map(|(_, v)| v)
}
fn num(r: &Reader, id: ObjId, tag: &[u8; 4]) -> Option<f64> {
match last(r, id, tag)? {
Value::Float(f) if f.is_finite() => Some(*f),
Value::UInt(u) => Some(*u as f64),
Value::Int(i) => Some(*i as f64),
_ => None,
}
}
fn sides(r: &Reader, id: ObjId, tag: &[u8; 4], default: f64) -> usize {
let n = num(r, id, tag).unwrap_or(default).round();
n.clamp(3.0, MAX_SIDES as f64) as usize
}
fn pt(x: f64, y: f64) -> Point {
Point { x, y }
}
fn polygon(pts: impl IntoIterator<Item = Point>) -> Option<SubPath> {
let mut it = pts.into_iter();
let start = it.next()?;
let segments = it.map(|p| [p, p, p]).collect();
Some(SubPath { start, segments, closed: true })
}
/// Outline of `shape` (a `Shpe` object) in the node box `b` = (x0, y0, x1, y1).
pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) -> Option<Path> {
let [x0, y0, x1, y1] = b;
let (w, h) = (x1 - x0, y1 - y0);
let (cx, cy, rx, ry) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, w / 2.0, h / 2.0);
let on_ellipse = |a: f64, f: f64| pt(cx + f * rx * a.cos(), cy + f * ry * a.sin());
let class = r.s.class(shape)?;
let sub = match &class.0.to_be_bytes() {
b"ShNR" | b"ShRR" => {
let rounded = matches!(last(r, shape, b"CTyp"), Some(Value::Array(a)) if !a.is_empty());
let radius = match last(r, shape, b"ShCR") {
Some(Value::Floats(v)) => v.first().copied().filter(|f| f.is_finite() && *f > 0.0),
_ => None,
};
if let (true, Some(f)) = (rounded, radius) {
if let Some(Value::Array(types)) = last(r, shape, b"CTyp")
&& types.iter().any(|t| !matches!(t, Value::Enum { id: 0 | 4, .. }))
{
r.warn("rectangles with straight, concave or other corner types (imported with round corners)");
}
let (crx, cry) = if r.s.bool(shape, b"AbSz") == Some(true) {
// An absolute radius is in document pixels, whatever the node's scale.
let [a, b2, c, d, ..] = world.0;
let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt());
if sx > 0.0 && sy > 0.0 { (f / sx, f / sy) } else { (0.0, 0.0) }
} else {
(f * w.min(h), f * w.min(h))
};
rounded_rect(b, crx.min(w / 2.0), cry.min(h / 2.0))
} else {
polygon([pt(x0, y0), pt(x1, y0), pt(x1, y1), pt(x0, y1)])?
}
}
b"ShpE" => ellipse(cx, cy, rx, ry),
b"ShPy" => {
let n = sides(r, shape, b"Side", 5.0);
let a = |i: usize| -FRAC_PI_2 + TAU * i as f64 / n as f64;
if r.s.object(shape).is_some_and(|_| matches!(last(r, shape, b"Smth"), Some(Value::Bool(true)))) {
// Smoothed: tangent handles of length curv·4/3·tan(π/2n) along the inscribed ellipse.
let k = num(r, shape, b"Curv").unwrap_or(0.0) * 4.0 / 3.0 * (PI / (2.0 * n as f64)).tan();
let v = |i: usize| on_ellipse(a(i), 1.0);
let t = |i: usize| pt(-rx * a(i).sin(), ry * a(i).cos());
let segments = (0..n)
.map(|i| {
let (p, q, tp, tq) = (v(i), v(i + 1), t(i), t(i + 1));
[pt(p.x + k * tp.x, p.y + k * tp.y), pt(q.x - k * tq.x, q.y - k * tq.y), q]
})
.collect();
SubPath { start: v(0), segments, closed: true }
} else {
polygon((0..n).map(|i| on_ellipse(a(i), 1.0)))?
}
}
b"ShSt" => {
let n = sides(r, shape, b"Pnts", 5.0);
let q = num(r, shape, b"IRad").unwrap_or(0.382).clamp(0.0, 1.0);
if [b"CrcI", b"CrcO", b"CrvL", b"CrvR"].iter().any(|t| num(r, shape, t).is_some_and(|v| v.abs() > 1e-9)) {
r.warn("stars with rounded or curved points (imported with sharp points)");
}
polygon((0..2 * n).map(|i| on_ellipse(-FRAC_PI_2 + PI * i as f64 / n as f64, if i % 2 == 0 { 1.0 } else { q })))?
}
b"ShSS" => {
let n = sides(r, shape, b"Side", 5.0);
let ri = num(r, shape, b"COut").unwrap_or(0.5).clamp(0.0, 1.0);
let step = PI / n as f64;
let (l, hw, inner) = (step.cos(), ri * step.sin(), ri * step.cos());
polygon((0..n).flat_map(|k| {
let phi = -FRAC_PI_2 + step + TAU * k as f64 / n as f64;
let (c, s) = (phi.cos(), phi.sin());
[(inner, -hw), (l, -hw), (l, hw), (inner, hw)].map(|(u, v)| pt(cx + rx * (u * c - v * s), cy + ry * (u * s + v * c)))
}))?
}
b"ShPi" => {
let start = num(r, shape, b"AngS").unwrap_or(0.0);
let mut end = num(r, shape, b"AngE").unwrap_or(TAU);
while end < start {
end += TAU;
}
let inner = num(r, shape, b"IRad").unwrap_or(0.0).clamp(0.0, 1.0);
pie(cx, cy, rx, ry, start, end.min(start + TAU), inner)
}
b"ShpT" => {
let p = num(r, shape, b"Pos ").unwrap_or(0.5);
polygon([pt(x0 + p * w, y0), pt(x1, y1), pt(x0, y1)])?
}
b"ShTz" => {
let (l, rr) = (num(r, shape, b"PosL").unwrap_or(0.25), num(r, shape, b"PosR").unwrap_or(0.75));
polygon([pt(x0 + l * w, y0), pt(x0 + rr * w, y0), pt(x1, y1), pt(x0, y1)])?
}
_ => {
r.warn("cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse)");
ellipse(cx, cy, rx, ry)
}
};
Some(Path { subpaths: vec![sub] })
}
pub(crate) fn rectangle(x0: f64, y0: f64, x1: f64, y1: f64) -> SubPath {
SubPath { start: pt(x0, y0), segments: [pt(x1, y0), pt(x1, y1), pt(x0, y1)].map(|p| [p, p, p]).to_vec(), closed: true }
}
fn ellipse(cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath {
let (kx, ky) = (KAPPA * rx, KAPPA * ry);
SubPath {
start: pt(cx + rx, cy),
segments: vec![
[pt(cx + rx, cy + ky), pt(cx + kx, cy + ry), pt(cx, cy + ry)],
[pt(cx - kx, cy + ry), pt(cx - rx, cy + ky), pt(cx - rx, cy)],
[pt(cx - rx, cy - ky), pt(cx - kx, cy - ry), pt(cx, cy - ry)],
[pt(cx + kx, cy - ry), pt(cx + rx, cy - ky), pt(cx + rx, cy)],
],
closed: true,
}
}
fn rounded_rect([x0, y0, x1, y1]: [f64; 4], rx: f64, ry: f64) -> SubPath {
let (kx, ky) = ((1.0 - KAPPA) * rx, (1.0 - KAPPA) * ry);
SubPath {
start: pt(x0 + rx, y0),
segments: vec![
[pt(x1 - rx, y0), pt(x1 - rx, y0), pt(x1 - rx, y0)],
[pt(x1 - kx, y0), pt(x1, y0 + ky), pt(x1, y0 + ry)],
[pt(x1, y1 - ry), pt(x1, y1 - ry), pt(x1, y1 - ry)],
[pt(x1, y1 - ky), pt(x1 - kx, y1), pt(x1 - rx, y1)],
[pt(x0 + rx, y1), pt(x0 + rx, y1), pt(x0 + rx, y1)],
[pt(x0 + kx, y1), pt(x0, y1 - ky), pt(x0, y1 - ry)],
[pt(x0, y0 + ry), pt(x0, y0 + ry), pt(x0, y0 + ry)],
[pt(x0, y0 + ky), pt(x0 + kx, y0), pt(x0 + rx, y0)],
],
closed: true,
}
}
/// Elliptical arc from angle `a0` to `a1` (radians, measured y-up as Affinity's pie angles are),
/// as cubic segments of at most a quarter turn.
fn arc(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, a1: f64, out: &mut Vec<[Point; 3]>) {
let n = ((a1 - a0).abs() / FRAC_PI_2).ceil().clamp(1.0, 8.0) as usize;
let step = (a1 - a0) / n as f64;
let k = 4.0 / 3.0 * (step / 4.0).tan();
let p = |a: f64| pt(cx + rx * a.cos(), cy - ry * a.sin());
let d = |a: f64| pt(-rx * a.sin(), -ry * a.cos());
for i in 0..n {
let (s, e) = (a0 + step * i as f64, a0 + step * (i + 1) as f64);
let (ps, pe, ds, de) = (p(s), p(e), d(s), d(e));
out.push([pt(ps.x + k * ds.x, ps.y + k * ds.y), pt(pe.x - k * de.x, pe.y - k * de.y), pe]);
}
}
fn pie(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, a1: f64, inner: f64) -> SubPath {
let start = pt(cx + rx * a0.cos(), cy - ry * a0.sin());
let mut segments = Vec::new();
arc(cx, cy, rx, ry, a0, a1, &mut segments);
if inner > 0.0 {
let p = pt(cx + inner * rx * a1.cos(), cy - inner * ry * a1.sin());
segments.push([p, p, p]);
arc(cx, cy, inner * rx, inner * ry, a1, a0, &mut segments);
} else {
let c = pt(cx, cy);
segments.push([c, c, c]);
}
SubPath { start, segments, closed: true }
}
/// A polyline with circular corners of the given radii (Affinity's live corners on curves).
pub(crate) fn fillet(pts: &[(Point, f64)], closed: bool) -> SubPath {
let n = pts.len();
let get = |i: isize| -> Option<Point> {
if closed { pts.get(i.rem_euclid(n as isize) as usize).map(|p| p.0) } else { usize::try_from(i).ok().and_then(|i| pts.get(i)).map(|p| p.0) }
};
let mut corners: Vec<(Point, Point, Point, Point)> = Vec::with_capacity(n);
for (i, (p, radius)) in pts.iter().enumerate() {
let (prev, next) = (get(i as isize - 1), get(i as isize + 1));
let (Some(a), Some(b)) = (prev, next) else {
corners.push((*p, *p, *p, *p));
continue;
};
let (u1, l1) = unit(*p, a);
let (u2, l2) = unit(*p, b);
let cos = (u1.x * u2.x + u1.y * u2.y).clamp(-1.0, 1.0);
let phi = cos.acos();
if *radius <= 0.0 || phi < 1e-6 || (PI - phi) < 1e-6 {
corners.push((*p, *p, *p, *p));
continue;
}
let t = (radius / (phi / 2.0).tan()).min(l1 / 2.0).min(l2 / 2.0);
let r = t * (phi / 2.0).tan();
let k = 4.0 / 3.0 * ((PI - phi) / 4.0).tan() * r;
let s = pt(p.x + u1.x * t, p.y + u1.y * t);
let e = pt(p.x + u2.x * t, p.y + u2.y * t);
corners.push((s, pt(s.x - u1.x * k, s.y - u1.y * k), pt(e.x - u2.x * k, e.y - u2.y * k), e));
}
let Some(first) = corners.first().copied() else { return SubPath { start: pt(0.0, 0.0), segments: vec![], closed } };
let mut segments = Vec::with_capacity(2 * n);
let mut cur = first.0;
let start = cur;
for (i, (s, c1, c2, e)) in corners.iter().enumerate() {
if i > 0 {
segments.push([cur, *s, *s]);
}
if s != e {
segments.push([*c1, *c2, *e]);
}
cur = *e;
}
if closed && cur != start {
segments.push([cur, start, start]);
}
SubPath { start, segments, closed }
}
fn unit(from: Point, to: Point) -> (Point, f64) {
let (dx, dy) = (to.x - from.x, to.y - from.y);
let l = (dx * dx + dy * dy).sqrt();
if l > 0.0 { (pt(dx / l, dy / l), l) } else { (pt(0.0, 0.0), 0.0) }
}

View File

@ -0,0 +1,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<i64>),
Floats(Vec<f64>),
/// Fixed-size record (curve nodes, ids) or fixed-size struct (colour components).
Bytes(Vec<u8>),
Blob(Vec<u8>),
/// Name of another archive entry (raster tiles, embedded files).
Entry(String),
Flags(u64),
/// `None` is a null object.
Obj(Option<ObjId>),
Array(Vec<Value>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Root,
/// Defined once and referenced by id elsewhere.
Shared,
Inline,
/// Untagged fields identified by position (curve data).
Positional,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Object {
pub class: Tag,
pub kind: Kind,
/// Class chain, most derived first (`ShpN`, `VNod`, `Node`).
pub chain: Vec<Tag>,
/// Fields in stream order; positional fields have tag 0.
pub fields: Vec<(Tag, Value)>,
}
impl Object {
pub fn get(&self, tag: Tag) -> Option<&Value> {
self.fields.iter().find(|(t, _)| *t == tag).map(|(_, v)| v)
}
}
#[derive(Debug)]
pub struct Stream {
pub root: ObjId,
pub objects: Vec<Object>,
}
/// Deepest object nesting accepted; real documents stay well below (each layer level adds three).
pub const MAX_DEPTH: usize = 384;
const MAX_OBJECTS: usize = 8_000_000;
struct Parser<'a> {
c: Cursor<'a>,
objects: Vec<Object>,
/// Shared ids, defined at first use and referenced backwards.
shared: std::collections::HashMap<u32, ObjId>,
depth: usize,
}
/// Parse a stream (`00 FF 4B 53` header). Bytes after the root's terminator are ignored.
pub fn parse(bytes: &[u8]) -> Result<Stream, Error> {
let mut c = Cursor::new(bytes, 0);
if c.take(4)? != b"\x00\xffKS" {
return Err(Error::Malformed("document stream signature"));
}
let version = c.u16()?;
if version > 2 {
return Err(Error::Unsupported("document stream version"));
}
let class = Tag(c.u32()?);
c.u16()?;
if version == 2 {
c.u32()?;
}
let mut p = Parser { c, objects: Vec::new(), shared: std::collections::HashMap::new(), depth: 0 };
let root = p.push(Object { class, kind: Kind::Root, chain: vec![class], fields: Vec::new() })?;
let fields = p.fields(true, class)?;
if let Some(o) = p.objects.get_mut(root) {
o.fields = fields;
}
Ok(Stream { root, objects: p.objects })
}
impl Parser<'_> {
fn push(&mut self, o: Object) -> Result<ObjId, Error> {
if self.objects.len() >= MAX_OBJECTS {
return Err(Error::Limit("too many objects"));
}
self.objects.push(o);
Ok(self.objects.len() - 1)
}
/// Element count of an array: each element takes at least `min` bytes, so a count the
/// remaining input cannot hold is rejected before anything is allocated.
fn count(&mut self, min: usize) -> Result<usize, Error> {
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?;
if n.checked_mul(min.max(1)).is_none_or(|bytes| bytes > self.c.remaining()) {
return Err(Error::Malformed("array longer than its data"));
}
Ok(n)
}
fn fields(&mut self, tagged: bool, class: Tag) -> Result<Vec<(Tag, Value)>, Error> {
self.depth += 1;
if self.depth > MAX_DEPTH {
return Err(Error::Limit("objects nested too deeply"));
}
let mut out = Vec::new();
loop {
let t = self.c.u8()?;
let code = t & 0x7F;
if code == 0 {
break;
}
let tag = if tagged { Tag(self.c.u32()?) } else { Tag(0) };
let v = if t & 0x80 != 0 { self.array(code, class)? } else { self.scalar(code, tagged, class)? };
out.push((tag, v));
}
self.depth -= 1;
Ok(out)
}
fn string(&mut self) -> Result<String, Error> {
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("string length"))?;
Ok(String::from_utf8_lossy(self.c.take(n)?).into_owned())
}
fn floats(&mut self, n: usize, wide: bool) -> Result<Value, Error> {
let mut v = Vec::with_capacity(n);
for _ in 0..n {
v.push(if wide { f64::from_le_bytes(self.c.array()?) } else { f64::from(f32::from_le_bytes(self.c.array()?)) });
}
Ok(Value::Floats(v))
}
fn scalar(&mut self, code: u8, tagged: bool, class: Tag) -> Result<Value, Error> {
let c = &mut self.c;
Ok(match code {
0x01 => Value::UInt(c.u8()?.into()),
0x02 => Value::UInt(c.u16()?.into()),
0x03 | 0x2f | 0x34 => Value::UInt(c.u32()?.into()),
0x04 => Value::UInt(c.u64()?),
0x05 => Value::Int(i8::from_le_bytes(c.array()?).into()),
0x06 => Value::Int(i16::from_le_bytes(c.array()?).into()),
0x07 => Value::Int(i32::from_le_bytes(c.array()?).into()),
0x08 => Value::Int(i64::from_le_bytes(c.array()?)),
0x09 => Value::Float(f32::from_le_bytes(c.array()?).into()),
0x0a => Value::Float(f64::from_le_bytes(c.array()?)),
0x15..=0x19 => {
let n = usize::from(code - 0x13);
let mut v = Vec::with_capacity(n);
for _ in 0..n {
v.push(i32::from_le_bytes(c.array()?).into());
}
Value::Ints(v)
}
0x1f..=0x23 => return self.floats(usize::from(code - 0x1d), false),
0x24..=0x28 => return self.floats(usize::from(code - 0x22), true),
0x29 => Value::Bool(c.u8()? != 0),
0x2a => Value::Enum { id: c.u16()?, version: c.u16()? },
0x2b | 0x2e => Value::Str(self.string()?),
0x2c => {
let n = usize::from(c.u16()?);
Value::Bytes(c.take(n)?.to_vec())
}
0x35..=0x74 => Value::Bytes(c.take(usize::from(code - 0x34))?.to_vec()),
0x2d => {
let n = usize::try_from(c.u32()?).map_err(|_| Error::Malformed("blob length"))?;
Value::Blob(c.take(n)?.to_vec())
}
0x33 => {
c.u32()?;
let n = usize::try_from(c.u32()?).map_err(|_| Error::Malformed("entry name length"))?;
Value::Entry(String::from_utf8_lossy(c.take(n)?).into_owned())
}
0x75 => {
c.u16()?;
let n = usize::from(c.u8()?);
if n > 8 {
return Err(Error::Malformed("flags wider than 64 bits"));
}
let mut b = [0u8; 8];
b.get_mut(..n).ok_or(Error::Malformed("flags"))?.copy_from_slice(c.take(n)?);
Value::Flags(u64::from_le_bytes(b))
}
0x30 => {
let fields = self.fields(false, class)?;
let id = self.push(Object { class, kind: Kind::Positional, chain: Vec::new(), fields })?;
Value::Obj(Some(id))
}
0x31 | 0x32 if !tagged => return Err(Error::Malformed("object without a field tag")),
0x31 => self.shared()?,
0x32 => self.inline(None)?,
_ => return Err(Error::Unsupported("unknown field type in document stream")),
})
}
fn array(&mut self, code: u8, class: Tag) -> Result<Value, Error> {
let min = match code {
0x01 | 0x05 | 0x29 => 1,
0x02 | 0x06 => 2,
0x03 | 0x07 | 0x09 | 0x2f | 0x34 => 4,
0x04 | 0x08 | 0x0a => 8,
0x15..=0x19 => 4 * usize::from(code - 0x13),
0x1f..=0x23 => 4 * usize::from(code - 0x1d),
0x24..=0x28 => 8 * usize::from(code - 0x22),
0x35..=0x74 => usize::from(code - 0x34),
_ => 1,
};
match code {
0x29 => {
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?;
let bits = self.c.take(n.div_ceil(8))?;
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<Tag>) -> Result<Value, Error> {
match self.c.u8()? {
0 => return Ok(Value::Obj(None)),
1 => {}
_ => return Err(Error::Malformed("object marker")),
}
let class = match class {
Some(c) => c,
None => {
let c = Tag(self.c.u32()?);
self.c.u16()?;
c
}
};
let fields = self.fields(true, class)?;
let id = self.push(Object { class, kind: Kind::Inline, chain: vec![class], fields })?;
Ok(Value::Obj(Some(id)))
}
fn shared(&mut self) -> Result<Value, Error> {
let flag = self.c.u8()?;
if flag == 0 {
return Ok(Value::Obj(None));
}
let key = self.c.u32()?;
match flag {
2 => return self.shared.get(&key).map(|id| Value::Obj(Some(*id))).ok_or(Error::Malformed("reference to an undefined object")),
1 => {}
_ => return Err(Error::Malformed("object marker")),
}
if self.shared.contains_key(&key) {
return Err(Error::Malformed("object defined twice"));
}
// Register before the fields: an object's own fields may refer back to it.
let id = self.push(Object { class: Tag(0), kind: Kind::Shared, chain: Vec::new(), fields: Vec::new() })?;
self.shared.insert(key, id);
let mut chain = Vec::new();
let mut fields = Vec::new();
loop {
match self.c.u8()? {
0 => {
let tag = Tag(self.c.u32()?);
self.c.u16()?;
chain.push(tag);
fields.extend(self.fields(true, tag)?);
}
1 => {
chain.push(Tag(self.c.u32()?));
break;
}
2 => break,
_ => return Err(Error::Malformed("object class chain")),
}
if chain.len() > 64 {
return Err(Error::Limit("object class chain too long"));
}
}
let class = chain.first().copied().unwrap_or(Tag(0));
fields.extend(self.fields(true, class)?);
if let Some(o) = self.objects.get_mut(id) {
*o = Object { class, kind: Kind::Shared, chain, fields };
}
Ok(Value::Obj(Some(id)))
}
}

View File

@ -0,0 +1,259 @@
//! Synthetic containers for tests and fuzz seeds (cargo feature `synth`).
//!
//! These builders write the layout the reader accepts, so tests can describe documents in a few
//! lines. They are **not** an Affinity exporter: nothing written here has been opened in Affinity.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use crate::stream::Tag;
/// A field value to encode.
#[derive(Debug, Clone)]
pub enum F {
U8(u8),
U32(u32),
I32(i32),
F32(f32),
F64(f64),
Bool(bool),
Enum(u16, u16),
Str(String),
/// f64 vector of 2..6 values (points, rectangles, transforms).
F64s(Vec<f64>),
/// Fixed-size struct (colour components), 1..64 bytes.
Struct(Vec<u8>),
/// Array of fixed-size records, all `size` bytes.
Records(u16, Vec<Vec<u8>>),
U8s(Vec<u8>),
Entry(String),
/// Shared object: `Def` defines id with class chain and fields, `Ref` links back.
Def(u32, Vec<Tag>, Vec<(Tag, F)>),
Ref(u32),
Null,
/// Inline object.
Obj(Tag, Vec<(Tag, F)>),
/// Positional (untagged) object.
Pos(Vec<F>),
/// Array of shared objects / links.
Shared(Vec<F>),
/// Array of inline objects of one class.
Objs(Tag, Vec<Vec<(Tag, F)>>),
/// Raw bytes appended verbatim (malformed-input tests).
Raw(Vec<u8>),
}
pub fn tag(s: &[u8; 4]) -> Tag {
Tag::of(s)
}
fn put_tag(out: &mut Vec<u8>, t: Tag) {
out.extend(t.0.to_le_bytes());
}
fn code(f: &F) -> u8 {
match f {
F::U8(_) => 0x01,
F::U32(_) => 0x03,
F::I32(_) => 0x07,
F::F32(_) => 0x09,
F::F64(_) => 0x0a,
F::Bool(_) => 0x29,
F::Enum(..) => 0x2a,
F::Str(_) => 0x2b,
F::F64s(v) => 0x22 + v.len() as u8,
F::Struct(b) => 0x34 + b.len() as u8,
F::Records(..) => 0x80 | 0x2c,
F::U8s(_) => 0x80 | 0x01,
F::Entry(_) => 0x33,
F::Def(..) | F::Ref(_) => 0x31,
F::Null => 0x31,
F::Obj(..) => 0x32,
F::Pos(_) => 0x30,
F::Shared(_) => 0x80 | 0x31,
F::Objs(..) => 0x80 | 0x32,
F::Raw(_) => 0,
}
}
fn payload(out: &mut Vec<u8>, f: &F) {
match f {
F::U8(v) => out.push(*v),
F::U32(v) => out.extend(v.to_le_bytes()),
F::I32(v) => out.extend(v.to_le_bytes()),
F::F32(v) => out.extend(v.to_le_bytes()),
F::F64(v) => out.extend(v.to_le_bytes()),
F::Bool(v) => out.push(u8::from(*v)),
F::Enum(id, ver) => {
out.extend(id.to_le_bytes());
out.extend(ver.to_le_bytes());
}
F::Str(s) => {
out.extend((s.len() as u32).to_le_bytes());
out.extend(s.as_bytes());
}
F::F64s(v) => v.iter().for_each(|x| out.extend(x.to_le_bytes())),
F::Struct(b) => out.extend(b),
F::Records(size, recs) => {
out.extend((recs.len() as u32).to_le_bytes());
out.extend(size.to_le_bytes());
recs.iter().for_each(|r| out.extend(r));
}
F::U8s(v) => {
out.extend((v.len() as u32).to_le_bytes());
out.extend(v);
}
F::Entry(name) => {
put_tag(out, tag(b"Data"));
out.extend((name.len() as u32).to_le_bytes());
out.extend(name.as_bytes());
}
F::Def(id, chain, fields) => {
out.push(1);
out.extend(id.to_le_bytes());
// Class chain: each level but the last carries no fields here; the last ends the chain.
let (last, rest) = chain.split_last().expect("class chain");
for t in rest {
out.push(0);
put_tag(out, *t);
out.extend(0u16.to_le_bytes());
out.push(0);
}
out.push(1);
put_tag(out, *last);
fields_into(out, fields, true);
}
F::Ref(id) => {
out.push(2);
out.extend(id.to_le_bytes());
}
F::Null => out.push(0),
F::Obj(class, fields) => {
out.push(1);
put_tag(out, *class);
out.extend(0u16.to_le_bytes());
fields_into(out, fields, true);
}
F::Pos(items) => {
for f in items {
out.push(code(f));
payload(out, f);
}
out.push(0);
}
F::Shared(items) => {
out.extend((items.len() as u32).to_le_bytes());
items.iter().for_each(|f| payload(out, f));
}
F::Objs(class, items) => {
out.extend((items.len() as u32).to_le_bytes());
put_tag(out, *class);
out.extend(0u16.to_le_bytes());
for fields in items {
out.push(1);
fields_into(out, fields, true);
}
}
F::Raw(b) => out.extend(b),
}
}
fn fields_into(out: &mut Vec<u8>, fields: &[(Tag, F)], tagged: bool) {
for (t, f) in fields {
if let F::Raw(b) = f {
out.extend(b);
continue;
}
out.push(code(f));
if tagged {
put_tag(out, *t);
}
payload(out, f);
}
out.push(0);
}
/// A `doc.dat` stream with root class `Pers`.
pub fn stream(root: &[(Tag, F)]) -> Vec<u8> {
let mut out = b"\x00\xffKS".to_vec();
out.extend(2u16.to_le_bytes());
put_tag(&mut out, tag(b"Pers"));
out.extend(0u16.to_le_bytes());
out.extend(30u32.to_le_bytes());
fields_into(&mut out, root, true);
out
}
/// How an entry is stored.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Method {
Stored,
Zlib,
Zstd,
}
/// A version-12 container with one `#FT4` table holding `entries`, then an optional thumbnail record.
pub fn container(entries: &[(&str, &[u8], Method)], thumbnail_png: Option<&[u8]>) -> Vec<u8> {
let mut out = vec![0u8; 72];
out[..4].copy_from_slice(crate::container::MAGIC);
out[4..6].copy_from_slice(&12u16.to_le_bytes());
out[6..8].copy_from_slice(&4u16.to_le_bytes());
out[8..12].copy_from_slice(b"nsrP");
out[12..16].copy_from_slice(b"#Inf");
out[64..68].copy_from_slice(b"Prot");
let mut records = Vec::new();
for (i, (name, data, method)) in entries.iter().enumerate() {
let packed = match method {
Method::Stored => data.to_vec(),
Method::Zlib => {
use std::io::Write;
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
e.write_all(data).unwrap();
e.finish().unwrap()
}
Method::Zstd => ruzstd::encoding::compress_to_vec(*data, ruzstd::encoding::CompressionLevel::Fastest),
};
let offset = out.len() as u64;
out.extend(b"#Fil");
out.extend(&packed);
let mut r = Vec::new();
r.extend((i as u32 + 1).to_le_bytes());
r.push(0);
r.extend(offset.to_le_bytes());
r.extend((data.len() as u64).to_le_bytes());
r.extend((packed.len() as u64).to_le_bytes());
r.extend(crc32fast::hash(data).to_le_bytes());
r.push(match method {
Method::Stored => 0,
Method::Zlib => 1,
Method::Zstd => 2,
});
r.extend(0u32.to_le_bytes());
r.extend(0u32.to_le_bytes());
r.extend((name.len() as u16).to_le_bytes());
r.extend(name.as_bytes());
records.push(r);
}
let fat = out.len() as u64;
out.extend(b"#FT4");
out.extend(0u64.to_le_bytes());
out.extend(1_700_000_000u64.to_le_bytes());
out.extend([0u8; 24]);
out.extend((records.len() as u32).to_le_bytes());
out.extend([0u8; 8]);
out.extend(0u16.to_le_bytes());
out.push(0);
records.iter().for_each(|r| out.extend(r));
let thumb = out.len() as u64;
out[16..24].copy_from_slice(&fat.to_le_bytes());
if let Some(png) = thumbnail_png {
out[24..32].copy_from_slice(&thumb.to_le_bytes());
out.extend(b"\xff\xff\xff\xffThmb");
out.extend(1u32.to_le_bytes());
out.extend((png.len() as u32 + 13).to_le_bytes());
out.extend(29u32.to_le_bytes());
out.extend(0u32.to_le_bytes());
out.extend((png.len() as u32).to_le_bytes());
out.push(1);
out.extend(png);
}
out
}

View File

@ -0,0 +1,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<u8> {
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
png.extend(13u32.to_be_bytes());
png.extend(b"IHDR");
png.extend(2u32.to_be_bytes());
png.extend(1u32.to_be_bytes());
png.extend([8, 6, 0, 0, 0]);
png.extend(crc32fast::hash(&png[12..]).to_be_bytes());
png.extend([0; 4]);
png.extend(b"IDAT");
png.extend(crc32fast::hash(b"IDAT").to_be_bytes());
png.extend([0; 4]);
png.extend(b"IEND");
png.extend(crc32fast::hash(b"IEND").to_be_bytes());
let mut b = vec![0; 72];
b[..4].copy_from_slice(MAGIC);
b[4..6].copy_from_slice(&12u16.to_le_bytes());
b[8..12].copy_from_slice(b"nsrP");
b[12..16].copy_from_slice(b"#Inf");
b[24..32].copy_from_slice(&72u64.to_le_bytes());
b[64..68].copy_from_slice(b"Prot");
b.extend(b"\xff\xff\xff\xffThmb");
b.extend(1u32.to_le_bytes());
b.extend((png.len() as u32 + 13).to_le_bytes());
b.extend(29u32.to_le_bytes());
b.extend(0u32.to_le_bytes());
b.extend((png.len() as u32).to_le_bytes());
b.push(1);
b.extend(png);
b
}
#[test]
fn indexed_preview_and_header() {
let b = fixture();
assert_eq!(header(&b).unwrap().version, 12);
let p = preview(&b).unwrap();
assert_eq!((p.width, p.height), (2, 1));
assert_eq!(p.png, &b[101..]);
}
#[test]
fn every_truncation_is_rejected() {
let b = fixture();
for end in 0..b.len() {
assert!(preview(&b[..end]).is_err(), "{end}");
}
}
#[test]
fn rejects_assets_versions_offsets_lengths_and_bombs() {
let b = fixture();
for (offset, value) in [
(4, 13u64.to_le_bytes().to_vec()),
(8, b"urBR".to_vec()),
(24, u64::MAX.to_le_bytes().to_vec()),
(24, 1u64.to_le_bytes().to_vec()),
(24, 0u64.to_le_bytes().to_vec()),
(84, u32::MAX.to_le_bytes().to_vec()),
(88, 30u32.to_le_bytes().to_vec()),
(96, u32::MAX.to_le_bytes().to_vec()),
(117, 5000u32.to_be_bytes().to_vec()),
] {
let mut broken = b.clone();
broken[offset..offset + value.len()].copy_from_slice(&value);
assert!(preview(&broken).is_err(), "offset {offset}");
}
// Versions 8 to 11 store the same record (checked on public Affinity 1 and 2 files).
for version in 8..12 {
let mut legacy = b.clone();
legacy[4..6].copy_from_slice(&(version as u16).to_le_bytes());
assert!(preview(&legacy).is_ok());
}
// Flags 4 and 8 occur in real files; the low two bits mark other container variants.
for (flags, ok) in [(4u16, true), (8, true), (1, false), (2, false)] {
let mut variant = b.clone();
variant[6..8].copy_from_slice(&flags.to_le_bytes());
assert_eq!(preview(&variant).is_ok(), ok, "flags {flags}");
}
let mut old = b.clone();
old[4..6].copy_from_slice(&7u16.to_le_bytes());
assert!(matches!(preview(&old), Err(Error::Unsupported(_))));
}
#[test]
fn an_unindexed_png_or_resource_is_never_used() {
let mut b = fixture();
b[24..32].copy_from_slice(&0u64.to_le_bytes());
assert!(preview(&b).is_err());
b[24..32].copy_from_slice(&72u64.to_le_bytes());
b[76..80].copy_from_slice(b"Meta");
assert!(preview(&b).is_err());
}
#[test]
fn every_chunk_checksum_including_the_end_is_checked() {
let b = fixture();
for at in [130, b.len() - 1] {
let mut damaged = b.clone();
damaged[at] ^= 1;
assert_eq!(preview(&damaged).unwrap_err(), Error::Malformed("PNG chunk checksum"));
}
let mut ancillary = vec![0; 4];
ancillary.extend(b"tEXt");
ancillary.extend(crc32fast::hash(b"tEXt").to_be_bytes());
let mut with_ancillary = b.clone();
let at = b.len() - 12;
with_ancillary.splice(at..at, ancillary);
let png_length = (with_ancillary.len() - 101) as u32;
with_ancillary[84..88].copy_from_slice(&(png_length + 13).to_le_bytes());
with_ancillary[96..100].copy_from_slice(&png_length.to_le_bytes());
assert!(preview(&with_ancillary).is_ok());
with_ancillary[at + 8] ^= 1;
assert_eq!(preview(&with_ancillary).unwrap_err(), Error::Malformed("PNG chunk checksum"));
}
#[test]
fn compressed_metadata_and_animation_are_not_preview_support() {
for kind in [b"zTXt", b"iTXt", b"iCCP", b"acTL", b"fcTL", b"fdAT"] {
let mut extra = vec![0; 4];
extra.extend(kind);
extra.extend(crc32fast::hash(kind).to_be_bytes());
let mut b = fixture();
let at = b.len() - 12;
b.splice(at..at, extra);
let length = (b.len() - 101) as u32;
b[84..88].copy_from_slice(&(length + 13).to_le_bytes());
b[96..100].copy_from_slice(&length.to_le_bytes());
assert!(matches!(preview(&b), Err(Error::Unsupported(_))));
}
}
#[test]
fn critical_chunks_follow_a_single_complete_png_layout() {
fn with_chunks(kinds: &[&[u8; 4]]) -> Vec<u8> {
let original = fixture();
let mut b = original[..134].to_vec(); // File, record and complete first IHDR.
for kind in kinds {
b.extend([0; 4]);
b.extend(*kind);
b.extend(crc32fast::hash(*kind).to_be_bytes());
}
let length = (b.len() - 101) as u32;
b[84..88].copy_from_slice(&(length + 13).to_le_bytes());
b[96..100].copy_from_slice(&length.to_le_bytes());
b
}
assert!(preview(&with_chunks(&[b"IDAT", b"IDAT", b"IEND"])).is_ok());
// PNG readers must not reject an otherwise valid unknown ancillary chunk just
// because its reserved (third-letter) bit is set; future versions can define it.
assert!(preview(&with_chunks(&[b"abct", b"IDAT", b"IEND"])).is_ok());
for kinds in [
vec![b"IEND"],
vec![b"IDAT", b"IHDR", b"IEND"],
vec![b"PLTE", b"PLTE", b"IDAT", b"IEND"],
vec![b"IDAT", b"PLTE", b"IEND"],
vec![b"IDAT", b"tEXt", b"IDAT", b"IEND"],
vec![b"ABCD", b"IDAT", b"IEND"],
vec![b"IDAT", b"IEND", b"tEXt"],
] {
assert!(preview(&with_chunks(&kinds)).is_err(), "{kinds:?}");
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn arbitrary_input_never_panics(b in prop::collection::vec(any::<u8>(), 0..2048)) {
let _ = header(&b);
let _ = preview(&b);
}
#[test]
fn mutations_never_panic(edits in prop::collection::vec((0usize..200, any::<u8>()), 0..32)) {
let mut b = fixture();
for (i, value) in edits { if let Some(v) = b.get_mut(i) { *v = value; } }
let _ = preview(&b);
}
}
}
mod archive {
use crate::Error;
use crate::container::{Archive, Limits};
use crate::stream::{self, Kind, Tag, Value};
use crate::synth::{self, F, Method, tag};
use proptest::prelude::*;
fn doc() -> Vec<u8> {
synth::stream(&[
(tag(b"Desc"), F::Str("Document".into())),
(tag(b"Visi"), F::Bool(true)),
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 5.0, 0.0, 1.0, -2.0])),
(tag(b"Colr"), F::Def(7, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct([0u8; 16].to_vec()))])),
(tag(b"Same"), F::Ref(7)),
(tag(b"Null"), F::Null),
(tag(b"Kids"), F::Shared(vec![F::Def(8, vec![tag(b"Grup"), tag(b"Node")], vec![(tag(b"Desc"), F::Str("g".into()))]), F::Ref(8)])),
(
tag(b"Crvs"),
F::Obj(tag(b"PCvD"), vec![(tag(b"Data"), F::Pos(vec![F::U8(0), F::U32(1), F::Bool(true), F::Records(18, vec![vec![0; 18]; 4])]))]),
),
(tag(b"Enum"), F::Enum(2, 1)),
(tag(b"Tile"), F::Entry("d/3".into())),
(tag(b"Stat"), F::U8s(vec![4, 0, 2])),
])
}
#[test]
fn every_method_round_trips_with_checksums() {
let d = doc();
for method in [Method::Stored, Method::Zlib, Method::Zstd] {
let file = synth::container(&[("doc.dat", &d, method), ("d/3", b"tile", method)], None);
let mut a = Archive::open(&file, Limits::default()).unwrap();
assert_eq!(a.header.version, 12);
assert_eq!(a.read("doc.dat").unwrap(), d);
assert_eq!(a.read("d/3").unwrap(), b"tile");
assert_eq!(a.extracted(), d.len() + 4);
assert!(a.read("missing").is_err());
}
}
#[test]
fn the_stream_parses_into_shared_objects_and_links() {
let s = stream::parse(&doc()).unwrap();
let root = &s.objects[s.root];
assert_eq!(root.class, Tag::of(b"Pers"));
assert_eq!(root.get(Tag::of(b"Desc")), Some(&Value::Str("Document".into())));
assert_eq!(root.get(Tag::of(b"Xfrm")), Some(&Value::Floats(vec![1.0, 0.0, 5.0, 0.0, 1.0, -2.0])));
let (Some(Value::Obj(Some(a))), Some(Value::Obj(Some(b)))) = (root.get(Tag::of(b"Colr")), root.get(Tag::of(b"Same"))) else { panic!() };
assert_eq!(a, b);
assert_eq!(s.objects[*a].class, Tag::of(b"RGBA"));
assert_eq!(s.objects[*a].kind, Kind::Shared);
let Some(Value::Array(kids)) = root.get(Tag::of(b"Kids")) else { panic!() };
assert_eq!(kids[0], kids[1]);
let Value::Obj(Some(g)) = kids[0] else { panic!() };
assert_eq!(s.objects[g].chain, vec![Tag::of(b"Grup"), Tag::of(b"Node")]);
assert_eq!(root.get(Tag::of(b"Enum")), Some(&Value::Enum { id: 2, version: 1 }));
assert_eq!(root.get(Tag::of(b"Tile")), Some(&Value::Entry("d/3".into())));
assert_eq!(root.get(Tag::of(b"Null")), Some(&Value::Obj(None)));
}
#[test]
fn checksums_sizes_and_budgets_are_enforced() {
let d = doc();
let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None);
// Flip a byte inside the compressed payload, past the "#Fil" tag at offset 72.
let mut broken = file.clone();
broken[90] ^= 0x55;
let mut a = Archive::open(&broken, Limits::default()).unwrap();
assert!(a.read("doc.dat").is_err());
let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX }).unwrap();
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1 }).unwrap();
a.read("doc.dat").unwrap();
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
}
#[test]
fn a_declared_size_smaller_than_the_data_is_rejected_not_truncated() {
let big = vec![7u8; 1 << 20];
let mut file = synth::container(&[("doc.dat", &big, Method::Zlib)], None);
// Rewrite the declared size in the table to 1 KiB: decoding must stop and fail.
let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()) as usize;
let size_at = fat + 59 + 5 + 8;
file[size_at..size_at + 8].copy_from_slice(&1024u64.to_le_bytes());
let mut a = Archive::open(&file, Limits::default()).unwrap();
assert_eq!(a.read("doc.dat").unwrap_err(), Error::Malformed("archive entry size mismatch"));
}
#[test]
fn libraries_and_cyclic_tables_are_rejected() {
let d = doc();
let mut file = synth::container(&[("doc.dat", &d, Method::Stored)], None);
let mut library = file.clone();
library[8..12].copy_from_slice(b"rAsA");
assert!(matches!(Archive::open(&library, Limits::default()), Err(Error::Unsupported(_))));
let fat = u64::from_le_bytes(file[16..24].try_into().unwrap());
let at = fat as usize + 4;
file[at..at + 8].copy_from_slice(&fat.to_le_bytes());
assert_eq!(Archive::open(&file, Limits::default()).unwrap_err(), Error::Malformed("allocation tables form a cycle"));
}
#[test]
fn hostile_streams_fail_cleanly() {
let deep = (0..2000).fold(Vec::new(), |inner, _| vec![(tag(b"Chld"), F::Obj(tag(b"Grup"), inner))]);
assert_eq!(stream::parse(&synth::stream(&deep)).unwrap_err(), Error::Limit("objects nested too deeply"));
// An array claiming four billion elements in a few bytes.
let huge = synth::stream(&[(tag(b"Big_"), F::Raw(vec![0x81, b'_', b'g', b'i', b'B', 0xff, 0xff, 0xff, 0xff, 0]))]);
assert_eq!(stream::parse(&huge).unwrap_err(), Error::Malformed("array longer than its data"));
let forward = synth::stream(&[(tag(b"Link"), F::Ref(99))]);
assert!(stream::parse(&forward).is_err());
let twice = synth::stream(&[(tag(b"A___"), F::Def(1, vec![tag(b"RGBA")], vec![])), (tag(b"B___"), F::Def(1, vec![tag(b"RGBA")], vec![]))]);
assert!(stream::parse(&twice).is_err());
let unknown = synth::stream(&[(tag(b"X___"), F::Raw(vec![0x0b, 1, 2, 3, 4]))]);
assert!(matches!(stream::parse(&unknown), Err(Error::Unsupported(_))));
let d = doc();
for end in 0..d.len() {
assert!(stream::parse(&d[..end]).is_err(), "{end}");
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(512))]
#[test]
fn mutated_containers_and_streams_never_panic(edits in prop::collection::vec((0usize..4096, any::<u8>()), 1..24)) {
let d = doc();
for method in [Method::Stored, Method::Zlib, Method::Zstd] {
let mut file = synth::container(&[("doc.dat", &d, method)], None);
for (i, v) in &edits {
let n = file.len();
file[i % n] = *v;
}
if let Ok(bytes) = Archive::open(&file, Limits::default()).and_then(|mut a| a.read("doc.dat")) {
let _ = stream::parse(&bytes);
}
}
let mut s = d.clone();
for (i, v) in &edits {
let n = s.len();
s[i % n] = *v;
}
let _ = stream::parse(&s);
}
}
}

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

@ -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<Text> {
let s = r.s;
let story = s.obj(id, b"StSt")?;
let frame = s.obj(id, b"TxtH")?;
let artistic = s.is(frame, b"ArFr");
let bounds = s.floats::<4>(frame, b"FrmB").map(rect)?;
let mut runs = Vec::new();
let mut align = None;
let mut total = 0usize;
let mut placeholders = false;
for block in s.objs(story, b"Blok") {
let glyphs = s.obj(block, b"Glyp")?;
let chars: Vec<char> = match s.str(glyphs, b"Utf8") {
Some(t) => t.chars().collect(),
None => {
// Inline objects (page numbers, anchors) each take one index before the segment text.
let mut v = Vec::new();
for seg in s.objs(glyphs, b"Mixd") {
let inline = s.objs(seg, b"Glys").len();
v.extend(std::iter::repeat_n('\u{FFFC}', inline));
placeholders |= inline > 0;
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars());
}
v
}
};
total = total.checked_add(chars.len())?;
if total > MAX_CHARS {
r.warn("text longer than a million characters (truncated)");
break;
}
let mut start = 0usize;
for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() {
let end = usize::try_from(s.int(run, b"Indx")?).ok()?.min(chars.len());
if end <= start {
continue;
}
let text: String = chars.get(start..end)?.iter().filter(|c| **c != '\0' && **c != '\u{FFFC}').collect();
start = end;
let Some(attrs) = s.obj(run, b"Item") else { continue };
if !text.is_empty() {
runs.push(run_attrs(r, attrs, text, world));
}
}
if align.is_none() {
align = s
.obj(block, b"PAtt")
.and_then(|p| s.objs(p, b"Runs").first().copied())
.and_then(|run| s.obj(run, b"Item"))
.map(|p| paragraph_align(r, p));
}
}
if placeholders {
r.warn("text fields such as page numbers (left out of the text)");
}
let align = align.unwrap_or(Align::Left);
let first_baseline = bounds.y0 + s.f64(frame, b"ArtV").unwrap_or(0.0);
let anchor_x = match align {
Align::Center => (bounds.x0 + bounds.x1) / 2.0,
Align::Right => bounds.x1,
_ => bounds.x0,
};
if !artistic {
if s.objs(frame, b"ColW").len() > 1 || matches!(s.field(frame, b"ColW"), Some(Value::Array(a)) if a.len() > 1) {
r.warn("text frames with several columns (imported as one column)");
}
if s.class(id) == Some(crate::stream::Tag::of(b"TxtC")) {
r.warn("text frames with a curved outline (imported as rectangular frames)");
}
}
Some(Text { runs, align, anchor: Point { x: anchor_x, y: first_baseline }, frame: if artistic { None } else { Some(bounds) }, transform: world })
}
fn slot<T: Copy>(v: Option<&Value>, i: usize, f: impl Fn(&Value) -> Option<T>) -> Option<T> {
match v? {
Value::Array(a) => a.get(i).and_then(f),
_ => None,
}
}
fn float(v: &Value) -> Option<f64> {
match v {
Value::Float(f) if f.is_finite() => Some(*f),
_ => None,
}
}
fn int(v: &Value) -> Option<i64> {
match v {
Value::Int(i) => Some(*i),
Value::UInt(u) => i64::try_from(*u).ok(),
_ => None,
}
}
fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine) -> TextRun {
let s = r.s;
let doubles = s.field(attrs, b"Doub");
let ints = s.field(attrs, b"Ints");
let size = slot(doubles, 0, float).filter(|v| *v > 0.0 && *v < 1e6).unwrap_or(12.0 * r.dpi / 72.0);
let tracking = slot(doubles, 4, float).unwrap_or(0.0);
let leading = (slot(ints, 4, int) == Some(1)).then(|| slot(doubles, 13, float)).flatten().filter(|v| *v > 0.0);
let objects = match s.field(attrs, b"Objs") {
Some(Value::Array(a)) => a.iter().map(|v| if let Value::Obj(o) = v { *o } else { None }).collect(),
_ => Vec::new(),
};
let fill = objects.first().copied().flatten().and_then(|f| paint::descriptor(r, f, world)).unwrap_or(Paint::None);
if objects.get(1).copied().flatten().and_then(|f| paint::descriptor(r, f, world)).is_some_and(|p| p != Paint::None) {
r.warn("outlined (stroked) text");
}
if slot(doubles, 10, float).is_some_and(|h| (h - 1.0).abs() > 1e-3) {
r.warn("horizontally scaled text");
}
let font = s.obj(attrs, b"DFnt");
let get = |tag: &[u8; 4]| font.and_then(|f| s.str(f, tag)).unwrap_or_default().to_string();
TextRun {
text,
postscript: get(b"Post"),
family: get(b"Famy"),
weight: font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400),
italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false),
size,
tracking,
leading,
fill,
}
}
fn paragraph_align(r: &mut Reader, p: ObjId) -> Align {
match slot(r.s.field(p, b"Ints"), 0, int) {
Some(1) => Align::Center,
Some(2) => Align::Right,
Some(3) => Align::Justify,
Some(0) | None => Align::Left,
Some(_) => {
r.warn("an unknown paragraph alignment (imported as left)");
Align::Left
}
}
}

View File

@ -0,0 +1,83 @@
//! Public Affinity documents: the pinned set `cargo xtask corpus --affinity` fetches into
//! `corpus/affinity`, or any folder of `.af`/`.afdesign`/`.afphoto`/`.afpub` files named by
//! `AFFINITY_SAMPLES` (searched recursively). Without either, these tests do nothing.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::{Path, PathBuf};
use photocraft_affinity::{Archive, Limits, stream};
fn files(dir: &Path, out: &mut Vec<PathBuf>) {
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let p = e.path();
if p.is_dir() {
files(&p, out);
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["af", "afdesign", "afphoto", "afpub", "aftemplate"].contains(&e))
&& std::fs::read(&p).is_ok_and(|b| b.starts_with(photocraft_affinity::MAGIC))
{
out.push(p);
}
}
}
fn samples() -> Option<PathBuf> {
let pinned = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/affinity");
std::env::var_os("AFFINITY_SAMPLES").map(PathBuf::from).or_else(|| pinned.join("SOURCES.md").exists().then_some(pinned))
}
#[test]
fn public_documents_parse_completely() {
let Some(dir) = samples() else { return };
let mut paths = Vec::new();
files(&dir, &mut paths);
assert!(!paths.is_empty(), "no Affinity documents under {dir:?}");
let mut failures = Vec::new();
for p in &paths {
let bytes = std::fs::read(p).unwrap();
let result = Archive::open(&bytes, Limits::default()).and_then(|mut a| {
let doc = a.read("doc.dat")?;
stream::parse(&doc).map(|s| s.objects.len())
});
match result {
Ok(n) => assert!(n > 0),
Err(e) => failures.push(format!("{}: {e}", p.display())),
}
}
assert!(failures.is_empty(), "{} of {} failed:\n{}", failures.len(), paths.len(), failures.join("\n"));
eprintln!("{} Affinity documents parsed", paths.len());
}
#[test]
fn public_documents_map_to_the_model() {
let Some(dir) = samples() else { return };
let mut paths = Vec::new();
files(&dir, &mut paths);
let mut warnings = std::collections::BTreeMap::<String, usize>::new();
let mut failures = Vec::new();
let (mut nodes, mut docs) = (0usize, 0usize);
fn count(n: &[photocraft_affinity::model::Node]) -> usize {
n.iter().map(|n| 1 + count(&n.children)).sum()
}
for p in &paths {
let bytes = std::fs::read(p).unwrap();
if !bytes.starts_with(photocraft_affinity::MAGIC) {
continue;
}
match photocraft_affinity::read(&bytes, Limits::default()) {
Ok(d) => {
docs += 1;
nodes += d.spreads.iter().map(|s| count(&s.nodes)).sum::<usize>();
for w in d.warnings {
let key = w.split(" (").next().unwrap_or(&w).to_string();
*warnings.entry(key).or_default() += 1;
}
}
Err(e) => failures.push(format!("{}: {e}", p.display())),
}
}
eprintln!("{docs} documents, {nodes} nodes");
for (w, n) in &warnings {
eprintln!("{n:4} documents: {w}");
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}

View File

@ -36,6 +36,7 @@ photocraft-codecs = { workspace = true }
flate2 = { workspace = true }
[dev-dependencies]
photocraft-affinity = { workspace = true, features = ["synth"] }
# Synthetic camera raw files for the open tests.
photocraft-raw = { workspace = true, features = ["testgen"] }
# WebAssembly text -> binary for plug-in test modules (Apache-2.0 WITH LLVM-exception).

View File

@ -181,9 +181,9 @@ pub(crate) fn import(name: &str, bytes: &[u8]) -> Result<Document> {
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)

View File

@ -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<u8> {
use photocraft_affinity::synth::{self, F, Method, tag};
let node = |x: f64, y: f64| {
let mut r = x.to_le_bytes().to_vec();
r.extend(y.to_le_bytes());
r.extend([1, 0]);
r
};
let red = F::Struct([1.0f32, 0.0, 0.0, 1.0].iter().flat_map(|v| v.to_le_bytes()).collect());
let square = F::Def(
3,
vec![tag(b"PCrv")],
vec![
(tag(b"Crvs"), F::Obj(tag(b"PCvD"), vec![(tag(b"Data"), F::Pos(vec![F::U8(0), F::U32(1), F::Bool(true), F::Records(18, vec![node(5.0, 5.0), node(15.0, 5.0), node(15.0, 15.0), node(5.0, 15.0), node(5.0, 5.0)])]))])),
(tag(b"BFFl"), F::Shared(vec![F::Def(4, vec![tag(b"FDsc")], vec![(tag(b"FDeF"), F::Def(5, vec![tag(b"FilS")], vec![(tag(b"Colr"), F::Def(6, vec![tag(b"RGBA")], vec![(tag(b"_col"), red)]))]))])])),
],
);
let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 40.0, 20.0])), (tag(b"SprT"), F::Bool(true)), (tag(b"Chld"), F::Shared(vec![square]))]);
let doc = synth::stream(&[(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
synth::container(&[("doc.dat", &doc, Method::Zstd)], None)
}
#[test]
fn native_documents_open_read_only_and_can_be_placed() {
let bytes = native();
let mut s = Session::new();
let r = open_bytes_as(&mut s, "art.af", &bytes, None, Some("/source/art.af".into())).unwrap();
assert!(!r["warnings"].to_string().contains("preview"), "{r}");
let st = s.active().unwrap();
assert!(st.path.is_none() && st.source_read_only);
assert_eq!((st.doc.size.width, st.doc.size.height), (40, 20));
s.execute("file.new", json!({"width": 64, "height": 64})).unwrap();
let before = s.active().unwrap().doc.layers.len();
photocraft_engine::file_cmds::place_bytes(&mut s, "art.af", bytes, None, &json!({})).unwrap();
assert_eq!(s.active().unwrap().doc.layers.len(), before + 1);
}

View File

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

View File

@ -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<ImportResult, IoError> {
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<String> = 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<ImportResult, IoError> {
let p = photocraft_affinity::preview(bytes).map_err(|e| IoError::Unsupported(format!("{native}; its embedded preview can't be read either ({e})")))?;
let opts = photocraft_codecs::DecodeOptions {
limits: photocraft_codecs::Limits { max_width: 4096, max_height: 4096, max_pixels: 4096 * 4096, max_alloc: 128 << 20 },
..Default::default()
@ -24,8 +61,559 @@ pub(crate) fn import(name: &str, bytes: &[u8]) -> Result<ImportResult, IoError>
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<String>,
/// Document pixels (in the canvas) per Affinity document pixel: always 1; spreads are offset.
format: PixelFormat,
canvas: Rect,
}
fn affine(a: af::Affine) -> Affine {
Affine { m: a.0 }
}
fn point(p: af::Point) -> Point {
Point::new(p.x, p.y)
}
impl Builder {
fn new(name: &str, d: &af::Document) -> Result<Self, IoError> {
// Spreads (Publisher pages) side by side, 64 px apart.
let mut w: f64 = 0.0;
let mut h: f64 = 0.0;
for (i, s) in d.spreads.iter().enumerate() {
w += (s.bounds.x1 - s.bounds.x0).max(1.0) + if i > 0 { 64.0 } else { 0.0 };
h = h.max((s.bounds.y1 - s.bounds.y0).max(1.0));
}
if !(w.is_finite() && h.is_finite()) || w > MAX_SIDE || h > MAX_SIDE {
return Err(IoError::Unsupported("the Affinity document is larger than 300 000 pixels on a side".into()));
}
let size = Size::new(w.ceil().max(1.0) as u32, h.ceil().max(1.0) as u32);
let mut doc = Document::new(name, size, ColorMode::Rgb, SampleType::U8);
doc.resolution_dpi = d.dpi as f32;
let canvas = Rect::new(0, 0, size.width as i32, size.height as i32);
Ok(Self { doc, warnings: Vec::new(), format: PixelFormat::RGBA8, canvas })
}
fn warn(&mut self, w: &str) {
if !self.warnings.iter().any(|x| x == w) {
self.warnings.push(w.to_string());
}
}
fn document(&mut self, d: &af::Document) -> Result<(), IoError> {
let pages = d.spreads.len() > 1 || d.spreads.iter().any(|s| s.pages.len() > 1);
let mut x = 0.0;
let mut layers = Vec::new();
for (i, spread) in d.spreads.iter().enumerate() {
let to_doc = Affine::translate(x - spread.bounds.x0, -spread.bounds.y0);
let mut children: Vec<Layer> = spread.nodes.iter().filter_map(|n| self.layer(n, to_doc, true)).collect();
if pages {
// Each page becomes an artboard holding the art drawn on it.
let rects: Vec<af::Rect> = if spread.pages.is_empty() { vec![spread.bounds] } else { spread.pages.clone() };
let page = rects.first().copied().unwrap_or(spread.bounds);
let r = self.bbox(to_doc, page);
let mut g = Layer::new(
format!("Page {}", i + 1),
LayerContent::Group(Group { children: std::mem::take(&mut children), expanded: true, artboard: None }),
);
if let LayerContent::Group(gr) = &mut g.content {
gr.artboard = Some(Artboard { rect: r, background: ArtboardBackground::White, preset: String::new() });
}
if rects.len() > 1 {
self.warn("facing pages import as one artboard per spread");
}
layers.push(g);
} else {
layers.append(&mut children);
}
x += (spread.bounds.x1 - spread.bounds.x0).max(1.0) + 64.0;
}
if d.spreads.first().is_some_and(|s| !s.transparent) && !self.doc_has_artboards(&layers) {
// An opaque page: a white background below the art.
let mut bg = Surface::with_default(self.format, &[1.0, 1.0, 1.0, 1.0]);
bg.prune();
let mut l = Layer::new("Background", LayerContent::Raster(bg));
l.locks.transparency = true;
layers.insert(0, l);
}
self.doc.layers = layers;
Ok(())
}
fn doc_has_artboards(&self, layers: &[Layer]) -> bool {
layers.iter().any(|l| matches!(&l.content, LayerContent::Group(g) if g.artboard.is_some()))
}
/// Integer bounding box of `r` mapped by `m`, clipped to the canvas.
fn bbox(&self, m: Affine, r: af::Rect) -> Rect {
let pts = [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| m.apply(Point::new(x, y)));
let fold = |f: fn(f64, f64) -> f64, init: f64, g: fn(&Point) -> f64| pts.iter().map(g).fold(init, f);
let x0 = fold(f64::min, f64::INFINITY, |p| p.x).floor();
let y0 = fold(f64::min, f64::INFINITY, |p| p.y).floor();
let x1 = fold(f64::max, f64::NEG_INFINITY, |p| p.x).ceil();
let y1 = fold(f64::max, f64::NEG_INFINITY, |p| p.y).ceil();
let c = |v: f64, lo: i32, hi: i32| (v.clamp(f64::from(lo), f64::from(hi))) as i32;
Rect::new(
c(x0, self.canvas.x0, self.canvas.x1),
c(y0, self.canvas.y0, self.canvas.y1),
c(x1, self.canvas.x0, self.canvas.x1),
c(y1, self.canvas.y0, self.canvas.y1),
)
}
fn blend(&mut self, b: Blend) -> BlendMode {
match b {
Blend::Normal => BlendMode::Normal,
Blend::PassThrough => BlendMode::PassThrough,
Blend::Darken => BlendMode::Darken,
Blend::DarkerColor => BlendMode::DarkerColor,
Blend::Multiply => BlendMode::Multiply,
Blend::ColorBurn => BlendMode::ColorBurn,
Blend::Lighten => BlendMode::Lighten,
Blend::LighterColor => BlendMode::LighterColor,
Blend::Screen => BlendMode::Screen,
Blend::ColorDodge => BlendMode::ColorDodge,
Blend::Add => BlendMode::LinearDodge,
Blend::Overlay => BlendMode::Overlay,
Blend::SoftLight => BlendMode::SoftLight,
Blend::HardLight => BlendMode::HardLight,
Blend::VividLight => BlendMode::VividLight,
Blend::PinLight => BlendMode::PinLight,
Blend::LinearLight => BlendMode::LinearLight,
Blend::HardMix => BlendMode::HardMix,
Blend::Difference => BlendMode::Difference,
Blend::Exclusion => BlendMode::Exclusion,
Blend::Subtract => BlendMode::Subtract,
Blend::Hue => BlendMode::Hue,
Blend::Saturation => BlendMode::Saturation,
Blend::Luminosity => BlendMode::Luminosity,
Blend::Color => BlendMode::Color,
Blend::Average | Blend::Negation | Blend::Reflect | Blend::Glow | Blend::Erase => {
self.warn("the Average, Negation, Reflect, Glow and Erase blend modes import as Normal");
BlendMode::Normal
}
}
}
fn base(&mut self, n: &af::Node, content: LayerContent) -> Layer {
let mut l = Layer::new(if n.name.is_empty() { default_name(&n.kind) } else { n.name.clone() }, content);
l.visible = n.visible;
l.opacity = n.opacity as f32;
l.blend = self.blend(n.blend);
if n.locked {
l.locks = Locks { all: true, ..Locks::default() };
}
l
}
fn group(&mut self, n: &af::Node, children: Vec<Layer>) -> Layer {
// Pass-through and Normal (isolated) groups map one to one.
self.base(n, LayerContent::Group(Group { children, expanded: false, artboard: None }))
}
/// One Affinity node and its subtree; `to_doc` maps Affinity document pixels to the canvas.
fn layer(&mut self, n: &af::Node, to_doc: Affine, top: bool) -> Option<Layer> {
let children: Vec<Layer> = n.children.iter().filter_map(|c| self.layer(c, to_doc, false)).collect();
let mut l = match &n.kind {
Kind::Layer | Kind::Group => self.group(n, children),
Kind::Unsupported if children.is_empty() => return None,
Kind::Unsupported => self.group(n, children),
Kind::Shape { path, fills, strokes, even_odd } => {
let path = self.path(path, to_doc, *even_odd);
let shape = self.shape(path.clone(), fills, strokes, to_doc);
if children.is_empty() {
self.base(n, LayerContent::Shape(shape))
} else {
// A shape clips its children: a group masked by the outline, the shape at its bottom.
let mut own = Layer::new(if n.name.is_empty() { "Shape".to_string() } else { n.name.clone() }, LayerContent::Shape(shape));
own.opacity = 1.0;
let mut kids = vec![own];
kids.extend(children);
let mut g = self.group(n, kids);
g.vector_mask = Some(VectorMask { path, enabled: true, linked: true, density: 1.0, feather: 0.0 });
if !strokes.is_empty() {
self.warn("strokes of shapes that clip other layers are clipped to the shape");
}
g
}
}
Kind::Artboard { rect, background } => {
let r = self.bbox(to_doc, *rect);
let bg = match background.first() {
Some(ap::Paint::Solid(c)) => {
let c = color(*c);
if c.alpha <= 0.0 { ArtboardBackground::Transparent } else { ArtboardBackground::Custom(c) }
}
Some(_) => {
self.warn("artboard backgrounds other than a solid colour import as transparent");
ArtboardBackground::Transparent
}
None => ArtboardBackground::Transparent,
};
let mut g = self.group(n, children);
if top {
if let LayerContent::Group(gr) = &mut g.content {
gr.artboard = Some(Artboard { rect: r, background: bg, preset: String::new() });
}
} else {
// PhotoCraft artboards are top-level groups: a nested one clips by a vector mask.
let p = Path::new(vec![Subpath::polygon(&[
(f64::from(r.x0), f64::from(r.y0)),
(f64::from(r.x1), f64::from(r.y0)),
(f64::from(r.x1), f64::from(r.y1)),
(f64::from(r.x0), f64::from(r.y1)),
])]);
g.vector_mask = Some(VectorMask { path: p, enabled: true, linked: true, density: 1.0, feather: 0.0 });
self.warn("artboards inside groups import as masked groups");
}
g
}
Kind::Text(t) => {
let layer = self.text(t, to_doc);
let l = self.base(n, LayerContent::Text(layer));
if children.is_empty() {
l
} else {
let mut kids = vec![l];
kids.extend(children);
self.group(n, kids)
}
}
Kind::Image(img) => match self.image(img, to_doc) {
Some(content) => self.base(n, content),
None if children.is_empty() => return None,
None => self.group(n, children),
},
};
if let Some(mask) = &n.mask {
l.vector_mask = Some(VectorMask { path: self.path(mask, to_doc, false), enabled: true, linked: true, density: 1.0, feather: 0.0 });
}
if let Some(mask) = &n.pixel_mask
&& let Some(surface) = self.mask(mask, to_doc)
{
l.mask = Some(LayerMask { surface, enabled: true, linked: true, density: 1.0, feather: 0.0 });
}
Some(l)
}
fn path(&self, p: &af::Path, to_doc: Affine, even_odd: bool) -> Path {
let m = to_doc;
let subpaths = p
.subpaths
.iter()
.map(|s| {
// Knots: each anchor with the control points of the segments arriving and leaving.
let mut knots: Vec<Knot> = Vec::with_capacity(s.segments.len() + 1);
let start = m.apply(point(s.start));
knots.push(Knot::corner(start.x, start.y));
for [c1, c2, e] in &s.segments {
if let Some(prev) = knots.last_mut() {
prev.out_ctrl = m.apply(point(*c1));
}
let e = m.apply(point(*e));
let mut k = Knot::corner(e.x, e.y);
k.in_ctrl = m.apply(point(*c2));
knots.push(k);
}
// A closed subpath that ends where it starts: the last knot is the first.
if s.closed
&& knots.len() > 1
&& let (Some(f), Some(l)) = (knots.first().copied(), knots.last().copied())
&& (f.anchor.x - l.anchor.x).abs() < 1e-9
&& (f.anchor.y - l.anchor.y).abs() < 1e-9
{
knots.pop();
if let Some(first) = knots.first_mut() {
first.in_ctrl = l.in_ctrl;
}
}
Subpath { closed: s.closed, knots, op: Default::default() }
})
.collect();
let mut path = Path::new(subpaths);
if even_odd {
// One component filled even-odd, so inner subpaths cut holes as in Affinity.
path.fill_rule = photocraft_doc::vector::FillRule::EvenOdd;
for s in path.subpaths.iter_mut().skip(1) {
s.op = photocraft_doc::vector::PathOp::Join;
}
}
path
}
fn fill(&mut self, p: &ap::Paint, frame: Rect, to_doc: Affine) -> Option<Fill> {
match p {
ap::Paint::None => None,
ap::Paint::Solid(c) => Some(Fill::Solid(color(*c))),
ap::Paint::Gradient(g) => {
let style = match g.kind {
GradientKind::Linear => GradientStyle::Linear,
GradientKind::Radial => GradientStyle::Radial,
GradientKind::Conical => GradientStyle::Angle,
};
let m = to_doc.mul(&affine(g.transform));
let (o, e, v) = (m.apply(Point::new(0.0, 0.0)), m.apply(Point::new(1.0, 0.0)), m.apply(Point::new(0.0, 1.0)));
let (u, w) = ((e.x - o.x).hypot(e.y - o.y), (v.x - o.x).hypot(v.y - o.y));
if style == GradientStyle::Radial && u > 0.0 && (w / u - 1.0).abs() > 0.01 {
self.warn("elliptical gradients import as circular ones");
}
let (angle, scale, offset) =
photocraft_compose::gradient_fill::from_handles(style, [o.x as f32, o.y as f32], [e.x as f32, e.y as f32], frame, 0.0);
let stops = g.stops.iter().map(|s| (s.offset as f32, Color { alpha: 1.0, ..color(s.color) })).collect();
let opacity_stops = g.stops.iter().map(|s| (s.offset as f32, s.color.alpha() as f32)).collect();
let midpoints = g.stops.iter().take(g.stops.len().saturating_sub(1)).map(|s| s.half_point() as f32).collect();
Some(Fill::Gradient { stops, angle, scale, style, reverse: false, opacity_stops, midpoints, offset, dither: false, align: true })
}
}
}
fn shape(&mut self, path: Path, fills: &[ap::Paint], strokes: &[ap::Stroke], to_doc: Affine) -> ShapeLayer {
let frame = path
.control_bounds()
.map(|(x0, y0, x1, y1)| Rect::new(x0.floor() as i32, y0.floor() as i32, x1.ceil() as i32, y1.ceil() as i32))
.unwrap_or(self.canvas);
if fills.len() > 1 || strokes.len() > 1 {
self.warn("only one fill and one stroke per shape import");
}
let fill = fills.first().and_then(|f| self.fill(f, frame, to_doc));
let stroke = strokes.first().and_then(|s| {
let paint = self.fill(&s.paint, frame, to_doc)?;
if s.behind {
self.warn("strokes drawn behind their fill import in front of it");
}
Some(ShapeStroke {
width: s.width as f32,
paint,
opacity: 1.0,
align: match s.align {
ap::Align::Center => StrokeAlign::Center,
ap::Align::Inside => StrokeAlign::Inside,
ap::Align::Outside => StrokeAlign::Outside,
},
cap: match s.cap {
Cap::Butt => LineCap::Butt,
Cap::Round => LineCap::Round,
Cap::Square => LineCap::Square,
},
join: match s.join {
Join::Miter => LineJoin::Miter,
Join::Round => LineJoin::Round,
Join::Bevel => LineJoin::Bevel,
},
miter_limit: s.miter_limit as f32,
// In multiples of the width, as Photoshop stores them.
dashes: s.dash.as_ref().map(|(d, _)| d.iter().map(|v| (v / s.width.max(1e-9)) as f32).collect()).unwrap_or_default(),
dash_offset: s.dash.as_ref().map_or(0.0, |(_, o)| (o / s.width.max(1e-9)) as f32),
})
});
let mut sh = ShapeLayer { path, fill, stroke, live: None, cache: None, psd_raw: None };
sh.cache = Some(photocraft_vector::render_shape(&sh, self.format, self.canvas));
sh
}
fn text(&mut self, t: &af::Text, to_doc: Affine) -> TextLayer {
let origin = match t.frame {
Some(f) => Point::new(f.x0, f.y0),
None => point(t.anchor),
};
let m = to_doc.mul(&affine(t.transform)).mul(&Affine::translate(origin.x, origin.y));
let k = m.determinant().abs().sqrt().max(1e-9);
let transform = m.mul(&Affine::scale(1.0 / k));
let dpi = self.doc.resolution_dpi.max(1.0) as f64;
let mut text = String::new();
let mut runs = Vec::new();
for r in &t.runs {
// Affinity separates paragraphs with U+2029 and lines with U+2028.
let s = r.text.replace(['\u{2029}', '\u{2028}'], "\r");
let style = CharStyle {
font_family: r.family.clone(),
postscript_name: (!r.postscript.is_empty()).then(|| r.postscript.clone()),
weight: r.weight.clamp(100, 900) as u16,
italic: r.italic,
size_pt: (r.size * k * 72.0 / dpi) as f32,
color: match &r.fill {
ap::Paint::Solid(c) => color(*c),
_ => Color::BLACK,
},
tracking: (r.tracking * 1000.0) as f32,
leading_pt: r.leading.map(|l| (l * k * 72.0 / dpi) as f32),
..CharStyle::default()
};
text.push_str(&s);
runs.push(TextRun { len: s.len(), style });
}
let align = match t.align {
Align::Left => TextAlign::Left,
Align::Center => TextAlign::Center,
Align::Right => TextAlign::Right,
Align::Justify => TextAlign::JustifyLeft,
};
let mut layer = TextLayer {
text: text.clone(),
font_family: runs.first().map(|r| r.style.font_family.clone()).unwrap_or_default(),
size_pt: runs.first().map_or(12.0, |r| r.style.size_pt),
color: runs.first().map_or(Color::BLACK, |r| r.style.color),
transform,
runs,
paragraphs: vec![ParagraphRun { len: text.len(), style: ParagraphStyle { align, ..ParagraphStyle::default() } }],
..TextLayer::default()
};
if let Some(f) = t.frame {
layer.shape = TextShape::Box { x: 0.0, y: 0.0, width: ((f.x1 - f.x0) * k) as f32, height: ((f.y1 - f.y0) * k) as f32 };
}
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<u8>)> {
match &img.pixels {
Pixels::Rgba8(v) => Some((img.width, img.height, v.clone())),
Pixels::Encoded(bytes) => {
let opts = photocraft_codecs::DecodeOptions {
limits: photocraft_codecs::Limits { max_width: 65_535, max_height: 65_535, max_pixels: MAX_PIXELS, max_alloc: 512 << 20 },
..Default::default()
};
match photocraft_codecs::decode_with(bytes, &opts) {
Ok(d) => {
let (w, h) = d.dimensions();
Some((w, h, d.to_rgba8()))
}
Err(_) => {
self.warn("an embedded image that could not be decoded was left out");
None
}
}
}
}
}
fn image(&mut self, img: &af::Image, to_doc: Affine) -> Option<LayerContent> {
let (w, h, rgba) = self.pixels(img)?;
// The pixel grid maps onto the Affinity image's own size, whatever the stored file's.
let m = to_doc
.mul(&affine(img.transform))
.mul(&Affine { m: [f64::from(img.width) / f64::from(w.max(1)), 0.0, 0.0, f64::from(img.height) / f64::from(h.max(1)), 0.0, 0.0] });
let surface = self.resample(w, h, &rgba, m, 4)?;
Some(match &img.pixels {
// A placed image keeps its file, as a smart object placed by the same transform.
Pixels::Encoded(bytes) => LayerContent::Smart(SmartObject::new(
SmartSource::Embedded { file_name: "Affinity image".into(), bytes: Arc::new(bytes.clone()) },
m,
Some(surface),
)),
Pixels::Rgba8(_) => LayerContent::Raster(surface),
})
}
fn mask(&mut self, img: &af::Image, to_doc: Affine) -> Option<Surface> {
let (w, h, rgba) = self.pixels(img)?;
let grey: Vec<u8> = rgba.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<Surface> {
let format = if channels == 1 { PixelFormat::GRAY8 } else { self.format };
let mut out = if channels == 1 { Surface::with_default(format, &[0.0]) } else { Surface::new(format) };
let r = self.bbox(m, af::Rect { x0: 0.0, y0: 0.0, x1: f64::from(w), y1: f64::from(h) });
if r.width() <= 0 || r.height() <= 0 {
return Some(out);
}
if u64::from(r.width() 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::<f64>().round().clamp(0.0, 255.0) as u8;
} else {
// Interpolate premultiplied colour so transparent pixels don't bleed.
let alpha: f64 = weights.iter().map(|((x, y), wgt)| at(*x, *y, 3) * wgt).sum();
for (ch, v) in px.iter_mut().enumerate().take(3) {
let pm: f64 = weights.iter().map(|((x, y), wgt)| at(*x, *y, ch) * at(*x, *y, 3) / 255.0 * wgt).sum();
*v = if alpha > 0.0 { (pm * 255.0 / alpha).round().clamp(0.0, 255.0) as u8 } else { 0 };
}
px[3] = alpha.round().clamp(0.0, 255.0) as u8;
}
}
}
out.write_interleaved(r, &buf);
out.prune();
Some(out)
}
}
fn default_name(k: &Kind) -> String {
match k {
Kind::Layer => "Layer",
Kind::Group => "Group",
Kind::Artboard { .. } => "Artboard",
Kind::Shape { .. } => "Shape",
Kind::Text(_) => "Text",
Kind::Image(_) => "Image",
Kind::Unsupported => "Group",
}
.into()
}
fn color(c: ap::Color) -> Color {
let f = |v: f64| v.clamp(0.0, 1.0) as f32;
match c {
ap::Color::Rgb { r, g, b, a } => Color::rgba(f(r), f(g), f(b), f(a)),
ap::Color::Cmyk { c, m, y, k, a } => {
let cmyk = Color { mode: ColorMode::Cmyk, c: [f(c), f(m), f(y), f(k)], alpha: f(a) };
let [r, g, b] = cmyk.to_rgb();
Color::rgba(r, g, b, f(a))
}
ap::Color::Gray { v, a } => Color::rgba(f(v), f(v), f(v), f(a)),
ap::Color::Lab { l, a, b, alpha } => {
let lab = Color { mode: ColorMode::Lab, c: [f(l / 100.0), f((a + 128.0) / 255.0), f((b + 128.0) / 255.0), 0.0], alpha: f(alpha) };
let [r, g, bl] = lab.to_rgb();
Color::rgba(r, g, bl, f(alpha))
}
}
}

View File

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

View File

@ -90,8 +90,11 @@ pub struct ImportResult {
pub document: Document,
/// Human-readable notes about anything approximated or dropped.
pub warnings: Vec<String>,
/// 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<ImportResult, IoError> {
// A declared native extension must reach its loader so malformed bundles retain format errors.
if has_extension(name, photocraft_format::EXTENSION) || photocraft_format::is_pcraft(bytes) {
return Ok(ImportResult { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new(), 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);

View File

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

View File

@ -1,4 +1,5 @@
//! Preview-only imports keep pixels and alpha, warn about native content, and never write .af.
//! Affinity documents: native import (layers, artboards, shapes, paints, text, masks) and the
//! embedded preview only as a warned fallback that keeps its pixels and alpha; never written back.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType};
@ -46,16 +47,16 @@ fn preview_is_named_and_warns_at_its_actual_size() {
assert_eq!((r.document.size.width, r.document.size.height), (2, 1));
assert_eq!(r.document.layers.len(), 1);
assert_eq!(r.document.layers[0].name, "Affinity preview");
assert!(r.source_read_only);
assert!(r.source_read_only && r.preview_only);
assert!(r.warnings[0].contains("2×1"));
assert!(r.warnings[0].contains("Native layers"));
assert!(r.warnings[0].contains("native Affinity document could not be read") && r.warnings[0].contains("no layers"), "{}", r.warnings[0]);
let out = export(&r.document, "copy.png", &ExportOptions::default()).unwrap();
let img = photocraft_codecs::decode(&out.bytes).unwrap();
assert_eq!(img.data(), &[220, 40, 60, 255, 0, 0, 0, 0]);
let native = export(&r.document, "copy.pcraft", &ExportOptions::default()).unwrap();
let back = import("copy.pcraft", &native.bytes).unwrap();
assert_eq!(back.document.size, r.document.size);
assert!(!back.source_read_only);
assert!(!back.source_read_only && !back.preview_only);
for ext in photocraft_io::affinity::EXTENSIONS {
assert!(export(&r.document, ext, &ExportOptions::default()).unwrap_err().to_string().contains("Affinity export"));
}
@ -66,10 +67,12 @@ fn magic_wins_over_the_name_and_renamed_legacy_files_are_rejected() {
assert!(import("renamed.png", &file(&png())).unwrap().source_read_only);
assert!(import("unknown", &file(&png())).unwrap().source_read_only);
assert!(import("fake.af", &png()).is_err());
// Affinity 1 and 2 (container versions 8 to 11) store the same preview record.
let mut legacy = file(&png());
legacy[4..6].copy_from_slice(&10u16.to_le_bytes());
assert!(import("legacy.afphoto", &legacy).unwrap_err().to_string().contains("legacy preview"));
assert!(import("legacy.af", &legacy).is_err());
assert!(import("legacy.afphoto", &legacy).unwrap().preview_only);
legacy[4..6].copy_from_slice(&13u16.to_le_bytes());
assert!(import("future.af", &legacy).unwrap_err().to_string().contains("unknown container version"));
}
#[test]
@ -91,7 +94,6 @@ fn previewless_current_files_return_an_export_fallback() {
bytes[24..32].fill(0);
let error = import("no-preview.af", &bytes).unwrap_err().to_string();
assert!(error.contains("no embedded preview"), "{error}");
assert!(error.contains("export PNG, PSD or SVG"), "{error}");
}
#[test]
@ -110,7 +112,7 @@ fn corruption_and_every_truncation_fail_without_a_document() {
fn crc_failures_after_idat_cannot_be_tolerated_as_partial_pngs() {
let mut bad_iend = file(&png());
*bad_iend.last_mut().unwrap() ^= 1;
assert!(import("bad-iend.af", &bad_iend).unwrap_err().to_string().contains("PNG chunk CRC"));
assert!(import("bad-iend.af", &bad_iend).unwrap_err().to_string().contains("PNG chunk checksum"));
let mut encoded = png();
let tagged = b"tEXtnote\0preview";
@ -121,7 +123,7 @@ fn crc_failures_after_idat_cannot_be_tolerated_as_partial_pngs() {
encoded.splice(end..end, chunk.clone());
assert!(import("valid-ancillary.af", &file(&encoded)).is_ok());
encoded[end + chunk.len() - 1] ^= 1;
assert!(import("bad-ancillary.af", &file(&encoded)).unwrap_err().to_string().contains("PNG chunk CRC"));
assert!(import("bad-ancillary.af", &file(&encoded)).unwrap_err().to_string().contains("PNG chunk checksum"));
}
#[test]
@ -179,7 +181,7 @@ fn invalid_critical_chunks_after_idat_fail_before_png_finish() {
let mut interrupted = chunk(b"tEXt", b"note\0preview");
interrupted.extend(chunk(b"IDAT", &[]));
encoded.splice(end..end, interrupted);
assert!(import("split-idat.af", &file(&encoded)).unwrap_err().to_string().contains("IDAT chunks must be contiguous"));
assert!(import("split-idat.af", &file(&encoded)).unwrap_err().to_string().contains("non-contiguous PNG image data"));
}
proptest! {
@ -192,21 +194,139 @@ proptest! {
}
}
/// Optional local oracle; authored by Affinity 3.3, never committed as a binary fixture.
#[test]
#[ignore = "set AFFINITY_ORACLE_DIR to synthetic Affinity .af and exported PNG files"]
fn native_affinity_oracle() {
let dir = std::env::var("AFFINITY_ORACLE_DIR").unwrap();
for (name, size) in [("synthetic-rectangle", (64, 48)), ("synthetic-large", (1024, 768))] {
let bytes = std::fs::read(format!("{dir}/{name}.af")).unwrap();
let r = import(&format!("{name}.af"), &bytes).unwrap();
assert_eq!((r.document.size.width, r.document.size.height), size);
let preview = photocraft_affinity::preview(&bytes).unwrap();
let source = photocraft_codecs::decode(&std::fs::read(format!("{dir}/{name}.png")).unwrap()).unwrap();
let decoded = photocraft_codecs::decode(preview.png).unwrap();
assert_eq!(decoded.data().len(), size.0 as usize * size.1 as usize * 4);
if name == "synthetic-rectangle" {
assert_eq!(decoded.data(), source.data());
mod native {
use super::*;
use photocraft_affinity::synth::{self, F, Method, tag};
use photocraft_doc::LayerContent;
fn rgba(r: f32, g: f32, b: f32, a: f32) -> F {
F::Struct([r, g, b, a].iter().flat_map(|v| v.to_le_bytes()).collect())
}
fn solid(id: u32, c: F) -> F {
F::Def(
id,
vec![tag(b"FDsc")],
vec![(tag(b"FDeF"), F::Def(id + 1, vec![tag(b"FilS")], vec![(tag(b"Colr"), F::Def(id + 2, vec![tag(b"RGBA")], vec![(tag(b"_col"), c)]))]))],
)
}
fn node(x: f64, y: f64) -> Vec<u8> {
let mut r = x.to_le_bytes().to_vec();
r.extend(y.to_le_bytes());
r.extend([1, 0]);
r
}
/// A version-12 document at 144 ppi, 200×100: an artboard "Board" moved by (20, 10) holding
/// a red ring (two subpaths, filled even-odd) with a 4 px blue stroke.
pub(super) fn document(method: Method) -> Vec<u8> {
let square = |s: f64, o: f64| vec![node(o, o), node(o + s, o), node(o + s, o + s), node(o, o + s), node(o, o)];
let ring = F::Def(
20,
vec![tag(b"PCrv")],
vec![
(tag(b"Desc"), F::Str("Ring".into())),
(
tag(b"Crvs"),
F::Obj(
tag(b"PCvD"),
vec![(
tag(b"Data"),
F::Pos(vec![
F::U8(0),
F::U32(2),
F::Bool(true),
F::Records(18, square(60.0, 0.0)),
F::Bool(true),
F::Records(18, square(20.0, 20.0)),
]),
)],
),
),
(tag(b"BFFl"), F::Shared(vec![solid(21, rgba(1.0, 0.0, 0.0, 1.0))])),
(tag(b"LIFl"), F::Shared(vec![solid(24, rgba(0.0, 0.0, 1.0, 1.0))])),
(
tag(b"LILn"),
F::Shared(vec![F::Def(27, vec![tag(b"LDsc")], vec![(tag(b"LDeL"), F::Def(28, vec![tag(b"LSty")], vec![(tag(b"Wght"), F::F64(4.0))]))])]),
),
],
);
let board = F::Def(
10,
vec![tag(b"ShpN")],
vec![
(tag(b"Desc"), F::Str("Board".into())),
(tag(b"ABEn"), F::Bool(true)),
(tag(b"Shpe"), F::Def(11, vec![tag(b"ShNR")], vec![])),
(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 120.0, 80.0])),
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 20.0, 0.0, 1.0, 10.0])),
(tag(b"BFFl"), F::Shared(vec![solid(12, rgba(1.0, 1.0, 1.0, 1.0))])),
(tag(b"Chld"), F::Shared(vec![ring])),
],
);
let spread = F::Def(
2,
vec![tag(b"Sprd")],
vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 200.0, 100.0])), (tag(b"SprT"), F::Bool(true)), (tag(b"Chld"), F::Shared(vec![board]))],
);
let doc = synth::stream(&[
(tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(144.0))])),
(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))])),
]);
synth::container(&[("doc.dat", &doc, method)], Some(&png()))
}
#[test]
fn artboards_shapes_and_paints_import_as_editable_layers() {
for method in [Method::Stored, Method::Zlib, Method::Zstd] {
let r = import("native.af", &document(method)).unwrap();
assert!(r.source_read_only && !r.preview_only, "{:?}", r.warnings);
let d = &r.document;
assert_eq!((d.size.width, d.size.height, d.resolution_dpi), (200, 100, 144.0));
let board = &d.layers[0];
assert_eq!(board.name, "Board");
let LayerContent::Group(g) = &board.content else { panic!("{:?}", board.content) };
let ab = g.artboard.as_ref().unwrap();
assert_eq!((ab.rect.x0, ab.rect.y0, ab.rect.x1, ab.rect.y1), (20, 10, 140, 90));
let ring = &g.children[0];
assert_eq!(ring.name, "Ring");
let LayerContent::Shape(sh) = &ring.content else { panic!() };
assert_eq!(sh.path.subpaths.len(), 2);
assert_eq!(sh.path.subpaths[0].knots.len(), 4, "the closing duplicate knot is merged");
assert_eq!(sh.path.subpaths[0].knots[0].anchor, photocraft_geom::Point::new(20.0, 10.0));
assert_eq!(sh.stroke.as_ref().unwrap().width, 4.0);
// The cache shows the ring: red on it, empty in its hole.
let cache = sh.cache.as_ref().unwrap();
let px = |x: i32, y: i32| cache.to_interleaved(photocraft_geom::Rect::new(x, y, x + 1, y + 1));
assert_eq!(px(30, 20), [255, 0, 0, 255]);
assert_eq!(px(50, 40)[3], 0, "even-odd hole");
// Saved as a native PhotoCraft document and opened again.
let saved = export(d, "copy.pcraft", &ExportOptions::default()).unwrap();
let back = import("copy.pcraft", &saved.bytes).unwrap();
assert_eq!(back.document.layers.len(), d.layers.len());
assert!(export(d, "copy.psd", &ExportOptions::default()).is_ok());
assert!(export(d, "copy.af", &ExportOptions::default()).is_err());
}
}
#[test]
fn a_damaged_native_document_falls_back_to_its_preview_with_the_reason() {
let mut bytes = document(Method::Zlib);
bytes[80] ^= 0xFF;
let r = import("broken.af", &bytes).unwrap();
assert!(r.preview_only);
assert!(r.warnings[0].contains("could not be read (damaged Affinity file"), "{}", r.warnings[0]);
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn mutated_native_documents_never_panic(edits in prop::collection::vec((0usize..2048, any::<u8>()), 1..16), stored in any::<bool>()) {
let mut b = document(if stored { Method::Stored } else { Method::Zstd });
let n = b.len();
for (i, v) in edits { b[i % n] = v; }
let _ = import("mutated.af", &b);
}
}
}

View File

@ -0,0 +1,107 @@
//! Public Affinity documents (`cargo xtask corpus --affinity`, pinned and sha256-verified) open
//! natively and look like Affinity's own picture of them: every document embeds a thumbnail
//! Affinity rendered when saving it. Each file's mean difference from that thumbnail (0–255 per
//! channel, both flattened on white, at the smaller of the two sizes) must stay under its ceiling,
//! set just above the difference measured when the importer landed.
#![cfg(feature = "corpus")]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::PathBuf;
use photocraft_io::{ExportOptions, export, import};
/// (file, ceiling). Measured differences when the importer landed are in the comments.
const FILES: &[(&str, f64)] = &[
("vector-art/mexican-guy.af", 3e+00), // 1.92: brush strokes as plain strokes
("vector-art/mexican-man.af", 4e+00), // 2.74
("vector-art/mexican-woman.af", 4e+00), // 2.24
("vector-art/playing-cards.af", 4e+00), // 3.04: layer effects
("vector-art/beer.afdesign", 4e+00), // 2.91
("vector-art/cactus.afdesign", 2e+00), // 0.57
("vector-art/car.afdesign", 2e+00), // 0.39
("vector-art/flowers.afdesign", 3e+00), // 2.09
("vector-art/lion-track.afdesign", 6e+00), // 4.41
("afdesignload/color.afdesign", 1e+00), // 0.00
("afdesignload/layer_mode.afdesign", 1e+00), // 0.00
("afdesignload/layer_test.afdesign", 1e+00), // 0.00
("afdesignload/margins.afdesign", 1e+00), // 0.00
("afdesignload/raster_test.afdesign", 2e+00), // 0.82: pixel layer
("afdesignload/revision_test.afdesign", 1e+00), // 0.01
("afdesignload/shape_test.afdesign", 6e+00), // 4.60: cloud and heart shapes as ellipses
("afdesignload/slice_test.afdesign", 1e+00), // 0.02
("afdesignload/test_path.afdesign", 2e+00), // 1.37
("jac21/SimpleLogo.afdesign", 2e+00), // 0.78
("jac21/SimpleLogoBanner.afdesign", 4e+00), // 2.72: placed images in a Display P3 document
("asset-store-template/AssetStore.aftemplate", 1e+00), // 0.00: artboards
];
fn corpus(rel: &str) -> Vec<u8> {
let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/affinity").join(rel);
std::fs::read(&p).unwrap_or_else(|e| panic!("{}: {e}: run `cargo xtask corpus --affinity`", p.display()))
}
/// Straight RGBA8 pixels flattened on white.
fn on_white(px: &[u8]) -> Vec<f64> {
px.chunks_exact(4)
.flat_map(|p| {
let a = f64::from(p[3]) / 255.0;
[0, 1, 2].map(|i| f64::from(p[i]) * a + 255.0 * (1.0 - a))
})
.collect()
}
/// Resample `src` (`sw`×`sh`, 3 channels) to `dw`×`dh`: each target pixel averages the source
/// pixels it covers (at least one, so small documents scale up too).
fn shrink(src: &[f64], sw: usize, sh: usize, dw: usize, dh: usize) -> Vec<f64> {
let span = |d: usize, s: usize, n: usize| {
let a = (d * s / n).min(s - 1);
(a, ((d + 1) * s).div_ceil(n).clamp(a + 1, s))
};
let mut out = Vec::with_capacity(dw * dh * 3);
for ty in 0..dh {
let (y0, y1) = span(ty, sh, dh);
for tx in 0..dw {
let (x0, x1) = span(tx, sw, dw);
let mut sum = [0.0f64; 3];
for y in y0..y1 {
for x in x0..x1 {
for (c, s) in sum.iter_mut().enumerate() {
*s += src[(y * sw + x) * 3 + c];
}
}
}
let n = ((y1 - y0) * (x1 - x0)) as f64;
out.extend(sum.map(|s| s / n));
}
}
out
}
#[test]
fn public_affinity_documents_look_like_their_affinity_thumbnails() {
let mut failures = Vec::new();
for (name, ceiling) in FILES {
let bytes = corpus(name);
let r = import(name, &bytes).unwrap();
assert!(!r.preview_only, "{name} opened as its preview: {:?}", r.warnings);
let thumb = photocraft_codecs::decode(photocraft_affinity::preview(&bytes).unwrap().png).unwrap();
let (tw, th) = thumb.dimensions();
let png = export(&r.document, "x.png", &ExportOptions::default()).unwrap().bytes;
let flat = photocraft_codecs::decode(&png).unwrap();
let (fw, fh) = flat.dimensions();
// Several pages sit side by side; the thumbnail shows the first.
let fw_first = ((f64::from(fh) * f64::from(tw) / f64::from(th)).round() as u32).min(fw);
let rgba = flat.to_rgba8();
let cropped: Vec<u8> = rgba.chunks_exact(fw as usize * 4).flat_map(|row| row[..fw_first as usize * 4].to_vec()).collect();
// Compare at the smaller of the two sizes, so neither picture is enlarged.
let (cw, ch) = if fh < th { (fw_first, fh) } else { (tw, th) };
let got = shrink(&on_white(&cropped), fw_first as usize, fh as usize, cw as usize, ch as usize);
let want = shrink(&on_white(&thumb.to_rgba8()), tw as usize, th as usize, cw as usize, ch as usize);
let diff = got.iter().zip(&want).map(|(a, b)| (a - b).abs()).sum::<f64>() / want.len() as f64;
eprintln!("{name}: {diff:.2} (ceiling {ceiling})");
if diff > *ceiling {
failures.push(format!("{name}: {diff:.2} > {ceiling}"));
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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