Files
photocraft/crates/io/tests/affinity.rs
callmeBallochandClaude Opus 5.5 ae0313d325 AF.3: native Affinity import with artboards, vectors, text and images
photocraft-affinity now reads native Affinity documents (container
versions 8-12): the FAT and revision chain, stored/zlib/zstd entries
with predictors and CRC checks, the tagged object stream in doc.dat,
and the document model (pages, artboards, layers, groups, curves,
geometric and compound shapes, fills, gradients, strokes, text, placed
images, pixel layers, masks and embedded-document caches). Every size
is bounded and malformed input returns an error.

photocraft-io maps it to an RGB8 document with artboards, groups, shape
layers, type layers, embedded smart objects, pixel layers and
vector/layer masks; what can't be mapped is listed in the warnings. The
embedded preview is now only a fallback when the native data can't be
read, and only that preview is refused by Place and warningless imports.

`cargo xtask corpus --affinity` pins 21 public CC0/MIT documents,
sha256-verified; the io corpus test compares each import with the
thumbnail Affinity saved in it (mean difference 0-4.6 of 255).
Affinity export is not implemented: no Affinity installation was
available to validate written files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-08 18:46:22 +00:00

333 lines
15 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Affinity documents: native import (layers, artboards, shapes, paints, text, masks) and the
//! embedded preview only as a warned fallback that keeps its pixels and alpha; never written back.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType};
use photocraft_io::{ExportOptions, export, import};
use proptest::prelude::*;
use std::io::Write as _;
fn png() -> Vec<u8> {
let img = Image::from_raw(2, 1, ChannelLayout::Rgba, SampleType::U8, vec![220, 40, 60, 255, 0, 0, 0, 0]).unwrap();
photocraft_codecs::encode(&img, Format::Png, &EncodeOptions::default()).unwrap()
}
fn chunk(kind: &[u8; 4], data: &[u8]) -> Vec<u8> {
let mut tagged = kind.to_vec();
tagged.extend(data);
let mut result = (data.len() as u32).to_be_bytes().to_vec();
result.extend(&tagged);
result.extend(crc32fast::hash(&tagged).to_be_bytes());
result
}
/// A synthetic record envelope, not an Affinity document writer (there is no object graph).
fn file(png: &[u8]) -> Vec<u8> {
let mut b = vec![0; 72];
b[..4].copy_from_slice(photocraft_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_is_named_and_warns_at_its_actual_size() {
let r = import("art.af", &file(&png())).unwrap();
assert_eq!((r.document.size.width, r.document.size.height), (2, 1));
assert_eq!(r.document.layers.len(), 1);
assert_eq!(r.document.layers[0].name, "Affinity preview");
assert!(r.source_read_only && r.preview_only);
assert!(r.warnings[0].contains("2×1"));
assert!(r.warnings[0].contains("native Affinity document could not be read") && r.warnings[0].contains("no layers"), "{}", r.warnings[0]);
let out = export(&r.document, "copy.png", &ExportOptions::default()).unwrap();
let img = photocraft_codecs::decode(&out.bytes).unwrap();
assert_eq!(img.data(), &[220, 40, 60, 255, 0, 0, 0, 0]);
let native = export(&r.document, "copy.pcraft", &ExportOptions::default()).unwrap();
let back = import("copy.pcraft", &native.bytes).unwrap();
assert_eq!(back.document.size, r.document.size);
assert!(!back.source_read_only && !back.preview_only);
for ext in photocraft_io::affinity::EXTENSIONS {
assert!(export(&r.document, ext, &ExportOptions::default()).unwrap_err().to_string().contains("Affinity export"));
}
}
#[test]
fn magic_wins_over_the_name_and_renamed_legacy_files_are_rejected() {
assert!(import("renamed.png", &file(&png())).unwrap().source_read_only);
assert!(import("unknown", &file(&png())).unwrap().source_read_only);
assert!(import("fake.af", &png()).is_err());
// Affinity 1 and 2 (container versions 8 to 11) store the same preview record.
let mut legacy = file(&png());
legacy[4..6].copy_from_slice(&10u16.to_le_bytes());
assert!(import("legacy.afphoto", &legacy).unwrap().preview_only);
legacy[4..6].copy_from_slice(&13u16.to_le_bytes());
assert!(import("future.af", &legacy).unwrap_err().to_string().contains("unknown container version"));
}
#[test]
fn preview_decoder_preserves_16_bit_gray_and_alpha() {
let img = Image::from_u16(2, 1, ChannelLayout::GrayA, &[12345, 65535, 0, 0]).unwrap();
let encoded = photocraft_codecs::encode(&img, Format::Png, &EncodeOptions::default()).unwrap();
let r = import("gray.af", &file(&encoded)).unwrap();
assert_eq!(r.document.depth, photocraft_color::SampleType::U16);
assert_eq!(r.document.mode, photocraft_color::ColorMode::Grayscale);
let out = export(&r.document, "copy.png", &ExportOptions::default()).unwrap();
let decoded = photocraft_codecs::decode(&out.bytes).unwrap();
assert_eq!(decoded.sample_type(), SampleType::U16);
assert_eq!(decoded.data(), img.data());
}
#[test]
fn previewless_current_files_return_an_export_fallback() {
let mut bytes = file(&png());
bytes[24..32].fill(0);
let error = import("no-preview.af", &bytes).unwrap_err().to_string();
assert!(error.contains("no embedded preview"), "{error}");
}
#[test]
fn corruption_and_every_truncation_fail_without_a_document() {
let b = file(&png());
for end in 0..b.len() {
assert!(import("bad.af", &b[..end]).is_err(), "{end}");
}
let mut bad_crc = b.clone();
// IHDR's CRC; the envelope reader rejects every PNG chunk's bad CRC.
bad_crc[130] ^= 1;
assert!(import("bad.af", &bad_crc).is_err());
}
#[test]
fn crc_failures_after_idat_cannot_be_tolerated_as_partial_pngs() {
let mut bad_iend = file(&png());
*bad_iend.last_mut().unwrap() ^= 1;
assert!(import("bad-iend.af", &bad_iend).unwrap_err().to_string().contains("PNG chunk checksum"));
let mut encoded = png();
let tagged = b"tEXtnote\0preview";
let mut chunk = ((tagged.len() - 4) as u32).to_be_bytes().to_vec();
chunk.extend(tagged);
chunk.extend(crc32fast::hash(tagged).to_be_bytes());
let end = encoded.len() - 12;
encoded.splice(end..end, chunk.clone());
assert!(import("valid-ancillary.af", &file(&encoded)).is_ok());
encoded[end + chunk.len() - 1] ^= 1;
assert!(import("bad-ancillary.af", &file(&encoded)).unwrap_err().to_string().contains("PNG chunk checksum"));
}
#[test]
fn compressed_metadata_and_apng_fail_before_reaching_the_decoder() {
for kind in [b"zTXt", b"iTXt", b"iCCP", b"acTL", b"fcTL", b"fdAT"] {
let mut encoded = png();
// A tiny declared payload stands in for arbitrarily large inflated metadata. It must
// return the format's unsupported error, not be passed to a decompressor.
let mut tagged = kind.to_vec();
tagged.extend(b"preview\0\0\x78\x9c");
let mut chunk = ((tagged.len() - 4) as u32).to_be_bytes().to_vec();
chunk.extend(&tagged);
chunk.extend(crc32fast::hash(&tagged).to_be_bytes());
let end = encoded.len() - 12;
encoded.splice(end..end, chunk);
let error = import("unsupported.af", &file(&encoded)).unwrap_err().to_string();
assert!(error.contains("unsupported Affinity file"), "{kind:?}: {error}");
assert!(error.contains("PNG metadata") || error.contains("animated PNG"), "{error}");
}
}
#[test]
fn many_valid_compressed_text_chunks_cannot_bypass_the_total_memory_budget() {
let mut compressor = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best());
for _ in 0..1024 {
compressor.write_all(&[b'x'; 1024]).unwrap();
}
let compressed = compressor.finish().unwrap();
assert!(compressed.len() < 2048);
let mut tagged = b"zTXtnote\0\0".to_vec();
tagged.extend(compressed);
let mut chunk = ((tagged.len() - 4) as u32).to_be_bytes().to_vec();
chunk.extend(&tagged);
chunk.extend(crc32fast::hash(&tagged).to_be_bytes());
let mut encoded = png();
let end = encoded.len() - 12;
// A few MiB of encoded PNG would otherwise carry 1 GiB of aggregate text.
encoded.splice(end..end, chunk.repeat(1024));
let error = import("text-bomb.af", &file(&encoded)).unwrap_err().to_string();
assert!(error.contains("compressed PNG metadata"), "{error}");
}
#[test]
fn invalid_critical_chunks_after_idat_fail_before_png_finish() {
let original = png();
for trailing in [chunk(b"IHDR", &original[16..29]), chunk(b"PLTE", &[0, 0, 0]), chunk(b"ABCD", &[])] {
let mut encoded = original.clone();
let end = encoded.len() - 12;
encoded.splice(end..end, trailing);
let error = import("bad-order.af", &file(&encoded)).unwrap_err().to_string();
assert!(error.contains("Affinity file"), "{error}");
}
let mut encoded = original;
let end = encoded.len() - 12;
let mut interrupted = chunk(b"tEXt", b"note\0preview");
interrupted.extend(chunk(b"IDAT", &[]));
encoded.splice(end..end, interrupted);
assert!(import("split-idat.af", &file(&encoded)).unwrap_err().to_string().contains("non-contiguous PNG image data"));
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(128))]
#[test]
fn preview_and_decoder_survive_mutations(edits in prop::collection::vec((0usize..300, any::<u8>()), 0..16)) {
let mut b = file(&png());
for (i, v) in edits { if let Some(byte) = b.get_mut(i) { *byte = v; } }
let _ = import("mutated.af", &b);
}
}
mod native {
use super::*;
use photocraft_affinity::synth::{self, F, Method, tag};
use photocraft_doc::LayerContent;
fn rgba(r: f32, g: f32, b: f32, a: f32) -> F {
F::Struct([r, g, b, a].iter().flat_map(|v| v.to_le_bytes()).collect())
}
fn solid(id: u32, c: F) -> F {
F::Def(
id,
vec![tag(b"FDsc")],
vec![(tag(b"FDeF"), F::Def(id + 1, vec![tag(b"FilS")], vec![(tag(b"Colr"), F::Def(id + 2, vec![tag(b"RGBA")], vec![(tag(b"_col"), c)]))]))],
)
}
fn node(x: f64, y: f64) -> Vec<u8> {
let mut r = x.to_le_bytes().to_vec();
r.extend(y.to_le_bytes());
r.extend([1, 0]);
r
}
/// A version-12 document at 144 ppi, 200×100: an artboard "Board" moved by (20, 10) holding
/// a red ring (two subpaths, filled even-odd) with a 4 px blue stroke.
pub(super) fn document(method: Method) -> Vec<u8> {
let square = |s: f64, o: f64| vec![node(o, o), node(o + s, o), node(o + s, o + s), node(o, o + s), node(o, o)];
let ring = F::Def(
20,
vec![tag(b"PCrv")],
vec![
(tag(b"Desc"), F::Str("Ring".into())),
(
tag(b"Crvs"),
F::Obj(
tag(b"PCvD"),
vec![(
tag(b"Data"),
F::Pos(vec![
F::U8(0),
F::U32(2),
F::Bool(true),
F::Records(18, square(60.0, 0.0)),
F::Bool(true),
F::Records(18, square(20.0, 20.0)),
]),
)],
),
),
(tag(b"BFFl"), F::Shared(vec![solid(21, rgba(1.0, 0.0, 0.0, 1.0))])),
(tag(b"LIFl"), F::Shared(vec![solid(24, rgba(0.0, 0.0, 1.0, 1.0))])),
(
tag(b"LILn"),
F::Shared(vec![F::Def(27, vec![tag(b"LDsc")], vec![(tag(b"LDeL"), F::Def(28, vec![tag(b"LSty")], vec![(tag(b"Wght"), F::F64(4.0))]))])]),
),
],
);
let board = F::Def(
10,
vec![tag(b"ShpN")],
vec![
(tag(b"Desc"), F::Str("Board".into())),
(tag(b"ABEn"), F::Bool(true)),
(tag(b"Shpe"), F::Def(11, vec![tag(b"ShNR")], vec![])),
(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 120.0, 80.0])),
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 20.0, 0.0, 1.0, 10.0])),
(tag(b"BFFl"), F::Shared(vec![solid(12, rgba(1.0, 1.0, 1.0, 1.0))])),
(tag(b"Chld"), F::Shared(vec![ring])),
],
);
let spread = F::Def(
2,
vec![tag(b"Sprd")],
vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 200.0, 100.0])), (tag(b"SprT"), F::Bool(true)), (tag(b"Chld"), F::Shared(vec![board]))],
);
let doc = synth::stream(&[
(tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(144.0))])),
(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))])),
]);
synth::container(&[("doc.dat", &doc, method)], Some(&png()))
}
#[test]
fn artboards_shapes_and_paints_import_as_editable_layers() {
for method in [Method::Stored, Method::Zlib, Method::Zstd] {
let r = import("native.af", &document(method)).unwrap();
assert!(r.source_read_only && !r.preview_only, "{:?}", r.warnings);
let d = &r.document;
assert_eq!((d.size.width, d.size.height, d.resolution_dpi), (200, 100, 144.0));
let board = &d.layers[0];
assert_eq!(board.name, "Board");
let LayerContent::Group(g) = &board.content else { panic!("{:?}", board.content) };
let ab = g.artboard.as_ref().unwrap();
assert_eq!((ab.rect.x0, ab.rect.y0, ab.rect.x1, ab.rect.y1), (20, 10, 140, 90));
let ring = &g.children[0];
assert_eq!(ring.name, "Ring");
let LayerContent::Shape(sh) = &ring.content else { panic!() };
assert_eq!(sh.path.subpaths.len(), 2);
assert_eq!(sh.path.subpaths[0].knots.len(), 4, "the closing duplicate knot is merged");
assert_eq!(sh.path.subpaths[0].knots[0].anchor, photocraft_geom::Point::new(20.0, 10.0));
assert_eq!(sh.stroke.as_ref().unwrap().width, 4.0);
// The cache shows the ring: red on it, empty in its hole.
let cache = sh.cache.as_ref().unwrap();
let px = |x: i32, y: i32| cache.to_interleaved(photocraft_geom::Rect::new(x, y, x + 1, y + 1));
assert_eq!(px(30, 20), [255, 0, 0, 255]);
assert_eq!(px(50, 40)[3], 0, "even-odd hole");
// Saved as a native PhotoCraft document and opened again.
let saved = export(d, "copy.pcraft", &ExportOptions::default()).unwrap();
let back = import("copy.pcraft", &saved.bytes).unwrap();
assert_eq!(back.document.layers.len(), d.layers.len());
assert!(export(d, "copy.psd", &ExportOptions::default()).is_ok());
assert!(export(d, "copy.af", &ExportOptions::default()).is_err());
}
}
#[test]
fn a_damaged_native_document_falls_back_to_its_preview_with_the_reason() {
let mut bytes = document(Method::Zlib);
bytes[80] ^= 0xFF;
let r = import("broken.af", &bytes).unwrap();
assert!(r.preview_only);
assert!(r.warnings[0].contains("could not be read (damaged Affinity file"), "{}", r.warnings[0]);
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn mutated_native_documents_never_panic(edits in prop::collection::vec((0usize..2048, any::<u8>()), 1..16), stored in any::<bool>()) {
let mut b = document(if stored { Method::Stored } else { Method::Zstd });
let n = b.len();
for (i, v) in edits { b[i % n] = v; }
let _ = import("mutated.af", &b);
}
}
}