Affinity import: special shapes and sample corpus (#1606) (#2035)

* Import Affinity issue 1606 special shapes

* Affinity corpus: give the #1606 sample ceilings a margin for font differences between machines

The samples' labels render with whatever fonts are installed, so the measured
differences move by up to 0.8 between machines; each ceiling is now the higher
measurement plus about 1. Also use as_chunks in on_white (clippy with --features corpus).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Alex 2026-10-09 19:27:35 -04:00 committed by GitHub
parent 0e011a69dc
commit 590c1496b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 331 additions and 30 deletions

View File

@ -89,7 +89,7 @@ next to the files:
| `corpus/psd/` (170 files) | Small selection of the psd-tools and ag-psd test files | Kota Yamaguchi; Agamnentzar | psd-tools (above) and [ag-psd `test/`](https://github.com/Agamnentzar/ag-psd/tree/master/test) | MIT (both) |
| `corpus/heif/` (9 files, 0.1 MB) | HEIC/HEIF test files: `heic-rs/` checkerboards, RGB strips and a grid-tiled photo with EXIF and XMP (synthetic pixels encoded by macOS `sips`, each `.ref.png` Apple's decode); `pillow-heif/` the 10-bit RGBA `RGBA_10__29x100.heif` and its source `RGBA_16__29x100.png` | Thomas Braun (heic-rs); Pillow-Heif contributors | [heic-rs `tests/fixtures`](https://github.com/tbraun96/heic-rs/tree/main/tests/fixtures), [pillow-heif `tests/images`](https://github.com/bigcat88/pillow_heif/tree/master/tests/images) | MIT OR Apache-2.0 (heic-rs); BSD-3-Clause (pillow-heif) |
| `corpus/exr/` (5 files, 2.3 MB) | The deep OpenEXR test images `11`, `42`, `64`, `multivariate` and `objectid.deep.exr` | Contributors to the OpenEXR Project | [openexr `src/test/bin/test_images`](https://github.com/AcademySoftwareFoundation/openexr/tree/main/src/test/bin/test_images) | BSD-3-Clause |
| `corpus/affinity/` (21 files, 2.4 MB) | Public Affinity documents, unmodified: `vector-art/` (9), `afdesignload/` (9), `jac21/` (2) and `asset-store-template/` (1), each with its upstream licence next to it | samuel-etver; Nick Beeuwsaert; Jeremy Cantu; DAIKI (eviltwo) | [vector-art `simple/`](https://github.com/samuel-etver/vector-art/tree/255f8add3c8f0740196e22bd59502b811b532f0b/simple), [AFDesignLoad `testDesigns/`](https://github.com/NickBeeuwsaert/AFDesignLoad/tree/a18dd50a7079fb861a28835eeefa0d015f03f4a1/testDesigns), [Jac21/Branding `Logos/JC/DesignerFiles/Affinity`](https://github.com/Jac21/Branding/tree/57ae3f45bf4637f6927c0b07de25b922c55f944f/Logos/JC/DesignerFiles/Affinity), [AssetStoreTemplate `AssetStoreTemplate/`](https://github.com/eviltwo/AssetStoreTemplate/tree/f671981ee89d15526285b3cc87b4393d3defe644/AssetStoreTemplate) | CC0-1.0 (vector-art); MIT, Copyright (c) 2015 Nick Beeuwsaert; MIT, Copyright (c) 2018 Jeremy Cantu; MIT, Copyright (c) 2024 DAIKI |
| `corpus/affinity/` (60 files) | Public Affinity documents and PNG references: `vector-art/` (9 documents), `afdesignload/` (9), `jac21/` (2), `asset-store-template/` (1), and `affinity-samples/` (18 CC0 documents plus exported references) | samuel-etver; Nick Beeuwsaert; Jeremy Cantu; DAIKI (eviltwo); satoshoe-dev | [vector-art `simple/`](https://github.com/samuel-etver/vector-art/tree/255f8add3c8f0740196e22bd59502b811b532f0b/simple), [AFDesignLoad `testDesigns/`](https://github.com/NickBeeuwsaert/AFDesignLoad/tree/a18dd50a7079fb861a28835eeefa0d015f03f4a1/testDesigns), [Jac21/Branding `Logos/JC/DesignerFiles/Affinity`](https://github.com/Jac21/Branding/tree/57ae3f45bf4637f6927c0b07de25b922c55f944f/Logos/JC/DesignerFiles/Affinity), [AssetStoreTemplate `AssetStoreTemplate/`](https://github.com/eviltwo/AssetStoreTemplate/tree/f671981ee89d15526285b3cc87b4393d3defe644/AssetStoreTemplate), [affinity-samples](https://github.com/satoshoe-dev/affinity-samples/tree/96aca6bd86bbe7a677f1cf0630bd832d23943be8) | CC0-1.0 (vector-art and affinity-samples); MIT, Copyright (c) 2015 Nick Beeuwsaert; MIT, Copyright (c) 2018 Jeremy Cantu; MIT, Copyright (c) 2024 DAIKI |
| `corpus/pngsuite/` | PngSuite | Willem van Schaik | <http://www.schaik.com/pngsuite/> | Public domain |
| `corpus/pixls/` (7 files, 226 MB) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked |

View File

@ -16,13 +16,13 @@ Affinity files.
| 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 |
| Rectangles with corner radii (relative or absolute), ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids, clouds, hearts, cogs, rounded and elliptical callouts, arrows, double stars, tears, crescents, diamonds and circular segments | 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 |
| Linear, elliptical, radial and conical 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) |
@ -33,13 +33,12 @@ Affinity files.
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).
transparency gradients, fill opacity, bitmap fills, master pages, unrecognized
parametric shapes (imported as their bounding ellipses), 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

View File

@ -44,7 +44,7 @@ pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> O
}
};
let b = s.floats::<4>(box_owner, b"ShpB")?;
crate::shapes::shape(r, shape, b, world).map(|p| (p, false))
crate::shapes::shape(r, shape, b, world)
}
}
}

View File

@ -43,7 +43,7 @@ fn polygon(pts: impl IntoIterator<Item = Point>) -> Option<SubPath> {
}
/// 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> {
pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) -> Option<(Path, bool)> {
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);
@ -131,12 +131,201 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) ->
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)])?
}
b"ShCl" => cloud(cx, cy, rx, ry, sides(r, shape, b"Bubl", 12.0)),
b"ShHt" => heart(b),
b"ShCg" => cog(r, shape, cx, cy, rx, ry),
b"ShCR" => callout_rect(r, shape, b, false),
b"ShCE" => callout_ellipse(r, shape, cx, cy, rx, ry),
b"ShDA" => arrow(r, shape, b),
b"ShDS" => double_star(r, shape, cx, cy, rx, ry),
b"ShTr" => tear(r, shape, b),
b"ShCr" => crescent(cx, cy, rx, ry),
b"ShpD" => polygon([pt(cx, y0), pt(x1, cy), pt(cx, y1), pt(x0, cy)])?,
b"ShSg" => segment(r, shape, cx, cy, rx, ry),
_ => {
r.warn("cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse)");
r.warn("an unrecognized parametric shape (imported as its bounding ellipse)");
ellipse(cx, cy, rx, ry)
}
};
Some(Path { subpaths: vec![sub] })
let mut path = Path { subpaths: vec![sub] };
if class == Tag::of(b"ShCg") {
let hole = num(r, shape, b"Hole").unwrap_or(0.3).clamp(0.0, 0.9);
if hole > 0.0 {
path.subpaths.push(ellipse(cx, cy, rx * hole, ry * hole));
}
return Some((path, true));
}
Some((path, false))
}
fn cloud(cx: f64, cy: f64, rx: f64, ry: f64, bubbles: usize) -> SubPath {
let n = bubbles.clamp(3, 128) * 4;
let points: Vec<Point> = (0..n)
.map(|i| {
let a = TAU * i as f64 / n as f64 - FRAC_PI_2;
let lobe = (0.5 - 0.5 * (TAU * i as f64 / 4.0).cos()).powi(2);
let radius = 0.78 + 0.22 * lobe;
pt(cx + rx * radius * a.cos(), cy + ry * radius * a.sin())
})
.collect();
let segments = (0..n)
.map(|i| {
let p0 = points[(i + n - 1) % n];
let p1 = points[i];
let p2 = points[(i + 1) % n];
let p3 = points[(i + 2) % n];
[pt(p1.x + (p2.x - p0.x) / 6.0, p1.y + (p2.y - p0.y) / 6.0), pt(p2.x - (p3.x - p1.x) / 6.0, p2.y - (p3.y - p1.y) / 6.0), p2]
})
.collect();
SubPath { start: points[0], segments, closed: true }
}
fn heart([x0, y0, x1, y1]: [f64; 4]) -> SubPath {
let (w, h) = (x1 - x0, y1 - y0);
let p = |x: f64, y: f64| pt(x0 + x * w, y0 + y * h);
let start = p(0.5, 0.92);
let segments = vec![
[p(0.38, 0.85), p(0.04, 0.67), p(0.04, 0.37)],
[p(0.04, 0.02), p(0.39, 0.00), p(0.5, 0.27)],
[p(0.61, 0.00), p(0.96, 0.02), p(0.96, 0.37)],
[p(0.96, 0.67), p(0.62, 0.85), start],
];
SubPath { start, segments, closed: true }
}
fn cog(r: &Reader, shape: ObjId, cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath {
let teeth = sides(r, shape, b"Teth", 12.0);
let tooth = num(r, shape, b"TtSz").unwrap_or(0.3).clamp(0.05, 0.9);
let notch = num(r, shape, b"NtSz").unwrap_or(0.45).clamp(0.05, 0.95);
let tip = 1.0;
let root = (1.0 - 0.2 * tooth - 0.08 * notch).clamp(0.55, 0.95);
let mut pts = Vec::with_capacity(teeth * 4);
for i in 0..teeth {
let base = TAU * i as f64 / teeth as f64 - FRAC_PI_2;
for (offset, radius) in [(-0.5, root), (-0.32, tip), (0.32, tip), (0.5, root)] {
let a = base + offset * TAU / teeth as f64;
pts.push(pt(cx + rx * radius * a.cos(), cy + ry * radius * a.sin()));
}
}
polygon(pts).unwrap_or_else(|| rectangle_sub(cx - rx, cy - ry, cx + rx, cy + ry))
}
fn callout_rect(r: &Reader, shape: ObjId, [x0, y0, x1, y1]: [f64; 4], _ellipse: bool) -> SubPath {
let (w, h) = (x1 - x0, y1 - y0);
let tail_h = num(r, shape, b"TlHg").unwrap_or(0.2).clamp(0.0, 0.8) * h;
let tail_w = num(r, shape, b"TlWd").unwrap_or(0.1).clamp(0.02, 0.8) * w;
let tip_x = x0 + num(r, shape, b"TlRP").unwrap_or(0.5).clamp(0.0, 1.0) * w;
let base_x = x0 + num(r, shape, b"TlEP").unwrap_or(0.4).clamp(0.0, 1.0) * w;
let a = (base_x - tail_w / 2.0).clamp(x0, x1);
let c = (base_x + tail_w / 2.0).clamp(x0, x1);
let body_bottom = y1 - tail_h;
let radius = (w.min(body_bottom - y0) * 0.12).max(0.0);
// The three contours overlap inside the fill; no internal edge is visible in a filled shape.
// Join the tail to the rounded body in one closed outline to avoid a seam in stroked shapes.
let start = pt(x0 + radius, y0);
let segments = vec![
[pt(x1 - radius, y0), pt(x1 - radius, y0), pt(x1 - radius, y0)],
[pt(x1 - (1.0 - KAPPA) * radius, y0), pt(x1, y0 + (1.0 - KAPPA) * radius), pt(x1, y0 + radius)],
[pt(x1, body_bottom - radius), pt(x1, body_bottom - radius), pt(x1, body_bottom - radius)],
[pt(x1, body_bottom), pt(x1, body_bottom), pt(x1 - radius, body_bottom)],
[pt(c + radius * 0.2, body_bottom), pt(tip_x, y1 - tail_h * 0.15), pt(tip_x, y1)],
[pt(tip_x, y1), pt(tip_x, y1), pt(tip_x, y1)],
[pt(tip_x, y1 - tail_h * 0.15), pt(a - radius * 0.2, body_bottom), pt(a, body_bottom)],
[pt(x0 + radius, body_bottom), pt(x0, body_bottom), pt(x0, body_bottom - radius)],
[pt(x0, y0 + radius), pt(x0, y0 + radius), pt(x0, y0 + radius)],
[pt(x0, y0 + (1.0 - KAPPA) * radius), pt(x0 + (1.0 - KAPPA) * radius, y0), start],
];
SubPath { start, segments, closed: true }
}
fn callout_ellipse(r: &Reader, shape: ObjId, cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath {
let tail_h = num(r, shape, b"TlHg").unwrap_or(0.2).clamp(0.0, 0.8);
let pos = num(r, shape, b"TlEP").unwrap_or(0.25).clamp(0.0, 1.0);
let angle = num(r, shape, b"TlAn").unwrap_or(0.0);
let a0 = pos * TAU - angle;
let a1 = a0 + 0.35;
let start = pt(cx + rx * a1.cos(), cy + ry * a1.sin());
let end = pt(cx + rx * a0.cos(), cy + ry * a0.sin());
let tip = pt(cx + rx * (a0 + 0.18).cos() * 1.08, cy + ry * (a0 + 0.18).sin() * (1.0 + tail_h));
// A single ellipse-like outline with a short triangular tail at its lower edge.
let mut segments = Vec::new();
arc(cx, cy, rx, ry, a1, a0 + TAU, &mut segments);
segments.push([end, end, tip]);
segments.push([tip, tip, start]);
SubPath { start, segments, closed: true }
}
fn arrow(r: &Reader, shape: ObjId, [x0, y0, x1, y1]: [f64; 4]) -> SubPath {
let h = y1 - y0;
let thick = num(r, shape, b"Thck").unwrap_or(0.35).clamp(0.05, 0.9) * h;
let shaft = (y0 + y1 - thick) / 2.0;
let lower = shaft + thick;
let head_start = x1 - h * 0.45;
polygon([pt(x0, shaft), pt(head_start, shaft), pt(head_start, y0), pt(x1, (y0 + y1) / 2.0), pt(head_start, y1), pt(head_start, lower), pt(x0, lower)])
.unwrap_or_else(|| rectangle_sub(x0, shaft, x1, lower))
}
fn double_star(r: &Reader, shape: ObjId, cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath {
let n = sides(r, shape, b"Pnts", 6.0).saturating_mul(2).clamp(8, 64);
let inner = num(r, shape, b"IRad").unwrap_or(0.6).clamp(0.05, 0.95);
let outer = num(r, shape, b"PRad").unwrap_or(0.9).clamp(0.2, 1.0);
polygon((0..2 * n).map(|i| {
let a = -FRAC_PI_2 + PI * i as f64 / n as f64;
let radius = if i % 2 == 0 { outer } else { outer * inner };
pt(cx + rx * radius * a.cos(), cy + ry * radius * a.sin())
}))
.unwrap_or_else(|| rectangle_sub(cx - rx, cy - ry, cx + rx, cy + ry))
}
fn tear(r: &Reader, shape: ObjId, [x0, y0, x1, y1]: [f64; 4]) -> SubPath {
let (w, h) = (x1 - x0, y1 - y0);
let bulge = num(r, shape, b"Ball").unwrap_or(0.25).clamp(0.0, 0.5);
let bend = num(r, shape, b"Bend").unwrap_or(0.0).clamp(-0.5, 0.5);
let top = pt((x0 + x1) / 2.0, y0);
let bottom = pt((x0 + x1) / 2.0 + bend * w * 0.25, y1);
let segments = vec![
[pt(x0 + w * 0.25, y0 + h * 0.42), pt(x0 + w * (0.5 - bulge), y1 - h * 0.03), bottom],
[pt(x1 - w * (0.5 - bulge), y1 - h * 0.03), pt(x1 - w * 0.25, y0 + h * 0.42), top],
];
SubPath { start: top, segments, closed: true }
}
fn crescent(cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath {
let start = pt(cx, cy - ry);
let mut segments = Vec::new();
arc(cx, cy, rx, ry, -FRAC_PI_2, -3.0 * FRAC_PI_2, &mut segments);
// The inner arc returns from the lower tip to the upper tip, leaving a crescent open to the right.
let ix = cx + rx * 0.72;
let irx = rx * 0.86;
let iry = ry * 0.82;
let from = pt(ix, cy + iry);
segments.push([from, from, from]);
let mut inner = Vec::new();
arc(ix, cy, irx, iry, FRAC_PI_2, -FRAC_PI_2, &mut inner);
segments.extend(inner);
SubPath { start, segments, closed: true }
}
fn segment(r: &Reader, shape: ObjId, cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath {
// Keep hostile but finite angles in a small range before constructing arc controls.
let angle = num(r, shape, b"Angl").unwrap_or(0.0).rem_euclid(TAU);
let p0 = num(r, shape, b"Pos0").unwrap_or(0.25).clamp(0.0, 1.0);
let p1 = num(r, shape, b"Pos1").unwrap_or(0.75).clamp(0.0, 1.0);
let a0 = angle + (p0 - 0.5) * TAU;
let mut a1 = angle + (p1 - 0.5) * TAU;
while a1 < a0 {
a1 += TAU;
}
let mut segments = Vec::new();
arc(cx, cy, rx, ry, a0, a1.min(a0 + TAU), &mut segments);
let start = pt(cx + rx * a0.cos(), cy + ry * a0.sin());
let end = pt(cx + rx * a1.cos(), cy + ry * a1.sin());
segments.push([end, end, start]);
SubPath { start, segments, closed: true }
}
fn rectangle_sub(x0: f64, y0: f64, x1: f64, y1: f64) -> SubPath {
rectangle(x0, y0, x1, y1)
}
pub(crate) fn rectangle(x0: f64, y0: f64, x1: f64, y1: f64) -> SubPath {

View File

@ -1,8 +1,8 @@
//! 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.
//! natively and look like Affinity's own picture of them: older corpus files embed a thumbnail,
//! while the #1606 samples include exported PNG references. Each file's mean difference (0–255 per
//! channel, flattened on white and compared at the smaller size) 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)]
@ -27,12 +27,30 @@ const FILES: &[(&str, f64)] = &[
("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/shape_test.afdesign", 6e+00), // 5.44 after importing the public cloud/heart geometry
("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
("affinity-samples/01-layer-effects.af", 11.0), // 8.86–9.64 measured (fonts differ by machine): effects and live filters not imported
("affinity-samples/02-adjustment-layers.af", 38.0), // 36.19–36.69 measured (fonts differ by machine): adjustment layers not imported
("affinity-samples/03-brush-strokes.af", 3.0), // 1.39–1.93 measured (fonts differ by machine): pressure strokes approximated
("affinity-samples/04-master-page.af", 3.0), // 1.49–1.51 measured (fonts differ by machine): master content not placed on pages
("affinity-samples/05-symbols.af", 21.5), // 19.49–20.47 measured (fonts differ by machine): cached symbol and embedded pictures
("affinity-samples/06-special-shapes.af", 7.5), // 5.68–6.42 measured (fonts differ by machine): special-shape geometry
("affinity-samples/07-transparency.af", 13.5), // 11.61–12.40 measured (fonts differ by machine): transparency and bitmap fills
("affinity-samples/08-gradients.af", 2.0), // 0.44–0.94 measured (fonts differ by machine): conical gradient currently maps to Angle
("affinity-samples/09-corners-stars.af", 2.5), // 0.80–1.48 measured (fonts differ by machine): non-round corners and rounded stars
("affinity-samples/10-multiple-fills.af", 11.5), // 9.50–10.29 measured (fonts differ by machine): only one fill/stroke per object
("affinity-samples/11-text.af", 4.5), // 2.66–3.41 measured (fonts differ by machine): outlined/scaled text and text fields
("affinity-samples/12-cmyk8.af", 16.0), // 14.04–14.67 measured (fonts differ by machine): document profile not applied
("affinity-samples/12-grey8.af", 26.5), // 24.85–25.47 measured (fonts differ by machine): grey document opened as RGB
("affinity-samples/12-lab16.af", 7.5), // 5.55–6.17 measured (fonts differ by machine): Lab profile and depth not preserved
("affinity-samples/12-rgb16.af", 2.5), // 0.47–1.09 measured (fonts differ by machine): image content remains close after conversion
("affinity-samples/12-rgb32.af", 87.5), // 86.05–86.19 measured (fonts differ by machine): 32-bit RGB is reduced to 8-bit
("affinity-samples/12-rgb8.af", 2.5), // 0.47–1.10 measured (fonts differ by machine): RGB/8 reference
("affinity-samples/13-blend-modes.af", 12.0), // 9.67–10.57 measured (fonts differ by machine): Affinity-only blend modes map to Normal
];
fn corpus(rel: &str) -> Vec<u8> {
@ -42,7 +60,9 @@ fn corpus(rel: &str) -> Vec<u8> {
/// Straight RGBA8 pixels flattened on white.
fn on_white(px: &[u8]) -> Vec<f64> {
px.chunks_exact(4)
px.as_chunks::<4>()
.0
.iter()
.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))
@ -78,14 +98,23 @@ fn shrink(src: &[f64], sw: usize, sh: usize, dw: usize, dh: usize) -> Vec<f64> {
}
#[test]
fn public_affinity_documents_look_like_their_affinity_thumbnails() {
fn public_affinity_documents_match_their_render_reference() {
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 reference = if name.starts_with("affinity-samples/") {
let path = if *name == "affinity-samples/04-master-page.af" {
"affinity-samples/04-master-page_1.png".to_string()
} else {
format!("{}.png", &name[..name.len() - 3])
};
photocraft_codecs::decode(&corpus(&path)).unwrap()
} else {
photocraft_codecs::decode(photocraft_affinity::preview(&bytes).unwrap().png).unwrap()
};
let (tw, th) = reference.dimensions();
let png = export(&r.document, "x.png", &ExportOptions::default()).unwrap().bytes;
let flat = photocraft_codecs::decode(&png).unwrap();
let (fw, fh) = flat.dimensions();
@ -96,7 +125,7 @@ fn public_affinity_documents_look_like_their_affinity_thumbnails() {
// 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 want = shrink(&on_white(&reference.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 {
@ -105,3 +134,25 @@ fn public_affinity_documents_look_like_their_affinity_thumbnails() {
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[test]
fn issue_1606_special_shapes_match_the_exported_sample() {
let name = "affinity-samples/06-special-shapes.af";
let bytes = corpus(name);
let imported = import(name, &bytes).unwrap();
assert!(
!imported.warnings.iter().any(|w| w.contains("unrecognized parametric shape")),
"supported special-shape sample still reports a bounding-ellipse fallback: {:?}",
imported.warnings
);
let rendered = photocraft_codecs::decode(&export(&imported.document, "x.png", &ExportOptions::default()).unwrap().bytes).unwrap();
let reference = photocraft_codecs::decode(&corpus("affinity-samples/06-special-shapes.png")).unwrap();
let (rw, rh) = reference.dimensions();
let (ow, oh) = rendered.dimensions();
let (w, h) = (rw.min(ow), rh.min(oh));
let got = shrink(&on_white(&rendered.to_rgba8()), ow as usize, oh as usize, w as usize, h as usize);
let want = shrink(&on_white(&reference.to_rgba8()), rw as usize, rh as usize, w as usize, h as usize);
let diff = got.iter().zip(&want).map(|(a, b)| (a - b).abs()).sum::<f64>() / want.len() as f64;
eprintln!("{name} vs exported PNG: {diff:.2} ({}×{} vs {}×{})", ow, oh, rw, rh);
assert!(diff <= 7.5, "special-shape sample exceeded its corpus ceiling: {diff:.2} > 7.5");
}

View File

@ -407,7 +407,7 @@ against committed **sha256 manifests**. All pins are in one place:
| `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/exr/` | the 5 deep OpenEXR test images (scanline deep data with half colour and u32 ID channels; 2.3 MB), checked against the ID manifests of their upstream sidecars | OpenEXR upstream at v3.5.2 (BSD-3-Clause) | `xtask/exr-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/affinity/` | 21 public Affinity documents and the 18 Affinity 3 samples for #1606, with exported PNG references | vector-art and affinity-samples (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

@ -39,6 +39,14 @@ approximated or left out, each with a warning; damaged or unknown files fall bac
preview. Affinity writing is not implemented: no Affinity installation was available to check
written files, so `.af` export stays unsupported.
2026-10-09: `corpus/affinity/` now has 39 pinned documents and 21 PNGs (20 rendered references and
one bitmap-fill texture): the prior 21 public documents plus 18 CC0 samples for #1606. The new
samples compare against their exported PNGs;
mean differences range from 0.44/255 (conical gradients) to 86.05/255 (RGB/32 reduced to 8-bit).
Clouds, hearts, cogs, callouts, arrows, double stars, tears, crescents, diamonds and circular segments
now import as parametric shapes; the special-shapes sample measures 5.68/255 against its PNG. The
remaining gaps in #1606 stay tracked with per-sample corpus ceilings.
## Honest parity assessment (2026-10-05)
This is the reference answer to "how close are we to Photoshop parity, really". Agents: read it

View File

@ -7,6 +7,45 @@ e56c35f876277b101790ff4384cfde103a093ed6c81790a2d62176de21da339f afdesignload/r
08946c60c0da1c8aeea92d65acfb244721eeb3b480ed358609f4a48bbecfc5b9 afdesignload/shape_test.afdesign
7306ab4fcbdc225d816f32a0d2aca15ac887053f60170459ac947e70f4ef343d afdesignload/slice_test.afdesign
e5d04bac0235af9386aea251cdd6903ed7810d335fdb6194d0483ea30b14e24a afdesignload/test_path.afdesign
5f5b98f0ab2121f75f24362792c00cd5b610767820eb754b5660668f478b0c86 affinity-samples/01-layer-effects.af
33c1ad5022abc14e6136f4cb9fa0751c9778a0a564bc3abc2e0e6b051f7e00bb affinity-samples/01-layer-effects.png
4d19e95abe3423692f5bdfd0ede5bf3aebdd4bf16c32eaa304cead91a7a2e9b7 affinity-samples/02-adjustment-layers.af
726b7939e23a745f7578fa84188b56c2eb845cde28493499f1b18b4ede9b8a6e affinity-samples/02-adjustment-layers.png
c6ca86439c89d83ba43c76e9d783865ac09ca0ff524d5b347caa77f473caac98 affinity-samples/03-brush-strokes.af
e57e36ffffdd88d3a62ebdb142435d7d3056286b22130f459c404a3ef529f61f affinity-samples/03-brush-strokes.png
e3bf04f7cc02e1d49ec79503f2b4044f385de1e890bd461013a5a6fca09b5223 affinity-samples/04-master-page.af
246279f00d1f08917409b34e60359bd3bba158bcf4e8d5847a806f575d8eb98f affinity-samples/04-master-page_1.png
0e023792ce89f724b1dfedb6360556348309e483c23e32b91d87afab5d64c9cc affinity-samples/04-master-page_2.png
f431b15027e20ab760c4d241aa6981130560409cb270bbc6d4dd9241d1883f0a affinity-samples/04-master-page_3.png
ef779b58a6721001ee961b42eacb96fb47dcaa35ccb7e6ccf2ec6f33106070b2 affinity-samples/05-symbols.af
6675fc4a1563f61b7fcaa64978447e0fa6e1b0bb8073b4e63f958db1cf726fef affinity-samples/05-symbols.png
9e463b4d1ca8e7a1b67b3c30351a28af8ed05b9cd11f3cea2159319c9974bc67 affinity-samples/06-special-shapes.af
a897c607631acbea31c5a926af6ca20c0acb2744636003b8c9ef07861752d947 affinity-samples/06-special-shapes.png
3aba0d83055f09da4dba465a3abf66c77d4362dc6060548e54db943029b3996e affinity-samples/07-transparency.af
3b73945b7302627caf679a961053f30a9593bab7d709e27acb1df54dadc53968 affinity-samples/07-transparency.png
4292c1571b6c8c19b80c530602ca21d5996df7b99221639a4cde76d8307ffbcb affinity-samples/08-gradients.af
f6fb427c9715ea193e595d0c441b462224971fcda2aa2b4d8b71631d0f59f992 affinity-samples/08-gradients.png
c913882b3bea7a4218aa88e96890181cdc08ce37f0859196a1ad1828be78b347 affinity-samples/09-corners-stars.af
068bcc7599336a174d7ce87962da414ccf429d549484ccbe8f68f669779d84d0 affinity-samples/09-corners-stars.png
f03e797d6dbf8a31eab9d0b9cf9a8bddcc8440c6003863f31d97fe23c195c4b2 affinity-samples/10-multiple-fills.af
e1951aa6d8acdbeccc281b6a20a03df9b257c0d739aa5f6aec27c105151fef3f affinity-samples/10-multiple-fills.png
6da8b59b40d0e082435c5a2004c50fa90e3c4f15c3a02763b8edf84b8b9f4506 affinity-samples/11-text.af
bb2040cda017cb753d0594416ac0501a1049bc333a7cf2c1c832356e2bbe1a45 affinity-samples/11-text.png
0c13cfe0f716902199775251b0983f28d60a64c1f79d3c6f496f2c019365bdb5 affinity-samples/12-cmyk8.af
cfef257c50201968121332e8ae12a7fe2ecdcc3bce1faeb5e581751662098c05 affinity-samples/12-cmyk8.png
03d4cfe5ce4b80833d83de89848e88639056e8984c049865c8f29b9ef41b40e5 affinity-samples/12-grey8.af
855e4f08f21c60f1d2080c9533b98f99c7a635c5804abf8ec9d6ee1a29029cef affinity-samples/12-grey8.png
9adc0d4242b5448ac84aef886bd7afc165e1959015125ce21bf402ddb65caba1 affinity-samples/12-lab16.af
6c30cef8304fd08539d631988280a6b3bc8570db3f1425fa771d83c1591dfc8d affinity-samples/12-lab16.png
26b47861275b2f83517e7d42ec051efc3cd22956440784b6df0bc016ecb0e008 affinity-samples/12-rgb16.af
967ba0cb7d93496a7e454dcc37013c1b19089cc283c7e08f2dea7023ad330e80 affinity-samples/12-rgb16.png
36e62b0de8488712c04831ba3ef466da68bf080ec3caf9fbe6646905d3fb87e8 affinity-samples/12-rgb32.af
0ebcf7211447d64158c21c27eeb19b8737cb2cdcc708dcdf3b4fed5041b449e9 affinity-samples/12-rgb32.png
4d210ce4724409fb6c9715046ad9efa0049442f8324a21174068a02c6df10632 affinity-samples/12-rgb8.af
4e4dda966c337119165ba5d88cb3d4af10bf6507b9a69986d33060a8eeb37c21 affinity-samples/12-rgb8.png
b84815dbb5e3a7f7842d860acb888f93dbd1f949bcb6a3bf858515b85ea3c3b7 affinity-samples/13-blend-modes.af
a49d2fe6043002aef4f4c6b8cf64a04838d21cf29795324c02fb1772d5519a21 affinity-samples/13-blend-modes.png
b73eceb73a45df1a424d60164b7e1b521b9f01f56fe86159e477b49c9cd90f78 affinity-samples/source/07-texture.png
e1b121d7c3d02356baa5dea512f5aa1fe77249c28d7b227e29cab2bc67ec323a asset-store-template/AssetStore.aftemplate
fbb196e918e302c16f8de9f2e6dffaef70ab3c317b32a9f44607fd4b2ead0206 jac21/SimpleLogo.afdesign
9d874245936483de3ec04cca48b383028d33666801dff1814246a44e695e29b8 jac21/SimpleLogoBanner.afdesign

View File

@ -139,8 +139,8 @@ fn list() {
pillow-heif@{} (BSD-3-Clause), manifest xtask/heif-corpus.sha256. Fetch: --heif
corpus/exr/ [{}] the deep OpenEXR test images (BSD-3-Clause), openexr@{}
manifest xtask/exr-corpus.sha256. Fetch: --exr
corpus/affinity/ [{}] 21 public Affinity 1–3 documents (CC0, MIT): vector-art, AFDesignLoad,
Jac21/Branding, AssetStoreTemplate; manifest xtask/affinity-corpus.sha256. Fetch: --affinity
corpus/affinity/ [{}] public Affinity documents and #1606 samples (CC0, MIT), with PNG references;
manifest xtask/affinity-corpus.sha256. Fetch: --affinity
corpus/pngsuite/ [{}] PngSuite (public domain), {PNGSUITE_URL}. Fetch: --pngsuite
corpus/pixls/ [opt-in] real camera raws from raw.pixls.us (public domain), one per decode
path plus the known-unsupported packed ORF. Fetch: --pixls (opt-in)

View File

@ -35,6 +35,9 @@ pub const AFDESIGNLOAD_COMMIT: &str = "a18dd50a7079fb861a28835eeefa0d015f03f4a1"
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";
/// https://github.com/satoshoe-dev/affinity-samples (CC0-1.0): purpose-made Affinity 3 documents
/// and exported PNG references for the gaps tracked in #1606.
pub const AFFINITY_SAMPLES_COMMIT: &str = "96aca6bd86bbe7a677f1cf0630bd832d23943be8";
/// PngSuite (public domain), a fixed release archive.
pub const PNGSUITE_URL: &str = "http://www.schaik.com/pngsuite/PngSuite-2017jul19.tgz";
@ -208,9 +211,16 @@ pub const AFFINITY: PinnedCorpus = PinnedCorpus {
subdir: "AssetStoreTemplate",
extras: &[("LICENSE", "asset-store-template/LICENSE")],
},
Upstream {
prefix: "affinity-samples/",
repo: "satoshoe-dev/affinity-samples",
commit: AFFINITY_SAMPLES_COMMIT,
subdir: "",
extras: &[("LICENSE", "affinity-samples/LICENSE"), ("README.md", "affinity-samples/README.md")],
},
],
manifest: "xtask/affinity-corpus.sha256",
exts: &["af", "afdesign", "aftemplate"],
exts: &["af", "afdesign", "aftemplate", "png"],
subset: true,
sources_md: |c| {
format!(
@ -218,7 +228,8 @@ pub const AFFINITY: PinnedCorpus = PinnedCorpus {
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). \
https://github.com/eviltwo/AssetStoreTemplate/tree/{ASSET_STORE_TEMPLATE_COMMIT}/AssetStoreTemplate (`asset-store-template/`, MIT) and \
https://github.com/satoshoe-dev/affinity-samples/tree/{AFFINITY_SAMPLES_COMMIT} (`affinity-samples/`, CC0-1.0; includes PNG references for issue #1606). \
Selected by and sha256-verified against `{}` by `cargo xtask corpus --affinity`. Gitignored; never commit these files.\n",
c.manifest
)

View File

@ -182,7 +182,11 @@ impl PinnedCorpus {
let mut tar = Command::new("tar");
tar.arg("-xzf").arg(&tgz).arg("-C").arg(&out).arg(format!("{top}/{}", up.subdir));
for (from, _) in up.extras {
tar.arg(format!("{top}/{from}"));
// A root-level corpus selection (`subdir == ""`) already extracts the whole repo
// root; adding the same root files as individual tar selectors fails on GNU tar.
if !up.subdir.is_empty() {
tar.arg(format!("{top}/{from}"));
}
}
run(tar, &format!("tar -xzf {}", tgz.display()))?;
let _ = std::fs::remove_file(&tgz);