AF.1: import bounded Affinity previews with explicit fidelity warnings

Read only the indexed PNG thumbnail observed in version-12 Affinity containers. Bound and validate the PNG before decoding, warn about omitted native content, clear the source save target, and reject auxiliary imports that cannot display that warning. Add malformed-input, lifecycle, property and optional native-oracle tests plus a fuzz target. Editable native layers and Affinity export remain unsupported.
This commit is contained in:
callmeBalloch 2026-10-08 17:19:18 +02:00
parent c13e4133e0
commit 3c7bdfe6a3
22 changed files with 868 additions and 17 deletions

10
Cargo.lock generated
View File

@ -4319,6 +4319,14 @@ dependencies = [
"winresource",
]
[[package]]
name = "vectorcraft-affinity"
version = "0.5.0"
dependencies = [
"crc32fast",
"proptest",
]
[[package]]
name = "vectorcraft-brush"
version = "0.5.0"
@ -4410,6 +4418,7 @@ dependencies = [
name = "vectorcraft-engine"
version = "0.5.0"
dependencies = [
"crc32fast",
"flate2",
"image",
"kurbo 0.13.1",
@ -4418,6 +4427,7 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
"vectorcraft-affinity",
"vectorcraft-brush",
"vectorcraft-cad",
"vectorcraft-color",

View File

@ -33,6 +33,7 @@ vectorcraft-cad = { path = "crates/cad" }
vectorcraft-eps = { path = "crates/eps" }
vectorcraft-metafile = { path = "crates/metafile" }
vectorcraft-plugins = { path = "crates/plugins" }
vectorcraft-affinity = { path = "crates/affinity" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
@ -42,6 +43,7 @@ serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
proptest = "1"
crc32fast = "1.5"
# tests: a TCP port bound without listening (connections to it are refused)
socket2 = "0.6"
# Sandboxed WebAssembly plug-ins: a pure-Rust interpreter (MIT OR Apache-2.0) that builds for wasm32

View File

@ -190,7 +190,11 @@ milestones, and honest time-to-parity estimates.
**Where we are (2026-10-07):** roughly 69–75% of Illustrator's features exist and work, and about 40–55% of
"a power user can't tell the difference". Everyday vector illustration is close to usable: drawing and path tools,
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package). The interface
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package).
Affinity `.af` files with a verified version-12 embedded PNG preview open as that preview,
with a warning; native Affinity vectors, layers and full document resolution are not imported.
Save makes a new VectorCraft copy. Place, templates and style/swatch library import are refused; export SVG, PDF or full-resolution PNG from Affinity first. [Preview scope and limits](crates/affinity/README.md).
The interface
speaks English, Japanese, Traditional and Simplified Chinese and Spanish (and Czech and Brazilian Portuguese in the menus).
The scores are
self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them.

View File

@ -38,7 +38,7 @@ since 2026-10-01.
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), Variables (data merge), scripting |
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Native `.ai` private data is out of scope by design; DWG has no open spec |
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Affinity `.af` has a bounded version-12 preview reader only; native editable import and export are missing, so this does not increase the parity score. Native `.ai` private data is out of scope by design; DWG has no open spec |
| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing |
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
@ -69,7 +69,8 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
6. **Automation:** Variables (data merge), a scripting surface over the command registry, batch. 8–12 h.
7. **Views and windows:** multiple windows and arrange, Consolidate All Windows, video
rulers. 15–25 h.
8. **Hardening at scale:** a corpus of real-world SVG/PDF/EPS files, idle-machine perf budgets, Windows, Linux and
8. **Hardening at scale:** a corpus of real-world SVG/PDF/EPS files and independently generated Affinity preview
oracles (the initial `.af` reader imports only a verified embedded preview; editable native import remains open), idle-machine perf budgets, Windows, Linux and
browser QA, pen tablets on Wayland and pen pressure beyond Windows (winit 0.31's tablet input, #491), accessibility, packaging. 50–80 h.
### Where we're going
@ -154,6 +155,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465).
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320).
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters, `cshow`, metadata skipped with `flushfile`), so Illustrator 30 EPS files open as vectors. It also runs what other generators write (Discord feedback, #466, #469): Illustrator 2026's procsets (`gcheck`, `rootfont`, `currentcolorrendering`: every Illustrator 2026 EPS fell back to its preview before), cairo/Inkscape (`status`/`flushfile`, images with a mask, `ImageType 3`, reusable streams), matplotlib's Type 3 fonts, Illustrator 8 EPS (stock art's usual format: executable strings such as its gradients' `ed`, `load` by integer keys, #474), Ghostscript's `//name` immediate names, `pdfmark`, `token`, `execform` and user paths. Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole).
- Affinity `.af` preview import: Open follows the container's indexed version-12 `Thmb` PNG record, verify dimensions and decoded pixels with limits, and report that native vectors, layers, text, effects, pages and colour settings were omitted. The preview can be downscaled; files without this verified record fail. Open drops the source save path, and Affinity export is unsupported. Place (including queued or renamed files) is refused so the preview cannot silently replace native art. This is an initial reader, not editable native support; [scope, provenance and remaining work](crates/affinity/README.md).
- Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so the desktop app asks for the power-saving GPU by default (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306). Preferences › Performance › Graphics Processor (`gpuPreference`) or `WGPU_POWER_PREF` picks the discrete one; Help › About and `ui.inspect` name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland.
@ -167,7 +169,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 |
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed; Shaper, the remaining Pen modifier nuances) | 15–20 |
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info; AF.1 adds bounded Affinity version-12 preview import only (no editable native content). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish. Editable Affinity import/export is separate unestimated work | 3–6 (existing scope) |
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel, Hebrew/Arabic bidirectional text and Paragraph Direction done; tab leaders, spell check, vertical type pending | 45–60 |
@ -212,7 +214,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard presets/rearrange polish (Document Setup, New Document and the rest of the Layers panel are done) | 4–7 |
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
| Guides, grids, smart guides, snapping, rulers | 3 | 82% | global/video rulers, smart-guide preference depth, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451) | 3–5 |
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, EPS checked against more real files (Illustrator 2026 and Inkscape files open as vectors; CorelDRAW, Affinity, LibreOffice, matplotlib and Ghostscript output checked with synthetic programs only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 |
| File formats | 6 | 85% | Affinity native vectors/layers and export (version-12 preview import only; native work unestimated), DWG (no open spec: DXF instead), PSD placement as layers, EPS checked against more real files (Illustrator 2026 and Inkscape files open as vectors; CorelDRAW, Affinity, LibreOffice, matplotlib and Ghostscript output checked with synthetic programs only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 (existing scope; Affinity native work unestimated) |
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |

View File

@ -0,0 +1,16 @@
[package]
name = "vectorcraft-affinity"
description = "Bounded Affinity container inspection and embedded PNG preview extraction"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]
crc32fast = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }

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

@ -0,0 +1,82 @@
# vectorcraft-affinity
An independent Rust parser for a narrow part of the Affinity container: the indexed PNG
preview observed in files saved by Affinity 3.3.0.4850 on macOS (container version 12).
It uses the MIT OR Apache-2.0 `crc32fast` crate already present in the workspace lockfile,
has no application-crate dependencies, and does not decompress or interpret the document graph.
This is **preview import**, not native editable Affinity support. The preview can be smaller
than the document: a synthetic 1600 × 1200 document saved a 1024 × 768 preview. Files without
the verified preview record fail with an actionable error. The parser makes no claim about
every `.af` file, Windows saves, older `.afdesign`/`.afphoto` files or native export.
## Provenance
The container header was researched against [VMDevCpp/afread](https://github.com/VMDevCpp/afread),
MIT licensed, at `04b672334a43e3e37ded6b5ffc57af231d589774`. That reader supports container
versions 7–11; it does not establish version-12 compatibility. This Rust implementation was
written independently, with the version-12 preview record observed through synthetic documents
created and saved by the public Affinity scripting SDK. No proprietary application source or
GPL/AGPL reader code was used.
## Accepted record
All container integers are little-endian. PNG integers are big-endian.
| File offset | Field checked |
|---|---|
| 0 | Magic `00 ff 4b 41` |
| 4 | `u16` version (inspection accepts 7–12; previews require 12) |
| 6 | Zero flags |
| 8 | Document class bytes `nsrP` (add-ons share the magic) |
| 12 | `#Inf` |
| 24 | `u64` thumbnail record offset |
| 64 | `Prot` for versions after 7 |
At the thumbnail offset, the accepted record is `ff ff ff ff Thmb`, followed by `u32` record
version 1, record length `PNG length + 13`, payload offset 29, zero, PNG length, encoding byte 1,
then exactly that many PNG bytes. Unknown versions, flags, classes and record layouts fail.
The parser follows only this offset; it never scans for PNG signatures in other resources or
saved revisions.
Encoded previews are capped at 16 MiB and dimensions at 4096 pixels per side. Every offset,
length and PNG chunk boundary is checked, every chunk CRC is validated (including IEND and
ancillary chunks after IDAT), and the PNG must end exactly at IEND. The consuming
engine additionally decodes the complete image pixels with a 128 MiB allocation
limit before embedding it. It warns about omitted native content and drops the source save path.
Compressed text/profile chunks (`zTXt`, `iTXt`, `iCCP`) and APNG chunks (`acTL`, `fcTL`, `fdAT`)
are rejected: the verified native previews have none, and pixel budgets alone do not bound
aggregate inflated metadata. IHDR is first and unique, IDAT is present and contiguous, PLTE is unique and precedes image data, unknown critical chunks fail, and IEND is last. Supporting additional layouts needs separate bounded decoding and oracles.
## Tests and next steps
The leaf tests use synthetic envelopes and exercise truncations, hostile fields and random
mutations. Engine tests exercise Open, placement rejection, warnings, raster validation and native save/reopen;
`engine/tests/import_fuzz.rs` exercises import followed by render/export.
A standalone `cargo-fuzz` target exercises both parser entry points:
```sh
cd crates/affinity
cargo +nightly fuzz run container -- -max_len=20971520 -max_total_time=60
```
Seed it with independently generated synthetic envelopes and metadata-reviewed native files;
keep local corpus/artifact files uncommitted. A short smoke campaign is not prolonged fuzzing.
Templates and document-derived style/swatch libraries reject Affinity instead of discarding the import warning. Placement is explicitly rejected by signature, even under another extension, in both direct and queued Place commands; their UI does not display successful import warnings. Export SVG/PDF or full-resolution PNG from Affinity before placing.
The ignored `native_affinity_oracle` engine test accepts an `AFFINITY_ORACLE_DIR` containing
`synthetic-rectangle.af`/`.png` and `synthetic-large.af`/`.png`, generated by Affinity. Native
oracles must be published through the shared corpus with a generator, verified metadata,
SHA-256 manifest and pinned commit before becoming a repeatable CI gate. No binary fixtures
belong in this repository.
Full editable import needs a separately bounded FAT/revision reader that resolves the current
`doc.dat` revision chain, zstd/deflate streams with their predictors and checksums, and a typed-tag
intermediate representation with object-reference, recursion and allocation bounds. Document
mapping then needs geometry, units, transforms, pages/artboards, groups, curves/shapes, fills,
gradients, strokes, clipping, embedded raster blobs, colour management, text/fonts with explicit
fallbacks, effects and unsupported-feature warnings. Unknown data needs an explicit preservation
or loss policy. Each supported version and feature needs independently generated native oracles.
Native export and round-trip fidelity are separate work.

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

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

View File

@ -0,0 +1,22 @@
[package]
name = "vectorcraft-affinity-fuzz"
version = "0.0.0"
edition = "2024"
publish = false
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
vectorcraft-affinity = { path = ".." }
[[bin]]
name = "container"
path = "fuzz_targets/container.rs"
test = false
doc = false
bench = false
[workspace]
members = ["."]

View File

@ -0,0 +1,8 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|bytes: &[u8]| {
let _ = vectorcraft_affinity::inspect(bytes);
let _ = vectorcraft_affinity::preview(bytes);
});

380
crates/affinity/src/lib.rs Normal file
View File

@ -0,0 +1,380 @@
//! Affinity container inspection, without interpreting the compressed document graph.
//!
//! The header layout was researched with MIT-licensed afread (see README). The version-12
//! thumbnail record was observed in synthetic documents saved by Affinity 3.3. Do not scan for
//! PNG signatures: an unrelated resource or an obsolete revision is not a document preview.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::fmt;
pub const MAGIC: &[u8; 4] = b"\x00\xffKA";
pub const MAX_PREVIEW_BYTES: usize = 16 << 20;
pub const MAX_PREVIEW_DIMENSION: u32 = 4096;
/// Container failure. The compressed graph is deliberately never decompressed here.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Error {
Malformed(&'static str),
Unsupported(&'static str),
Limit(&'static str),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let (kind, reason) = match self {
Self::Malformed(s) => ("damaged Affinity file", s),
Self::Unsupported(s) => ("unsupported Affinity file", s),
Self::Limit(s) => ("Affinity preview limit exceeded", s),
};
write!(f, "{kind}: {reason}; export PNG, PSD or SVG from Affinity for full-resolution artwork")
}
}
impl std::error::Error for Error {}
/// Header identity; offsets remain private so callers cannot bypass validation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Header {
pub version: u16,
thumbnail_offset: u64,
}
/// Borrowed embedded thumbnail, at its own dimensions, never the native document dimensions.
#[derive(Debug)]
pub struct Preview<'a> {
pub png: &'a [u8],
pub width: u32,
pub height: u32,
}
pub fn is_affinity(bytes: &[u8]) -> bool {
bytes.starts_with(MAGIC)
}
fn bytes_at(bytes: &[u8], offset: usize, length: usize) -> Result<&[u8], Error> {
let end = offset.checked_add(length).ok_or(Error::Malformed("offset overflow"))?;
bytes.get(offset..end).ok_or(Error::Malformed("truncated record"))
}
fn le16(bytes: &[u8], offset: usize) -> Result<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, 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 = 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 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 })
}
#[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]);
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!(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_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 _ = 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);
}
}
}

View File

@ -25,6 +25,7 @@ vectorcraft-cad = { workspace = true }
vectorcraft-eps = { workspace = true }
vectorcraft-metafile = { workspace = true }
vectorcraft-plugins = { workspace = true }
vectorcraft-affinity = { workspace = true }
image = { workspace = true }
flate2 = { workspace = true }
kurbo = { workspace = true }
@ -34,6 +35,7 @@ thiserror = { workspace = true }
log = { workspace = true }
[dev-dependencies]
crc32fast = { workspace = true }
vectorcraft-testkit = { workspace = true }
proptest = { workspace = true }
wat = { workspace = true }

View File

@ -0,0 +1,27 @@
//! Explicit preview-only Affinity import; native vectors and document layers are not decoded.
use std::io::Cursor;
use vectorcraft_doc::Document;
use super::load::{err, raster_doc};
use crate::Result;
pub(super) fn import(name: &str, bytes: &[u8]) -> Result<(Document, Vec<String>)> {
let p = vectorcraft_affinity::preview(bytes).map_err(err)?;
// `raster_doc` normally probes PNG dimensions and keeps its encoded bytes. Verify this
// untrusted preview's complete pixels first, so corrupt data cannot create a blank image.
let mut reader = image::ImageReader::with_format(Cursor::new(p.png), image::ImageFormat::Png);
let mut limits = image::Limits::default();
limits.max_image_width = Some(4096);
limits.max_image_height = Some(4096);
limits.max_alloc = Some(128 << 20);
reader.limits(limits);
let _pixels = reader.decode().map_err(err)?;
let d = raster_doc(name, p.png)?;
let warnings = vec![format!(
"Opened only Affinity's embedded {}×{} PNG preview, which may be smaller than the document. Native vectors, layers, 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 SVG or PDF from Affinity for editable vectors.",
p.width, p.height
)];
Ok((d, warnings))
}

View File

@ -48,6 +48,9 @@ pub fn file_name(name: &str) -> String {
/// extension of `name`.
pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> {
let by_name = format_for_name(name).filter(|f| f.read);
if vectorcraft_affinity::is_affinity(bytes) {
return format("affinity");
}
if vectorcraft_format::sniff(bytes) {
// A template keeps its meaning from the extension, like .ait.
return by_name.filter(|f| f.id == "template").or_else(|| format("vectorcraft"));
@ -174,6 +177,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
let (doc, warnings) = super::metafile::import(bytes)?;
(doc, warnings, false)
}
"affinity" => {
let (doc, warnings) = super::affinity::import(&title, bytes)?;
(doc, warnings, false)
}
_ if format.raster => (raster_doc(&title, bytes)?, vec![], false),
_ => return Err(err(format!("{} files can't be opened yet", format.label))),
};
@ -220,7 +227,13 @@ pub fn open_bytes_with(s: &mut Session, name: &str, bytes: &[u8], path: Option<S
/// File → New from Template: any readable file (at `path`, if it is one: its links are looked for
/// from there) as a new untitled document.
pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&str>) -> Result<Value> {
open_loaded(s, load(name, bytes)?, path.map(str::to_string), &LoadOptions::default(), true)
let loaded = load(name, bytes)?;
if loaded.format.id == "affinity" {
return Err(err(
"Affinity templates are not supported: use File › Open to inspect the embedded preview with its warning, or export SVG, PDF or full-resolution PNG from Affinity first",
));
}
open_loaded(s, loaded, path.map(str::to_string), &LoadOptions::default(), true)
}
/// Make a loaded file the new active document; `opts` are the options it was read with.
@ -312,7 +325,7 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
}
/// An image as a document of its pixel size (1 px = 1 pt), the image named after the file.
fn raster_doc(name: &str, bytes: &[u8]) -> Result<Document> {
pub(super) fn raster_doc(name: &str, bytes: &[u8]) -> Result<Document> {
let RasterImage { key, blob, width, height, .. } = raster_image(bytes)?;
let mut d = Document::new(width as f64, height as f64);
let layer = d.layers[0].id;

View File

@ -19,6 +19,7 @@
//! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`],
//! agents query `document.formats`.
mod affinity;
mod batch;
pub mod dxf;
pub mod dxfimport;
@ -555,6 +556,7 @@ pub const FORMATS: &[Format] = &[
Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS },
Format { id: "tga", label: "Targa", extensions: &["tga"], mime: "image/x-tga", read: false, write: true, raster: true, options: TGA_OPTIONS },
Format { id: "psd", label: "PSD", extensions: &["psd"], mime: "image/x-psd", read: false, write: true, raster: true, options: PSD_OPTIONS },
reader("affinity", "Affinity (embedded preview)", &["af"], "application/affinity", false),
];
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
@ -579,6 +581,7 @@ pub const OPEN_EXTS: &[&str] = &[
"emf",
"wmf",
"eps",
"af",
];
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
@ -596,7 +599,7 @@ pub fn export_extensions() -> Vec<&'static str> {
/// Text files: File → Place sets them as area type (Text Import Options).
pub const TEXT_EXTS: &[&str] = &["txt"];
/// Every extension File → Place reads: [`OPEN_EXTS`] and [`TEXT_EXTS`].
/// Every extension File → Place reads: [`OPEN_EXTS`] except preview-only Affinity, plus [`TEXT_EXTS`].
pub const PLACE_EXTS: &[&str] = &[
"vectorcraft",
"drawcraft",
@ -638,9 +641,11 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
}
/// File → Place dialog filters: "All placeable files", then one per readable format, then text.
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text.
pub fn place_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
std::iter::once(("All placeable files", PLACE_EXTS)).chain(format_filters()).chain(std::iter::once(("Text", TEXT_EXTS)))
std::iter::once(("All placeable files", PLACE_EXTS))
.chain(format_filters().filter(|(_, exts)| exts.iter().all(|ext| PLACE_EXTS.contains(ext))))
.chain(std::iter::once(("Text", TEXT_EXTS)))
}
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`).
@ -860,6 +865,8 @@ pub(crate) fn write_or_return(path: Option<&str>, bytes: &[u8], extra: Value) ->
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_affinity;
#[cfg(test)]
mod tests_pdf;
#[cfg(test)]
mod tests_pdfcolor;

View File

@ -0,0 +1,185 @@
//! Affinity is explicitly a preview-only reader, through the same commands and save policy.
use super::*;
use proptest::prelude::*;
use serde_json::json;
use std::io::Cursor;
fn png() -> Vec<u8> {
let im = image::RgbaImage::from_fn(2, 1, |x, _| if x == 0 { image::Rgba([220, 40, 60, 255]) } else { image::Rgba([0, 0, 0, 0]) });
let mut bytes = Vec::new();
im.write_to(&mut Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
bytes
}
/// Synthetic envelope without a native object graph; not a native Affinity writer.
fn file(png: &[u8]) -> Vec<u8> {
let mut b = vec![0; 72];
b[..4].copy_from_slice(vectorcraft_affinity::MAGIC);
b[4..6].copy_from_slice(&12u16.to_le_bytes());
b[8..12].copy_from_slice(b"nsrP");
b[12..16].copy_from_slice(b"#Inf");
b[24..32].copy_from_slice(&72u64.to_le_bytes());
b[64..68].copy_from_slice(b"Prot");
b.extend(b"\xff\xff\xff\xffThmb");
b.extend(1u32.to_le_bytes());
b.extend((png.len() as u32 + 13).to_le_bytes());
b.extend(29u32.to_le_bytes());
b.extend(0u32.to_le_bytes());
b.extend((png.len() as u32).to_le_bytes());
b.push(1);
b.extend(png);
b
}
#[test]
fn preview_opens_with_a_warning_and_never_saves_to_the_source() {
let mut s = Session::new();
let bytes = file(&png());
for name in ["drawing.af", "renamed.png", "unknown"] {
let result = open_bytes(&mut s, name, &bytes, Some(format!("/source/{name}"))).unwrap();
assert_eq!(result["format"], "affinity");
assert!(result["warnings"][0].as_str().unwrap().contains("2×1"));
assert!(result["warnings"][0].as_str().unwrap().contains("Native vectors"));
let st = s.doc().unwrap();
assert!(st.path.is_none());
assert_eq!(st.format, "vectorcraft");
assert_eq!((st.doc.artboards[0].rect.width(), st.doc.artboards[0].rect.height()), (2.0, 1.0));
assert_eq!(st.doc.images.values().next().unwrap().bytes.as_slice(), png());
let saved = vectorcraft_format::save_file(&st.doc);
let reopened = vectorcraft_format::load_file(&saved).unwrap();
assert_eq!(reopened.doc.images, st.doc.images);
let plan = save_plan(&s, SaveMode::Save, &json!({})).unwrap();
assert!(plan.path.is_none(), "Save must ask for a new destination");
assert_eq!(plan.format.id, "vectorcraft");
assert!(plan.name.ends_with(".vectorcraft"));
}
let f = format("affinity").unwrap();
assert!(f.read && !f.write);
assert!(OPEN_EXTS.contains(&"af"));
assert!(!PLACE_EXTS.contains(&"af"));
assert!(!SAVE_FORMATS.contains(&"affinity"));
assert!(s.execute("document.serialize", &json!({"format":"af"})).is_err());
assert!(s.execute("document.export", &json!({"format":"affinity"})).is_err());
assert!(save_format(None, Some("drawing.af")).is_err());
}
#[test]
fn preview_reaches_the_document_open_command() {
let mut s = Session::new();
let r = s.execute("document.open", &json!({"name":"test.af", "dataBase64":vectorcraft_format::base64_encode(&file(&png()))})).unwrap();
assert_eq!(r["format"], "affinity");
assert_eq!(r["warnings"].as_array().unwrap().len(), 1);
}
#[test]
fn placing_a_preview_is_rejected_even_when_renamed_or_queued() {
let mut s = Session::new();
s.execute("file.new", &json!({"width":100,"height":100})).unwrap();
let original = s.doc().unwrap().doc.clone();
for name in ["test.af", "renamed.png", "renamed.txt"] {
let p = json!({"name":name, "dataBase64":vectorcraft_format::base64_encode(&file(&png())), "link":true});
for cmd in ["file.place", "file.place.info"] {
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert!(e.contains("File › Open") && e.contains("not supported"), "{e}");
}
let e = s.execute("file.place.queue", &json!({"files":[p]})).unwrap_err().to_string();
assert!(e.contains("File › Open") && e.contains("not supported"), "{e}");
}
assert_eq!(s.doc().unwrap().doc, original);
assert!(place_filters().all(|(_, exts)| !exts.contains(&"af")));
}
#[test]
fn templates_and_libraries_cannot_silently_extract_preview_content() {
let mut s = Session::new();
s.execute("file.new", &json!({"width":100,"height":100})).unwrap();
let original = s.doc().unwrap().doc.clone();
for name in ["test.af", "renamed.svg"] {
let p = json!({"name":name, "dataBase64":vectorcraft_format::base64_encode(&file(&png()))});
for cmd in ["file.newFromTemplate", "swatch.library.load", "graphicStyle.loadLibrary"] {
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert!(e.contains("File › Open"), "{cmd}: {e}");
}
}
assert_eq!(s.documents().len(), 1);
assert_eq!(s.doc().unwrap().doc, original);
}
#[test]
fn malformed_crc_and_every_truncation_fail() {
let b = file(&png());
for end in 0..b.len() {
assert!(load("bad.af", &b[..end]).is_err(), "{end}");
}
let mut crc = b.clone();
crc[130] ^= 1;
assert!(load("bad.af", &crc).is_err());
let mut legacy = b.clone();
legacy[4..6].copy_from_slice(&10u16.to_le_bytes());
assert!(load("old.af", &legacy).err().unwrap().to_string().contains("legacy preview"));
}
#[test]
fn end_and_post_image_chunk_crcs_cannot_escape_pixel_decoding() {
let mut end_crc = png();
*end_crc.last_mut().unwrap() ^= 1;
assert!(load("bad-end.af", &file(&end_crc)).is_err());
let body = b"Note\0Synthetic";
let mut text = (body.len() as u32).to_be_bytes().to_vec();
text.extend(b"tEXt");
text.extend(body);
let mut h = crc32fast::Hasher::new();
h.update(b"tEXt");
h.update(body);
text.extend(h.finalize().to_be_bytes());
let mut post_image = png();
let at = post_image.len() - 12;
post_image.splice(at..at, text);
assert!(load("valid-text.af", &file(&post_image)).is_ok());
post_image[at + 8 + body.len()] ^= 1;
assert!(load("bad-text.af", &file(&post_image)).is_err());
}
#[test]
fn a_previewless_file_fails_without_replacing_the_active_document() {
let mut s = Session::new();
s.execute("file.new", &json!({"width":100,"height":100})).unwrap();
let original = s.doc().unwrap().doc.clone();
let mut bytes = file(&png());
bytes[24..32].copy_from_slice(&0u64.to_le_bytes());
let e = s.execute("document.open", &json!({"name":"no-preview.af", "dataBase64":vectorcraft_format::base64_encode(&bytes)})).unwrap_err();
assert!(e.to_string().contains("no embedded preview"));
assert_eq!(s.documents().len(), 1);
assert_eq!(s.doc().unwrap().doc, original);
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(128))]
#[test]
fn bounded_mutations_do_not_panic(edits in prop::collection::vec((0usize..300, any::<u8>()), 0..16)) {
let mut b = file(&png());
for (i, value) in edits { if let Some(byte) = b.get_mut(i) { *byte = value; } }
let _ = load("mutated.af", &b);
}
}
#[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.0, 48.0)), ("synthetic-large", (1024.0, 768.0))] {
let bytes = std::fs::read(format!("{dir}/{name}.af")).unwrap();
let r = load(&format!("{name}.af"), &bytes).unwrap();
let ab = r.doc.artboards[0].rect;
assert_eq!((ab.width(), ab.height()), size);
let preview = vectorcraft_affinity::preview(&bytes).unwrap();
assert_eq!(r.doc.images.values().next().unwrap().bytes.as_slice(), preview.png);
if name == "synthetic-rectangle" {
let source = image::load_from_memory(&std::fs::read(format!("{dir}/{name}.png")).unwrap()).unwrap().to_rgba8();
let decoded = image::load_from_memory(preview.png).unwrap().to_rgba8();
assert_eq!(decoded, source);
}
}
}

View File

@ -129,6 +129,14 @@ pub(crate) fn pt_per_px(ppi: Option<(f64, f64)>) -> (f64, f64) {
fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
let src = fileio::source(p, cmd)?;
let name = fileio::file_name(src.name);
// Place and the queued place cursor do not surface successful import warnings. An Affinity
// preview must never silently stand in for the full document there, even under another name.
if vectorcraft_affinity::is_affinity(&src.bytes) || fileio::format_for_name(src.name).is_some_and(|f| f.id == "affinity") {
return Err(bad(
cmd,
"Affinity placement is not supported: use File › Open to inspect the embedded preview with its warning, or export SVG, PDF or full-resolution PNG from Affinity before placing",
));
}
if fileio::TEXT_EXTS.contains(&fileio::extension(src.name).as_str()) {
let text = text::import(&src.bytes, text::TextOptions::parse(p, cmd)?, cmd)?;
if text.trim().is_empty() {

View File

@ -222,8 +222,11 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
match std::str::from_utf8(bytes).ok().filter(|t| style_libs::sniff(t)) {
Some(t) => style_libs::read(t, stem(file)).map_err(|e| bad(C, e)),
None => {
let doc = super::fileio::load(file, bytes).map_err(|e| bad(C, e.to_string()))?.doc;
StyleLibrary::from_document(&doc, &[], stem(file).to_string()).map_err(|e| bad(C, e))
let loaded = super::fileio::load(file, bytes).map_err(|e| bad(C, e.to_string()))?;
if loaded.format.id == "affinity" {
return Err(bad(C, "Affinity graphic styles are not decoded; use File › Open to inspect the embedded preview with its warning"));
}
StyleLibrary::from_document(&loaded.doc, &[], stem(file).to_string()).map_err(|e| bad(C, e))
}
}
})?;

View File

@ -354,8 +354,11 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
match std::str::from_utf8(bytes).ok().filter(|t| palette_io::sniff(t)) {
Some(t) => palette_io::read(t, stem(file)).map_err(|e| bad(C, e)),
None => {
let doc = super::fileio::load(file, bytes).map_err(|e| bad(C, e.to_string()))?.doc;
document_library(&doc, &[], stem(file).to_string(), C)
let loaded = super::fileio::load(file, bytes).map_err(|e| bad(C, e.to_string()))?;
if loaded.format.id == "affinity" {
return Err(bad(C, "Affinity swatches are not decoded; use File › Open to inspect the embedded preview with its warning"));
}
document_library(&loaded.doc, &[], stem(file).to_string(), C)
}
}
})?;

View File

@ -76,9 +76,10 @@ fn text_files_report_and_queue_with_their_options() {
}
#[test]
fn place_reads_what_open_reads_and_text_files() {
fn place_reads_open_formats_and_text_except_affinity_previews() {
use cmd::fileio::{OPEN_EXTS, PLACE_EXTS, TEXT_EXTS};
assert_eq!(PLACE_EXTS, [OPEN_EXTS, TEXT_EXTS].concat());
let open: Vec<_> = OPEN_EXTS.iter().copied().filter(|ext| *ext != "af").collect();
assert_eq!(PLACE_EXTS, [open.as_slice(), TEXT_EXTS].concat());
let filters: Vec<_> = cmd::fileio::place_filters().collect();
assert_eq!((filters.first(), filters.last()), (Some(&("All placeable files", PLACE_EXTS)), Some(&("Text", TEXT_EXTS))));
assert!(!cmd::fileio::open_filters().any(|(_, exts)| exts.contains(&"txt")), "Open doesn't read text files");

View File

@ -4,7 +4,8 @@
//! must load as an error or as a document that then renders and exports, without a panic; nor may
//! bitmaps, PDF and text pasted from other apps, nor EMF and WMF pictures (damaged files, records
//! of every kind with random contents) opened, placed or pasted, nor EPS and PostScript files
//! (damaged ones, hostile programs) read by the PostScript interpreter.
//! (damaged ones, hostile programs) read by the PostScript interpreter, nor indexed PNG previews
//! in Affinity containers (truncated or mutated records and hostile image dimensions).
//!
//! `PROPTEST_CASES=20000 cargo test -p vectorcraft-engine --test import_fuzz` runs a deeper search.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
@ -25,6 +26,69 @@ fn config() -> ProptestConfig {
c
}
// ---------- Affinity previews ----------
/// Original pixels in a synthetic container envelope, not an Affinity document writer.
fn affinity_preview_sample() -> Vec<u8> {
let im = image::RgbaImage::from_pixel(2, 1, image::Rgba([220, 40, 60, 255]));
let mut png = Vec::new();
im.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut b = vec![0; 72];
b[..4].copy_from_slice(vectorcraft_affinity::MAGIC);
b[4..6].copy_from_slice(&12u16.to_le_bytes());
b[8..12].copy_from_slice(b"nsrP");
b[12..16].copy_from_slice(b"#Inf");
b[24..32].copy_from_slice(&72u64.to_le_bytes());
b[64..68].copy_from_slice(b"Prot");
b.extend(b"\xff\xff\xff\xffThmb");
b.extend(1u32.to_le_bytes());
b.extend((png.len() as u32 + 13).to_le_bytes());
b.extend(29u32.to_le_bytes());
b.extend(0u32.to_le_bytes());
b.extend((png.len() as u32).to_le_bytes());
b.push(1);
b.extend(png);
b
}
proptest! {
#![proptest_config(config())]
#[test]
fn affinity_garbage_never_panics(tail in prop::collection::vec(any::<u8>(), 0..2048)) {
let mut bytes = vectorcraft_affinity::MAGIC.to_vec();
bytes.extend(tail);
survive("Affinity garbage", || vectorcraft_engine::cmd::fileio::load("x.af", &bytes).ok().map(|l| l.doc))?;
}
#[test]
fn affinity_mutated_previews_render_and_export_without_panicking(
cut in 0usize..400,
edits in prop::collection::vec((0usize..400, any::<u8>()), 0..16),
) {
let mut bytes = affinity_preview_sample();
for (at, b) in edits {
if let Some(byte) = bytes.get_mut(at) { *byte = b; }
}
bytes.truncate(cut);
survive("Affinity preview", || vectorcraft_engine::cmd::fileio::load("x.af", &bytes).ok().map(|l| l.doc))?;
}
#[test]
fn affinity_placement_is_rejected_without_panicking(tail in prop::collection::vec(any::<u8>(), 0..2048)) {
let mut bytes = vectorcraft_affinity::MAGIC.to_vec();
bytes.extend(tail);
let r = catch_quiet(|| {
let mut s = vectorcraft_engine::Session::new();
s.execute("file.new", &json!({"width":100,"height":100})).unwrap();
let p = json!({"name":"renamed.png", "dataBase64":vectorcraft_format::base64_encode(&bytes)});
for cmd in ["file.place", "file.place.info"] { assert!(s.execute(cmd, &p).is_err()); }
assert!(s.execute("file.place.queue", &json!({"files":[p]})).is_err());
});
prop_assert!(r.is_ok(), "Affinity placement panicked: {:?}", r.err());
}
}
/// Import must not panic; whatever comes back must render and export without panicking either.
fn survive(what: &str, import: impl FnOnce() -> Option<Document>) -> Result<(), TestCaseError> {
let r = catch_quiet(|| {

View File

@ -88,6 +88,12 @@
<key>CFBundleTypeRole</key><string>Editor</string>
<key>LSHandlerRank</key><string>Alternate</string>
</dict>
<dict>
<key>CFBundleTypeName</key><string>Affinity (embedded preview)</string>
<key>CFBundleTypeExtensions</key><array><string>af</string></array>
<key>CFBundleTypeRole</key><string>Viewer</string>
<key>LSHandlerRank</key><string>Alternate</string>
</dict>
<dict>
<key>CFBundleTypeName</key><string>DXF</string>
<key>CFBundleTypeExtensions</key><array><string>dxf</string></array>

View File

@ -60,6 +60,7 @@ pub const TABLE: &[(&str, Class)] = &[
("eps", Class::Layer(3)),
("metafile", Class::Layer(3)),
("plugins", Class::Layer(2)),
("affinity", Class::Standalone),
];
/// Explicit orderings *within* a layer (earlier may be used by later).