Files
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

177 lines
7.7 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.
//! Crafted documents can't make the reader allocate or work without bound: tables, shared
//! stories, paragraphs and embedded documents stay within the whole read's budget, however often
//! a file links or embeds the same content. Original synthetic documents in the reader's
//! accepted schema; no Affinity software or files were used.
use crate::text_fixture::*;
use craft_affinity::{
Document, Error, Limits,
model::{Kind, Node},
read,
synth::{self, F, Method, tag},
};
type Field = (craft_affinity::stream::Tag, F);
/// A document of one spread holding `nodes`, plus these other archive entries.
fn file(nodes: Vec<F>, entries: &[(&str, &[u8])]) -> Vec<u8> {
let spread = F::Obj(tag(b"Sprd"), vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(nodes))]);
let mut root = vec![(tag(b"DocR"), F::Obj(tag(b"DocN"), vec![(tag(b"Chld"), F::Shared(vec![spread]))]))];
let mut next_id = 1000;
for (_, value) in &mut root {
shared_objects(value, &mut next_id);
}
let data = synth::stream(&root);
let mut all = vec![("doc.dat", data.as_slice(), Method::Zlib)];
all.extend(entries.iter().map(|(name, bytes)| (*name, *bytes, Method::Stored)));
synth::container(&all, None)
}
fn open(nodes: Vec<F>) -> Document {
read(&file(nodes, &[]), Limits::default()).unwrap()
}
fn all(nodes: &[Node]) -> Vec<&Node> {
let mut out = Vec::new();
for n in nodes {
out.push(n);
out.extend(all(&n.children));
}
out
}
fn warned(d: &Document, needle: &str) -> bool {
d.warnings.iter().any(|w| w.contains(needle))
}
fn story(text: &str) -> F {
F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![block(utf8(text), vec![run(text.chars().count() as i32, None)], &[])]))])
}
fn artistic(story: F) -> F {
F::Obj(
tag(b"TxtA"),
vec![
(tag(b"StSt"), story),
(tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 100.0, 20.0])), (tag(b"ArtV"), F::F64(16.0))])),
],
)
}
/// A table frame (`TbFr`) with a grid of `cols` × `rows` cells and no cell objects or lines.
fn grid(cols: usize, rows: usize) -> Vec<Field> {
let pos = |n: usize| {
let v: Vec<f64> = (0..=n).map(|i| i as f64).collect();
F::Obj(tag(b"TPos"), vec![(tag(b"Posn"), array_f64(b"Posn", &v))])
};
let table = F::Obj(tag(b"Tabl"), vec![(tag(b"CPos"), pos(cols)), (tag(b"RPos"), pos(rows))]);
vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, cols as f64, rows as f64])), (tag(b"Tabl"), table)]
}
fn table(frame: F) -> F {
F::Obj(tag(b"TxtT"), vec![(tag(b"StSt"), story("A")), (tag(b"TxtH"), frame)])
}
fn tables(d: &Document) -> Vec<&craft_affinity::model::Table> {
all(&d.spreads[0].nodes).into_iter().filter_map(|n| if let Kind::Table(t) = &n.kind { Some(t) } else { None }).collect()
}
#[test]
fn a_table_of_more_than_65536_cells_is_left_out_before_its_cells_are_made() {
// 4096 × 4096 is within each dimension's limit but 16.7 million cells.
let d = open(vec![table(F::Obj(tag(b"TbFr"), grid(4096, 4096))), table(F::Obj(tag(b"TbFr"), grid(256, 256)))]);
let t = tables(&d);
assert_eq!(t.len(), 1);
assert_eq!((t[0].cells.len(), t[0].vertical.len(), t[0].horizontal.len()), (65536, 0, 0), "no lines when they don't match");
assert!(warned(&d, "tables with more than 65536 cells"), "{:?}", d.warnings);
}
#[test]
fn tables_sharing_one_grid_stay_within_the_cells_of_one_document() {
// One 65536-cell grid linked from five tables: four fill the document's 262144 cells.
let shared = F::Def(2_000_000, vec![tag(b"TbFr")], grid(256, 256));
let d = open((0..5).map(|i| table(if i == 0 { shared.clone() } else { F::Ref(2_000_000) })).collect());
assert_eq!(tables(&d).len(), 4);
assert!(warned(&d, "tables beyond the cells one document may hold"), "{:?}", d.warnings);
}
#[test]
fn a_shared_story_is_read_within_the_characters_of_one_document() {
// A story of a million characters linked from twenty text layers: 16 million in all.
let text = "x".repeat(1 << 20);
let shared = F::Def(2_000_000, vec![tag(b"Stry")], vec![(tag(b"Blok"), F::Shared(vec![block(utf8(&text), vec![run(1 << 20, None)], &[])]))]);
let d = open((0..20).map(|i| artistic(if i == 0 { shared.clone() } else { F::Ref(2_000_000) })).collect());
let chars: usize = all(&d.spreads[0].nodes)
.into_iter()
.filter_map(|n| if let Kind::Text(t) = &n.kind { Some(t.runs.iter().map(|r| r.text.len()).sum::<usize>()) } else { None })
.sum();
assert_eq!(chars, 16 << 20);
assert!(warned(&d, "text beyond the characters one document may hold"), "{:?}", d.warnings);
}
#[test]
fn many_paragraphs_and_runs_are_matched_in_about_linear_time() {
// 200 000 paragraphs, each with its own run and format.
let n = 200_000;
let text = "a\u{2029}".repeat(n);
let fmt =
|end: usize| vec![(tag(b"Indx"), F::I32(end as i32)), (tag(b"Item"), F::Obj(tag(b"PAtt"), vec![(tag(b"Ints"), array_i32(b"Ints", &[1]))]))];
let blk = F::Obj(
tag(b"StBl"),
vec![
(tag(b"Glyp"), utf8(&text)),
(tag(b"GAtt"), F::Obj(tag(b"GlAS"), vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), (1..=n).map(|i| run(2 * i as i32, None)).collect()))])),
(tag(b"PAtt"), F::Obj(tag(b"PaAS"), vec![(tag(b"Runs"), F::Objs(tag(b"PaAR"), (1..=n).map(|i| fmt(2 * i)).collect()))])),
],
);
let started = std::time::Instant::now();
let d = open(vec![artistic(F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))]))]);
let Kind::Text(t) = &d.spreads[0].nodes[0].kind else { panic!("text") };
assert_eq!(t.runs.len(), n);
// (About half a second; quadratic matching took over half a minute on the same machine.)
assert!(started.elapsed().as_secs() < 10, "{:?}", started.elapsed());
}
/// A shape node of `size` px.
fn rect(size: f64) -> F {
F::Obj(tag(b"ShpN"), vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, size, size])), (tag(b"Shpe"), F::Obj(tag(b"ShNR"), vec![]))])
}
/// An instance of the embedded document in archive entry `edc/1`, with `children` of its own.
fn embedded(children: Vec<F>) -> F {
let content = F::Obj(tag(b"EmbC"), vec![(tag(b"EmbC"), F::Entry("edc/1".into()))]);
let mut fields = vec![(tag(b"Bitm"), F::Obj(tag(b"EmbR"), vec![(tag(b"EmCn"), content)]))];
if !children.is_empty() {
fields.push((tag(b"Chld"), F::Shared(children)));
}
F::Obj(tag(b"EmbN"), fields)
}
fn embedding(inner: &[u8], nodes: Vec<F>) -> Vec<u8> {
let mut entry = b"EmDc\0\0\0\0".to_vec();
entry.extend_from_slice(inner);
file(nodes, &[("edc/1", &entry)])
}
#[test]
fn an_embedded_documents_content_stays_with_its_node() {
// Read before the node's own children, which must not take it.
let inner = file(vec![rect(10.0), rect(20.0)], &[]);
let d = read(&embedding(&inner, vec![embedded(vec![rect(5.0)])]), Limits::default()).unwrap();
let node = &d.spreads[0].nodes[0];
assert_eq!(node.children.len(), 3, "its own child and the embedded document's two shapes");
assert!(node.children.iter().all(|c| c.children.is_empty()));
}
#[test]
fn embedding_one_document_many_times_shares_one_budget() {
// 2000 shapes, embedded 300 times and read twice each: 1.2 million nodes, past the 500 000
// a whole read may take (each embedding used to have an allowance of its own).
let inner = file((0..2000).map(|_| rect(10.0)).collect(), &[]);
let outer = embedding(&inner, (0..300).map(|_| embedded(Vec::new())).collect());
assert!(matches!(read(&outer, Limits::default()), Err(Error::Limit(_))));
// Embedded fewer times, it opens.
let d = read(&embedding(&inner, (0..50).map(|_| embedded(Vec::new())).collect()), Limits::default()).unwrap();
assert_eq!(all(&d.spreads[0].nodes).len(), 50 + 50 * 2000);
}