Files
craft-libs/crates/affinity/tests/it/outline_limits.rs
Dylan M. Taylor 0984f412a6 craft-affinity: bounded reader for native Affinity documents, from VectorCraft and PhotoCraft (#914)
* craft-affinity: bounded reader for native Affinity documents, moved from VectorCraft

The reader of .af, .afdesign, .afphoto and .afpub files (archive, object stream and document
model) as it is in storytold/vectorcraft at 6e873b80 (crates/affinity), under a neutral name.
Its corpus tests read the pinned public documents scripts/fetch-affinity-corpus.sh fetches.

* craft-affinity: fold in what only PhotoCraft's copy of the reader had

From storytold/photocraft at 63b641b5 (crates/affinity), onto the VectorCraft copy:

- Special shapes: clouds, hearts, cogs, callouts, arrows, double stars, tears, crescents,
  diamonds and segments come in as their own outlines, not their bounding ellipse.
- Null slots of object lists are charged to the document's work before a list is scanned,
  on every visit of a shared object (children, attachments, effects, compound operands,
  page lists, paint lists, gradient colours, text blocks and runs, table cells, tile lists).
- A colour or gradient position record is decoded only as far as its components are read.
- 16-bit and 8-bit planes are interleaved through tables, not an f64 for each pixel.
- Limits::max_pixel_bytes: the caller sets what the pixel layers of one import may hold.
- Pixels::cmyka8_to_rgba8 for a caller without colour management.

The crate README and docs describe the model on its own terms.

* craft-affinity: a callout with a right-to-left box no longer panics; an oversized shape imports as its box

Ported from storytold/photocraft#10473 by DJHoltkamp. A callout rectangle clamped its tail
with clamp(x0, x1), which panics when the box is stored right to left. A box with finite
corners whose size overflows gave infinite or NaN outline points; it now imports as its box
with a warning. Also drops the inflated doc.dat once it is parsed.
2026-11-11 17:42:11 -05:00

283 lines
13 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.
//! Original synthetic graph tests for bounded `.af` outline expansion. These model the reader's
//! accepted schema, without using Affinity software, third-party parser code or external assets.
use craft_affinity::{
Document, Limits,
model::{Kind, Path, Point},
synth::{self, F, Method, tag},
};
fn compound(id: u32, children: Vec<F>) -> F {
F::Def(id, vec![tag(b"Comp")], vec![(tag(b"Chld"), F::Shared(children))])
}
fn shape(id: u32, class: &[u8; 4], parameters: Vec<(craft_affinity::stream::Tag, F)>) -> F {
F::Def(
id,
vec![tag(b"ShpN")],
vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(id + 10_000, vec![tag(class)], parameters))],
)
}
fn read(definitions: Vec<F>, top: u32) -> Document {
try_read(definitions, top).unwrap()
}
fn try_read(definitions: Vec<F>, top: u32) -> Result<Document, craft_affinity::Error> {
let data = synth::stream(&[
(tag(b"Defs"), F::Shared(definitions)),
(
tag(b"DocR"),
F::Def(
1,
vec![tag(b"DocN")],
vec![(
tag(b"Chld"),
F::Shared(vec![F::Def(
2,
vec![tag(b"Sprd")],
vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(vec![F::Ref(top)]))],
)]),
)],
),
),
]);
let bytes = synth::container(&[("doc.dat", &data, Method::Zlib)], None);
craft_affinity::read(&bytes, Limits::default())
}
fn path(document: &Document) -> &Path {
match &document.spreads[0].nodes[0].kind {
Kind::Shape { path, .. } => path,
other => panic!("expected shape, got {other:?}"),
}
}
fn rejected(document: &Document, warning: &str) {
assert!(matches!(document.spreads[0].nodes[0].kind, Kind::Unsupported));
assert!(document.warnings.iter().any(|value| value.contains(warning)), "{:?}", document.warnings);
}
#[test]
fn self_cycle_and_mutual_cycle_discard_the_entire_compound() {
// A valid first operand ensures a malformed later operand cannot leave a plausible partial path.
let rectangle = shape(100, b"ShNR", vec![]);
let self_cycle = compound(101, vec![F::Ref(100), F::Ref(101)]);
rejected(&read(vec![rectangle.clone(), self_cycle], 101), "compound outline that contains itself");
let mutual_cycle = compound(101, vec![F::Ref(100), compound(102, vec![F::Ref(101)])]);
rejected(&read(vec![rectangle, mutual_cycle], 101), "compound outline that contains itself");
}
#[test]
fn deep_reference_chain_is_bounded_independently_of_stream_nesting() {
let mut definitions = vec![shape(100, b"ShNR", vec![])];
for id in 101..=250 {
definitions.push(compound(id, vec![F::Ref(id - 1)]));
}
rejected(&read(definitions, 250), "compound outlines nested deeper than 128 levels");
}
#[test]
fn outline_at_the_depth_limit_is_still_imported() {
let mut definitions = vec![shape(100, b"ShNR", vec![])];
for id in 101..=227 {
definitions.push(compound(id, vec![F::Ref(id - 1)]));
}
let document = read(definitions, 227);
assert!(document.warnings.is_empty(), "{:?}", document.warnings);
assert_eq!(path(&document).subpaths.len(), 1);
assert_eq!(path(&document).subpaths[0].segments.len(), 3);
}
#[test]
fn excessive_declared_curve_count_is_rejected_before_expansion() {
let curve = F::Def(
100,
vec![tag(b"PCrv")],
vec![(tag(b"Crvs"), F::Def(101, vec![tag(b"PCvD")], vec![(tag(b"Data"), F::Pos(vec![F::U8(0), F::U32(u32::MAX)]))]))],
);
rejected(&read(vec![curve], 100), "compound outlines exceeding the geometry work limit");
}
#[test]
fn empty_shared_fanout_cannot_bypass_the_operand_visit_limit() {
// Only 21 on-disk objects, but an unbounded traversal would visit over two million operands.
let mut definitions = vec![compound(100, vec![])];
for id in 101..=120 {
definitions.push(compound(id, vec![F::Ref(id - 1), F::Ref(id - 1)]));
}
rejected(&read(definitions, 120), "compound outlines exceeding the operand visit limit");
}
#[test]
fn shared_large_operands_are_charged_for_cumulative_geometry_work() {
// A small reference graph expands a 20,000-segment star. Count work across every reuse and
// transform, rather than permitting each individual operand's allocation limit repeatedly.
let mut definitions = vec![shape(100, b"ShSt", vec![(tag(b"Pnts"), F::I32(10_000))])];
for id in 101..=112 {
definitions.push(compound(id, vec![F::Ref(id - 1), F::Ref(id - 1)]));
}
rejected(&read(definitions, 112), "compound outlines exceeding the geometry work limit");
}
#[test]
fn ordinary_nested_compound_preserves_repeated_operands_and_transforms() {
let mut rectangle = shape(100, b"ShNR", vec![]);
if let F::Def(_, _, fields) = &mut rectangle {
fields.push((tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 3.0, 0.0, 1.0, 7.0])));
}
let inner = compound(101, vec![F::Ref(100), F::Ref(100)]);
let mut outer = compound(102, vec![F::Ref(101)]);
if let F::Def(_, _, fields) = &mut outer {
fields.push((tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 100.0, 0.0, 1.0, 200.0])));
}
let document = read(vec![rectangle, inner, outer], 102);
assert!(document.warnings.is_empty(), "{:?}", document.warnings);
let path = path(&document);
assert_eq!(path.subpaths.len(), 2);
assert_eq!(path.subpaths[0], path.subpaths[1]);
assert_eq!(path.subpaths[0].start, Point { x: 103.0, y: 207.0 });
assert_eq!(path.subpaths[0].segments.last().unwrap()[2], Point { x: 103.0, y: 227.0 });
}
#[test]
fn overflowing_compound_transform_is_reported_before_processing_geometry() {
let mut child = shape(100, b"ShNR", vec![]);
if let F::Def(_, _, fields) = &mut child {
fields.push((tag(b"Xfrm"), F::F64s(vec![1.0e308, 0.0, 0.0, 0.0, 1.0, 0.0])));
}
let mut outer = compound(101, vec![F::Ref(100)]);
if let F::Def(_, _, fields) = &mut outer {
fields.push((tag(b"Xfrm"), F::F64s(vec![1.0e308, 0.0, 0.0, 0.0, 1.0, 0.0])));
}
rejected(&read(vec![child, outer], 101), "compound operand with an invalid transform");
}
#[test]
fn pie_angles_with_huge_finite_values_do_not_loop_or_emit_nonfinite_points() {
let document = read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(1.0e308)), (tag(b"AngE"), F::F64(-1.0e308))])], 100);
for subpath in &path(&document).subpaths {
assert!(subpath.start.x.is_finite() && subpath.start.y.is_finite());
assert!(subpath.segments.len() <= 20);
assert!(subpath.segments.iter().flatten().all(|point| point.x.is_finite() && point.y.is_finite()));
}
}
#[test]
fn wrapped_pie_angles_keep_the_same_geometry_as_the_positive_span() {
let make = |end| read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(0.0)), (tag(b"AngE"), F::F64(end))])], 100);
assert_eq!(path(&make(-std::f64::consts::FRAC_PI_2)), path(&make(3.0 * std::f64::consts::FRAC_PI_2)));
}
/// `CTyp`: an array of corner-type enums (top left, top right, bottom right, bottom left).
fn corner_types(ids: [u16; 4]) -> F {
let mut raw = vec![0xaa];
raw.extend(tag(b"CTyp").0.to_le_bytes());
raw.extend(4u32.to_le_bytes());
raw.extend(0u16.to_le_bytes());
raw.extend(ids.iter().flat_map(|id| id.to_le_bytes()));
F::Raw(raw)
}
#[test]
fn unlocked_rectangle_corners_keep_their_own_radius_and_type() {
let p = |x: f64, y: f64| Point { x, y };
// 10×20 box: relative radii scale by 10. Round 5, round 1, straight 3, none.
let corners = |lock: Option<bool>| {
let mut fields = vec![(tag(b"ShCR"), F::F64s(vec![0.5, 0.1, 0.3, 0.0])), (tag(b"CTyp"), corner_types([0, 0, 1, 4]))];
fields.extend(lock.map(|l| (tag(b"Lock"), F::Bool(l))));
read(vec![shape(100, b"ShNR", fields)], 100)
};
let unlocked = corners(Some(false));
assert!(unlocked.warnings.is_empty(), "{:?}", unlocked.warnings);
let sub = &path(&unlocked).subpaths[0];
assert_eq!(sub.start, p(5.0, 0.0));
let ends: Vec<Point> = sub.segments.iter().map(|s| s[2]).collect();
assert_eq!(ends, [p(9.0, 0.0), p(10.0, 1.0), p(10.0, 17.0), p(7.0, 20.0), p(0.0, 20.0), p(0.0, 5.0), p(5.0, 0.0)]);
assert_eq!(sub.segments[3], [p(7.0, 20.0); 3], "the straight corner is a line");
assert_ne!(sub.segments[1][0], p(9.0, 0.0), "the round corner is a curve");
// Locked (the default): every corner follows the top left one.
let locked = corners(None);
let sub = &path(&locked).subpaths[0];
assert_eq!(sub.segments.len(), 8);
assert_eq!(sub.segments[3][2], p(5.0, 20.0));
}
/// The special shapes come in as their own outlines: one closed contour of finite points inside
/// (or, for a callout's tail, just below) the node's box, and none as the bounding ellipse.
#[test]
fn special_shapes_have_their_own_closed_outlines() {
for class in [b"ShCl", b"ShHt", b"ShCg", b"ShCR", b"ShCE", b"ShDA", b"ShDS", b"ShTr", b"ShCr", b"ShpD", b"ShSg"] {
let name = String::from_utf8_lossy(class).into_owned();
let document = read(vec![shape(100, class, vec![])], 100);
assert!(!document.warnings.iter().any(|w| w.contains("bounding ellipse")), "{name}: {:?}", document.warnings);
let (Kind::Shape { path, even_odd, .. }, cog) = (&document.spreads[0].nodes[0].kind, class == b"ShCg") else { panic!("{name}: no shape") };
// A cog is its teeth and its hole, filled even-odd.
assert_eq!((path.subpaths.len(), *even_odd), if cog { (2, true) } else { (1, false) }, "{name}");
for sub in &path.subpaths {
assert!(sub.closed && sub.segments.len() >= 2, "{name}");
for p in std::iter::once(sub.start).chain(sub.segments.iter().flatten().copied()) {
assert!(p.x.is_finite() && p.y.is_finite(), "{name}: {p:?}");
assert!((-5.0..=15.0).contains(&p.x) && (-10.0..=30.0).contains(&p.y), "{name}: {p:?}");
}
}
}
// A class the reader does not know is still its bounding ellipse, and said.
let document = read(vec![shape(100, b"ShZZ", vec![])], 100);
assert!(document.warnings.iter().any(|w| w.contains("unrecognized parametric shape")), "{:?}", document.warnings);
}
fn group(id: u32, children: Vec<F>) -> F {
F::Def(id, vec![tag(b"Grup")], vec![(tag(b"Chld"), F::Shared(children))])
}
/// A list's null slots yield no node, operand or segment, but each visit of a shared object scans
/// them all: they are charged to the document's work before the scan.
#[test]
fn null_slots_of_shared_lists_are_charged_for_every_visit() {
// A compound of a million null operands, placed 20 times: 20 million slots, over the 2^24.
let document = read(vec![compound(100, vec![F::Null; 1_000_000]), group(200, vec![F::Ref(100); 20])], 200);
assert!(document.warnings.iter().any(|w| w.contains("geometry work limit")), "{:?}", document.warnings);
let shapes = document.spreads[0].nodes[0].children.iter().filter(|n| matches!(n.kind, Kind::Shape { .. })).count();
assert_eq!(shapes, 16, "each placement costs a million slots of the 2^24");
// A group of a million null children, placed 20 times, fails the read like too many layers.
let result = try_read(vec![group(100, vec![F::Null; 1_000_000]), group(200, vec![F::Ref(100); 20])], 200);
assert!(matches!(result, Err(craft_affinity::Error::Limit(_))), "{:?}", result.map(|d| d.warnings));
// Within the work of one document the same lists are read, nulls skipped.
let document = read(vec![group(100, vec![F::Null; 1_000]), group(200, vec![F::Ref(100); 20])], 200);
assert_eq!((document.spreads[0].nodes[0].children.len(), document.warnings.len()), (20, 0));
}
fn boxed(class: &[u8; 4], b: Vec<f64>) -> F {
F::Def(100, vec![tag(b"ShpN")], vec![(tag(b"ShpB"), F::F64s(b)), (tag(b"Shpe"), F::Def(10_100, vec![tag(class)], vec![]))])
}
/// A callout rectangle (`ShCR`) whose box runs right to left (x0 > x1) panicked in `f64::clamp`
/// (min > max), found by the `stream` fuzz target. It reads, inside its box.
#[test]
fn callout_rectangle_with_a_reversed_box_reads_inside_it() {
for b in [vec![10.0_f64, 0.0, 0.0, 20.0], vec![6.8e38, 0.0, 10.0, 20.0]] {
let (lo, hi) = (b[0].min(b[2]), b[0].max(b[2]));
let document = read(vec![boxed(b"ShCR", b)], 100);
let sub = &path(&document).subpaths[0];
let points = std::iter::once(&sub.start).chain(sub.segments.iter().flatten());
assert!(points.into_iter().all(|q| q.x.is_finite() && q.y.is_finite() && (lo..=hi).contains(&q.x)), "{sub:?}");
}
}
/// A box with finite corners whose width overflows gave infinite and NaN outline points. Such a
/// shape imports as its box, with a warning.
#[test]
fn a_shape_too_large_to_compute_imports_as_its_box() {
for class in [b"ShCR", b"ShpE", b"ShCg"] {
let document = read(vec![boxed(class, vec![f64::MAX, 0.0, -f64::MAX, 20.0])], 100);
let sub = &path(&document).subpaths;
assert_eq!(sub.len(), 1, "{class:?}");
let corners: Vec<Point> = std::iter::once(sub[0].start).chain(sub[0].segments.iter().map(|s| s[2])).collect();
assert!(corners.iter().all(|p| p.x.abs() == f64::MAX && (p.y == 0.0 || p.y == 20.0)), "{class:?}: {corners:?}");
assert!(document.warnings.iter().any(|w| w.contains("too large to compute")), "{:?}", document.warnings);
}
}