mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
- Overlays bake child-first: a group's overlay now lies over its children's (they used to be applied after it and win). Each one pays out of the import's overlay work budget. - Master page instances inside embedded documents are left out with a warning: their indices count the embedded document's masters, which aren't kept, and would show the outer document's. - A text outline's gradient stops and dashes are charged to the text attribute budget, like the fill's. Addresses CodeRabbit's second review on #3548. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
179 lines
15 KiB
Markdown
179 lines
15 KiB
Markdown
# vectorcraft-affinity
|
||
|
||
An independent, bounded Rust reader for native Affinity documents: `.af` from Affinity 3 and
|
||
`.afdesign`, `.afphoto` and `.afpub` from Affinity 1 and 2 (container versions 8 to 12). It reads
|
||
the archive, the tagged object stream in `doc.dat` and the part of the document model listed below,
|
||
and hands the engine a document in pixels with every transform composed. It does not write
|
||
Affinity files.
|
||
|
||
## What is imported
|
||
|
||
| Affinity | VectorCraft |
|
||
|---|---|
|
||
| Pages, Publisher spreads (one or two pages) | artboards, spreads side by side |
|
||
| Master pages (`MpCh`) shown on pages through master page instances (`MPIN`: master, page offset, master page, count) | a locked "Master A, B…" layer at the bottom holding each master page's objects on the pages that show it |
|
||
| Artboards (legacy `ABEn` or Affinity 3 `phrp`/`aprp`), with name, paint and any transform | artboards plus a clip group with the actual vector outline, background and stroke |
|
||
| Layers (`Scop`), groups (`Grup`), pass-through or isolated | layers and sublayers, groups |
|
||
| Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | paths (multi-subpath curves fill even-odd) |
|
||
| Rectangles with corner radii (relative or absolute, locked or per corner) and round or straight corners, ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids | paths |
|
||
| Compound shapes (`Comp`, add and subtract) | one even-odd path |
|
||
| A shape or curve with children clips them | clip group whose clipping path keeps its fill (behind) and stroke (over) |
|
||
| Vector masks (`AdCh` curves and shapes) | clip group |
|
||
| Pixel masks (`MRst`, attached or as a layer in a group) | opacity mask |
|
||
| Solid fills: RGBA, HSLA, CMYK, grey, Lab (D50) | fill appearance (alpha as fill opacity) |
|
||
| Linear, elliptical and radial gradients with Affinity's midpoint bias | gradients (the bias as the midpoint where the blend is half way) |
|
||
| Strokes: weight, scale with object, dash pattern and phase, alignment, miter limit, behind the fill; a stroke whose style is None (every shape stores one, black and 1 px) is left out | stroke appearance |
|
||
| Fill layers (`FRst`) | filled rectangles |
|
||
| Artistic and frame text: Unicode scalar-indexed runs, stored fallback font, family/weight/style, boolean OpenType features, all caps, underline, strikethrough, size, tracking, horizontal scale, fixed leading, colour, outlined characters, first baseline; each paragraph's alignment, indents, space before and after, fixed and automatic leading; frame text sized by its text scale (`FTxS`), not its frame's transform | point type and area type, one paragraph style per paragraph; artistic text whose paragraphs align differently keeps them aligned within its longest line (by indents), as Affinity draws it; a frame's first line hangs from its capitals' tops, and a frame whose last lines' descenders hang out of it is made just tall enough to show them; line breaks inside a paragraph become paragraphs without spacing between them; table cells place their text as Affinity does (centred from the capitals' tops to the last baseline, reaching out of a cell too short for it) |
|
||
| Tables (`TxtT`): grid, cell text, merged cells, cell insets and vertical alignment, the line on every cell edge | a group of area type per cell and a stroked line per edge (VectorCraft has no tables) |
|
||
| Placed images (`ImgN`): the original JPEG/PNG embedded in the file | embedded images |
|
||
| Pixel layers (`Rstr`): RGBA 8/16-bit and CMYK 8-bit tiles, cropped to their content; source-backed RGBA8 JPEG/PNG tiles with matching dimensions and zero origin; the document's CMYK colour profile (`CMYP`) | embedded PNG images; CMYK layers through the document's profile (relative colorimetric, black-point compensation, as Affinity converts them), or, without one, CMYK images that keep their inks |
|
||
| Embedded Affinity documents (`EmbN` with an `EmDc` entry), four deep at most | their first page's content, editable (their artboards only clip, they don't become artboards; their master pages are left out); other embedded documents and symbols as the picture Affinity cached of them |
|
||
| Layer effects: drop shadows, outer glows, inner shadows; outlines; colour and gradient overlays in any blend mode | Stylize › Drop Shadow and Outer Glow, Inner Glow at the edge (approximated); one more stroke on shapes (around their own stroke), the characters' stroke on text; overlays baked into fill, stroke, text colours and pixels (a group's over its children's) |
|
||
| Opacity, visibility, lock, names, the blend modes VectorCraft has | the same |
|
||
|
||
Everything else is reported in the import warnings, one line per kind with a count, never
|
||
dropped silently: bevel, blur and other layer effects, adjustment layers and live filters, brush and pressure strokes,
|
||
transparency gradients, fill opacity, bitmap fills, master pages based on other master pages, conical gradients (as radial),
|
||
special shapes (cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner
|
||
types other than round or straight, stars with rounded points, several fills or strokes on one object (the
|
||
active one is used), skewed or flipped text, characters of different transparency in one text, text fields such as page numbers, text threading, frames with
|
||
columns or a curved outline, table cell fills, unsupported font selectors, grey/Lab/32-bit pixels, CMYK pixels with transparency in a document
|
||
without a CMYK profile (converted through VectorCraft's working CMYK profile) and Affinity-only blend modes (Add, Linear Light… as Normal).
|
||
|
||
When the native document can't be read at all, the engine opens the embedded PNG preview instead,
|
||
with a warning that names the reason; that preview can't be placed, used as a template or mined
|
||
for swatches and styles. Save never writes back over the Affinity file.
|
||
|
||
## Provenance and clean-room
|
||
|
||
Affinity has no published file-format specification, so this reader is built only from a public
|
||
description of the format and from public files, never from Affinity itself. This is how the sibling app PhotoCraft already treats formats that
|
||
are only partly documented:
|
||
its PSD reader fills the gaps in Adobe's specification from the MIT-licensed psd-tools and
|
||
ag-psd, and its camera raw decoder recovered Nikon's compression tables by black-box analysis
|
||
of CC0 sample files.
|
||
|
||
* **No Affinity software.** Affinity was never downloaded, installed, run, scripted, screenshotted or
|
||
disassembled for this work, and nothing from an Affinity installation (program code, resources,
|
||
presets, fonts, colour profiles) was read. No file was made with Affinity for it: every sample
|
||
is a document its author published.
|
||
* **A public, permissively licensed description.** The container and object-stream layout was
|
||
learned from [VMDevCpp/afread](https://github.com/VMDevCpp/afread) (MIT, at
|
||
`04b672334a43e3e37ded6b5ffc57af231d589774`, written for container versions 7–11) and re-described
|
||
in our own words before this Rust code was written; no code was translated. No GPL/AGPL code (such
|
||
as Inkscape's Affinity extension) was read.
|
||
* **Every structure checked against public files.** afread doesn't say how its author learned the
|
||
format, so nothing here rests on it alone: each structure the reader relies on is confirmed by
|
||
the public documents. Every archive entry carries a CRC-32 that must match, and every field in
|
||
the object stream names its own type, so a misread layout fails loudly instead of producing
|
||
plausible wrong data.
|
||
* **Meaning fitted to Affinity's own pictures.** What shapes, paints, text and pixel data mean was
|
||
worked out by comparing our render with the thumbnail every Affinity document embeds (Affinity's
|
||
render of itself), on 176 public documents saved by Affinity 1.x, 2.x and 3.0/3.1 on Windows,
|
||
macOS and iPad. The collection has since grown to 189 distinct documents, and the reader opens
|
||
all of them. Most are published under CC0, MIT, BSD, Apache-2.0 or CC BY (-SA); the others
|
||
(no licence stated, or GPL or non-commercial terms) were only opened locally to compare
|
||
pictures, never committed or redistributed. The corpus test keeps the comparison for the pinned
|
||
files, which are CC0, MIT or Apache-2.0 only.
|
||
* **Nothing of Affinity's is in this repository**: no program code, assets or documents. The test
|
||
files are fetched at pinned commits and sha256-verified, never committed.
|
||
* **Read-only.** Nothing is written in Affinity's format; export waits until someone can check
|
||
written files in Affinity itself.
|
||
|
||
The reader also has independent copies in PhotoCraft's `photocraft-affinity` and DesignCraft's
|
||
`designcraft-affinity`. Tables, paragraph formats, text scale, master pages, layer effects,
|
||
overlays, embedded documents, large pixel layers and HEIC photos were added in DesignCraft
|
||
([storytold/designcraft#291](https://github.com/storytold/designcraft/pull/291)) and brought back
|
||
here, from the same public fixtures and the thumbnails of documents their owners made, used only
|
||
locally. Automatic leading (the font size plus the font's line gap, for artistic and frame text)
|
||
was fitted on the thumbnails of one such local document with 56 artboards and on the public Patchy
|
||
text-frame fixture.
|
||
|
||
## Validation
|
||
|
||
* `engine/tests/affinity_features.rs` covers pages, several artboards per canvas and per spread,
|
||
master pages, tables, paragraph formats, text scale, layer effects, overlays and embedded
|
||
documents (whose artboards don't become artboards) on synthetic documents, and mutates them.
|
||
`engine/tests/affinity_local.rs` compares every artboard of local documents with their thumbnail
|
||
when `AFFINITY_SAMPLES` points at a folder (never in CI; nothing is committed).
|
||
* 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.
|
||
* `cargo xtask corpus --affinity` fetches 51 public CC0/MIT/Apache-2.0 documents at pinned
|
||
commits, SHA-256 verified against `xtask/affinity-corpus.sha256`, with upstream license notices.
|
||
Of these, 33 are current `.af`: four CC0 vector illustrations and 28 original Affinity 3.2.3
|
||
feature probes plus one separately identified derived regression input from
|
||
[SethRobinson/Patchy](https://github.com/SethRobinson/Patchy). Only document data and provenance
|
||
notices were inspected, never that project's importer or authoring scripts.
|
||
* `tests/real_files.rs` parses every file and freezes the modeled features of all 33 current
|
||
`.af` files. Native raster tests compare decoded pixels with independent embedded images;
|
||
text tests assert Unicode run boundaries, fallback fonts and feature retention.
|
||
* `engine/tests/affinity_corpus.rs` requires editable native import, exact `.vectorcraft`
|
||
save/reload, and SVG/PDF/PSD export of every board in all 33 current files. It checks SVG/PDF
|
||
dimensions and reimport, and independently decodes PSD merged pixels using the published
|
||
specification. These are VectorCraft consistency checks, not independent Affinity PSD oracles.
|
||
Saved-thumbnail comparisons cover the existing 22 documents plus nine new representative
|
||
fixtures, checking every board; unsupported effects and text layout are not claimed visually
|
||
faithful. The Affinity corpus workflow requires the files, so missing fixtures cannot silently
|
||
pass CI. Run locally with `AFFINITY_CORPUS_REQUIRED=1` for the same strict gate.
|
||
* Synthetic engine regressions cover nested, rotated and curved artboards, negative origins,
|
||
multiple spreads and resolution conversion, alongside text recovery and native save/export.
|
||
* `engine/tests/import_fuzz.rs` mutates a synthetic native document's stream and archive, and
|
||
documents with layer effects, colour overlays (also over tables), line styles, paragraph and
|
||
character formats, tables, master pages, CMYK pixel layers and embedded documents (the embedding
|
||
and the embedded stream), and checks that whatever opens also renders and exports.
|
||
`tests/import_limits.rs` builds hostile documents against the limits below: grids of 16 million
|
||
cells, grids and stories shared by many layers, 200 000 paragraphs, a document embedded hundreds
|
||
of times. `fuzz/` has `cargo-fuzz` targets for the whole reader (`container`) and the object
|
||
stream (`stream`):
|
||
|
||
```sh
|
||
cd crates/affinity
|
||
cargo +nightly fuzz run stream -- -max_total_time=600
|
||
```
|
||
|
||
The source audit, licensing decisions and remaining complex-document/font cases are recorded in
|
||
[affinity-validation.md](../../docs/affinity-validation.md). Reopening VectorCraft's exports in
|
||
Affinity remains an independent validation step that hasn't been done.
|
||
|
||
What has not been verified: files from Affinity builds or platforms outside the public set, files
|
||
written by other applications, rotated or skewed images against Affinity's render, mask polarity
|
||
against a render with and without the mask, and anything listed as a warning above. The `synth`
|
||
builders write only what this reader accepts; Affinity has never opened their output, so they are
|
||
test fixtures, not an exporter.
|
||
|
||
## Safety limits
|
||
|
||
Every size is checked before it is allocated: 256 MiB per archive entry and 1 GiB per import by
|
||
default (`Limits`), a 64 MiB zstd window, 4096 saved revisions, array lengths no longer than the
|
||
bytes left, 16 777 216 decoded values per document stream (fields and array elements together, counted before
|
||
anything is allocated: a value takes about 40 bytes in memory however few it took in the file; the largest of
|
||
those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve
|
||
nodes per curve; compound outlines also cap recursion at 128 levels, operand visits at 100 000
|
||
and cumulative geometry work at four million, rejecting cycles and partial results on limits.
|
||
Pie sweeps normalize finite angles in constant time. Pixel layers are decoded at no more than 64
|
||
megapixels each (cropped to their content first; larger ones are read at a half, a quarter… of
|
||
their resolution) and 512 megapixels for the whole document; pixel masks are read only over what
|
||
they mask (all of it under artistic text, whose glyphs only the layout places). One budget covers
|
||
the whole read: layers, pixels, 16 million characters of stories and 262 144 table cells, so
|
||
content that many layers link, or a document embedded many times, can't multiply them. Embedded
|
||
documents nest four deep at most; both of their readings are charged to that budget and to the
|
||
importing document's archive limits, also when they fail part of the way. Tables have at most
|
||
4096 rows and columns and 65 536 cells, and 64 layer effects per node are read. Gradients are
|
||
limited to 1024 stops. Text collection is bounded to a million characters per node before
|
||
allocation, with a cumulative 16 MiB budget for owned font names (256 bytes at most each),
|
||
features and gradient stops; paragraph formats are matched to runs by binary search. Exhaustion
|
||
warns and uses default attributes for remaining text, preserving its characters. Colour and
|
||
gradient overlays share one work budget per import (`overlay::MAX_WORK`: the nodes and pixels
|
||
each is baked into); past it they are left out, and said. In VectorCraft, master pages are
|
||
copied onto at most as many objects, characters and pixels as a document may hold, a few times
|
||
over, and objects beyond `MAX_COORD` of the origin are left out.
|
||
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.
|