Files
vectorcraft/crates/engine/tests/affinity_features.rs
T
dwetscherandClaude Opus 5.5 fd50bd6f84 AF.5: Affinity import — strokes, text decoration, text placement, outline effects, CMYK photos
- Strokes set to None no longer import as black 1px strokes.
- Underline and strikethrough are kept.
- Frame text starts at cap height; lines hanging out of a frame make it taller;
  mixed-alignment artistic text lines up within its longest line; line breaks
  inside a paragraph get no paragraph spacing.
- Table cell text taller than its cell is centred and stays visible.
- Outline effects on stroked shapes are drawn around the stroke.
- CMYK pixel layers go through the document's embedded CMYK profile; without
  one, opaque layers stay CMYK.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 18:24:57 +07:00

1044 lines
47 KiB
Rust
Raw 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 → VectorCraft mapping of pages, master pages, tables, paragraph formats, text scale,
//! layer effects and embedded documents, on original synthetic documents built with the reader's
//! `synth` feature (no Affinity software or files were used). The fixtures follow DesignCraft's
//! Affinity import tests, whose reader VectorCraft shares.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
#[path = "../../affinity/tests/support/text_fixture.rs"]
mod text_fixture;
use std::sync::Arc;
use kurbo::Rect;
use text_fixture::{array_f64, array_i32, attrs, block, run, shared_objects, utf8};
use vectorcraft_affinity::synth::{self, F, Method, tag};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::appearance::{AppearanceItem, StrokeAlign};
use vectorcraft_doc::text::{TextKind, TextObject, VerticalAlign};
use vectorcraft_doc::{Document, Node, NodeKind};
use vectorcraft_engine::cmd::fileio::{Loaded, load};
type Field = (vectorcraft_affinity::stream::Tag, F);
/// Encode a stream whose shared arrays may list inline objects.
fn stream(mut root: Vec<Field>) -> Vec<u8> {
let mut next_id = 1000;
for (_, value) in &mut root {
shared_objects(value, &mut next_id);
}
synth::stream(&root)
}
/// 300 dpi: a document pixel is 0.24 pt.
const DPI: f64 = 300.0;
const PT: f64 = 72.0 / DPI;
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
fn rgba(r: f32, g: f32, b: f32) -> F {
F::Obj(tag(b"RGBA"), vec![(tag(b"_col"), F::Struct([r, g, b, 1.0].iter().flat_map(|v| v.to_le_bytes()).collect()))])
}
fn cmyk(c: f32, m: f32, y: f32, k: f32) -> F {
F::Obj(
tag(b"FilS"),
vec![(tag(b"Colr"), F::Obj(tag(b"CMYK"), vec![(tag(b"_col"), F::Struct([c, m, y, k, 1.0].iter().flat_map(|v| v.to_le_bytes()).collect()))]))],
)
}
/// A magenta rectangle named `name`.
fn named_rect(id: u32, name: &str, at: (f64, f64), size: (f64, f64)) -> F {
F::Def(
id,
vec![tag(b"ShpN")],
vec![
(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, size.0, size.1])),
(tag(b"Shpe"), F::Def(id + 10_000, vec![tag(b"ShNR")], vec![])),
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, at.0, 0.0, 1.0, at.1])),
(tag(b"BFil"), cmyk(0.0, 1.0, 0.0, 0.0)),
(tag(b"Desc"), F::Str(name.into())),
],
)
}
fn rectangle(id: u32, at: (f64, f64), size: (f64, f64)) -> F {
named_rect(id, "Magenta box", at, size)
}
/// An artboard (`ABEn`) named `name` over `bounds` at `at`, holding `children`.
fn board(id: u32, name: &str, at: (f64, f64), size: (f64, f64), children: Vec<F>) -> F {
F::Def(
id,
vec![tag(b"ShpN")],
vec![
(tag(b"Desc"), F::Str(name.into())),
(tag(b"ABEn"), F::Bool(true)),
(tag(b"Shpe"), F::Def(id + 10_000, vec![tag(b"ShNR")], vec![])),
(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, size.0, size.1])),
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, at.0, 0.0, 1.0, at.1])),
(tag(b"Chld"), F::Shared(children)),
],
)
}
fn artistic_text(id: u32, text: &str, size: f64) -> F {
F::Def(
id,
vec![tag(b"TxtA")],
vec![
(
tag(b"StSt"),
F::Obj(
tag(b"Stry"),
vec![(
tag(b"Blok"),
F::Shared(vec![block(
utf8(text),
vec![run(text.chars().count() as i32, Some(attrs("Inter", "Inter-Bold", 700, false, size)))],
&[1],
)]),
)],
),
),
(tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 400.0, 100.0])), (tag(b"ArtV"), F::F64(80.0))])),
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 1000.0, 0.0, 1.0, 500.0])),
],
)
}
fn page(x0: f64, w: f64, h: f64) -> F {
F::Obj(tag(b"PagR"), vec![(tag(b"rctp"), F::F64s(vec![x0, 0.0, x0 + w, h]))])
}
/// A Publisher spread of A4 pages at 300 dpi holding `nodes`.
fn spread(id: u32, pages: usize, nodes: Vec<F>) -> F {
let w = 2480.0 * pages as f64;
F::Def(
id,
vec![tag(b"Sprd")],
vec![
(tag(b"SprB"), F::F64s(vec![0.0, 0.0, w, 3508.0])),
(
tag(b"SpMd"),
F::Obj(tag(b"SpMd"), vec![(tag(b"PagR"), F::Shared((0..pages).map(|i| page(2480.0 * i as f64, 2480.0, 3508.0)).collect()))]),
),
(tag(b"Chld"), F::Shared(nodes)),
],
)
}
fn publisher_with(spreads: Vec<F>) -> Vec<u8> {
let data = stream(vec![
(tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(DPI))])),
(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(spreads))])),
]);
synth::container(&[("doc.dat", &data, Method::Zstd)], None)
}
/// A two-page spread: a layer with a CMYK rectangle, and centred artistic text.
fn publisher() -> Vec<u8> {
let layer = F::Def(
3,
vec![tag(b"Scop")],
vec![(tag(b"Desc"), F::Str("Background".into())), (tag(b"Chld"), F::Shared(vec![rectangle(10, (100.0, 100.0), (100.0, 200.0))]))],
);
publisher_with(vec![spread(2, 2, vec![layer, artistic_text(20, "Hello\u{2029}World\u{2029}", 100.0)])])
}
/// One A4 page at 300 dpi holding `node`.
fn page_with(node: F) -> Vec<u8> {
publisher_with(vec![spread(2, 1, vec![node])])
}
/// A document at 72 dpi holding `nodes` on a canvas without pages; `embedded` is `edc/1`.
fn canvas_with(nodes: Vec<F>, embedded: Option<&[u8]>) -> Vec<u8> {
let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 1000.0, 1000.0])), (tag(b"Chld"), F::Shared(nodes))]);
let data = stream(vec![(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
match embedded {
Some(inner) => {
let mut entry = b"EmDc\0\0\0\0".to_vec();
entry.extend_from_slice(inner);
synth::container(&[("doc.dat", &data, Method::Zstd), ("edc/1", &entry, Method::Zstd)], None)
}
None => synth::container(&[("doc.dat", &data, Method::Zstd)], None),
}
}
/// An instance of the embedded document `edc/1`, scaled 2× with its content's centre at (500, 300).
fn embedded_node(id: u32) -> F {
let content = F::Obj(tag(b"EmbC"), vec![(tag(b"EmbC"), F::Entry("edc/1".into()))]);
F::Def(
id,
vec![tag(b"EmbN")],
vec![
(tag(b"Bitm"), F::Obj(tag(b"EmbR"), vec![(tag(b"EmCn"), content), (tag(b"PBBx"), F::Enum(2, 0))])),
(tag(b"Xfrm"), F::F64s(vec![2.0, 0.0, 500.0, 0.0, 2.0, 300.0])),
],
)
}
/// A blue 2 px line on a table edge.
fn table_edge() -> F {
F::Obj(
tag(b"TEdg"),
vec![
(tag(b"Line"), F::Obj(tag(b"LDsc"), vec![(tag(b"LDeL"), F::Obj(tag(b"LSty"), vec![(tag(b"Wght"), F::F64(2.0))]))])),
(tag(b"Fill"), F::Obj(tag(b"FDsc"), vec![(tag(b"FDeF"), cmyk(1.0, 0.5, 0.0, 0.0))])),
],
)
}
/// A 3 × 2 table whose cell (1, 1) is merged into (1, 0); cells "A".."E" in reading order, each
/// ended by a cell break. Cells are 100 × 50 px from (0, 0).
fn table_node(id: u32, transform: [f64; 6]) -> F {
table_with(id, transform, ["A", "B", "C", "D", "E"])
}
/// [`table_node`] with these cell texts.
fn table_with(id: u32, transform: [f64; 6], texts: [&str; 5]) -> F {
let brk = || F::Obj(tag(b"BrGl"), vec![(tag(b"HdBk"), F::Enum(4, 1))]);
let segments: Vec<F> = texts
.iter()
.enumerate()
.map(|(i, t)| {
let mut fields = vec![(tag(b"Utf8"), F::Str((*t).into()))];
if i > 0 {
fields.insert(0, (tag(b"Glys"), F::Shared(vec![brk()])));
}
F::Obj(tag(b"GSeg"), fields)
})
.collect();
let len = (texts.iter().map(|t| t.chars().count()).sum::<usize>() + texts.len() - 1) as i32;
let glyphs = F::Obj(tag(b"GStr"), vec![(tag(b"Mixd"), F::Shared(segments))]);
let story = block(glyphs, vec![run(len, Some(attrs("Inter", "Inter-Regular", 400, false, 40.0)))], &[]);
let cell = |merged: bool| {
let mut f = vec![(tag(b"AliY"), F::Enum(1, 0)), (tag(b"Inse"), F::F64s(vec![5.0, 6.0, 7.0, 8.0]))];
if merged {
f.push((tag(b"BrLf"), F::Bool(true)));
}
F::Obj(tag(b"TCel"), f)
};
let pos = |values: &[f64]| F::Obj(tag(b"TPos"), vec![(tag(b"Posn"), array_f64(b"Posn", values))]);
let edges = |n: usize| F::Obj(tag(b"TEds"), vec![(tag(b"Edge"), F::Shared((0..n).map(|_| table_edge()).collect()))]);
let table = F::Obj(
tag(b"Tabl"),
vec![
(tag(b"CPos"), pos(&[0.0, 100.0, 200.0, 300.0])),
(tag(b"RPos"), pos(&[0.0, 50.0, 100.0])),
(tag(b"CEdg"), edges(8)),
(tag(b"REdg"), edges(9)),
(tag(b"Cell"), F::Obj(tag(b"TCls"), vec![(tag(b"Cell"), F::Shared((0..6).map(|i| cell(i == 4)).collect()))])),
],
);
F::Def(
id,
vec![tag(b"TxtT")],
vec![
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![story]))])),
(tag(b"TxtH"), F::Obj(tag(b"TbFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 300.0, 100.0])), (tag(b"Tabl"), table)])),
(tag(b"Xfrm"), F::F64s(transform.to_vec())),
],
)
}
/// Character attributes without their own fixed leading.
fn auto_leading(mut attrs: F) -> F {
if let F::Obj(_, fields) = &mut attrs {
for (t, v) in fields.iter_mut() {
if *t == tag(b"Ints") {
*v = array_i32(b"Ints", &[0, 0, 0, 0, 0]);
}
}
}
attrs
}
fn open(name: &str, bytes: &[u8]) -> Loaded {
let loaded = load(name, bytes).unwrap();
assert!(!loaded.preview_only, "{:?}", loaded.warnings);
loaded
}
/// Every node of the document, depth first.
fn all(d: &Document) -> Vec<&Node> {
fn walk<'a>(nodes: &'a [Arc<Node>], out: &mut Vec<&'a Node>) {
for n in nodes {
out.push(n);
if let Some(c) = n.children() {
walk(c, out);
}
}
}
let mut out = Vec::new();
walk(&d.layers, &mut out);
out
}
fn named<'a>(d: &'a Document, name: &str) -> &'a Node {
all(d).into_iter().find(|n| n.name.as_deref() == Some(name)).unwrap_or_else(|| panic!("no node {name}"))
}
fn texts(d: &Document) -> Vec<&TextObject> {
all(d).into_iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.as_ref()) } else { None }).collect()
}
fn content(t: &TextObject) -> String {
t.runs.iter().map(|r| r.text.as_str()).collect()
}
/// An area text's frame in document space.
fn frame(t: &TextObject) -> Rect {
let TextKind::Area { frame } = &t.kind else { panic!("area type: {:?}", t.kind) };
t.xf.transform_rect_bbox(frame.bounds().unwrap())
}
fn rect_near(a: Rect, b: Rect) -> bool {
near(a.x0, b.x0) && near(a.y0, b.y0) && near(a.x1, b.x1) && near(a.y1, b.y1)
}
fn layer_names(d: &Document) -> Vec<&str> {
d.layers.iter().map(|l| l.name.as_deref().unwrap_or("")).collect()
}
#[test]
fn publisher_pages_become_artboards_in_points() {
let d = open("brochure.afpub", &publisher()).doc;
let boards: Vec<_> = d.artboards.iter().map(|b| (b.name.as_str(), b.rect)).collect();
assert_eq!(boards.len(), 2);
assert_eq!((boards[0].0, boards[1].0), ("Page 1", "Page 2"));
assert!(rect_near(boards[0].1, Rect::new(0.0, 0.0, 2480.0 * PT, 3508.0 * PT)), "{boards:?}");
assert!(rect_near(boards[1].1, Rect::new(2480.0 * PT, 0.0, 4960.0 * PT, 3508.0 * PT)), "{boards:?}");
assert!(layer_names(&d).contains(&"Background"));
let rect = named(&d, "Magenta box");
assert!(rect_near(rect.geometric_bounds().unwrap(), Rect::new(100.0 * PT, 100.0 * PT, 200.0 * PT, 300.0 * PT)));
let Some(AppearanceItem::Fill(fill)) = rect.appearance.items.first() else { panic!("{:?}", rect.appearance) };
assert_eq!(fill.paint, Paint::solid(Color::cmyk(0.0, 1.0, 0.0, 0.0)), "CMYK stays CMYK");
}
/// Several spreads of several pages: every page is an artboard, the spreads side by side.
#[test]
fn every_page_of_every_spread_is_an_artboard() {
let file = publisher_with(vec![
spread(2, 1, vec![rectangle(10, (10.0, 10.0), (20.0, 20.0))]),
spread(3, 2, vec![rectangle(20, (2500.0, 10.0), (20.0, 20.0))]),
spread(4, 2, vec![]),
]);
let d = open("book.afpub", &file).doc;
let names: Vec<_> = d.artboards.iter().map(|b| b.name.as_str()).collect();
assert_eq!(names, ["Page 1", "Page 2", "Page 3", "Page 4", "Page 5"]);
assert_eq!(d.artboards.iter().map(|b| b.id).collect::<Vec<_>>(), [1, 2, 3, 4, 5]);
let (w, h) = (2480.0 * PT, 3508.0 * PT);
for b in &d.artboards {
assert!(near(b.rect.width(), w) && near(b.rect.height(), h), "{b:?}");
}
// The two pages of a spread touch; spreads are 36 pt apart.
let x: Vec<f64> = d.artboards.iter().map(|b| b.rect.x0).collect();
assert!(near(x[1], w + 36.0) && near(x[2], 2.0 * w + 36.0) && near(x[3], 3.0 * w + 72.0), "{x:?}");
// The second spread's box is on its second page.
let boxes: Vec<Rect> = all(&d).into_iter().filter(|n| n.name.as_deref() == Some("Magenta box")).map(|n| n.geometric_bounds().unwrap()).collect();
assert_eq!(boxes.len(), 2);
assert!(d.artboards[2].rect.contains(boxes[1].center()), "{boxes:?} {:?}", d.artboards[2]);
let reopened = vectorcraft_format::load_file(&vectorcraft_format::save_file(&d)).unwrap();
assert_eq!(reopened.doc.artboards, d.artboards);
}
/// Artboards on one canvas keep their names, sizes, places and own content; objects beside them
/// stay on the canvas.
#[test]
fn several_artboards_keep_their_own_content() {
let file = canvas_with(
vec![
board(10, "Icon", (0.0, 0.0), (64.0, 64.0), vec![named_rect(11, "Icon art", (8.0, 8.0), (48.0, 48.0))]),
named_rect(20, "Loose", (80.0, 0.0), (10.0, 10.0)),
board(30, "Banner", (100.0, 0.0), (300.0, 100.0), vec![named_rect(31, "Banner art", (110.0, 10.0), (280.0, 80.0))]),
board(40, "Card", (0.0, 200.0), (85.0, 55.0), vec![]),
],
None,
);
let d = open("set.af", &file).doc;
let boards: Vec<_> = d.artboards.iter().map(|b| (b.name.as_str(), b.rect)).collect();
assert_eq!(
boards,
[("Icon", Rect::new(0.0, 0.0, 64.0, 64.0)), ("Banner", Rect::new(100.0, 0.0, 400.0, 100.0)), ("Card", Rect::new(0.0, 200.0, 85.0, 255.0))]
);
// Each artboard is a layer holding its clip group, its art inside.
for (board, art) in [("Icon", "Icon art"), ("Banner", "Banner art")] {
let layer = d.layers.iter().find(|l| l.name.as_deref() == Some(board)).unwrap();
let group = &layer.children().unwrap()[0];
assert!(matches!(group.kind, NodeKind::Group { clip: true, .. }));
assert!(group.children().unwrap().iter().any(|c| c.name.as_deref() == Some(art)), "{board}");
}
assert!(rect_near(named(&d, "Loose").geometric_bounds().unwrap(), Rect::new(80.0, 0.0, 90.0, 10.0)));
}
#[test]
fn artistic_text_keeps_its_paragraphs_size_and_leading() {
let d = open("brochure.afpub", &publisher()).doc;
let t = texts(&d)[0];
assert_eq!(content(t), "Hello\nWorld", "paragraph marks become paragraphs; the final one goes");
assert!(near(t.runs[0].style.size, 100.0 * PT));
assert_eq!(t.runs[0].style.font_style, "Bold");
assert_eq!(t.para.justify, vectorcraft_doc::text::Justify::Center);
assert!(matches!(t.kind, TextKind::Point));
// Centred on the anchor (x = 1000 + 200), first baseline 80 px below the frame top (y = 500).
let origin = t.xf * kurbo::Point::ZERO;
assert!(near(origin.x, 1200.0 * PT) && near(origin.y, 580.0 * PT), "{origin:?}");
// Lines are the font size plus the font's line gap apart (VectorCraft's default is 120 %).
let face = vectorcraft_text::FontDb::global().face(&t.runs[0].style.font_family, &t.runs[0].style.font_style).unwrap();
let expected = 100.0 * (1.0 + face.line_gap() / face.units_per_em());
assert!(near(t.para.justification.auto_leading, expected), "{} vs {expected}", t.para.justification.auto_leading);
}
#[test]
fn tables_become_cell_text_and_edge_lines() {
// Stretched to twice its width by its transform: the grid follows, the type doesn't.
let loaded = open("table.afpub", &page_with(table_node(30, [2.0, 0.0, 400.0, 0.0, 1.0, 600.0])));
let d = &loaded.doc;
let table = named(d, "Table");
let cells: Vec<&TextObject> =
table.children().unwrap().iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.as_ref()) } else { None }).collect();
assert_eq!(cells.iter().map(|t| content(t)).collect::<Vec<_>>(), ["A", "B", "C", "D", "E"]);
let a = cells[0];
assert!(near(a.runs[0].style.size, 40.0 * PT), "type keeps its size");
assert!(near(a.runs[0].style.h_scale, 100.0), "type isn't stretched with the grid");
assert_eq!(a.area.vertical_align, VerticalAlign::Center);
// Insets (5, 6, 7, 8 px) inside the cell, scaled with the grid; the text centred in them as
// Affinity centres it.
let f = frame(a);
assert!(near(f.x0, (400.0 + 10.0) * PT) && near(f.width(), 176.0 * PT), "{f:?}");
let (top, last) = cap_to_last_baseline(a);
assert!(((top + last) / 2.0 - (600.0 + 24.0) * PT).abs() < 1e-6, "{top} {last}");
// The merged cell spans two columns.
assert!(near(frame(cells[3]).width(), (400.0 - 24.0) * PT), "{:?}", frame(cells[3]));
// 8 vertical edges less the one inside the merged cell, and 9 horizontal ones.
let lines: Vec<&Arc<Node>> = table.children().unwrap().iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).collect();
assert_eq!(lines.len(), 16);
let Some(AppearanceItem::Stroke(s)) = lines[0].appearance.items.first() else { panic!() };
assert_eq!(s.paint, Paint::solid(Color::cmyk(1.0, 0.5, 0.0, 0.0)));
assert!(s.width > 0.0);
}
/// Where a cell's text goes in the document: its first line's capitals' tops and its last
/// baseline (y).
fn cap_to_last_baseline(t: &TextObject) -> (f64, f64) {
let db = vectorcraft_text::FontDb::global();
// Hung from the frame's top by its cap height, the first baseline is that far down.
let mut top = t.clone();
top.area.vertical_align = VerticalAlign::Top;
let cap = vectorcraft_text::layout(db, &top).lines[0].baseline;
let lines = vectorcraft_text::layout(db, t).lines;
let y = t.xf.translation().y;
(y + lines[0].baseline - cap, y + lines.last().unwrap().baseline)
}
#[test]
fn cell_text_taller_than_its_cell_is_centred_on_it_and_shows() {
// "A" over "a" (a line break inside the paragraph) in a cell 36 px tall inside its insets.
let loaded = open("cells.afpub", &page_with(table_with(30, [1.0, 0.0, 400.0, 0.0, 1.0, 600.0], ["A\u{2028}a", "B", "C", "D", "E"])));
assert!(!loaded.warnings.iter().any(|w| w.contains("don't fit")), "{:?}", loaded.warnings);
let a = texts(&loaded.doc)[0];
assert_eq!(content(a), "A\na");
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), a);
assert_eq!(lay.lines.len(), 2, "both lines show");
assert!(!lay.overflow);
assert!(frame(a).height() > 36.0 * PT, "the frame grew: {:?}", frame(a));
let (top, last) = cap_to_last_baseline(a);
assert!(top < (600.0 + 6.0) * PT, "reaching out of the cell");
assert!(((top + last) / 2.0 - (600.0 + 24.0) * PT).abs() < 1e-6, "centred: {top} {last}");
}
#[test]
fn line_breaks_inside_a_paragraph_get_no_paragraph_spacing() {
// Doub slots: 4 first line, 5 space before, 6 after (see the test below).
let attrs_para = F::Obj(tag(b"PAtt"), vec![(tag(b"Doub"), array_f64(b"Doub", &[1.0, 0.0, 0.0, 0.0, 25.0, 9.0, 17.0]))]);
let blk = F::Obj(
tag(b"StBl"),
vec![
(tag(b"Glyp"), utf8("One\u{2028}two\u{2029}Three\u{2029}")),
(
tag(b"GAtt"),
F::Obj(
tag(b"GlAS"),
vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), vec![run(14, Some(attrs("Inter", "Inter-Regular", 400, false, 30.0)))]))],
),
),
(
tag(b"PAtt"),
F::Obj(tag(b"PaAS"), vec![(tag(b"Runs"), F::Objs(tag(b"PaAR"), vec![vec![(tag(b"Indx"), F::I32(14)), (tag(b"Item"), attrs_para)]]))]),
),
],
);
let node = F::Def(
40,
vec![tag(b"TxtF")],
vec![
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))])),
(tag(b"TxtH"), F::Obj(tag(b"FrFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 500.0, 300.0]))])),
],
);
let t = texts(&open("breaks.afpub", &page_with(node)).doc)[0].clone();
assert_eq!(content(&t), "One\ntwo\nThree");
let p = &t.paras;
let spacing: Vec<(f64, f64, f64)> = p.iter().map(|p| (p.space_before / PT, p.space_after / PT, p.first_line_indent / PT)).collect();
let r = |v: (f64, f64, f64)| ((v.0 * 1e6).round() / 1e6, (v.1 * 1e6).round() / 1e6, (v.2 * 1e6).round() / 1e6);
assert_eq!(
spacing.into_iter().map(r).collect::<Vec<_>>(),
[(9.0, 0.0, 25.0), (0.0, 17.0, 0.0), (0.0, 17.0, 25.0)],
"space before the paragraph and after it, the first-line indent on its first line"
);
}
#[test]
fn each_paragraph_keeps_its_alignment_indents_and_spacing() {
// Doub slots: 1 leading (Ints slot 1 = 2), 2 left, 3 right, 4 first line, 5 space before, 6 after.
let para = |end: i32, align: i32| {
vec![
(tag(b"Indx"), F::I32(end)),
(
tag(b"Item"),
F::Obj(
tag(b"PAtt"),
vec![
(tag(b"Doub"), array_f64(b"Doub", &[1.0, 5.0, 40.0, 3.0, 25.0, 9.0, 17.0])),
(tag(b"Ints"), array_i32(b"Ints", &[align, 2])),
],
),
),
]
};
let blk = F::Obj(
tag(b"StBl"),
vec![
(tag(b"Glyp"), utf8("One\u{2029}Two\u{2029}")),
(
tag(b"GAtt"),
F::Obj(
tag(b"GlAS"),
vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), vec![run(8, Some(auto_leading(attrs("Inter", "Inter-Regular", 400, false, 30.0))))]))],
),
),
(tag(b"PAtt"), F::Obj(tag(b"PaAS"), vec![(tag(b"Runs"), F::Objs(tag(b"PaAR"), vec![para(4, 0), para(8, 2)]))])),
],
);
let node = F::Def(
40,
vec![tag(b"TxtF")],
vec![
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))])),
(tag(b"TxtH"), F::Obj(tag(b"FrFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 500.0, 300.0]))])),
],
);
let loaded = open("paragraphs.afpub", &page_with(node));
assert!(!loaded.warnings.iter().any(|w| w.contains("paragraph")), "{:?}", loaded.warnings);
let t = texts(&loaded.doc)[0];
assert_eq!(content(t), "One\nTwo");
assert_eq!(t.paras.len(), 2, "{:?}", t.paras);
let p = &t.paras;
use vectorcraft_doc::text::Justify;
assert_eq!((p[0].justify, p[1].justify), (Justify::Left, Justify::Right));
assert!(near(p[0].left_indent, 40.0 * PT) && near(p[0].first_line_indent, -15.0 * PT) && near(p[0].right_indent, 3.0 * PT), "{:?}", p[0]);
assert!(near(p[0].space_before, 9.0 * PT) && near(p[0].space_after, 17.0 * PT));
// The larger of 17 after "One" and 9 before "Two": nothing more before "Two".
assert!(near(p[1].space_before, 0.0) && near(p[1].space_after, 17.0 * PT));
assert!(t.runs.iter().all(|r| r.style.leading.is_some_and(|l| near(l, 5.0 * PT))), "the paragraph's fixed leading");
}
#[test]
fn frame_type_follows_the_frame_text_scale_not_the_frame_transform() {
// The same frame scaled 3× by its transform, without and with a text scale (FTxS) of 3.
let frame_node = |ftxs: bool| {
let story = block(utf8("Hi"), vec![run(2, Some(attrs("Inter", "Inter-Regular", 400, false, 10.0)))], &[]);
let mut fields = vec![
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![story]))])),
(tag(b"TxtH"), F::Obj(tag(b"FrFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 100.0, 50.0]))])),
(tag(b"Xfrm"), F::F64s(vec![3.0, 0.0, 0.0, 0.0, 3.0, 0.0])),
];
if ftxs {
fields.push((tag(b"FTxS"), F::F64s(vec![3.0, 0.0, 0.0, 0.0, 3.0, 0.0])));
}
F::Def(40, vec![tag(b"TxtF")], fields)
};
for (ftxs, size) in [(false, 10.0), (true, 30.0)] {
let d = open("frame.afpub", &page_with(frame_node(ftxs))).doc;
let t = texts(&d)[0];
assert!(near(frame(t).width(), 300.0 * PT), "the frame scales either way: {:?}", frame(t));
assert!(near(t.runs[0].style.size, size * PT), "FTxS {ftxs}: {}", t.runs[0].style.size);
assert!(near(t.xf.determinant(), 1.0), "the type isn't scaled by the placement");
}
}
#[test]
fn embedded_affinity_documents_come_in_as_their_content() {
let inner = canvas_with(vec![rectangle(10, (0.0, 0.0), (100.0, 50.0))], None);
let outer = canvas_with(vec![embedded_node(20)], Some(&inner));
let loaded = open("logo.af", &outer);
let rect = named(&loaded.doc, "Magenta box");
// Content bounds (0,0)-(100,50), centre (50,25) → origin, then 2× at (500,300).
assert!(rect_near(rect.geometric_bounds().unwrap(), Rect::new(400.0, 250.0, 600.0, 350.0)), "{:?}", rect.geometric_bounds());
assert!(!loaded.warnings.iter().any(|w| w.contains("cached picture")), "{:?}", loaded.warnings);
}
/// An embedded document's artboard frames its content but is no artboard of the document it is in.
#[test]
fn artboards_of_embedded_documents_are_not_artboards_here() {
let inner = canvas_with(vec![board(10, "Logo board", (0.0, 0.0), (100.0, 50.0), vec![rectangle(11, (10.0, 10.0), (80.0, 30.0))])], None);
let outer = canvas_with(vec![board(30, "Poster", (0.0, 0.0), (1000.0, 800.0), vec![embedded_node(20)])], Some(&inner));
let d = open("poster.af", &outer).doc;
assert_eq!(d.artboards.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["Poster"]);
// The logo still comes in, clipped by its own board.
let logo = named(&d, "Logo board");
assert!(matches!(logo.kind, NodeKind::Group { clip: true, .. }));
assert!(rect_near(named(&d, "Magenta box").geometric_bounds().unwrap(), Rect::new(420.0, 270.0, 580.0, 330.0)));
}
#[test]
fn embedded_documents_nest_only_so_deep() {
let mut doc = canvas_with(vec![rectangle(10, (0.0, 0.0), (100.0, 50.0))], None);
for _ in 0..6 {
doc = canvas_with(vec![embedded_node(20)], Some(&doc));
}
let loaded = open("deep.af", &doc);
assert!(loaded.warnings.iter().any(|w| w.contains("nested more than four deep")), "{:?}", loaded.warnings);
}
/// A two-page master spread (pages 0–100 and 100–200 wide) with a box on each page; the
/// document's page shows its second page, as Affinity's instance says (MPOf 1).
fn with_master(pages: usize) -> Vec<u8> {
let master = F::Def(
7,
vec![tag(b"Sprd")],
vec![
(tag(b"SpMd"), F::Obj(tag(b"SpMd"), vec![(tag(b"PagR"), F::Shared(vec![page(0.0, 100.0, 150.0), page(100.0, 100.0, 150.0)]))])),
(
tag(b"Chld"),
F::Shared(vec![
named_rect(30, "Left master box", (10.0, 10.0), (20.0, 20.0)),
named_rect(31, "Right master box", (110.0, 10.0), (30.0, 40.0)),
]),
),
],
);
let instance = F::Def(
5,
vec![tag(b"MPIN")],
vec![
(tag(b"SLnk"), F::Obj(tag(b"ILSN"), vec![(tag(b"ILOb"), F::Shared(vec![master, F::Ref(5)]))])),
(tag(b"PgOf"), F::U32(0)),
(tag(b"MPOf"), F::U32(1)),
(tag(b"PgCt"), F::U32(pages as u32)),
],
);
let spread = F::Def(
2,
vec![tag(b"Sprd")],
vec![
(tag(b"SpMd"), F::Obj(tag(b"SpMd"), vec![(tag(b"PagR"), F::Shared((0..pages).map(|i| page(100.0 * i as f64, 100.0, 150.0)).collect()))])),
(tag(b"Chld"), F::Shared(vec![instance, named_rect(40, "Page box", (50.0, 100.0), (10.0, 10.0))])),
],
);
let data = stream(vec![(
tag(b"DocR"),
F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread])), (tag(b"MpCh"), F::Shared(vec![F::Ref(7)]))]),
)]);
synth::container(&[("doc.dat", &data, Method::Zstd)], None)
}
#[test]
fn master_pages_show_on_the_pages_that_use_them() {
let loaded = open("master.afpub", &with_master(1));
let d = &loaded.doc;
assert!(!loaded.warnings.iter().any(|w| w.contains("master")), "{:?}", loaded.warnings);
assert_eq!(d.artboards.len(), 1, "a master spread is no artboard");
// Behind the page's own objects, on a locked layer of its own.
let master = &d.layers[0];
assert_eq!(master.name.as_deref(), Some("Master A"));
assert!(master.locked);
// Only the second master page's box, moved onto the page.
assert!(all(d).iter().all(|n| n.name.as_deref() != Some("Left master box")));
let b = named(d, "Right master box").geometric_bounds().unwrap();
assert!(rect_near(b, Rect::new(10.0, 10.0, 40.0, 50.0)), "{b:?}");
assert!(named(d, "Page box").geometric_bounds().is_some());
}
#[test]
fn a_master_page_shows_on_every_page_it_covers() {
// Two pages show master pages 2 and 3 (the master spread has two: the last repeats).
let d = open("master2.afpub", &with_master(2)).doc;
assert_eq!(d.artboards.len(), 2);
let boxes: Vec<Rect> =
all(&d).into_iter().filter(|n| n.name.as_deref() == Some("Right master box")).map(|n| n.geometric_bounds().unwrap()).collect();
assert_eq!(boxes.len(), 2, "{boxes:?}");
for (b, board) in boxes.iter().zip(&d.artboards) {
assert!(board.rect.contains(b.center()), "{b:?} on {:?}", board.rect);
}
}
fn pixel(d: &Document, x: usize, y: usize) -> [u8; 4] {
let img = vectorcraft_render::Renderer::new().render_region(d, d.artboards[0].rect, 1.0, false);
img.to_straight()[(y * img.width as usize + x) * 4..][..4].try_into().unwrap()
}
fn with_effects(mut node: F, effects: Vec<F>) -> F {
if let F::Def(_, _, fields) = &mut node {
fields.push((tag(b"FiEf"), F::Shared(effects)));
}
node
}
#[test]
fn shadows_go_where_affinity_puts_them() {
// A blue shadow 10 px straight below (angle π/2), blurred 2 px, at half opacity.
let shadow = F::Obj(
tag(b"Shad"),
vec![
(tag(b"Enab"), F::Bool(true)),
(tag(b"Opac"), F::F64(0.5)),
(tag(b"Radi"), F::F64(2.0)),
(tag(b"Offs"), F::F64(10.0)),
(tag(b"Angl"), F::F64(std::f64::consts::FRAC_PI_2)),
(tag(b"Colr"), rgba(0.0, 0.0, 1.0)),
],
);
let bevel = F::Obj(tag(b"BevE"), vec![(tag(b"Enab"), F::Bool(true))]);
let loaded = open("shadow.af", &canvas_with(vec![with_effects(rectangle(10, (0.0, 0.0), (50.0, 50.0)), vec![shadow, bevel])], None));
let d = &loaded.doc;
let rect = named(d, "Magenta box");
let fx = &rect.appearance.effects;
assert_eq!(fx.len(), 1, "{fx:?}");
assert_eq!(fx[0].id, "stylize.dropShadow");
let p = &fx[0].params;
assert!(near(p["x"].as_f64().unwrap(), 0.0) && near(p["y"].as_f64().unwrap(), 10.0), "{p}");
assert_eq!((p["blur"].as_f64(), p["opacity"].as_f64()), (Some(2.0), Some(50.0)));
// A bevel without a radius draws nothing, in Affinity as here.
assert!(!loaded.warnings.iter().any(|w| w.contains("bevel")), "{:?}", loaded.warnings);
// Drawn: blue at half strength below the box, the box itself magenta.
let [r, g, b, a] = pixel(d, 25, 57);
assert!(r < 10 && g < 10 && b > 245 && (100..160).contains(&a), "{:?}", [r, g, b, a]);
assert_eq!(pixel(d, 25, 25)[3], 255);
}
#[test]
fn glows_inner_shadows_and_bevels_are_kept_or_reported() {
let effect = |class: &[u8; 4], radius: f64| {
F::Obj(
tag(class),
vec![(tag(b"Enab"), F::Bool(true)), (tag(b"Opac"), F::F64(0.8)), (tag(b"Radi"), F::F64(radius)), (tag(b"Colr"), rgba(1.0, 1.0, 0.0))],
)
};
let node = with_effects(rectangle(10, (0.0, 0.0), (50.0, 50.0)), vec![effect(b"OutG", 6.0), effect(b"InnS", 3.0), effect(b"BevE", 4.0)]);
let loaded = open("glow.af", &canvas_with(vec![node], None));
let fx = &named(&loaded.doc, "Magenta box").appearance.effects;
assert_eq!(fx.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.outerGlow", "stylize.innerGlow"]);
assert_eq!((fx[0].params["blur"].as_f64(), fx[0].params["opacity"].as_f64()), (Some(6.0), Some(80.0)));
for w in ["inner shadows (imported as inner glows", "bevel and emboss effects (left out)"] {
assert!(loaded.warnings.iter().any(|x| x.contains(w)), "{w}: {:?}", loaded.warnings);
}
}
#[test]
fn outline_effects_become_strokes() {
let outline = |align: u16| {
F::Obj(
tag(b"Strk"),
vec![(tag(b"Enab"), F::Bool(true)), (tag(b"Radi"), F::F64(3.0)), (tag(b"Alig"), F::Enum(align, 0)), (tag(b"Colr"), rgba(1.0, 0.0, 0.0))],
)
};
let shape = with_effects(rectangle(10, (0.0, 0.0), (50.0, 50.0)), vec![outline(0)]);
let text = with_effects(artistic_text(20, "Hi", 40.0), vec![outline(2)]);
let d = open("outline.af", &canvas_with(vec![shape, text], None)).doc;
let rect = named(&d, "Magenta box");
let Some(AppearanceItem::Stroke(s)) = rect.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))) else { panic!() };
assert_eq!((s.width, s.align), (3.0, StrokeAlign::Outside));
assert_eq!(s.paint, Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
let t = texts(&d)[0];
assert_eq!((t.runs[0].style.stroke.clone(), t.runs[0].style.stroke_width), (Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 3.0));
}
#[test]
fn colour_overlays_are_baked_into_the_paint() {
let overlay = F::Obj(tag(b"ColO"), vec![(tag(b"Enab"), F::Bool(true)), (tag(b"Opac"), F::F64(1.0)), (tag(b"Colr"), rgba(0.0, 1.0, 0.0))]);
let d = open("overlay.af", &canvas_with(vec![with_effects(rectangle(10, (0.0, 0.0), (50.0, 50.0)), vec![overlay])], None)).doc;
let rect = named(&d, "Magenta box");
assert!(rect.appearance.effects.is_empty());
let [r, g, b, a] = pixel(&d, 25, 25);
assert!(r < 5 && g > 250 && b < 5 && a == 255, "{:?}", [r, g, b, a]);
}
#[test]
fn outline_effects_on_stroked_shapes_go_around_their_stroke() {
let outline = |align: u16, width: f64| {
F::Obj(
tag(b"Strk"),
vec![
(tag(b"Enab"), F::Bool(true)),
(tag(b"Radi"), F::F64(width)),
(tag(b"Alig"), F::Enum(align, 0)),
(tag(b"Colr"), rgba(1.0, 0.0, 0.0)),
],
)
};
// A black stroke 2 px wide, centred on the path: it reaches 1 px out of the shape.
let stroked = |id: u32| {
let mut node = rectangle(id, (0.0, 0.0), (50.0, 50.0));
if let F::Def(_, _, fields) = &mut node {
fields.push((tag(b"PFil"), cmyk(0.0, 0.0, 0.0, 1.0)));
fields.push((tag(b"LSty"), F::Obj(tag(b"LSty"), vec![(tag(b"Wght"), F::F64(2.0))])));
}
node
};
let loaded = open("outline.af", &canvas_with(vec![with_effects(stroked(10), vec![outline(2, 4.0), outline(0, 3.0)])], None));
assert!(!loaded.warnings.iter().any(|w| w.contains("outline")), "{:?}", loaded.warnings);
let strokes: Vec<(f64, StrokeAlign, Paint)> = named(&loaded.doc, "Magenta box")
.appearance
.items
.iter()
.filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some((s.width, s.align, s.paint.clone())) } else { None })
.collect();
let (red, black) = (Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::cmyk(0.0, 0.0, 0.0, 1.0)));
assert_eq!(
strokes,
[
// Outside: from the path to 3 px past the stroke, under it.
(4.0, StrokeAlign::Outside, red.clone()),
(2.0, StrokeAlign::Center, black),
// Centred on the stroke's outer edge, over it (as far in as out).
(6.0, StrokeAlign::Center, red),
]
);
}
/// Artistic text "Wide line" over "Mid", the paragraphs aligned `first` and `second`
/// (0 left, 1 centre, 2 right).
fn mixed_alignment(first: i32, second: i32) -> F {
let para = |end: i32, align: i32| {
vec![(tag(b"Indx"), F::I32(end)), (tag(b"Item"), F::Obj(tag(b"PAtt"), vec![(tag(b"Ints"), array_i32(b"Ints", &[align]))]))]
};
let blk = F::Obj(
tag(b"StBl"),
vec![
(tag(b"Glyp"), utf8("Wide line\u{2029}Mid\u{2029}")),
(
tag(b"GAtt"),
F::Obj(
tag(b"GlAS"),
vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), vec![run(14, Some(attrs("Inter", "Inter-Regular", 400, false, 40.0)))]))],
),
),
(tag(b"PAtt"), F::Obj(tag(b"PaAS"), vec![(tag(b"Runs"), F::Objs(tag(b"PaAR"), vec![para(10, first), para(14, second)]))])),
],
);
F::Def(
50,
vec![tag(b"TxtA")],
vec![
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))])),
(tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 400.0, 100.0])), (tag(b"ArtV"), F::F64(80.0))])),
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 100.0, 0.0, 1.0, 100.0])),
],
)
}
#[test]
fn artistic_text_aligns_its_lines_within_the_longest() {
// (first, second alignment) → where "Mid" goes in the span of "Wide line" (0 its left end,
// 0.5 centred, 1 its right end).
for (first, second, at) in [(0, 1, 0.5), (0, 2, 1.0), (1, 0, 0.0), (2, 1, 0.5), (1, 2, 1.0)] {
let d = open("mixed.af", &canvas_with(vec![mixed_alignment(first, second)], None)).doc;
let t = texts(&d)[0];
assert!(matches!(t.kind, TextKind::Point));
let lines = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).lines;
let (wide, mid) = (&lines[0], &lines[1]);
let w = wide.x1 - wide.x0;
assert!(w > 0.0);
// The block stays where the first paragraph's alignment puts it on the anchor.
let start = [0.0, -0.5, -1.0][first as usize] * w;
assert!((wide.x0 - start).abs() < 1e-6, "{first}/{second}: {} vs {start}", wide.x0);
let expected = wide.x0 + (w - (mid.x1 - mid.x0)) * at;
assert!((mid.x0 - expected).abs() < 1e-6, "{first}/{second}: {} vs {expected}", mid.x0);
// Each paragraph keeps its own alignment.
use vectorcraft_doc::text::Justify;
let j = |a: i32| [Justify::Left, Justify::Center, Justify::Right][a as usize];
assert_eq!((t.paras[0].justify, t.paras[1].justify), (j(first), j(second)));
}
// One alignment throughout: no indents.
let d = open("same.af", &canvas_with(vec![mixed_alignment(1, 1)], None)).doc;
assert!(texts(&d)[0].paras.iter().all(|p| p.left_indent == 0.0 && p.right_indent == 0.0));
}
/// Frame text of two 100 px lines in a frame `height` tall.
fn two_line_frame(height: f64) -> F {
let blk = block(utf8("One\u{2029}Two\u{2029}"), vec![run(8, Some(auto_leading(attrs("Inter", "Inter-Regular", 400, false, 100.0))))], &[0]);
F::Def(
60,
vec![tag(b"TxtF")],
vec![
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))])),
(tag(b"TxtH"), F::Obj(tag(b"FrFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 500.0, height]))])),
],
)
}
#[test]
fn frame_text_hangs_from_cap_height_and_shows_lines_whose_baseline_fits() {
use vectorcraft_doc::text::FirstBaseline;
let db = vectorcraft_text::FontDb::global();
// Room for both lines: where they go.
let loaded = open("tall.af", &canvas_with(vec![two_line_frame(1000.0)], None));
let t = texts(&loaded.doc)[0];
assert_eq!(t.area.first_baseline, FirstBaseline::CapHeight);
let lines = vectorcraft_text::layout(db, t).lines;
assert_eq!(lines.len(), 2);
// Higher than VectorCraft's default, the font's ascent.
let ascent = vectorcraft_text::layout(db, &TextObject { area: Default::default(), ..t.clone() }).lines[0].baseline;
assert!(lines[0].baseline < ascent - 1.0, "first baseline {} vs ascent {ascent}", lines[0].baseline);
let (baseline, bottom) = (lines[1].baseline, lines[1].baseline + lines[1].descent);
// A frame the second line's baseline fits in but not its descenders: Affinity shows the line.
let height = (baseline + bottom) / 2.0;
let loaded = open("short.af", &canvas_with(vec![two_line_frame(height)], None));
assert!(loaded.warnings.iter().any(|w| w.contains("made taller so the lines show")), "{:?}", loaded.warnings);
let t = texts(&loaded.doc)[0];
let lay = vectorcraft_text::layout(db, t);
assert_eq!(lay.lines.len(), 2);
assert!(!lay.overflow);
assert!(frame(t).height() > height && frame(t).height() < bottom + 1.0, "{:?}", frame(t));
// One whose second baseline is outside it too: that line stays out, as in Affinity.
let loaded = open("shorter.af", &canvas_with(vec![two_line_frame(baseline - 5.0)], None));
let t = texts(&loaded.doc)[0];
assert_eq!(vectorcraft_text::layout(db, t).lines.len(), 1);
assert!((frame(t).height() - (baseline - 5.0)).abs() < 1e-6);
}
#[test]
fn underlined_and_struck_through_characters_stay_so() {
let decorated = |ints: &[i32]| {
let mut a = attrs("Inter", "Inter-Regular", 400, false, 40.0);
if let F::Obj(_, fields) = &mut a {
for (t, v) in fields.iter_mut() {
if *t == tag(b"Ints") {
*v = array_i32(b"Ints", ints);
}
}
}
a
};
let blk = block(
utf8("Plain under struck"),
vec![
run(6, Some(attrs("Inter", "Inter-Regular", 400, false, 40.0))),
run(12, Some(decorated(&[1, 0, 0, 0, 1]))),
run(18, Some(decorated(&[0, 1, 0, 0, 1]))),
],
&[0],
);
let node = F::Def(
70,
vec![tag(b"TxtA")],
vec![
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))])),
(tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 400.0, 100.0])), (tag(b"ArtV"), F::F64(80.0))])),
],
);
let loaded = open("underline.af", &canvas_with(vec![node], None));
assert!(!loaded.warnings.iter().any(|w| w.contains("decoration")), "{:?}", loaded.warnings);
let t = texts(&loaded.doc)[0];
assert_eq!(
t.runs.iter().map(|r| (r.text.as_str(), r.style.underline, r.style.strikethrough)).collect::<Vec<_>>(),
[("Plain ", false, false), ("under ", true, false), ("struck", false, true)]
);
}
/// A 16 × 16 CMYK pixel layer of pure cyan, opaque or (`transparent`) with no alpha, in a
/// document that embeds `profile` as its CMYK profile.
fn cmyk_layer(transparent: bool, profile: Option<&[u8]>) -> Vec<u8> {
let mut bitmap = vec![(tag(b"Frmt"), F::Enum(4, 0)), (tag(b"BmpW"), F::I32(16)), (tag(b"BmpH"), F::I32(16))];
for c in 1..=5 {
// Tile state 2: every sample 255; 0: every sample 0.
let full = c == 1 || (c == 5 && !transparent);
bitmap.push((tag(&[b'T', b'W', b'i', b'0' + c]), F::I32(1)));
bitmap.push((tag(&[b'T', b'H', b'i', b'0' + c]), F::I32(1)));
bitmap.push((tag(&[b'S', b't', b'a', b'0' + c]), F::U8s(vec![if full { 2 } else { 0 }])));
}
let layer = F::Def(10, vec![tag(b"Rstr")], vec![(tag(b"Bitm"), F::Obj(tag(b"DyBm"), bitmap)), (tag(b"Desc"), F::Str("Photo".into()))]);
let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 16.0, 16.0])), (tag(b"Chld"), F::Shared(vec![layer]))]);
let mut root = vec![(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))];
if let Some(icc) = profile {
let mut blob = vec![0x2d];
blob.extend(tag(b"Data").0.to_le_bytes());
blob.extend((icc.len() as u32).to_le_bytes());
blob.extend_from_slice(icc);
root.push((tag(b"CMYP"), F::Obj(tag(b"ICCP"), vec![(tag(b"Data"), F::Raw(blob))])));
}
synth::container(&[("doc.dat", &stream(root), Method::Zstd)], None)
}
/// The image of the node named "Photo", and the blob it shows.
fn photo(d: &Document) -> &vectorcraft_doc::ImageBlob {
let NodeKind::Image(img) = &named(d, "Photo").kind else { panic!("an image") };
&d.images[&img.key]
}
#[test]
fn cmyk_pixel_layers_show_through_the_documents_cmyk_profile() {
use vectorcraft_color::cms::{self, IccProfile, Intent};
// A CMYK profile written from a built-in space, embedded as the document's.
let icc = cms::icc_bytes(cms::GENERIC_CMYK).unwrap();
let loaded = open("photo.af", &cmyk_layer(false, Some(&icc)));
let blob = photo(&loaded.doc);
assert!(!blob.is_cmyk());
let px = image::load_from_memory(&blob.bytes).unwrap().to_rgba8();
let profile = IccProfile::from_bytes(None, &icc).unwrap();
let srgb = cms::icc::builtin_rgb(cms::SRGB).unwrap();
let want = profile.to_rgb_of(&srgb, &[1.0, 0.0, 0.0, 0.0], Intent::RelativeColorimetric, true).unwrap().map(|v| (v * 255.0).round() as i32);
let got = px.get_pixel(8, 8).0;
assert!((0..3).all(|i| (i32::from(got[i]) - want[i]).abs() <= 2) && got[3] == 255, "{got:?} vs {want:?}");
// Not the profile-free formula's pure cyan.
assert_ne!(&got[..3], &[0, 255, 255]);
assert!(!loaded.warnings.iter().any(|w| w.contains("CMYK")), "{:?}", loaded.warnings);
}
#[test]
fn cmyk_pixel_layers_without_a_profile_keep_their_inks_or_use_the_working_profile() {
// Opaque: a CMYK image with the layer's inks.
let loaded = open("photo.af", &cmyk_layer(false, None));
let inks = photo(&loaded.doc).cmyk().expect("a CMYK image");
assert_eq!((inks.width, inks.height, &inks.data[..4]), (16, 16, &[255, 0, 0, 0][..]));
// With transparency: RGBA through the working CMYK profile, and said.
let loaded = open("photo.af", &cmyk_layer(true, None));
let blob = photo(&loaded.doc);
assert!(!blob.is_cmyk());
assert_eq!(image::load_from_memory(&blob.bytes).unwrap().to_rgba8().get_pixel(8, 8).0[3], 0);
assert!(loaded.warnings.iter().any(|w| w.contains("CMYK pixel layers with transparency")), "{:?}", loaded.warnings);
}
proptest::proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(128))]
#[test]
fn mutated_documents_never_panic(edits in proptest::collection::vec((0usize..4096, proptest::prelude::any::<u8>()), 1..16)) {
// The stream is mutated before it is packed, so edits reach the object graph instead of
// failing the archive's checksum.
let mut data = {
let shadow = F::Obj(tag(b"Shad"), vec![(tag(b"Radi"), F::F64(2.0)), (tag(b"Offs"), F::F64(3.0)), (tag(b"Colr"), rgba(0.0, 0.0, 0.0))]);
let nodes = vec![
with_effects(rectangle(10, (1.0, 2.0), (3.0, 4.0)), vec![shadow]),
artistic_text(20, "Hi\u{2029}there", 40.0),
table_node(30, [1.0, 0.0, 0.0, 0.0, 1.0, 0.0]),
];
stream(vec![(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread(2, 2, nodes)]))]))])
};
let n = data.len();
for (at, v) in edits {
if let Some(b) = data.get_mut(at % n) {
*b = v;
}
}
let file = synth::container(&[("doc.dat", &data, Method::Stored)], None);
if let Ok(loaded) = load("mutated.af", &file)
&& let Some(board) = loaded.doc.artboards.first()
{
let _ = vectorcraft_render::Renderer::new().render_region(&loaded.doc, board.rect, 0.05, false);
}
}
}