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

Open and Place read Affinity documents natively (.af from Affinity 3,
.afdesign/.afphoto/.afpub from Affinity 1 and 2): layers and sublayers,
groups, artboards with their names and backgrounds, Publisher pages side
by side, curves with live corners, parametric shapes, compound shapes,
clipping by nesting, vector and pixel masks, solid and gradient fills,
strokes, fill layers, artistic and frame text, placed images (their
original JPEG/PNG), pixel layers, embedded documents as their cached
picture, opacity, visibility, locking and blend modes, at the document's
resolution in points. Unsupported content (effects, adjustments, brushes,
master pages, special shapes, Affinity-only blend modes...) is listed in
the import warning, one line per kind. A file whose native data can't be
read opens as its embedded preview with the reason, and that preview
cannot be placed or used as a template or library. Save never writes back
over the Affinity file and there is no Affinity export.

The document semantics were fitted to the thumbnails Affinity embeds in
176 public documents. `cargo xtask corpus --affinity` fetches 22 public
CC0/MIT/Apache-2.0 documents at pinned commits, sha256-verified; the new
engine test renders each and compares it with Affinity's own thumbnail
(0-4.4 of 255 mean difference, per-file ceilings). import_fuzz mutates a
synthetic native document's stream and container through open, render
and export; the fuzz crate gains a stream target.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
This commit is contained in:
callmeBallochandClaude Opus 5.5 authored and Claude committed 2026-10-08 17:58:11 +00:00
1 parent 1c47480a09
commit 297a01b6aa
30 files changed
+3081 -147

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
c499cf3ca73cf7cd3e2e65bed048d1b8ecf03e7167b74d54f0ae6db31868cab8 affinity3-mexican-guy.af
730e3fae51d9044ada58f51c7b5f9327d99e8d62376453bc251d0d26cda47743 affinity3-mexican-man.af
5f5fccc0bd8be2bb3b2a07461ec77a012a61eef1883485d7c80f72d4383231ce affinity3-mexican-woman.af
0a52bb923c949c566a4339b996bce1a67b683f61def1a73dfefb910effaa450d affinity3-playing-cards.af
87a7bd93de16c404f6c6dd9cdebff96ec47832a3b342d2ce0af73d9a87e209e0 designer-beer.afdesign
09ba790c428e8e09dc0b9ddebb4f3db6daefaa2af1da492ee34e73fba1b8af69 designer-cactus.afdesign
c3bcd0395b407493bc9d2877d424029b70f0e4c817a56e893f7ac74973df52e5 designer-car.afdesign
112562bc91e7cccf003d34364430544208373c4fb4bb4ab9acdaf27cf0125047 designer-flowers.afdesign
023531b8216f2d4da24ab715e3b313506712e50d5f5bf7da927f434def1d0ea2 designer-lion-track.afdesign
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
fbb196e918e302c16f8de9f2e6dffaef70ab3c317b32a9f44607fd4b2ead0206 jac21-SimpleLogo.afdesign
9d874245936483de3ec04cca48b383028d33666801dff1814246a44e695e29b8 jac21-SimpleLogoBanner.afdesign
e1b121d7c3d02356baa5dea512f5aa1fe77249c28d7b227e29cab2bc67ec323a eviltwo-AssetStore.aftemplate
ec1072cb03fcd0ed7ac1dfc7e087f4920df545fe9c84a386a7dac2fd33fbbe1f leakcanary-vector_icon.afdesign
+110
View File
@@ -0,0 +1,110 @@
//! `cargo xtask corpus --affinity`: public Affinity documents saved by Affinity 1.x to 3.x, from
//! their upstream repositories at pinned commits, each checked against `xtask/affinity-corpus.sha256`.
//! They land in `corpus/affinity/` (gitignored, never committed). `vectorcraft-affinity`'s
//! `real_files` test and the engine's `affinity_corpus` test use them when present.
//!
//! Only permissively licensed files (CC0, MIT, Apache-2.0) whose metadata holds no personal paths
//! are listed. Moving a pin: change the commit, re-run, review the manifest diff.
use std::path::Path;
use std::process::Command;
use crate::{root, run, sha256};
/// (repository, commit, licence, [(path in the repository, file name in corpus/affinity)]).
type Source = (&'static str, &'static str, &'static str, &'static [(&'static str, &'static str)]);
const SOURCES: &[Source] = &[
(
"samuel-etver/vector-art",
"255f8add3c8f0740196e22bd59502b811b532f0b",
"CC0-1.0",
&[
("simple/mexican-guy.af", "affinity3-mexican-guy.af"),
("simple/mexican-man.af", "affinity3-mexican-man.af"),
("simple/mexican-woman.af", "affinity3-mexican-woman.af"),
("simple/playing-cards.af", "affinity3-playing-cards.af"),
("simple/beer.afdesign", "designer-beer.afdesign"),
("simple/cactus.afdesign", "designer-cactus.afdesign"),
("simple/car.afdesign", "designer-car.afdesign"),
("simple/flowers.afdesign", "designer-flowers.afdesign"),
("simple/lion-track.afdesign", "designer-lion-track.afdesign"),
],
),
(
"NickBeeuwsaert/AFDesignLoad",
"a18dd50a7079fb861a28835eeefa0d015f03f4a1",
"MIT",
&[
("testDesigns/color.afdesign", "afdesignload-color.afdesign"),
("testDesigns/layer_mode.afdesign", "afdesignload-layer_mode.afdesign"),
("testDesigns/layer_test.afdesign", "afdesignload-layer_test.afdesign"),
("testDesigns/margins.afdesign", "afdesignload-margins.afdesign"),
("testDesigns/raster_test.afdesign", "afdesignload-raster_test.afdesign"),
("testDesigns/revision_test.afdesign", "afdesignload-revision_test.afdesign"),
("testDesigns/shape_test.afdesign", "afdesignload-shape_test.afdesign"),
("testDesigns/slice_test.afdesign", "afdesignload-slice_test.afdesign"),
("testDesigns/test_path.afdesign", "afdesignload-test_path.afdesign"),
],
),
(
"Jac21/Branding",
"57ae3f45bf4637f6927c0b07de25b922c55f944f",
"MIT",
&[
("Logos/JC/DesignerFiles/Affinity/SimpleLogo.afdesign", "jac21-SimpleLogo.afdesign"),
("Logos/JC/DesignerFiles/Affinity/SimpleLogoBanner.afdesign", "jac21-SimpleLogoBanner.afdesign"),
],
),
(
"eviltwo/AssetStoreTemplate",
"f671981ee89d15526285b3cc87b4393d3defe644",
"MIT",
&[("AssetStoreTemplate/AssetStore.aftemplate", "eviltwo-AssetStore.aftemplate")],
),
(
"square/leakcanary",
"00880a8fb66eb4046a0124bff8303a04874bc07e",
"Apache-2.0",
&[("docs/assets/vector_icon.afdesign", "leakcanary-vector_icon.afdesign")],
),
];
const MANIFEST: &str = "xtask/affinity-corpus.sha256";
pub fn fetch() -> Result<(), String> {
let root = root();
let manifest = std::fs::read_to_string(root.join(MANIFEST)).map_err(|e| format!("{MANIFEST}: {e}"))?;
let expected: Vec<(&str, &str)> = manifest.lines().filter_map(|l| l.split_once(" ")).collect();
let dest = root.join("corpus").join("affinity");
std::fs::create_dir_all(&dest).map_err(|e| format!("create {}: {e}", dest.display()))?;
let mut sources = String::from(
"# Public Affinity documents\n\nFetched by `cargo xtask corpus --affinity` and checked against `xtask/affinity-corpus.sha256`.\nGitignored; never commit these files.\n\n| File | Source | Licence |\n|---|---|---|\n",
);
let mut count = 0;
for (repo, commit, licence, files) in SOURCES {
for (path, name) in *files {
let sha = expected.iter().find(|(_, n)| n == name).map(|(s, _)| *s).ok_or_else(|| format!("{name} is missing from {MANIFEST}"))?;
let out = dest.join(name);
if !verified(&out, sha) {
let url = format!("https://raw.githubusercontent.com/{repo}/{commit}/{path}");
let mut curl = Command::new("curl");
curl.args(["-fsSL", "--retry", "3", "-A", "VectorCraft-dev", "-o"]).arg(&out).arg(&url);
run(curl, &format!("curl {url}"))?;
if !verified(&out, sha) {
let _ = std::fs::remove_file(&out);
return Err(format!("{name}: sha256 does not match {MANIFEST}"));
}
}
sources.push_str(&format!("| {name} | https://github.com/{repo}/blob/{commit}/{path} | {licence} |\n"));
count += 1;
}
}
std::fs::write(dest.join("SOURCES.md"), sources).map_err(|e| format!("SOURCES.md: {e}"))?;
println!("Affinity: {count} documents verified in {}", dest.display());
Ok(())
}
fn verified(path: &Path, sha: &str) -> bool {
std::fs::read(path).is_ok_and(|b| sha256::hex(&b) == sha)
}
+8 -2
View File
@@ -3,11 +3,13 @@
//! Pure Rust (std + serde_json). External tools (`cargo`, `curl`, `tar`) are
//! invoked through `std::process::Command`.
mod affinity_corpus;
mod assets;
mod brands;
mod bundle;
mod ico;
mod layers;
mod sha256;
mod stats;
mod version;
@@ -23,8 +25,9 @@ commands:
layers enforce the crate dependency layering (plan/architecture.md §3)
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
ci fmt --check, clippy -D warnings, test, assets, brands, layers, wasm (stops at first failure)
corpus [--download]
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
corpus [--download] [--affinity]
show where test corpora live; --download fetches PngSuite into corpus/pngsuite,
--affinity the pinned public Affinity documents into corpus/affinity
bundle build dist/VectorCraft.app (macOS) with assets/app-icon/vectorcraft.icns
ico <out.ico> <png>...
pack PNGs into a Windows .ico (used by packaging/icons.sh)
@@ -40,6 +43,7 @@ fn main() -> ExitCode {
Some("layers") => cmd_layers(),
Some("wasm") => cmd_wasm(),
Some("ci") => cmd_ci(),
Some("corpus") if rest.contains(&"--affinity") => affinity_corpus::fetch(),
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
Some("bundle") => bundle::run(&root()),
Some("ico") => ico::run(&rest),
@@ -231,6 +235,8 @@ Tests that use a corpus skip cleanly when it is absent.
corpus/tiff/ libtiff pics (optional)
corpus/exr/ OpenEXR sample images (optional)
corpus/raw/ raw.pixls.us samples, CC0 (optional)
corpus/affinity/ public Affinity documents (CC0/MIT/Apache-2.0) at pinned commits, sha256
verified: oracles for the Affinity reader. Fetch: cargo xtask corpus --affinity
",
corpus.display()
);
+58
View File
@@ -0,0 +1,58 @@
//! SHA-256 (FIPS 180-4), std only, for verifying downloaded test corpora.
const K: [u32; 64] = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
];
/// Lower-case hex digest of `data`.
pub fn hex(data: &[u8]) -> String {
let mut h: [u32; 8] = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
let mut msg = data.to_vec();
let bits = (data.len() as u64).wrapping_mul(8);
msg.push(0x80);
while msg.len() % 64 != 56 {
msg.push(0);
}
msg.extend(bits.to_be_bytes());
for block in msg.chunks_exact(64) {
let mut w = [0u32; 64];
for (i, word) in block.chunks_exact(4).enumerate() {
w[i] = u32::from_be_bytes([word[0], word[1], word[2], word[3]]);
}
for i in 16..64 {
let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
w[i] = w[i - 16].wrapping_add(s0).wrapping_add(w[i - 7]).wrapping_add(s1);
}
let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh] = h;
for i in 0..64 {
let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
let ch = (e & f) ^ (!e & g);
let t1 = hh.wrapping_add(s1).wrapping_add(ch).wrapping_add(K[i]).wrapping_add(w[i]);
let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
let maj = (a & b) ^ (a & c) ^ (b & c);
let t2 = s0.wrapping_add(maj);
(hh, g, f, e, d, c, b, a) = (g, f, e, d.wrapping_add(t1), c, b, a, t1.wrapping_add(t2));
}
for (x, y) in h.iter_mut().zip([a, b, c, d, e, f, g, hh]) {
*x = x.wrapping_add(y);
}
}
h.iter().map(|x| format!("{x:08x}")).collect()
}
#[cfg(test)]
mod tests {
#[test]
fn known_digests() {
assert_eq!(super::hex(b""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
assert_eq!(super::hex(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
let million = vec![b'a'; 1_000_000];
assert_eq!(super::hex(&million), "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0");
}
}