AF.4: validate and harden Affinity 3 artboards, text and images

This commit is contained in:
Riccardo Balloch
2026-10-09 13:45:55 +00:00
parent c2711df30b
commit 79c5d673ff
28 changed files with 2343 additions and 116 deletions
+37 -8
View File
@@ -206,6 +206,13 @@ impl Builder {
/// One Affinity node (and its subtree) as VectorCraft art; `to_doc` maps document pixels to
/// points. `layer_level`: the node is directly in a spread or a layer, so a layer stays a layer.
fn node(&mut self, n: &af::Node, to_doc: Affine, boards: &mut Vec<(String, Rect)>, layer_level: bool) -> Option<Arc<Node>> {
// Register before descending so nested artboards keep their document order.
if let Kind::Artboard { rect, .. } = &n.kind {
boards.push((
if n.name.is_empty() { format!("Artboard {}", boards.len() + 1) } else { n.name.clone() },
to_doc.transform_rect_bbox(rect_of(*rect)),
));
}
let child_level = matches!(n.kind, Kind::Layer) && layer_level;
let children: Vec<Arc<Node>> = n.children.iter().filter_map(|c| self.node(c, to_doc, boards, child_level)).collect();
let mut out = match &n.kind {
@@ -241,14 +248,18 @@ impl Builder {
self.base(n, NodeKind::Group { children: kids, clip: true })
}
}
Kind::Artboard { rect, background } => {
let r = to_doc.transform_rect_bbox(rect_of(*rect));
boards.push((if n.name.is_empty() { format!("Artboard {}", boards.len() + 1) } else { n.name.clone() }, r));
Kind::Artboard { path, even_odd, background, strokes, .. } => {
let mut clip = Node::new(
self.doc.alloc_id(),
NodeKind::Path { path: shapes::rectangle(r), rule: FillRule::NonZero, live: None, clipping: true, guide: false },
NodeKind::Path {
path: self.path(path, to_doc),
rule: if *even_odd { FillRule::EvenOdd } else { FillRule::NonZero },
live: None,
clipping: true,
guide: false,
},
);
clip.appearance = self.appearance(background, &[], to_doc);
clip.appearance = self.appearance(background, strokes, to_doc);
let mut kids = vec![Arc::new(clip)];
kids.extend(children);
let mut g = self.base(n, NodeKind::Group { children: kids, clip: true });
@@ -432,9 +443,27 @@ impl Builder {
.runs
.iter()
.map(|r| {
let mut style =
CharStyle { font_family: if r.family.is_empty() { r.postscript.clone() } else { r.family.clone() }, ..CharStyle::default() };
style.font_style = font_style(r.weight, r.italic);
let mut style = CharStyle::default();
if !r.family.is_empty() {
style.font_family = r.family.clone();
} else if !r.postscript.is_empty() {
style.font_family = r.postscript.clone();
} else {
self.warn("text without a font name uses the default font");
}
// A family/weight pair cannot identify width variants such as Arial Narrow.
// Resolve a known PostScript identity to the exact installed family and style.
if let Some((family, face_style)) = vectorcraft_text::FontDb::global().by_postscript_name(&r.postscript) {
style.font_family = family;
style.font_style = face_style;
} else {
style.font_style = font_style(r.weight, r.italic);
}
style.features = r.features.clone();
if r.features.iter().any(|feature| !vectorcraft_text::OtFeatures::known_tag(feature)) {
self.warn("unsupported text OpenType features (retained but not rendered)");
}
style.all_caps = r.all_caps;
style.size = r.size * k;
style.leading = r.leading.map(|l| l * k);
style.tracking = r.tracking * 1000.0;
+147
View File
@@ -0,0 +1,147 @@
//! Synthetic version-12 regression documents exercise placement and clipping, independently of
//! the pinned real-file corpus. These are reader fixtures, never claimed as Affinity-authored.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use kurbo::Rect;
use vectorcraft_affinity::synth::{self, F, Method, tag};
use vectorcraft_doc::{Document, Node, NodeKind};
fn field(t: &[u8; 4], f: F) -> (vectorcraft_affinity::stream::Tag, F) {
(tag(t), f)
}
fn red(id: u32) -> F {
F::Def(
id,
vec![tag(b"FDsc")],
vec![field(
b"FDeF",
F::Def(
id + 1,
vec![tag(b"FilS")],
vec![field(
b"Colr",
F::Def(
id + 2,
vec![tag(b"RGBA")],
vec![field(b"_col", F::Struct([1f32, 0.0, 0.0, 1.0].into_iter().flat_map(f32::to_le_bytes).collect()))],
),
)],
),
)],
)
}
fn shape(id: u32, name: &str, board: bool, ellipse: bool, bounds: [f64; 4], transform: [f64; 6], children: Vec<F>) -> F {
F::Def(
id,
vec![tag(b"ShpN")],
vec![
field(b"Desc", F::Str(name.into())),
field(b"ABEn", F::Bool(board)),
field(b"Shpe", F::Def(id + 1, vec![tag(if ellipse { b"ShpE" } else { b"ShNR" })], vec![])),
field(b"ShpB", F::F64s(bounds.to_vec())),
field(b"Xfrm", F::F64s(transform.to_vec())),
field(b"BFFl", F::Shared(vec![red(id + 2)])),
field(b"Chld", F::Shared(children)),
],
)
}
const IDENTITY: [f64; 6] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0];
fn document(spreads: Vec<Vec<F>>, dpi: f64, method: Method) -> Vec<u8> {
let spreads = spreads
.into_iter()
.enumerate()
.map(|(i, nodes)| {
F::Def(1000 + i as u32, vec![tag(b"Sprd")], vec![field(b"SprB", F::F64s(vec![0.0, 0.0, 200.0, 100.0])), field(b"Chld", F::Shared(nodes))])
})
.collect();
let doc = synth::stream(&[
field(b"UVCn", F::Obj(tag(b"UVCn"), vec![field(b"UPPI", F::F64(dpi))])),
field(b"DocR", F::Def(2000, vec![tag(b"DocN")], vec![field(b"Chld", F::Shared(spreads))])),
]);
synth::container(&[("doc.dat", &doc, method)], None)
}
fn find<'a>(nodes: &'a [std::sync::Arc<Node>], name: &str) -> Option<&'a Node> {
nodes.iter().find_map(|n| {
if n.name.as_deref() == Some(name) && matches!(n.kind, NodeKind::Group { .. }) {
Some(n.as_ref())
} else {
n.children().and_then(|c| find(c, name))
}
})
}
#[test]
fn nested_boards_keep_order_and_negative_origins_at_document_resolution() {
let inner = shape(20, "Inner", true, false, [0.0, 0.0, 20.0, 10.0], [1.0, 0.0, 10.0, 0.0, 1.0, 5.0], vec![]);
let outer = shape(10, "Outer", true, false, [0.0, 0.0, 100.0, 60.0], [1.0, 0.0, -40.0, 0.0, 1.0, -20.0], vec![inner]);
let last = shape(30, "Last", true, false, [0.0, 0.0, 40.0, 80.0], [1.0, 0.0, 150.0, 0.0, 1.0, 0.0], vec![]);
for method in [Method::Stored, Method::Zlib, Method::Zstd] {
let loaded = vectorcraft_engine::cmd::fileio::load("boards.af", &document(vec![vec![outer.clone(), last.clone()]], 144.0, method)).unwrap();
let boards = &loaded.doc.artboards;
assert_eq!(boards.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["Outer", "Inner", "Last"]);
assert_eq!(boards.iter().map(|b| b.id).collect::<Vec<_>>(), [1, 2, 3]);
assert_eq!(boards[0].rect, Rect::new(-20.0, -10.0, 30.0, 20.0));
assert_eq!(boards[1].rect, Rect::new(-15.0, -7.5, -5.0, -2.5));
assert_eq!(boards[2].rect, Rect::new(75.0, 0.0, 95.0, 40.0));
let reopened = vectorcraft_format::load_file(&vectorcraft_format::save_file(&loaded.doc)).unwrap();
assert_eq!(reopened.doc.artboards, loaded.doc.artboards);
assert_eq!(reopened.doc.layers, loaded.doc.layers);
}
}
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()
}
#[test]
fn rotated_board_clips_to_its_outline_instead_of_its_export_rectangle() {
let child = shape(20, "Oversized", false, false, [-100.0, -100.0, 200.0, 200.0], IDENTITY, vec![]);
let c = std::f64::consts::FRAC_1_SQRT_2;
let board = shape(10, "Diamond", true, false, [0.0, 0.0, 80.0, 80.0], [c, -c, 60.0, c, c, 0.0], vec![child]);
let loaded = vectorcraft_engine::cmd::fileio::load("rotated.af", &document(vec![vec![board]], 72.0, Method::Zstd)).unwrap();
let d = &loaded.doc;
let g = find(&d.layers, "Diamond").unwrap();
let NodeKind::Path { path, clipping: true, .. } = &g.children().unwrap()[0].kind else { panic!() };
let a = &path.subpaths[0].anchors;
assert!((a[0].p.x - 60.0).abs() < 1e-8 && a[0].p.y.abs() < 1e-8);
assert!((a[1].p.x - (60.0 + 80.0 * c)).abs() < 1e-8);
assert_eq!(pixel(d, 2, 2), [0, 0, 0, 0], "bbox corners are outside the diamond");
assert_eq!(pixel(d, 55, 55), [255, 0, 0, 255]);
}
#[test]
fn converted_ellipse_artboard_keeps_curved_clipping() {
let child = shape(20, "Oversized", false, false, [-20.0, -20.0, 120.0, 120.0], IDENTITY, vec![]);
let board = shape(10, "Ellipse", true, true, [0.0, 0.0, 100.0, 100.0], IDENTITY, vec![child]);
let loaded = vectorcraft_engine::cmd::fileio::load("ellipse.af", &document(vec![vec![board]], 72.0, Method::Stored)).unwrap();
assert_eq!(pixel(&loaded.doc, 2, 2), [0, 0, 0, 0]);
assert_eq!(pixel(&loaded.doc, 50, 50), [255, 0, 0, 255]);
}
#[test]
fn invalid_board_keeps_children_and_reports_the_loss() {
let child = shape(20, "Retained", false, false, [1.0, 2.0, 10.0, 20.0], IDENTITY, vec![]);
let board = shape(10, "Invalid", true, false, [0.0, 0.0, 0.0, 100.0], IDENTITY, vec![child]);
let loaded = vectorcraft_engine::cmd::fileio::load("invalid.af", &document(vec![vec![board]], 72.0, Method::Stored)).unwrap();
assert!(loaded.warnings.iter().any(|w| w.contains("artboard without valid bounds")));
assert!(!loaded.preview_only);
assert_eq!(loaded.doc.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0));
let invalid = find(&loaded.doc.layers, "Invalid").unwrap();
assert_eq!(invalid.children().unwrap()[0].name.as_deref(), Some("Retained"));
}
#[test]
fn several_spreads_preserve_each_board_and_leave_room_between_spreads() {
let a = shape(10, "First", true, false, [0.0, 0.0, 100.0, 60.0], IDENTITY, vec![]);
let b = shape(20, "Second", true, false, [0.0, 0.0, 100.0, 60.0], IDENTITY, vec![]);
let loaded = vectorcraft_engine::cmd::fileio::load("spreads.af", &document(vec![vec![a], vec![b]], 72.0, Method::Stored)).unwrap();
assert_eq!(loaded.doc.artboards.len(), 2);
assert_eq!(loaded.doc.artboards[0].rect, Rect::new(0.0, 0.0, 100.0, 60.0));
assert_eq!(loaded.doc.artboards[1].rect, Rect::new(136.0, 0.0, 236.0, 60.0));
}
+184 -17
View File
@@ -4,14 +4,27 @@
//! (0–255 per channel, both flattened on white, at the thumbnail's size) must stay under its
//! ceiling, which sits just above the difference measured when the importer landed.
//!
//! Skips when `corpus/affinity` is absent.
//! Each artboard is checked. Local runs may skip an absent corpus; CI requires the complete
//! pinned corpus with `AFFINITY_CORPUS_REQUIRED=1`. Tests recheck hashes before using inputs.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::PathBuf;
#[path = "../../affinity/tests/support/corpus.rs"]
mod corpus_gate;
/// (file, ceiling). Measured differences when the importer landed are in the comments.
const FILES: &[(&str, f64)] = &[
("patchy-artboards.af", 0.25), // 0.00 across both boards, including overflow clipping
("patchy-rgba8.af", 3.0), // 2.34: embedded thumbnail versus raster sampling
("patchy-rgba16.af", 3.0), // 2.34
("patchy-dpi300.af", 1.5), // 0.87
("patchy-group.af", 0.25), // 0.00
("patchy-transform.af", 1.0), // 0.19
("patchy-vector.af", 1.0), // 0.12
("patchy-lazy-placed.af", 1.0), // 0.50: upstream byte-derived lazy-image regression
("patchy-embedded-jpeg.af", 1.0), // 0.50
("affinity3-mexican-guy.af", 3.0), // 1.80: brush strokes as plain strokes
("affinity3-mexican-man.af", 3.5), // 2.24
("affinity3-mexican-woman.af", 3.5), // 2.55
@@ -37,8 +50,7 @@ const FILES: &[(&str, f64)] = &[
];
fn corpus() -> Option<PathBuf> {
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/affinity");
dir.join("SOURCES.md").exists().then_some(dir)
corpus_gate::pinned()
}
/// Mean absolute difference of two RGBA8 images of one size, both composited on white.
@@ -69,23 +81,178 @@ fn public_affinity_documents_render_like_their_affinity_thumbnails() {
assert!(!loaded.preview_only, "{name} opened as its preview: {:?}", loaded.warnings);
let d = &loaded.doc;
let thumb = image::load_from_memory(vectorcraft_affinity::preview(&bytes).unwrap().png).unwrap().to_rgba8();
// The thumbnail shows the first page, or every artboard: compare the largest artboard
// with its part of the thumbnail.
// The embedded thumbnail shows the union of the artboards. Check every board,
// including small ones that the previous largest-board-only check omitted.
let union = d.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b)).unwrap();
assert!(union.width().is_finite() && union.width() > 0.0);
let scale = f64::from(thumb.width()) / union.width();
let board = d.artboards.iter().max_by(|a, b| a.rect.area().total_cmp(&b.rect.area())).unwrap().rect;
let crop = |v: f64| (v * scale).round().max(0.0) as u32;
let (x, y) = (crop(board.x0 - union.x0), crop(board.y0 - union.y0));
let (w, h) = (crop(board.width()).min(thumb.width() - x), crop(board.height()).min(thumb.height() - y));
let expected = image::imageops::crop_imm(&thumb, x, y, w, h).to_image();
let rendered = vectorcraft_render::Renderer::new().render_region(d, board, scale, false);
let got = image::RgbaImage::from_raw(rendered.width, rendered.height, rendered.to_straight()).unwrap();
let got = image::imageops::resize(&got, w, h, image::imageops::FilterType::Triangle);
let diff = difference(expected.as_raw(), got.as_raw());
eprintln!("{name}: {diff:.2} (ceiling {ceiling})");
if diff > *ceiling {
failures.push(format!("{name}: {diff:.2} > {ceiling}"));
for (index, artboard) in d.artboards.iter().enumerate() {
let board = artboard.rect;
let crop = |v: f64| (v * scale).round().max(0.0) as u32;
let (x, y) = (crop(board.x0 - union.x0), crop(board.y0 - union.y0));
assert!(x < thumb.width() && y < thumb.height(), "{name} board {index}: thumbnail crop outside image");
let (w, h) = (crop(board.width()).min(thumb.width() - x), crop(board.height()).min(thumb.height() - y));
assert!(w > 0 && h > 0, "{name} board {index}: empty thumbnail crop");
let expected = image::imageops::crop_imm(&thumb, x, y, w, h).to_image();
let rendered = vectorcraft_render::Renderer::new().render_region(d, board, scale, false);
let got = image::RgbaImage::from_raw(rendered.width, rendered.height, rendered.to_straight()).unwrap();
let got = image::imageops::resize(&got, w, h, image::imageops::FilterType::Triangle);
let diff = difference(expected.as_raw(), got.as_raw());
eprintln!("{name} board {index} ({}): {diff:.2} (ceiling {ceiling})", artboard.name);
if diff > *ceiling {
failures.push(format!("{name} board {index}: {diff:.2} > {ceiling}"));
}
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
/// Every actual .af fixture must open as editable art, retain its complete native document,
/// and export every artboard. Reimporting our SVG/PDF and comparing our PSD merged pixels
/// checks our export paths; it is not an independent Affinity-versus-PSD fidelity oracle.
#[test]
fn affinity3_all_artboards_survive_native_save_and_export() {
let Some(dir) = corpus() else { return };
let names: Vec<_> = include_str!("../../../xtask/affinity-corpus.sha256")
.lines()
.filter_map(|l| l.split_once(" "))
.map(|(_, name)| name)
.filter(|name| name.ends_with(".af"))
.collect();
for name in names {
let bytes = std::fs::read(dir.join(name)).unwrap();
let loaded = vectorcraft_engine::cmd::fileio::load(name, &bytes).unwrap();
assert!(!loaded.preview_only, "{name}: preview-only import: {:?}", loaded.warnings);
let doc = &loaded.doc;
assert!(!doc.artboards.is_empty(), "{name}: no artboards");
let native = vectorcraft_format::save(doc, false);
let reloaded = vectorcraft_format::load(&native).unwrap();
assert_eq!(serde_json::to_value(&reloaded).unwrap(), serde_json::to_value(doc).unwrap(), "{name}: native save/reload lost document data");
if doc.artboards.len() > 1 {
let params = serde_json::json!({"range": "all", "useArtboards": true, "preserveEditing": false});
let svg = vectorcraft_engine::cmd::fileio::encode_all(doc, "svg", &params).unwrap();
assert_eq!(svg.files.len(), doc.artboards.len(), "{name}: SVG must write every artboard");
assert_eq!(
svg.files.iter().map(|(index, _)| *index).collect::<Vec<_>>(),
(0..doc.artboards.len()).map(Some).collect::<Vec<_>>(),
"{name}: SVG artboard order"
);
let pdf = vectorcraft_engine::cmd::fileio::encode(doc, "pdf", &params).unwrap();
assert_eq!(
vectorcraft_pdf::info(&pdf, None).unwrap().pages.len(),
doc.artboards.len(),
"{name}: PDF must write every artboard as a page"
);
}
for (index, board) in doc.artboards.iter().enumerate() {
assert!(
board.rect.width().is_finite() && board.rect.width() > 0.0 && board.rect.height().is_finite() && board.rect.height() > 0.0,
"{name} board {index}: invalid bounds"
);
let scale = 128.0 / board.rect.width().max(board.rect.height());
let params =
serde_json::json!({"artboard": index, "useArtboards": true, "scale": scale, "background": "transparent", "preserveEditing": false});
let svg = vectorcraft_engine::cmd::fileio::encode(doc, "svg", &params).unwrap();
let svg = vectorcraft_svg::import(std::str::from_utf8(&svg).unwrap()).unwrap();
assert_eq!(svg.artboards.len(), 1, "{name} board {index}: SVG page count");
assert!((svg.artboards[0].rect.width() - board.rect.width()).abs() < 0.01, "{name} board {index}: SVG width");
assert!((svg.artboards[0].rect.height() - board.rect.height()).abs() < 0.01, "{name} board {index}: SVG height");
let pdf = vectorcraft_engine::cmd::fileio::encode(doc, "pdf", &params).unwrap();
let info = vectorcraft_pdf::info(&pdf, None).unwrap();
assert_eq!(info.pages.len(), 1, "{name} board {index}: PDF page count");
assert!((info.pages[0].width - board.rect.width()).abs() < 0.01, "{name} board {index}: PDF width");
assert!((info.pages[0].height - board.rect.height()).abs() < 0.01, "{name} board {index}: PDF height");
assert_eq!(vectorcraft_pdf::import(&pdf).unwrap().artboards.len(), 1, "{name} board {index}: PDF reimport");
let psd = vectorcraft_engine::cmd::fileio::encode(doc, "psd", &params).unwrap();
let png = vectorcraft_engine::cmd::fileio::encode(doc, "png", &params).unwrap();
let png = image::load_from_memory(&png).unwrap().to_rgba8();
let (merged, has_alpha) = psd_merged(&psd);
assert_eq!(merged.dimensions(), png.dimensions(), "{name} board {index}: PSD image dimensions");
// PSD merged RGB is stored on a white matte for readers that ignore alpha;
// the separate alpha plane, when present, keeps the original coverage.
for (pixel, (p, q)) in png.pixels().zip(merged.pixels()).enumerate() {
if has_alpha {
assert_eq!(p[3], q[3], "{name} board {index} pixel {pixel}: PSD alpha");
}
let alpha = u32::from(p[3]);
for channel in 0..3 {
let expected = ((u32::from(p[channel]) * alpha + 255 * (255 - alpha) + 127) / 255) as u8;
assert!(
q[channel].abs_diff(expected) <= 1,
"{name} board {index} pixel {pixel} channel {channel}: PSD white-matted RGB {} differs from PNG on white {expected}",
q[channel]
);
}
}
eprintln!("{name} board {index}: native/SVG/PDF/PSD verified (import warnings: {:?})", loaded.warnings);
}
}
}
/// Minimal test-only decoder written from the public PSD file-format layout:
/// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/
/// It independently reads the merged RGB planes and PackBits rows, skipping metadata/layers.
fn psd_merged(bytes: &[u8]) -> (image::RgbaImage, bool) {
let be16 = |at: usize| u16::from_be_bytes(bytes[at..at + 2].try_into().unwrap());
let be32 = |at: usize| u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) as usize;
assert_eq!(&bytes[..4], b"8BPS");
assert_eq!(be16(4), 1);
assert_eq!(&bytes[6..12], &[0; 6]);
let channels = usize::from(be16(12));
assert!((3..=4).contains(&channels));
let (height, width) = (be32(14), be32(18));
assert_eq!(be16(22), 8);
assert_eq!(be16(24), 3);
let mut at = 26;
for _ in 0..3 {
at += 4 + be32(at);
}
let compression = be16(at);
at += 2;
let mut planes = vec![vec![0; width * height]; channels];
match compression {
0 => {
for plane in &mut planes {
plane.copy_from_slice(&bytes[at..at + width * height]);
at += width * height;
}
}
1 => {
let row_lengths: Vec<_> = (0..channels * height).map(|i| usize::from(be16(at + i * 2))).collect();
at += channels * height * 2;
for (channel, plane) in planes.iter_mut().enumerate() {
for row in 0..height {
let end = at + row_lengths[channel * height + row];
let mut decoded = Vec::new();
while at < end {
let control = bytes[at] as i8;
at += 1;
match control {
0..=127 => {
let n = control as usize + 1;
assert!(at + n <= end);
decoded.extend_from_slice(&bytes[at..at + n]);
at += n;
}
-127..=-1 => {
assert!(at < end);
decoded.extend(std::iter::repeat_n(bytes[at], (1i16 - i16::from(control)) as usize));
at += 1;
}
-128 => {}
}
}
assert_eq!(decoded.len(), width);
plane[row * width..(row + 1) * width].copy_from_slice(&decoded);
}
}
}
_ => panic!("unexpected PSD compression {compression}"),
}
assert_eq!(at, bytes.len(), "PSD merged planes must consume the complete image section");
let image = image::RgbaImage::from_fn(width as u32, height as u32, |x, y| {
let i = y as usize * width + x as usize;
image::Rgba([planes[0][i], planes[1][i], planes[2][i], planes.get(3).map_or(255, |p| p[i])])
});
(image, channels == 4)
}
+198
View File
@@ -0,0 +1,198 @@
//! Editable text import regressions on original synthetic version-12 `.af` streams and pinned
//! public Affinity 3 files. Font rendering depends on installed fonts; these are structure and
//! conversion oracles, not claims of pixel-identical typography.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
#[path = "../../affinity/tests/support/corpus.rs"]
mod corpus;
#[path = "../../affinity/tests/support/text_fixture.rs"]
mod fixture;
use fixture::*;
use vectorcraft_affinity::synth::{F, tag};
use vectorcraft_doc::{
Document, Node, NodeKind, TextObject,
text::{CharStyle, Justify, TextKind},
};
use vectorcraft_engine::cmd::fileio;
fn texts(d: &Document) -> Vec<&TextObject> {
fn walk<'a>(n: &'a Node, out: &mut Vec<&'a TextObject>) {
if let NodeKind::Text(t) = &n.kind {
out.push(t);
}
if let Some(children) = n.children() {
for child in children {
walk(child, out);
}
}
}
let mut out = Vec::new();
for n in &d.layers {
walk(n, &mut out);
}
out
}
fn import(blocks: Vec<F>, frame: bool, dpi: f64, transform: [f64; 6]) -> fileio::Loaded {
let loaded = fileio::load("original-text.af", &document(blocks, frame, dpi, transform)).unwrap();
assert!(!loaded.preview_only);
loaded
}
#[test]
fn unicode_mixed_fonts_line_breaks_and_styles_remain_editable_after_save() {
let loaded = import(
vec![block(
utf8("é😀\u{2028}ab\u{2029}尾\0"),
vec![run(2, Some(attrs("First", "FirstPS", 700, true, 24.0))), run(8, Some(attrs("Second", "SecondPS", 300, false, 20.0)))],
&[1],
)],
false,
96.0,
[2.0, 0.0, 30.0, 0.0, 2.0, 40.0],
);
let t = texts(&loaded.doc)[0];
assert_eq!(t.runs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(), ["é😀", "\nab\n尾"]);
assert_eq!(t.runs[0].style.font_family, "First");
assert_eq!(t.runs[0].style.font_style, "Bold Italic");
assert_eq!(t.runs[1].style.font_style, "Light");
assert_eq!((t.runs[0].style.size, t.runs[0].style.leading, t.runs[0].style.tracking), (36.0, Some(45.0), 25.0));
assert_eq!(t.para.justify, Justify::Center);
assert!(matches!(t.kind, TextKind::Point));
assert_eq!(t.xf.as_coeffs(), [1.0, 0.0, 0.0, 1.0, 187.5, 87.0]);
let saved = vectorcraft_format::save_file(&loaded.doc);
let restored = vectorcraft_format::load(&saved).unwrap();
assert_eq!(texts(&restored)[0], t);
}
#[test]
fn absent_font_name_uses_named_default_and_postscript_name_is_preserved() {
let loaded =
import(vec![block(utf8("ab"), vec![run(1, Some(attrs("", "OnlyPostScript", 400, false, 12.0))), run(2, None)], &[])], false, 72.0, IDENTITY);
let t = texts(&loaded.doc)[0];
assert_eq!(t.runs[0].style.font_family, "OnlyPostScript");
assert_eq!(t.runs[1].style.font_family, CharStyle::default().font_family);
assert_eq!(t.runs[1].style.fill.color().unwrap().to_rgb(), [0.0, 0.0, 0.0]);
assert!(loaded.warnings.iter().any(|w| w.contains("without a font name")));
assert!(loaded.warnings.iter().any(|w| w.contains("without character attributes")));
}
#[test]
fn frame_type_keeps_dimensions_alignment_and_unit_conversion() {
let loaded = import(vec![block(utf8("text"), vec![run(4, Some(attrs("Frame", "", 500, false, 20.0)))], &[3])], true, 144.0, IDENTITY);
let t = texts(&loaded.doc)[0];
assert_eq!(t.runs[0].style.size, 10.0);
assert_eq!(t.runs[0].style.font_style, "Medium");
assert_eq!(t.para.justify, Justify::JustifyLeft);
let TextKind::Area { frame } = &t.kind else { panic!("expected area type") };
assert_eq!(frame.bounds().unwrap().width(), 100.0);
assert_eq!(frame.bounds().unwrap().height(), 50.0);
}
#[test]
fn each_font_weight_and_italic_maps_to_the_expected_style() {
let cases = [
(100, "Thin"),
(200, "ExtraLight"),
(300, "Light"),
(400, "Regular"),
(500, "Medium"),
(600, "SemiBold"),
(700, "Bold"),
(800, "ExtraBold"),
(900, "Black"),
];
for (weight, expected) in cases {
for italic in [false, true] {
let loaded = import(vec![block(utf8("x"), vec![run(1, Some(attrs("Family", "", weight, italic, 12.0)))], &[])], false, 72.0, IDENTITY);
let style = &texts(&loaded.doc)[0].runs[0].style.font_style;
let expected = if !italic {
expected.into()
} else if expected == "Regular" {
"Italic".into()
} else {
format!("{expected} Italic")
};
assert_eq!(*style, expected);
}
}
}
#[test]
fn caps_and_feature_overrides_pass_through_to_editable_type() {
let mut a = attrs("Family", "", 400, false, 12.0);
if let F::Obj(_, fields) = &mut a {
fields.iter_mut().find(|(t, _)| *t == tag(b"Objs")).unwrap().1 = F::Shared(vec![
solid(1.0),
F::Null,
F::Null,
F::Null,
F::Null,
F::Null,
F::Null,
F::Obj(
tag(b"OtAt"),
vec![(
tag(b"Setn"),
F::Objs(
tag(b"OTFS"),
vec![
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"CAP\x01"))), (tag(b"Valu"), F::I32(1))],
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"liga"))), (tag(b"Valu"), F::I32(0))],
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"ss01"))), (tag(b"Valu"), F::I32(1))],
],
),
)],
),
]);
}
let loaded = import(vec![block(utf8("Mixed"), vec![run(5, Some(a))], &[])], false, 72.0, IDENTITY);
let t = texts(&loaded.doc)[0];
assert_eq!(t.runs[0].text, "Mixed");
assert!(t.runs[0].style.all_caps);
assert_eq!(t.runs[0].style.features, ["-liga", "ss01"]);
assert!(loaded.warnings.iter().any(|w| w.contains("retained but not rendered")));
let restored = vectorcraft_format::load(&vectorcraft_format::save_file(&loaded.doc)).unwrap();
assert_eq!(texts(&restored)[0], t);
}
#[test]
fn pinned_affinity3_text_opens_as_editable_unicode_with_resolved_emoji_font() {
let Some(corpus) = corpus::pinned() else { return };
let load = |name: &str| {
let loaded = fileio::load(name, &std::fs::read(corpus.join(name)).unwrap()).unwrap();
assert!(!loaded.preview_only);
loaded
};
let loaded = load("patchy-text-runs.af");
let t = texts(&loaded.doc)[0];
assert_eq!(t.runs.iter().map(|r| r.text.as_str()).collect::<String>(), "ABcdÉ😀X");
assert_eq!(t.runs[3].style.font_family, "Segoe UI Emoji");
assert_eq!(t.runs[4].style.font_family, "Courier New");
let loaded = load("patchy-text-caps.af");
let ts = texts(&loaded.doc);
assert!(ts[0].runs[0].style.all_caps);
assert_eq!(ts[1].runs[0].style.features, ["smcp"]);
for name in ["patchy-text-artistic.af", "patchy-text-frame.af", "patchy-text-rotated.af", "patchy-text-indent.af", "patchy-text-para-spacing.af"]
{
let loaded = load(name);
assert!(!texts(&loaded.doc).is_empty(), "{name}");
let restored = vectorcraft_format::load(&vectorcraft_format::save_file(&loaded.doc)).unwrap();
assert_eq!(texts(&restored), texts(&loaded.doc), "{name}");
}
}
#[test]
fn installed_postscript_identity_overrides_ambiguous_family_and_weight() {
// This is an OS font metadata check. No font file is committed or required; the structural
// fallback cases above always run, including when the optional font repository is absent.
let db = vectorcraft_text::FontDb::global();
let Some((family, face_style)) = db.by_postscript_name("DejaVuSans") else {
eprintln!("DejaVuSans is not installed; exact PostScript face test skipped");
return;
};
let loaded =
import(vec![block(utf8("x"), vec![run(1, Some(attrs("Ambiguous Family", "DejaVuSans", 900, true, 12.0)))], &[])], false, 72.0, IDENTITY);
let style = &texts(&loaded.doc)[0].runs[0].style;
assert_eq!(style.font_family, family);
assert_eq!(style.font_style, face_style);
}