mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 16:33:22 -06:00
AF.4: validate and harden Affinity 3 artboards, text and images
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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", ¶ms).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", ¶ms).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", ¶ms).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", ¶ms).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", ¶ms).unwrap();
|
||||
let png = vectorcraft_engine::cmd::fileio::encode(doc, "png", ¶ms).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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user