From 590c1496b428e04ea6a71197dff3b40573560533 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 9 Oct 2026 19:27:35 -0400 Subject: [PATCH] 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 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ATTRIBUTION.md | 2 +- crates/affinity/README.md | 17 ++- crates/affinity/src/geometry.rs | 2 +- crates/affinity/src/shapes.rs | 195 ++++++++++++++++++++++++++++- crates/io/tests/affinity_corpus.rs | 71 +++++++++-- docs/development.md | 2 +- docs/roadmap.md | 8 ++ xtask/affinity-corpus.sha256 | 39 ++++++ xtask/src/corpus.rs | 4 +- xtask/src/corpus_pins.rs | 15 ++- xtask/src/pinned.rs | 6 +- 11 files changed, 331 insertions(+), 30 deletions(-) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 32c27346..1b2ba643 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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 | | 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 | diff --git a/crates/affinity/README.md b/crates/affinity/README.md index 3672eb3a..0fcc9647 100644 --- a/crates/affinity/README.md +++ b/crates/affinity/README.md @@ -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 diff --git a/crates/affinity/src/geometry.rs b/crates/affinity/src/geometry.rs index 6a63190c..08554134 100644 --- a/crates/affinity/src/geometry.rs +++ b/crates/affinity/src/geometry.rs @@ -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) } } } diff --git a/crates/affinity/src/shapes.rs b/crates/affinity/src/shapes.rs index 7f845dfe..5e825fa8 100644 --- a/crates/affinity/src/shapes.rs +++ b/crates/affinity/src/shapes.rs @@ -43,7 +43,7 @@ fn polygon(pts: impl IntoIterator) -> Option { } /// 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 { +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 = (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 { diff --git a/crates/io/tests/affinity_corpus.rs b/crates/io/tests/affinity_corpus.rs index e4cb2921..52579e7f 100644 --- a/crates/io/tests/affinity_corpus.rs +++ b/crates/io/tests/affinity_corpus.rs @@ -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 { @@ -42,7 +60,9 @@ fn corpus(rel: &str) -> Vec { /// Straight RGBA8 pixels flattened on white. fn on_white(px: &[u8]) -> Vec { - 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 { } #[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::() / 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::() / 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"); +} diff --git a/docs/development.md b/docs/development.md index d9eb1150..7b9a90df 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 diff --git a/docs/roadmap.md b/docs/roadmap.md index 15979398..bd2b8e28 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 diff --git a/xtask/affinity-corpus.sha256 b/xtask/affinity-corpus.sha256 index 933e4dcf..45b97742 100644 --- a/xtask/affinity-corpus.sha256 +++ b/xtask/affinity-corpus.sha256 @@ -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 diff --git a/xtask/src/corpus.rs b/xtask/src/corpus.rs index 5f9ec78a..43f943c8 100644 --- a/xtask/src/corpus.rs +++ b/xtask/src/corpus.rs @@ -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) diff --git a/xtask/src/corpus_pins.rs b/xtask/src/corpus_pins.rs index ef7bebe0..b8d37704 100644 --- a/xtask/src/corpus_pins.rs +++ b/xtask/src/corpus_pins.rs @@ -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 ) diff --git a/xtask/src/pinned.rs b/xtask/src/pinned.rs index 917c0b86..c18de369 100644 --- a/xtask/src/pinned.rs +++ b/xtask/src/pinned.rs @@ -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);