Files
vectorcraft/crates/eps/src/import/ai/tests.rs
T
dotCooCoo c3093fcafb .ai import: a wraparound group's graphics wrap with no offset
Every graphic in a version 7 wraparound group (`*w` … `*W`), before
or after its type, is now a wrap object with an offset of 0 instead of
the 6 pt default, and a group with no type in it marks all of its
graphics. A graphic before the type is below it, so the type flows
around it only after it is moved above. Operands on `*w` or `*W`, and
a `*w` inside a compound path, are errors.

Docs: docs/file-format-parity.md.
2026-11-02 12:04:06 +07:00

3576 lines
193 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.
//! The editing data reader on synthetic data, written from the format as `read` documents it.
use std::sync::Arc;
use kurbo::{Point, Rect};
use vectorcraft_color::gradient::GradientKind;
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{AppearanceItem, ColorMode, Document, Guide, Knockout, LayerColor, LineCap, LineJoin, Node, NodeKind, Unit};
use super::*;
/// [`super::read`] as an EPS reads it.
fn read(data: &[u8]) -> Result<Structure, String> {
super::read(data, true)
}
/// Editing data: header comments (crop marks 0 0 200 100), then `body`.
fn stream(body: &str) -> String {
format!(
"%!PS-Adobe-3.0\n%%Creator: test\n%AI5_FileFormat 14.0\n%AI3_Cropmarks: 0 0 200 100\n%%EndComments\n%%BeginProlog\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n{body}\n%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n"
)
}
/// One layer named `L` holding `art`.
fn layer(art: &str) -> String {
format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n0 A\n0 Xw\n{art}\nLB\n%AI5_EndLayer--\n")
}
fn read_ok(body: &str) -> Structure {
match read(stream(body).as_bytes()) {
Ok(i) => i,
Err(e) => panic!("{e}"),
}
}
fn assert_unread(r: &Structure, kind: &str) {
assert_eq!(r.unread.len(), 1);
let (id, kinds) = &r.unread[0];
assert_eq!(kinds, &[kind]);
assert!(r.doc.node(*id).is_some(), "the unread object has a placeholder");
}
#[test]
fn ruler_units_from_the_header_set_the_documents_units() {
let body = layer("");
let with_mm = format!(
"%!PS-Adobe-3.0\n%%Creator: test\n%AI5_FileFormat 14.0\n%AI5_RulerUnits: 1\n%AI3_Cropmarks: 0 0 200 100\n%%EndComments\n%%BeginProlog\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n{body}\n%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n"
);
assert_eq!(read_ok(&with_mm).doc.units, Unit::Millimeters);
assert_eq!(read_ok(&stream(&body)).doc.units, Unit::Points, "default when the comment is absent");
let with_in = with_mm.replace("AI5_RulerUnits: 1", "AI5_RulerUnits: 0");
assert_eq!(read(with_in.as_bytes()).unwrap().doc.units, Unit::Inches);
let with_px = with_mm.replace("AI5_RulerUnits: 1", "AI5_RulerUnits: 6");
assert_eq!(read(with_px.as_bytes()).unwrap().doc.units, Unit::Pixels);
}
/// The children of the first layer.
fn art(doc: &Document) -> Vec<Arc<Node>> {
doc.layers.first().and_then(|l| l.children()).cloned().unwrap_or_default()
}
fn fill(n: &Node) -> Paint {
n.appearance.fill().map(|f| f.paint.clone()).unwrap_or_default()
}
fn rect_path(x0: f64, y0: f64, x1: f64, y1: f64) -> String {
format!("{x0} {y0} m\n{x0} {y1} L\n{x1} {y1} L\n{x1} {y0} L\n{x0} {y0} L\n")
}
const RED: &str = "0 1 1 0 1 0 0 Xa\n";
#[test]
fn layers_and_sublayers() {
let body = format!(
"%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Bottom) Ln\n{RED}{}f\n\
%AI5_BeginLayer\n0 1 0 0 0 0 0 -1 10 200 30 0 50 0 Lb\n(Inner) Ln\n{}f\nLB\n%AI5_EndLayer--\nLB\n%AI5_EndLayer--\n\
%AI5_BeginLayer\n1 0 1 1 1 0 1 3 79 79 255 0 70 0 Lb\n(Top) Ln\n{}f\nLB\n%AI5_EndLayer--\n",
rect_path(0.0, 0.0, 10.0, 10.0),
rect_path(20.0, 0.0, 30.0, 10.0),
rect_path(40.0, 0.0, 50.0, 10.0)
);
let doc = read_ok(&body).doc;
let names: Vec<_> = doc.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect();
assert_eq!(names, ["Bottom", "Top"], "bottom first");
let bottom = &doc.layers[0];
assert!(bottom.visible && !bottom.locked);
let kids = bottom.children().unwrap();
assert_eq!(kids.len(), 2);
let inner = &kids[1];
assert_eq!(inner.name.as_deref(), Some("Inner"));
assert!(!inner.visible && inner.locked);
let NodeKind::Layer { color, printable, .. } = &inner.kind else { panic!("a sublayer") };
assert_eq!((*color, *printable), (LayerColor::Custom([10, 200, 30]), false));
let NodeKind::Layer { color, preview, dim_images, .. } = &doc.layers[1].kind else { panic!() };
assert_eq!((*color, *preview, *dim_images), (LayerColor::Preset(3), false, Some(70)));
}
#[test]
fn groups_names_hidden_and_locked() {
let art_ = format!(
"0 Ae\nu\n{RED}{}f\n%_/ArtDictionary :\n%_/XMLUID : (My_Rect__x23_2) ; (AI10_ArtUID) ,\n%_;\n%_\n\
1 A\n0 Ae\nu\n0 A\n1 Xw\n{}f\n0 Xw\nU\n%_/ArtDictionary :\n%_(Inner group) /String (AIArtName) ,\n%_;\n9 () XW\n\
0 A\nU\n%_/ArtDictionary :\n%_/XMLUID : (Outer) ; (AI10_ArtUID) ,\n%_;\n%_\n9 () XW\n",
rect_path(0.0, 0.0, 10.0, 10.0),
rect_path(20.0, 0.0, 30.0, 10.0),
);
let doc = read_ok(&layer(&art_)).doc;
let top = art(&doc);
assert_eq!(top.len(), 1);
let outer = &top[0];
assert_eq!(outer.name.as_deref(), Some("Outer"));
let kids = outer.children().unwrap();
assert_eq!(kids[0].name.as_deref(), Some("My Rect #2"));
let inner = &kids[1];
assert_eq!(inner.name.as_deref(), Some("Inner group"));
assert!(inner.locked, "locked when it opened");
let hidden = &inner.children().unwrap()[0];
assert!(!hidden.visible);
}
/// A node's Attributes panel note (empty: none).
fn note(n: &Node) -> String {
n.extras().attrs.as_deref().map(|a| a.note.clone()).unwrap_or_default()
}
/// The notes of the first layer's objects, in order.
fn notes(doc: &Document) -> Vec<String> {
art(doc).iter().map(|n| note(n)).collect()
}
/// A note comment names the paths after it until another one replaces it, and an empty one clears
/// it (§5.3.1: `%AI3_Note: <note>`).
#[test]
fn a_note_comment_is_the_note_of_the_paths_after_it() {
let [a, b, c, d] = [0.0, 20.0, 40.0, 60.0].map(|x| rect_path(x, 0.0, x + 10.0, 10.0));
let body = format!("%AI3_Note:hello\n{a}f\n{b}f\n%AI3_Note:\n{c}f\n%AI3_Note:world\n{d}f\n");
assert_eq!(notes(&read_ok(&layer(&body)).doc), ["hello", "hello", "", "world"]);
// `%%Note:` sets it too.
assert_eq!(notes(&read_ok(&layer(&format!("%%Note:old style\n{a}f\n"))).doc), ["old style"]);
// A path without a note has no attributes.
assert!(art(&read_ok(&layer(&format!("{a}f\n"))).doc)[0].extras().attrs.is_none());
}
/// `%AI5_NoteMore:` adds a line to the note; the text is kept as written, and its bytes are read as
/// UTF-8, else Windows-1252.
#[test]
fn a_note_continues_on_more_lines_and_keeps_its_text() {
let a = rect_path(0.0, 0.0, 10.0, 10.0);
let body = format!("%AI3_Note: two (words) caf@ \n%AI5_NoteMore:more\n{a}f\n");
let data = stream(&layer(&body)).replace('@', "\u{1}").into_bytes();
let data: Vec<u8> = data.into_iter().map(|b| if b == 1 { 0xE9 } else { b }).collect();
assert_eq!(notes(&read(&data).unwrap().doc), [" two (words) café \nmore"]);
// A continuation of an empty note is its second line; one with no note before it is ignored.
assert_eq!(notes(&read_ok(&layer(&format!("%AI3_Note:\n%AI5_NoteMore:x\n{a}f\n"))).doc), ["\nx"]);
assert_eq!(notes(&read_ok(&layer(&format!("%AI5_NoteMore:x\n{a}f\n"))).doc), [""]);
// A note too long is cut at 1,024 characters, however many lines it has.
let long = format!("%AI3_Note:{}\n{}{a}f\n", "x".repeat(5000), "%AI5_NoteMore:yy\n".repeat(3000));
assert_eq!(notes(&read_ok(&layer(&long)).doc)[0].chars().count(), 1024);
let long = format!("%AI3_Note:x\n{}{a}f\n", "%AI5_NoteMore:yy\n".repeat(3000));
assert_eq!(notes(&read_ok(&layer(&long)).doc)[0].chars().count(), 1024);
// A long UTF-8 line stays UTF-8 where it is cut.
let long = format!("%AI3_Note:{}\n{a}f\n", "é".repeat(3000));
assert_eq!(notes(&read_ok(&layer(&long)).doc), ["é".repeat(1024)]);
// A note on a `%_` line is read too.
assert_eq!(notes(&read_ok(&layer(&format!("%_%AI3_Note:hidden\n{a}f\n"))).doc), ["hidden"]);
}
/// A note before a group or a compound path isn't theirs: their paths keep their own.
#[test]
fn a_note_before_a_group_or_compound_path_is_not_theirs() {
let [a, b] = [0.0, 20.0].map(|x| rect_path(x, 0.0, x + 10.0, 10.0));
for (open, close) in [("u", "U"), ("*u", "*U")] {
let body = format!("%AI3_Note:outer\n0 Ae\n{open}\n%AI3_Note:inner\n{a}f\n{b}f\n{close}\n");
let doc = read_ok(&layer(&body)).doc;
let top = &art(&doc)[0];
assert_eq!(note(top), "", "{open}");
let kids: Vec<String> = top.children().unwrap().iter().map(|k| note(k)).collect();
assert_eq!(kids, ["inner", "inner"], "{open}");
}
}
/// A clipping path's note is its own, not that of the copy that paints its fill.
#[test]
fn a_clipping_paths_note_is_not_copied_to_its_painted_fill() {
let body =
format!("0 Ae\nq\n{RED}%AI3_Note:clip\n{}W\nf\n%AI3_Note:\n{}f\nQ\n", rect_path(5.0, 5.0, 25.0, 25.0), rect_path(0.0, 0.0, 30.0, 30.0));
let doc = read_ok(&layer(&body)).doc;
fn walk(nodes: &[Arc<Node>], out: &mut Vec<(bool, String)>) {
for n in nodes {
out.push((matches!(n.kind, NodeKind::Path { clipping: true, .. }), note(n)));
walk(n.children().map(Vec::as_slice).unwrap_or_default(), out);
}
}
let mut all = Vec::new();
walk(&art(&doc), &mut all);
let noted: Vec<_> = all.iter().filter(|(_, n)| n == "clip").collect();
assert_eq!(noted, [&(true, "clip".to_string())], "{all:?}");
assert!(all.len() >= 3, "the painted copy is there: {all:?}");
}
/// `1 Ap` shows the center point of the paths after it until `0 Ap` (§12, showCenter), which leaves
/// them at an imported path's default: hidden.
#[test]
fn ap_shows_the_center_of_the_paths_after_it() {
let [a, b, c] = [0.0, 20.0, 40.0].map(|x| rect_path(x, 0.0, x + 10.0, 10.0));
let ellipse = "50 0 m\n50 5.5 45.5 10 40 10 c\n34.5 10 30 5.5 30 0 c\n30 -5.5 34.5 -10 40 -10 c\n45.5 -10 50 -5.5 50 0 c\n";
let body = format!("1 Ap\n{a}f\n{b}f\n0 Ap\n{c}f\n{ellipse}f\n2 Ap\n{a}f\n");
let doc = read_ok(&layer(&body)).doc;
let shown: Vec<_> = art(&doc).iter().map(|n| (n.extras().attrs.as_deref().and_then(|a| a.show_center), n.shows_center())).collect();
assert_eq!(shown, [(Some(true), true), (Some(true), true), (None, false), (None, false), (None, false)]);
}
/// What a skipped section sets doesn't reach the art after it.
#[test]
fn a_note_or_ap_in_a_skipped_section_does_not_reach_the_art_after_it() {
let a = rect_path(0.0, 0.0, 10.0, 10.0);
let body = format!("%AI3_BeginBrushPattern\n1 Ap\n%AI3_Note:brush\n{a}f\n%AI3_EndBrushPattern\n{}", layer(&format!("{a}f\n")));
let doc = read_ok(&body).doc;
let n = &art(&doc)[0];
assert_eq!(note(n), "");
assert!(!n.shows_center());
}
#[test]
fn paths_colours_and_strokes() {
let art_ = format!(
"{RED}0 R\n0 0 1 0 0 0 1 XA\n1 J 2 j 3 w 4 M [6 3 ]1 d\n1 XR\n{}b\n\
0.1 0.2 0.3 0.4 k\n{}f\n0.7 g\n{}F\n0 0.5 1 0 (Ink) 0.4 x\n{}f\n\
10 50 m\n20 60 30 60 40 50 c\n50 40 60 40 v\n70 60 80 60 y\nS\n",
rect_path(0.0, 0.0, 10.0, 10.0),
rect_path(20.0, 0.0, 30.0, 10.0),
rect_path(40.0, 0.0, 50.0, 10.0),
rect_path(60.0, 0.0, 70.0, 10.0),
);
let doc = read_ok(&layer(&art_)).doc;
let a = art(&doc);
assert_eq!(a.len(), 5);
// y up from the crop marks' top: (0, 0)-(10, 10) is at y 90-100.
let NodeKind::Path { path, rule, .. } = &a[0].kind else { panic!() };
assert_eq!(path.bounds(), Some(Rect::new(0.0, 90.0, 10.0, 100.0)));
assert!(path.subpaths[0].closed);
assert_eq!(path.subpaths[0].anchors.len(), 4, "the closing point merges with the first");
assert_eq!(*rule, vectorcraft_geom::FillRule::EvenOdd);
assert_eq!(fill(&a[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
let st = a[0].appearance.stroke().unwrap();
assert_eq!((st.width, st.cap, st.join, st.miter_limit), (3.0, LineCap::Round, LineJoin::Bevel, 4.0));
assert_eq!(st.paint.color(), Some(Color::rgb(0.0, 0.0, 1.0)));
let dash = st.dash.clone().unwrap();
assert_eq!((dash.pattern, dash.offset), (vec![6.0, 3.0], 1.0));
assert_eq!(fill(&a[1]).color(), Some(Color::cmyk(0.1, 0.2, 0.3, 0.4)));
assert!(matches!(fill(&a[2]).color(), Some(Color::Gray { k }) if (k - 0.3).abs() < 1e-6));
assert!(!matches!(&a[2].kind, NodeKind::Path { path, .. } if path.subpaths[0].closed), "F leaves the path open");
let Paint::Solid { swatch, tint, .. } = fill(&a[3]) else { panic!() };
assert_eq!(swatch.as_deref(), Some("Ink"));
assert!((tint - 0.6).abs() < 1e-6);
let ink = doc.swatch("Ink").unwrap();
assert!(ink.spot && ink.global);
// Curves: `c`, `v` (first control at the current point) and `y` (second at the end).
let NodeKind::Path { path, .. } = &a[4].kind else { panic!() };
assert_eq!(path.subpaths[0].anchors.len(), 4);
assert!(a[4].appearance.fill().is_none() && a[4].appearance.stroke().is_some());
}
/// `Xk` and `XK` are global process colours: a name, a tint and whether the colour is RGB, as
/// `Xx` has them (#1025). They come in as global swatches that aren't inks, linked at their tint.
#[test]
fn global_process_colours_are_global_swatches_at_their_tint() {
let art_ = format!("0 0 0 1 (Black global) 0.4 0 Xk\n0 0 0 0 1 0.5 0 (Brand orange) 0 1 XK\n{}b\n", rect_path(0.0, 0.0, 10.0, 10.0));
let doc = read_ok(&layer(&art_)).doc;
let a = art(&doc);
let Paint::Solid { swatch, tint, color } = fill(&a[0]) else { panic!("{:?}", fill(&a[0])) };
assert_eq!(swatch.as_deref(), Some("Black global"));
assert!((tint - 0.6).abs() < 1e-6 && matches!(color, Color::Cmyk { k, .. } if (k - 0.6).abs() < 1e-6), "{tint} {color:?}");
let stroke = a[0].appearance.stroke().unwrap().paint.clone();
assert!(
matches!(&stroke, Paint::Solid { swatch: Some(s), tint, color } if s == "Brand orange" && *tint == 1.0 && *color == Color::rgb(1.0, 0.5, 0.0)),
"{stroke:?}"
);
for name in ["Black global", "Brand orange"] {
let sw = doc.swatch(name).unwrap();
assert!(sw.global && !sw.spot, "{sw:?}");
}
assert_eq!(doc.swatch("Black global").unwrap().paint.color(), Some(Color::cmyk(0.0, 0.0, 0.0, 1.0)), "the swatch is the full colour");
}
/// A gradient stop naming a global process colour doesn't make it an ink (#1771): the colour
/// operator's definition (`Xk`) makes the swatch, though the gradient, defined first, names the
/// colour first. The lines are the ones posted in #1771, with a neutral name.
#[test]
fn a_gradient_stop_naming_a_process_colour_leaves_its_swatch_process() {
let defs = "%AI5_BeginGradient: (Brand)\n(Brand) 0 2 Bd\n[\n<00FF>\n0\n\
0.930000007152557 0.100000001490116 0 0 1 1 6 50 0 %_BS\n\
%_0.930000007152557 0.100000001490116 0 0 (Test Cyan) 0 0 5 1 6 50 0 Bs\n\
%_0 0 0 0 1 0 0 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
let art_ = format!("0.930000007152557 0.100000001490116 0 0 (Test Cyan) 0 0 Xk\n{}f\n", rect_path(0.0, 0.0, 10.0, 10.0));
let near =
|c: Option<Color>| matches!(c, Some(Color::Cmyk { c, m, y, k }) if (c - 0.93).abs() < 1e-3 && (m - 0.1).abs() < 1e-3 && y == 0.0 && k == 0.0);
let doc = read_ok(&format!("{defs}{}", layer(&art_))).doc;
let sw = doc.swatch("Test Cyan").unwrap();
assert!(sw.global && !sw.spot, "a global process colour, not an ink: {sw:?}");
assert!(near(sw.paint.color()), "C=93 M=10: {sw:?}");
assert!(near(fill(&art(&doc)[0]).color()), "the fill: {:?}", fill(&art(&doc)[0]));
// Named only by the stop, the colour is still a swatch of its own, at the stop's values.
let doc = read_ok(&format!("{defs}{}", layer(&format!("{}f\n", rect_path(0.0, 0.0, 10.0, 10.0))))).doc;
let sw = doc.swatch("Test Cyan").unwrap();
assert!(near(sw.paint.color()), "{sw:?}");
}
/// A pattern's definition (`%AI3_BeginPattern`): its name and tile (8 points square here, y up),
/// then its art on `%_` lines (a group of the tile's unpainted box and a dot, a round-capped
/// stroke of no length), `E`.
const DOTS: &str = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 8 8\n%_0 A\n%_0 Xw\n%_0 Ae\n%_u\n%_0 O\n%_0 8 m\n%_8 8 L\n%_8 0 L\n%_0 0 L\n%_0 8 L\n%_n\n\
%_0 R\n%_0 0 0 1 K\n%_1 J 0 j 2 w 4 M []0 d\n%_4 4 m\n%_4 4 L\n%_S\n%_U\n%_/ArtDictionary :\n%_;\n%_\n%_9 () XW\nE\n%AI3_EndPattern\n";
/// Patterns are read into pattern swatches, and `p` fills with one (#1025): the art keeps its
/// layers, the fill places the tiles by its matrix.
#[test]
fn patterns_are_pattern_swatches_and_fill_with_them() {
let sub = |name: &str, art: &str| format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{art}LB\n%AI5_EndLayer--\n");
let body = format!(
"{DOTS}%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Ground) Ln\n{}{}LB\n%AI5_EndLayer--\n",
sub("Lawn", &format!("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(10.0, 20.0, 70.0, 80.0))),
sub("Paving", &format!("(Dots) 10 20 2 2 0 0 0 0 0 [2 0 0 2 10 20] P\n{}S\n", rect_path(90.0, 20.0, 150.0, 80.0))),
);
let s = read_ok(&body);
let doc = &s.doc;
let ground = &doc.layers[0];
let subs: Vec<_> = ground.children().unwrap().iter().map(|l| l.name.clone().unwrap_or_default()).collect();
assert_eq!(subs, ["Lawn", "Paving"]);
let def = doc.pattern("Dots").unwrap();
assert_eq!(def.tile, Rect::new(0.0, -8.0, 8.0, 0.0), "the tile, y down");
assert_eq!(def.art.len(), 1, "the tile's group");
let tile_box = &def.art[0].children().unwrap()[0];
assert_eq!(tile_box.geometric_bounds(), Some(def.tile), "the tile's box is where the tile is");
assert_eq!(def.art[0].children().unwrap().len(), 2);
assert!(matches!(&doc.swatch("Dots").unwrap().paint, Paint::Pattern { pattern, .. } if pattern == "Dots"));
let lawn = &ground.children().unwrap()[0].children().unwrap()[0];
let Paint::Pattern { pattern, xf } = fill(lawn) else { panic!("{:?}", fill(lawn)) };
assert_eq!(pattern, "Dots");
// The tile's top left (0, 8) in art space is (0, 92) on the page (crop marks 100 high); a dot
// centre (4, 4) is at (4, 96).
assert_eq!(xf * Point::ZERO, Point::new(0.0, 92.0));
assert_eq!(xf * Point::new(4.0, 4.0), Point::new(4.0, 96.0));
// A stroke with it, scaled twice and moved by (10, 20): the tile's top left is at (10, 36) in
// art space.
let paving = &ground.children().unwrap()[1].children().unwrap()[0];
let Some(Paint::Pattern { xf, .. }) = paving.appearance.stroke().map(|st| st.paint.clone()) else { panic!() };
assert_eq!(xf * Point::ZERO, Point::new(10.0, 64.0));
assert!(s.warnings.is_empty(), "{:?}", s.warnings);
}
/// Where a definition draws its tile doesn't move the fill: the tile's lower left is the pattern's
/// origin, so the same dots drawn at 30..38 × 40..48 tile as they do at 0..8 × 0..8.
#[test]
fn a_pattern_tiles_from_its_lower_left_wherever_its_definition_drew_it() {
let moved = DOTS
.replace("(Dots) 0 0 8 8", "(Dots) 30 40 38 48")
.replace("%_0 8 m\n%_8 8 L\n%_8 0 L\n%_0 0 L\n%_0 8 L", "%_30 48 m\n%_38 48 L\n%_38 40 L\n%_30 40 L\n%_30 48 L")
.replace("%_4 4 m\n%_4 4 L", "%_34 44 m\n%_34 44 L");
let body = format!("{moved}{}", layer(&format!("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(10.0, 20.0, 70.0, 80.0))));
let s = read_ok(&body);
assert_eq!(s.doc.pattern("Dots").unwrap().tile, Rect::new(30.0, -48.0, 38.0, -40.0));
let Paint::Pattern { xf, .. } = fill(&art(&s.doc)[0]) else { panic!() };
assert_eq!(xf * Point::ZERO, Point::new(0.0, 92.0));
}
/// A gradient in a pattern's definition is placed by an `Xm` written y down about the canvas point of
/// art space's origin (here 7941, 7941: the canvas's centre less the template box's): its ramp from
/// (10, 0) up to (10, 20) in the pattern's space is `0 -20 20 0 -7931 7941 Xm`.
#[test]
fn a_gradient_in_a_patterns_definition_is_placed_from_the_canvas() {
let defs =
"%AI5_BeginGradient: (G)\n(G) 0 2 Bd\n[\n<00FF>\n0\n%_0 0 0 0 1 0 0 2 1 6 50 0 Bs\n%_0 0 0 0 0 0 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
let tile = rect_path(0.0, 0.0, 20.0, 20.0).replace('\n', "\n%_");
let pattern = format!(
"%AI3_BeginPattern: (Shade)\n(Shade) 0 0 20 20\n%_0 A\n%_0 Xw\n%_0 Ae\n%_u\n%_Bb\n%_1 (G) 0 0 90 1 1 0 0 1 0 0 1 Bg\n\
%_0 -20 20 0 -7931 7941 Xm\n%_{tile}f\n%_0 BB\n%_U\nE\n%AI3_EndPattern\n"
);
let file = stream(&format!("{defs}{pattern}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n"))))
.replace("%%EndComments", "%AI3_TemplateBox: 250.5 -250.5 250.5 -250.5\n%%EndComments");
let doc = read(file.as_bytes()).unwrap().doc;
let def = doc.pattern("Shade").unwrap();
let shaded = &def.art[0].children().unwrap()[0];
let Paint::Gradient(g) = fill(shaded) else { panic!("{:?}", fill(shaded)) };
let geom = g.geom.unwrap();
// The pattern's space is y down.
assert!(geom.start.distance(Point::new(10.0, 0.0)) < 1e-9 && geom.end.distance(Point::new(10.0, -20.0)) < 1e-9, "{geom:?}");
}
/// A pattern whose definition it can't read whole isn't kept: a fill with it is something the file
/// has that isn't read, where it shows. Its definition costs nothing else.
#[test]
fn a_pattern_it_cant_read_is_an_error_only_where_it_fills() {
let unread = "%AI3_BeginPattern: (Old)\n(Old) 0 0 8 8 [\n(0 O 0 R 0 g) @\n(\n0 0 m 8 8 L S\n) &\n] E\n%AI3_EndPattern\n\
%AI3_BeginPattern: (Odd)\n(Odd) 0 0 8 8\n%_U\nE\n%AI3_EndPattern\n%AI3_BeginPattern: (Flat)\n(Flat) 0 0 8 0\n%_0 0 m\n%_8 0 L\n%_S\nE\n%AI3_EndPattern\n";
let s = read_ok(&format!("{unread}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n"))));
assert!(s.doc.patterns.is_empty() && s.doc.swatches.iter().all(|w| !matches!(w.paint, Paint::Pattern { .. })));
for name in ["Old", "Odd", "Flat"] {
let r = read(
stream(&format!("{unread}{}", layer(&format!("({name}) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(0.0, 0.0, 1.0, 1.0)))))
.as_bytes(),
)
.unwrap();
assert_unread(&r, "pattern fills");
}
// Type in a pattern isn't read either.
let typed = "%AI3_BeginPattern: (Words)\n(Words) 0 0 8 8\n%_/AI11Text :\n%_0 /StoryIndex ,\n%_;\nE\n%AI3_EndPattern\n";
assert!(read_ok(&format!("{typed}{}", layer(""))).doc.patterns.is_empty());
}
/// A pattern whose definition paints with a pattern it doesn't have isn't kept, on `%_` lines or
/// not: a fill with it is something the file has that isn't read.
#[test]
fn a_pattern_painting_with_an_unread_pattern_is_not_kept() {
for prefix in ["%_", ""] {
let tile = rect_path(0.0, 0.0, 8.0, 8.0).replace('\n', &format!("\n{prefix}"));
let def = format!(
"%AI3_BeginPattern: (Nest)\n(Nest) 0 0 8 8\n{prefix}(Missing) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{prefix}{tile}f\nE\n%AI3_EndPattern\n"
);
let s = read_ok(&format!("{def}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n"))));
assert!(s.doc.patterns.is_empty(), "{prefix:?}: {:?}", s.doc.patterns.iter().map(|p| &p.name).collect::<Vec<_>>());
let r = read(
stream(&format!("{def}{}", layer(&format!("(Nest) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(0.0, 0.0, 1.0, 1.0))))).as_bytes(),
)
.unwrap();
assert_unread(&r, "pattern fills");
// A color set before the tile is drawn replaces the missing pattern, and the pattern is kept.
let repainted = def.replace(&format!("{prefix}{tile}f"), &format!("{prefix}0 g\n{prefix}{tile}f"));
let s = read_ok(&format!("{repainted}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n"))));
assert!(s.doc.pattern("Nest").is_some(), "{prefix:?}");
}
}
#[test]
fn compound_paths_and_clipping() {
let art_ = format!(
"0 Ae\n*u\n{RED}{}f\n{}f\n*U\n%_/ArtDictionary :\n%_/XMLUID : (Donut) ; (AI10_ArtUID) ,\n%_;\n\
0 Ae\nq\n{}f\n{}h\nW\nn\nQ\n%_/ArtDictionary :\n%_/XMLUID : (Clipped) ; (AI10_ArtUID) ,\n%_;\n9 () XW\n",
rect_path(0.0, 0.0, 30.0, 30.0),
rect_path(10.0, 10.0, 20.0, 20.0),
rect_path(40.0, 0.0, 80.0, 40.0),
rect_path(50.0, 10.0, 60.0, 20.0),
);
let doc = read_ok(&layer(&art_)).doc;
let a = art(&doc);
let NodeKind::Compound { children, .. } = &a[0].kind else { panic!("{:?}", a[0].kind) };
assert_eq!((children.len(), a[0].name.as_deref()), (2, Some("Donut")));
assert_eq!(fill(&a[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
let NodeKind::Group { children, clip: true } = &a[1].kind else { panic!("a clipping group") };
assert_eq!(a[1].name.as_deref(), Some("Clipped"));
// The clipping path goes first.
assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
assert!(matches!(children[1].kind, NodeKind::Path { clipping: false, .. }));
// A clipping path with a fill paints it too, under the art it clips; without one, nothing.
let filled = format!("0 Ae\nq\n{RED}{}f\n0 0 1 0 0 0 1 Xa\n{}W\nf\nQ\n", rect_path(0.0, 0.0, 30.0, 30.0), rect_path(5.0, 5.0, 25.0, 25.0));
let doc = read_ok(&layer(&filled)).doc;
let NodeKind::Group { children, clip: true } = &art(&doc)[0].kind else { panic!("a clipping group") };
let kinds: Vec<_> = children.iter().map(|c| (matches!(c.kind, NodeKind::Path { clipping: true, .. }), fill(c).color())).collect();
assert_eq!(kinds, [(true, None), (false, Some(Color::rgb(0.0, 0.0, 1.0))), (false, Some(Color::rgb(1.0, 0.0, 0.0)))]);
// A clipping path in a layer clips the layer.
let doc = read_ok(&layer(&format!("{}f\n{}W\nn\n", rect_path(0.0, 0.0, 9.0, 9.0), rect_path(0.0, 0.0, 5.0, 5.0)))).doc;
let NodeKind::Layer { clip, children, .. } = &doc.layers[0].kind else { panic!() };
assert!(*clip && matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
}
/// A stroked clipping path (`W S`, `W B`, or a gradient fill with `1 BB`) keeps its stroke on the clipping path, and a
/// fill goes on the painted copy under the clipped art.
#[test]
fn a_clipping_paths_stroke_stays_on_the_clipping_path() {
let blue = Some(Color::rgb(0.0, 0.0, 1.0));
let red = Some(Color::rgb(1.0, 0.0, 0.0));
// (clipping, fill colour, a gradient fill, stroke colour and width) of each of the clipping group's children.
let children = |doc: &Document| {
let NodeKind::Group { children, clip: true } = &art(doc)[0].kind else { panic!("{:?}", art(doc)[0].kind) };
children
.iter()
.map(|c| {
let clipping = matches!(c.kind, NodeKind::Path { clipping: true, .. });
(clipping, fill(c).color(), matches!(fill(c), Paint::Gradient(_)), c.appearance.stroke().map(|s| (s.paint.color(), s.width)))
})
.collect::<Vec<_>>()
};
let under = rect_path(0.0, 0.0, 30.0, 30.0);
let clip = rect_path(5.0, 5.0, 25.0, 25.0);
let stroked = format!("0 Ae\nq\n{RED}{under}f\n0 0 1 0 0 0 1 XA\n3 w\n{clip}W\nS\nQ\n");
assert_eq!(children(&read_ok(&layer(&stroked)).doc), [(true, None, false, Some((blue, 3.0))), (false, red, false, None)]);
let both = format!("0 Ae\nq\n{RED}{under}f\n0 1 0 0 0 1 0 Xa\n0 0 1 0 0 0 1 XA\n3 w\n{clip}W\nB\nQ\n");
assert_eq!(
children(&read_ok(&layer(&both)).doc),
[(true, None, false, Some((blue, 3.0))), (false, Some(Color::rgb(0.0, 1.0, 0.0)), false, None), (false, red, false, None)]
);
let defs = "%AI5_BeginGradient: (G)\n(G) 0 2 Bd\n[\n0 0 0 0 1 0 0 2 0 6 %_BS\n%_0 0 0 0 1 0 0 2 0 6 0 0 Bs\n\
%_0 0 0 0 0 0 1 2 1 6 100 50 Bs\nBD\n%AI5_EndGradient\n";
let gradient = format!("0 Ae\nq\n{RED}{under}f\n0 0 1 0 0 0 1 XA\n3 w\n{clip}W\nBb\n0 0 0 0 Bh\n1 (G) 5 5 0 20 1 0 0 1 0 0 1 Bg\nf\n1 BB\nQ\n");
assert_eq!(
children(&read_ok(&format!("{defs}{}", layer(&gradient))).doc),
[(true, None, false, Some((blue, 3.0))), (false, None, true, None), (false, red, false, None)]
);
// Several clipping paths clip as one compound path, which keeps their stroke when they all have the same one.
let inner = rect_path(10.0, 10.0, 20.0, 20.0);
let compound_stroke = |first: &str, second: &str| {
let body = format!("0 Ae\nq\n{RED}{under}f\n0 0 1 0 0 0 1 XA\n3 w\n{first}{clip}W\nS\n{second}{inner}W\nS\nQ\n");
let doc = read_ok(&layer(&body)).doc;
let NodeKind::Group { children, clip: true } = &art(&doc)[0].kind else { panic!("{:?}", art(&doc)[0].kind) };
assert!(matches!(children[0].kind, NodeKind::Compound { .. }), "{:?}", children[0].kind);
(children[0].appearance.stroke().map(|s| (s.paint.color(), s.width)), children[0].opacity, children[0].blend)
};
assert_eq!(compound_stroke("", ""), (Some((blue, 3.0)), 1.0, BlendMode::Normal));
assert_eq!(compound_stroke("9 0.5 0 0 0 Xy\n", ""), (Some((blue, 3.0)), 0.5, BlendMode::Lighten));
assert_eq!(compound_stroke("", "5 w\n"), (None, 1.0, BlendMode::Normal), "strokes that differ");
}
/// A clipping group's clipping path can sit alone in a group of its own, after the art it clips (`q … u <path> W n U
/// … Q`): it still clips the clipping group.
#[test]
fn a_clipping_path_alone_in_a_group_clips_its_clipping_group() {
let body = format!("0 Ae\nq\n{RED}{}f\n0 Ae\nu\n{}h\nW\nn\nU\nQ\n", rect_path(0.0, 0.0, 30.0, 30.0), rect_path(5.0, 5.0, 25.0, 25.0));
let doc = read_ok(&layer(&body)).doc;
let NodeKind::Group { children, clip: true } = &art(&doc)[0].kind else { panic!("{:?}", art(&doc)[0].kind) };
assert_eq!(children.len(), 2, "{children:?}");
assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
assert_eq!(fill(&children[1]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
}
/// #1410: a compound path shows its last path's paint, and paths grouped in it stay its paths.
#[test]
fn compound_path_takes_its_last_paths_paint() {
let paths = format!("0 g\n{}f\n{RED}{}f\n", rect_path(0.0, 0.0, 30.0, 30.0), rect_path(10.0, 10.0, 20.0, 20.0));
let bodies = [
format!("0 Ae\n*u\n{paths}*U\n"),
// The paths in a group.
format!("0 Ae\n*u\n0 Ae\nu\n{paths}U\n*U\n"),
// The last path unpainted.
format!("*u\n{paths}{}n\n*U\n", rect_path(1.0, 1.0, 2.0, 2.0)),
];
for body in bodies {
let a = art(&read_ok(&layer(&body)).doc);
let NodeKind::Compound { children, .. } = &a[0].kind else { panic!("{:?}", a[0].kind) };
assert!(children.len() >= 2 && children.iter().all(|c| matches!(c.kind, NodeKind::Path { .. })), "{body}");
assert_eq!(fill(&a[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0)), "{body}");
}
}
#[test]
fn a_group_whose_state_has_no_transparency_takes_the_current_one() {
// GUARDS: a group's own state block keeps the last `Xy`, as the PDF part draws it; a drawn look's group stays opaque.
let art_ = format!(
"{RED}0 Ae\nu\n0 0.5 0 0 0 Xy\n{}f\nU\n0 0 Xd\n6 () XW\n0 1 0 0 0 Xy\n0 Ae\nu\n0 0.5 0 0 0 Xy\n{}f\nU\n9 () XW\n",
rect_path(0.0, 0.0, 10.0, 10.0),
rect_path(20.0, 0.0, 30.0, 10.0)
);
let doc = read_ok(&layer(&art_)).doc;
let a = art(&doc);
assert_eq!((a[0].opacity, a[0].children().unwrap()[0].opacity), (0.5, 0.5));
assert_eq!((a[1].opacity, a[1].children().unwrap()[0].opacity), (1.0, 0.5));
}
#[test]
fn a_layer_keeps_its_own_transparency() {
// Illustrator writes a layer's own opacity, blend and knockout after its art, before `LB`
// (`Xy`, `Xd`, then `n () XW`); they belong to the layer, not to its last object (#2003).
let art_ = format!("{RED}{}f\n0 0.35 0 2 0 Xy\n0 0 Xd\n7 () XW\n", rect_path(0.0, 0.0, 10.0, 10.0));
let doc = read_ok(&layer(&art_)).doc;
let l = &doc.layers[0];
assert_eq!((l.opacity, l.blend), (0.35, BlendMode::Normal), "the layer's own state");
assert_eq!((l.isolate, l.knockout), (false, Knockout::Neutral));
// The art inside keeps its own state.
assert_eq!(art(&doc)[0].opacity, 1.0);
// A layer whose last object is a group keeps that group's state apart from the layer's.
let art_ = format!("0 Ae\nu\n0 0.5 0 0 0 Xy\n{}f\nU\n0 0 Xd\n6 () XW\n", rect_path(0.0, 0.0, 10.0, 10.0));
let doc = read_ok(&layer(&art_)).doc;
assert_eq!(doc.layers[0].opacity, 1.0, "no layer state written: the layer stays opaque");
assert_eq!(art(&doc)[0].opacity, 0.5, "the group's own state");
// A layer whose last object is a gradient mesh: an `Xy` right after the mesh's end is the
// layer's when `LB` follows (directly or after the layer's `Xd` and `7 () XW`), and the mesh's
// when its own `6 () XW` follows.
let corner =
|x: i32, y: i32, next: (i32, i32), prev: (i32, i32)| format!("%_[1 1 1 1 {x} {y} {} {} 1 {} {} 1] /N X#\n", next.0, next.1, prev.0, prev.1);
let mesh = format!(
"0 0.8 0 0 0 Xy\n/Mesh X!\n%_(DeviceRGB) /CS X#\n%_[0 0] /P X#\n{}{}{}{}%_0 /E X#\n/End X!\n",
corner(0, 0, (0, 10), (10, 0)),
corner(0, 40, (10, 40), (0, 30)),
corner(40, 40, (40, 30), (30, 40)),
corner(40, 0, (30, 0), (40, 10)),
);
for (after, layer_state, mesh_state) in [
("1 0.35 0 0 0 Xy\n", (0.35, BlendMode::Multiply), (0.8, BlendMode::Normal)),
("1 0.35 0 0 0 Xy\n0 0 Xd\n7 () XW\n", (0.35, BlendMode::Multiply), (0.8, BlendMode::Normal)),
("1 0.35 0 0 0 Xy\n0 0 Xd\n6 () XW\n", (1.0, BlendMode::Normal), (0.35, BlendMode::Multiply)),
// The stroke style in the layer's state block.
("1 0.35 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d\n0 XR\n0 0 Xd\n7 () XW\n", (0.35, BlendMode::Multiply), (0.8, BlendMode::Normal)),
] {
let doc = read_ok(&layer(&format!("{mesh}{after}"))).doc;
let l = &doc.layers[0];
assert!(matches!(art(&doc)[0].kind, NodeKind::Mesh(_)), "{after:?}");
assert_eq!((l.opacity, l.blend), layer_state, "the layer's state after {after:?}");
assert_eq!((art(&doc)[0].opacity, art(&doc)[0].blend), mesh_state, "the mesh's state after {after:?}");
}
}
#[test]
fn a_layers_state_keeps_through_the_stroke_style_written_in_it() {
// GUARDS: a layer's state block can have the stroke style between its `Xy` and `Xd`; read as
// the art moving on, it lost the layer's opacity and the layer drew opaque.
let state = "0 0.25 0 2 0 Xy\n0 J 0 j 1 w 10 M []0 d\n0 XR\n0 0 Xd\n7 () XW\n";
let clip_group = format!(
"q\n{}h\nW\nn\n0 Ae\nu\n{RED}{}f\nU\n9 () XW\nQ\n9 () XW\n{state}",
rect_path(10.0, 10.0, 90.0, 90.0),
rect_path(0.0, 0.0, 100.0, 100.0)
);
let path = format!("{RED}{}f\n{state}", rect_path(0.0, 0.0, 10.0, 10.0));
for body in [clip_group, path] {
let doc = read_ok(&layer(&body)).doc;
let l = &doc.layers[0];
assert_eq!((l.opacity, l.knockout), (0.25, Knockout::Neutral), "{body}");
assert_eq!(art(&doc)[0].opacity, 1.0, "{body}");
}
// An object's own state block (`6 () XW`) isn't the layer's.
let note = format!("{RED}{}f\n{}", rect_path(0.0, 0.0, 10.0, 10.0), state.replace("7 () XW", "6 () XW"));
assert_eq!(read_ok(&layer(&note)).doc.layers[0].opacity, 1.0);
}
#[test]
fn transparency_of_objects_and_groups() {
let art_ = format!(
"{RED}1 0.5 0 0 0 Xy\n{}f\n0 Ae\nu\n0 1 0 0 0 Xy\n{}f\nU\n2 0.7 1 1 0 Xy\n0 0 Xd\n6 () XW\n0 1 0 0 0 Xy\n{}f\n",
rect_path(0.0, 0.0, 10.0, 10.0),
rect_path(20.0, 0.0, 30.0, 10.0),
rect_path(40.0, 0.0, 50.0, 10.0),
);
let doc = read_ok(&layer(&art_)).doc;
let a = art(&doc);
assert_eq!((a[0].opacity, a[0].blend), (0.5, BlendMode::Multiply));
assert_eq!((a[1].opacity, a[1].blend, a[1].isolate, a[1].knockout), (0.7, BlendMode::Screen, true, Knockout::On));
let inner = &a[1].children().unwrap()[0];
assert_eq!(inner.opacity, 1.0);
assert_eq!((a[2].opacity, a[2].blend), (1.0, BlendMode::Normal), "the next object's state isn't the group's");
}
#[test]
fn a_gradient_mesh_takes_the_transparency_written_after_it() {
// GUARDS: a mesh's own state (`Xy`) comes right after its `/End X!`, before `Xd` and `XW`, as a
// group's does after `U`; read as the next object's, a Multiply shadow mesh painted opaque.
let corner =
|x: i32, y: i32, next: (i32, i32), prev: (i32, i32)| format!("%_[1 1 1 1 {x} {y} {} {} 1 {} {} 1] /N X#\n", next.0, next.1, prev.0, prev.1);
let mesh = |x: i32| {
format!(
"/Mesh X!\n%_(DeviceRGB) /CS X#\n%_[0 0] /P X#\n{}{}{}{}%_0 /E X#\n/End X!\n",
corner(x, 0, (x, 10), (x + 10, 0)),
corner(x, 40, (x + 10, 40), (x, 30)),
corner(x + 40, 40, (x + 40, 30), (x + 30, 40)),
corner(x + 40, 0, (x + 30, 0), (x + 40, 10)),
)
};
let art_ = format!(
"0 1 0 0 0 Xy\n{}1 0.8 0 0 0 Xy\n0 0 Xd\n6 () XW\n{}0 0 Xd\n6 () XW\n2 0.7 0 0 0 Xy\n{RED}{}f\n",
mesh(0),
mesh(50),
rect_path(100.0, 0.0, 110.0, 10.0)
);
let doc = read_ok(&layer(&art_)).doc;
let a = art(&doc);
assert!(matches!(a[0].kind, NodeKind::Mesh(_)) && matches!(a[1].kind, NodeKind::Mesh(_)), "{a:#?}");
assert_eq!((a[0].opacity, a[0].blend), (0.8, BlendMode::Multiply), "the state after the mesh is its own");
assert_eq!((a[1].opacity, a[1].blend), (0.8, BlendMode::Multiply), "the next mesh keeps the current state");
assert_eq!((a[2].opacity, a[2].blend), (0.7, BlendMode::Screen), "a state after the mesh's `Xd` and `XW` is the next object's");
}
#[test]
fn a_masked_gradient_mesh_takes_the_transparency_written_after_its_mask() {
// GUARDS: a mesh with an opacity mask has its state after the mask's dictionary, whose art (on
// `%_` lines) has operators of its own; read as the next object's, each masked mesh in a run
// drew with the blend mode of the one before it.
let corners: String = ["10 20 10 25 1 15 20 1", "10 50 15 50 1 10 45 1", "40 50 40 45 1 35 50 1", "40 20 35 20 1 40 25 1"]
.iter()
.map(|g| format!("%_[0.5 1 {g}] /N X#\n"))
.collect();
let mesh = format!("/Mesh X!\n%_/DeviceGray /CS X#\n{corners}%_/E X#\n/End X!\n");
let mask = "%_/ArtDictionary :\n%_/Mask :\n%_X=\n%_u\n%_1 g\n%_10 20 m\n%_10 50 L\n%_40 50 L\n%_40 20 L\n%_f\n%_U\n%_X+\n\
%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_1 /Bool (Linked) ,\n%_; (AI9_Mask) ,\n%_;\n";
let next = format!("0 1 0 0 0 Xy\n{RED}{}f\n", rect_path(60.0, 20.0, 80.0, 40.0));
for (state, expected) in [("1 0.5 0 0 0 Xy\n", (0.5, BlendMode::Multiply)), ("", (1.0, BlendMode::Overlay))] {
let a = art(&read_ok(&layer(&format!("3 1 0 0 0 Xy\n{mesh}{mask}{state}0 0 Xd\n6 () XW\n{next}"))).doc);
assert_eq!(a.len(), 2, "{a:?}");
assert!(matches!(a[0].kind, NodeKind::Mesh(_)) && a[0].extras().mask.is_some(), "{:?}", a[0]);
assert_eq!((a[0].opacity, a[0].blend), expected, "{state:?}: the state after the mask is the mesh's");
assert_eq!((a[1].opacity, a[1].blend), (1.0, BlendMode::Normal), "the next object's own state");
}
}
#[test]
fn a_transparency_set_in_a_dictionary_s_art_stays_after_it() {
// GUARDS: an area text's frame (its `/ConfiningPath` art) sets its own transparency; the objects
// after the text, with no `Xy` of their own, draw with it. Read as the dictionary's alone, they
// drew with the Lighten of the path before the text and vanished on a light background.
let text = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n/Art :\nX=\n0 Ap\n0 1 0 0 0 Xy\n0 w\n100 0 m\n100 10 L\nn\nX+\n\
; /ConfiningPath ,\n2 /TextAntialiasing ,\n;\n";
let body = format!("9 0.5 0 0 0 Xy\n{RED}{}f\n{text}{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0), rect_path(20.0, 0.0, 30.0, 10.0));
let a = art(&read_ok(&layer(&body)).doc);
let paths: Vec<_> = a.iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).collect();
assert_eq!(paths.len(), 2, "{a:#?}");
assert_eq!((paths[0].opacity, paths[0].blend), (0.5, BlendMode::Lighten), "the state before the text");
assert_eq!((paths[1].opacity, paths[1].blend), (1.0, BlendMode::Normal), "the state the text's art set");
}
#[test]
fn gradients() {
let defs = "%AI5_BeginGradient: (G)\n(G) 1 2 Bd\n[\n<00FF>\n0\n0 0 0 0 1 0 0 2 1 6 %_BS\n\
%_0 0 0 0 1 0 0 2 1 6 40 0 Bs\n%_0 0 0 0 0 0 1 2 0.5 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
let art_ = format!(
"{RED}{}Bb\n0 0 0 0 Bh\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n50 0 0 -50 100 50 Bm\nf\n0 BB\n{}f\n\
Bb\n2 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB\n0 0 0 0 0 0 1 Xa\n{}f\n\
{}Bb\n-20 -10 144 0.77 Bh\n1 (G) -0.1 0.4 0 1.35 1 0 0 -1 0 16384 1 Bg\n50 0 0 -50 100 50 Bm\nf\n0 BB\n",
rect_path(50.0, 0.0, 150.0, 100.0),
rect_path(0.0, 0.0, 10.0, 10.0),
rect_path(20.0, 0.0, 30.0, 10.0),
rect_path(50.0, 0.0, 150.0, 100.0),
);
let doc = read_ok(&format!("{defs}{}", layer(&art_))).doc;
let a = art(&doc);
let Paint::Gradient(g) = fill(&a[0]) else { panic!("{:?}", fill(&a[0])) };
assert_eq!(g.gradient.kind, GradientKind::Radial);
assert_eq!(g.gradient.stops.len(), 2);
assert!((g.gradient.stops[0].midpoint - 0.4).abs() < 1e-6);
assert_eq!(g.gradient.stops[1].opacity, 0.5);
let geom = g.geom.unwrap();
assert_eq!((geom.start, geom.end), (Point::new(100.0, 50.0), Point::new(150.0, 50.0)));
// The gradient was the one object's.
assert_eq!(fill(&a[1]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
// A gradient instance that paints nothing doesn't stick to the next object, which sets its colour.
assert_eq!(fill(&a[2]).color(), Some(Color::rgb(0.0, 0.0, 1.0)));
// A radial gradient is its matrix's unit circle whatever `Bg`'s origin and length, and its
// highlight an offset from the centre in art space.
let Paint::Gradient(g) = fill(&a[3]) else { panic!("{:?}", fill(&a[3])) };
let geom = g.geom.unwrap();
assert_eq!((geom.start, geom.end), (Point::new(100.0, 50.0), Point::new(150.0, 50.0)));
let focal = geom.focal.unwrap();
assert!((focal - Point::new(80.0, 60.0)).hypot() < 1e-6, "{focal:?}");
// A linear gradient with a stop in the middle: `Xm` places its whole ramp, a `Bm` each of its
// two parts. The object's own note of its gradient after it paints nothing.
let defs = "(L) 0 3 Bd\n%_1 0 50 100 Bs\n%_0.5 0 50 40 Bs\n%_0 0 50 0 Bs\nBD\n";
let art_ = format!(
"{RED}{}Bb\n1 (L) 0 0 -90 1 1 0 0 1 0 0 1 Bg\n0 -100 -100 0 150 100 Xm\n0 -9000 -100 0 150 9100 Bc\n0 -40 -100 0 150 100 Bm\n\
0 -60 -100 0 150 60 Bm\n0 -9000 -100 0 150 0 Bc\nf\n0 BB\n0 1 Xd\nBb\n2 (L) 0 0 -90 1 1 0 0 1 0 0 1 Bg\n0 BB\n6 () XW\n{}f\n",
rect_path(50.0, 0.0, 150.0, 100.0),
rect_path(0.0, 0.0, 10.0, 10.0),
);
let doc = read_ok(&format!("{defs}{}", layer(&art_))).doc;
let a = art(&doc);
let Paint::Gradient(g) = fill(&a[0]) else { panic!("{:?}", fill(&a[0])) };
assert_eq!(g.gradient.stops.len(), 3);
// Art space has y up from the crop marks' bottom.
let geom = g.geom.unwrap();
assert!(geom.start.distance(Point::new(150.0, 0.0)) < 1e-9 && geom.end.distance(Point::new(150.0, 100.0)) < 1e-9, "{geom:?}");
assert_eq!(fill(&a[1]).color(), Some(Color::rgb(1.0, 0.0, 0.0)), "the note after the object isn't the next path's paint");
}
#[test]
fn a_compound_path_holding_its_drawn_look_paints_its_paths_apart() {
// GUARDS: made one compound path, the look's paths take a single fill and the inner one punches a hole.
let art_ = format!(
"0 Ae\n*u\n0 Ae\nu\n{RED}{}f\n0 0 0 0 0 0 1 Xa\n{}f\nU\n9 () XW\n*U\n",
rect_path(0.0, 0.0, 20.0, 20.0),
rect_path(5.0, 5.0, 15.0, 15.0)
);
let a = art(&read_ok(&layer(&art_)).doc);
fn leaves(n: &Node, out: &mut Vec<Paint>) {
match n.children() {
Some(children) if !matches!(n.kind, NodeKind::Compound { .. }) => children.iter().for_each(|c| leaves(c, out)),
_ => out.push(fill(n)),
}
}
let mut painted = vec![];
a.iter().for_each(|n| leaves(n, &mut painted));
let fills: Vec<_> = painted.iter().map(Paint::color).collect();
assert_eq!(fills, [Some(Color::rgb(1.0, 0.0, 0.0)), Some(Color::rgb(0.0, 0.0, 1.0))], "{a:#?}");
}
#[test]
fn a_compound_path_of_a_plain_group_stays_one_compound_path() {
// GUARDS: #980. Files write `9 () XW` after every group, the one a compound path keeps its
// subpaths in too: read as a drawn look, the compound became a group of filled paths, its
// holes filled in. Here a ring (outer path, inner one reversed, one paint) and a path beside it.
let ring = format!("{RED}1 D\n{}f\n0 D\n{}f\n", rect_path(0.0, 0.0, 20.0, 20.0), "5 5 m\n15 5 L\n15 15 L\n5 15 L\n5 5 L\n");
let art_ = format!("0 Ae\nu\n0 Ae\n*u\n0 Ae\nu\n{ring}U\n9 () XW\n*U\n{}f\nU\n9 () XW\n", rect_path(8.0, 8.0, 12.0, 12.0));
let a = art(&read_ok(&layer(&art_)).doc);
let NodeKind::Group { children, .. } = &a[0].kind else { panic!("{a:#?}") };
let NodeKind::Compound { children: subpaths, .. } = &children[0].kind else { panic!("a compound path: {:#?}", children[0]) };
assert_eq!(subpaths.len(), 2, "{subpaths:#?}");
assert!(subpaths.iter().all(|p| matches!(p.kind, NodeKind::Path { .. })));
assert_eq!(fill(&children[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
}
#[test]
fn gradients_through_a_skewed_matrix() {
// GUARDS: a sheared `Xm` leaves the matrix's columns oblique. A linear ramp runs along the
// normal to its isolines, not along its first column; a radial's ellipse is the one the unit
// circle maps to, set by the principal axes.
let defs = |name: &str, kind: u8| {
format!(
"%AI5_BeginGradient: ({name})\n({name}) {kind} 2 Bd\n[\n<00FF>\n0\n%_0 0 0 0 1 0 0 2 1 6 50 0 Bs\n%_0 0 0 0 0 0 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n"
)
};
let instance = |name: &str| format!("Bb\n1 ({name}) 0 0 0 1 1 0 0 1 0 0 1 Bg\n10 0 10 10 100 50 Xm\nf\n0 BB\n");
let art_ = format!("{}{}{}{}", rect_path(0.0, 0.0, 200.0, 100.0), instance("Lin"), rect_path(0.0, 0.0, 200.0, 100.0), instance("Rad"));
let doc = read_ok(&format!("{}{}{}", defs("Lin", 0), defs("Rad", 1), layer(&art_))).doc;
let a = art(&doc);
let Paint::Gradient(g) = fill(&a[0]) else { panic!("{:?}", fill(&a[0])) };
let geom = g.geom.unwrap();
assert!((geom.start - Point::new(100.0, 50.0)).hypot() < 1e-9 && (geom.end - Point::new(105.0, 55.0)).hypot() < 1e-9, "{geom:?}");
let Paint::Gradient(g) = fill(&a[1]) else { panic!("{:?}", fill(&a[1])) };
let geom = g.geom.unwrap();
let golden = (1.0 + 5f64.sqrt()) / 2.0;
assert!((geom.start.distance(geom.end) - 10.0 * golden).abs() < 1e-9, "{geom:?}");
assert!((geom.aspect - 1.0 / golden.powi(2)).abs() < 1e-9, "{geom:?}");
}
#[test]
fn stops_sharing_a_ramp_point_keep_their_order() {
// GUARDS: stops come last first, so a sort alone puts the two at 100 the wrong way round and
// the ramp blends towards the colour meant only for past its end.
let defs = "%AI5_BeginGradient: (S)\n(S) 0 3 Bd\n[\n<00FF>\n0\n%_0 0 0 0 1 0 0 2 1 6 50 100 Bs\n\
%_0 0 0 0 0 1 0 2 1 6 50 100 Bs\n%_0 0 0 0 0 0 1 2 1 6 50 0 Bs\nBD\n%AI5_EndGradient\n";
let art_ = format!("{}Bb\n1 (S) 0 0 0 1 1 0 0 1 0 0 1 Bg\n10 0 0 10 100 50 Xm\nf\n0 BB\n", rect_path(0.0, 0.0, 200.0, 100.0));
let doc = read_ok(&format!("{defs}{}", layer(&art_))).doc;
let Paint::Gradient(g) = fill(&art(&doc)[0]) else { panic!() };
let colors: Vec<_> = g.gradient.stops.iter().map(|s| s.color).collect();
assert_eq!(colors, [Color::rgb(0.0, 0.0, 1.0), Color::rgb(0.0, 1.0, 0.0), Color::rgb(1.0, 0.0, 0.0)]);
}
#[test]
fn stops_listed_upwards_keep_the_midpoint_of_the_one_that_starts_the_ramp() {
// GUARDS: reversing a list that runs upwards swaps two stops at 0, and the ramp to 100 takes the
// midpoint of the zero-length part between them.
let defs = "%AI5_BeginGradient: (U)\n(U) 0 3 Bd\n[\n<00FF>\n0\n%_0 0 0 0 1 1 1 2 1 6 50 0 Bs\n\
%_0 0 0 0 1 1 1 2 1 6 87 0 Bs\n%_0 0 0 0 0 0 0 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
let art_ = format!("{}Bb\n1 (U) 0 0 0 1 1 0 0 1 0 0 1 Bg\n10 0 0 10 100 50 Xm\nf\n0 BB\n", rect_path(0.0, 0.0, 200.0, 100.0));
let doc = read_ok(&format!("{defs}{}", layer(&art_))).doc;
let Paint::Gradient(g) = fill(&art(&doc)[0]) else { panic!() };
let midpoints: Vec<_> = g.gradient.stops.iter().map(|s| s.midpoint).collect();
assert_eq!(midpoints, [0.5, 0.87, 0.5]);
}
#[test]
fn per_anchor_data_reads() {
let art_ = format!("{RED}{}f\n(00800080) Xt 0 XD\n%_(00004000) Xt 1 XD\n", rect_path(0.0, 0.0, 10.0, 10.0));
assert_eq!(art(&read_ok(&layer(&art_)).doc).len(), 1);
}
#[test]
fn artboards() {
let data = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\
%_/Dictionary :\n%_0 200 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_300 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_(One) /UnicodeString (Name) ,\n%_; ,\n\
%_/Dictionary :\n%_400 0 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_600 -150 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\
%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n";
let art_ = rect_path(420.0, -70.0, 470.0, -20.0) + "f\n";
let doc = read_ok(&format!("{data}{}", layer(&art_))).doc;
let boards: Vec<_> = doc.artboards.iter().map(|a| (a.name.clone(), a.rect)).collect();
assert_eq!(boards, [("One".to_string(), Rect::new(0.0, 0.0, 300.0, 200.0)), ("Artboard 2".to_string(), Rect::new(400.0, 200.0, 600.0, 350.0))]);
let NodeKind::Path { path, .. } = &art(&doc)[0].kind else { panic!() };
assert_eq!(path.bounds(), Some(Rect::new(420.0, 220.0, 470.0, 270.0)));
// Without artboards or crop marks: the art size around the template box's centre.
let old = format!(
"%!PS-Adobe-3.0\n%AI5_ArtSize: 600 400\n%AI3_TemplateBox: 300.5 199.5 300.5 199.5\n%%EndComments\n{}",
layer("10 10 m\n20 20 L\nS\n")
);
let doc = read(old.as_bytes()).unwrap().doc;
assert_eq!(doc.artboards[0].rect, Rect::new(0.0, 0.0, 600.0, 400.0));
}
/// A variable library in the document data isn't read: a note gives how many variables and data
/// sets it has, counting the elements of the library alone (a data set's element for a variable named
/// `variable` isn't a variable).
#[test]
fn a_variable_library_is_noted_with_its_counts() {
use vectorcraft_testkit::ai::variable_library;
let square = layer(&format!("{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0)));
let notes = |library: &str| read_ok(&format!("{library}{square}")).warnings.into_iter().filter(|w| w.contains("variables")).collect::<Vec<_>>();
assert_eq!(
notes(&variable_library(&["variable", "Team"], &["Home", "Away", "Third"])),
["the file's variables and data sets weren't read (variables: 2, data sets: 3): the Variables panel doesn't list them"]
);
assert_eq!(
notes(&variable_library(&["Team"], &["Home"])),
["the file's variables and data sets weren't read (variables: 1, data sets: 1): the Variables panel doesn't list them"]
);
assert!(notes(&variable_library(&[], &[])).is_empty(), "an empty library gives no warning");
assert!(notes("").is_empty());
// When the reader can't read the layers, `variables` still counts the library, for the warning
// of the file read from its PDF part.
let unread = stream(&format!("{}{}", variable_library(&["Team"], &["Home", "Away"]), layer("1 Zz\n")));
assert!(read(unread.as_bytes()).is_err());
assert_eq!(variables(unread.as_bytes()), read::VariableLibrary { variables: 1, data_sets: 2 });
// The same when the reading stops before it reaches the library (at an `LB` without its layer).
let early = stream(&format!("LB\n{}{square}", variable_library(&["Team"], &["Home", "Away"])));
assert!(read(early.as_bytes()).is_err());
assert_eq!(variables(early.as_bytes()), read::VariableLibrary { variables: 1, data_sets: 2 });
assert_eq!(variables(stream(&square).as_bytes()), read::VariableLibrary::default());
}
#[test]
fn a_drawn_look_stands_for_its_object() {
// The look (a group, with a part on `%_` lines for newer readers), then the object on `%_`
// lines and its style.
let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_");
let art_ = format!(
"0 Ae\nu\n%_0 Ae\n%_u\n%_{hidden_rect}S\n%_U\n{RED}{}f\n0 0 0 0 0 0 1 XA\n{}S\nU\n\
%_/ArtDictionary :\n%_/XMLUID : (Neon_00000092429200007574234970000005974767392385616558_) ; (AI10_ArtUID) ,\n%_;\n\
0 0.4 0 0 0 Xy\n0 0 Xd\n6 () XW\n%_{hidden_rect}n\n%_/ArtDictionary :\n%_/XMLUID : (Neon) ; (AI10_ArtUID) ,\n%_;\n1 (Neon style) XW\n\
0 1 0 0 0 Xy\n{}f\n",
rect_path(0.0, 0.0, 10.0, 10.0),
rect_path(0.0, 0.0, 10.0, 10.0),
rect_path(30.0, 0.0, 40.0, 10.0),
);
let r = read_ok(&layer(&art_));
let a = art(&r.doc);
assert_eq!(a.len(), 2, "{a:#?}");
assert_eq!(a[0].name.as_deref(), Some("Neon"));
assert_eq!(a[0].opacity, 0.4);
assert_eq!(a[0].children().unwrap().len(), 2, "the newer readers' part is left out");
assert!(r.warnings.iter().any(|w| w.contains("drawn look (1)")), "{:?}", r.warnings);
}
/// Editing data with one layer holding an image: `matrix` (`[a b c d tx ty]`), its size, colour
/// space, kind and alpha channels, its samples, and the size the data comment declares.
fn image(matrix: &str, (w, h): (u32, u32), (space, kind, alpha): (&str, u32, u32), data: &[u8], declared: usize) -> Vec<u8> {
let mut s = format!(
"%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/{space} XN\n{matrix} {w} {h} 0 Xh\n\
{matrix} 0 0 {w} {h} {w} {h} 8 {kind} {alpha} 0 1 0 4 4 0 0\n%%BeginData: {declared}\rXI\n"
)
.into_bytes();
s.extend_from_slice(data);
s.extend_from_slice(b"%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN\r\nLB\r\n%AI5_EndLayer--\r\n");
let mut out = stream("").into_bytes();
let at = out.windows(14).position(|w| w == b"%%PageTrailer\n").unwrap();
out.splice(at..at, s);
out
}
/// An effect that rasterizes (a blur) leaves its drawn look as one image, then the object itself on
/// `%_` lines: the image stands for the object, as for a drawn look that's a group.
#[test]
fn a_drawn_look_that_is_an_image_stands_for_its_object() {
let mut data = image("[ 10 0 0 10 50 90 ]", (1, 1), ("DeviceRGB", 3, 0), &[255, 0, 0], 3);
let object = format!("%_0 Ae\r\n%_u\r\n%_{}f\r\n%_U\r\n1 (Blurred) XW\r\n", rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\r\n%_"));
let at = data.windows(6).position(|w| w == b"N\r\nLB\r").unwrap() + 3;
data.splice(at..at, object.into_bytes());
let r = read(&data).unwrap();
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Image(_)), "{:?}", a[0].kind);
assert!(r.warnings.iter().any(|w| w.contains("drawn look (1)")), "{:?}", r.warnings);
}
fn pixels(doc: &Document) -> (image::RgbaImage, kurbo::Affine) {
let a = art(doc);
let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) };
let blob = doc.images.get(&im.key).unwrap();
(image::load_from_memory(&blob.bytes).unwrap().to_rgba8(), im.xf)
}
/// A 1-bit image's row holds each pixel's channels in turn, one bit each, padded to whole bytes;
/// a set bit is the channel at full strength.
#[test]
fn one_bit_images_read_each_channel() {
let one_bit = |src: Vec<u8>, kind: u32| {
let text = String::from_utf8(src).unwrap();
text.replacen(&format!(" 8 {kind} 0 0 1 0 "), &format!(" 1 {kind} 0 0 0 0 "), 1).into_bytes()
};
// 3 × 2 RGB in hex: red, green, blue (100 010 001), then white, black, yellow (111 000 110).
let doc = read(&one_bit(image("[ 1 0 0 1 0 100 ]", (3, 2), ("DeviceRGB", 3, 0), b"8880e300", 11), 3)).unwrap().doc;
let img = pixels(&doc).0;
let row = |y: u32| (0..3).map(|x| img.get_pixel(x, y).0).collect::<Vec<_>>();
assert_eq!(row(0), [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255]]);
assert_eq!(row(1), [[255, 255, 255, 255], [0, 0, 0, 255], [255, 255, 0, 255]]);
// 2 × 1 CMYK: cyan, then black (1000 0001).
let doc = read(&one_bit(image("[ 1 0 0 1 0 100 ]", (2, 1), ("DeviceCMYK", 4, 0), b"81", 5), 4)).unwrap().doc;
let img = pixels(&doc).0;
for (x, ink) in [[1.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0]].into_iter().enumerate() {
let [r, g, b, _] = Color::cmyk(ink[0], ink[1], ink[2], ink[3]).to_rgba8(1.0);
assert_eq!(img.get_pixel(x as u32, 0).0, [r, g, b, 255], "pixel {x}");
}
// 3 × 1 gray: a bit a pixel (101).
let doc = read(&one_bit(image("[ 1 0 0 1 0 100 ]", (3, 1), ("DeviceGray", 1, 0), b"a0", 5), 1)).unwrap().doc;
let img = pixels(&doc).0;
assert_eq!((0..3).map(|x| img.get_pixel(x, 0).0[0]).collect::<Vec<_>>(), [255, 0, 255]);
}
#[test]
fn images_with_alpha_channels() {
// RGB samples, then the alpha channel (not counted in the data's size).
let data = [255, 0, 0, 0, 255, 0, 255, 128];
let doc = read(&image("[ 10 0 0 10 50 90 ]", (2, 1), ("DeviceRGB", 3, 1), &data, 10)).unwrap().doc;
let (img, xf) = pixels(&doc);
assert_eq!((img.get_pixel(0, 0).0, img.get_pixel(1, 0).0), ([255, 0, 0, 255], [0, 255, 0, 128]));
// Pixel space (y down) to the top left at (50, 90) in art space: (50, 10) on the page.
assert_eq!(xf * Point::ZERO, Point::new(50.0, 10.0));
assert_eq!(xf * Point::new(2.0, 1.0), Point::new(70.0, 20.0));
// CMYK and grey.
let doc = read(&image("[ 1 0 0 1 0 100 ]", (1, 1), ("DeviceCMYK", 4, 0), &[0, 0, 0, 255], 6)).unwrap().doc;
let px = pixels(&doc).0.get_pixel(0, 0).0;
assert!(px[0] < 64 && px[3] == 255, "100% black is dark: {px:?}");
let doc = read(&image("[ 1 0 0 1 0 100 ]", (2, 1), ("DeviceGray", 1, 0), &[0, 200], 6)).unwrap().doc;
assert_eq!(pixels(&doc).0.get_pixel(1, 0).0, [200, 200, 200, 255]);
// Data cut short: the image goes, with a warning.
let r = read(&image("[ 1 0 0 1 0 100 ]", (4, 4), ("DeviceRGB", 3, 0), &[1, 2, 3], 6)).unwrap();
assert!(art(&r.doc).is_empty() && r.warnings.iter().any(|w| w.contains("cut short")));
}
/// Editing data `data` opens without its one image and with the warning "an image VectorCraft can't read was left out" once.
fn image_left_out_with_a_warning(data: &[u8]) {
let r = read(data).unwrap();
assert!(art(&r.doc).is_empty(), "{:#?}", art(&r.doc));
let left_out = r.warnings.iter().filter(|w| *w == "an image VectorCraft can't read was left out").count();
assert_eq!(left_out, 1, "{:?}", r.warnings);
}
#[test]
fn an_image_without_a_matrix_is_left_out_with_a_warning() {
image_left_out_with_a_warning(&image("", (1, 1), ("DeviceRGB", 3, 0), &[255, 0, 0], 3));
}
#[test]
fn an_image_with_too_few_operands_is_left_out_with_a_warning() {
// The operands hold 10 numbers after the matrix, one short of the binary flag.
let text = String::from_utf8(image("[ 1 0 0 1 0 100 ]", (1, 1), ("DeviceRGB", 3, 0), b"abc", 3)).unwrap();
let data = text.replacen("] 0 0 1 1 1 1 8 3 0 0 1 0 4 4 0 0\n", "] 0 0 1 1 1 1 8 3 0 0\n", 1);
assert_ne!(data, text);
image_left_out_with_a_warning(data.as_bytes());
}
#[test]
fn an_image_whose_matrix_isnt_six_numbers_is_left_out_with_a_warning() {
image_left_out_with_a_warning(&image("[ 1 0 0 1 ]", (1, 1), ("DeviceRGB", 3, 0), &[255, 0, 0], 3));
}
#[test]
fn an_image_whose_matrix_has_no_area_is_left_out_with_a_warning() {
image_left_out_with_a_warning(&image("[ 0 0 0 0 0 0 ]", (1, 1), ("DeviceRGB", 3, 0), &[255, 0, 0], 3));
// This matrix maps the image onto a line.
image_left_out_with_a_warning(&image("[ 1 2 2 4 0 100 ]", (1, 1), ("DeviceRGB", 3, 0), &[255, 0, 0], 3));
}
/// The raster matrix `[a b c d tx ty]` is an art-space matrix (y up) applied to the image's pixels
/// with their rows running downwards, `(u, -v)`: art x = a·u − c·v + tx, art y = b·u − d·v + ty.
/// Without rotation (b = c = 0) that is the same as flipping the matrix's result; a rotated image
/// tells the two apart (#2269).
#[test]
fn a_rotated_image_is_placed_as_its_matrix_maps_it() {
// Turned 90° counter-clockwise: in art space each row runs upwards, the next row to its right.
let doc = read(&image("[ 0 10 -10 0 50 50 ]", (2, 1), ("DeviceRGB", 3, 0), &[255, 0, 0, 0, 0, 255], 6)).unwrap().doc;
let xf = pixels(&doc).1;
// Art (x, y) is (x, 100 - y) on the page.
let page = |x: f64, y: f64| Point::new(x, 100.0 - y);
assert_eq!(xf * Point::ZERO, page(50.0, 50.0), "the top left pixel's corner stays at the matrix's origin");
assert_eq!(xf * Point::new(2.0, 0.0), page(50.0, 70.0), "the first row runs up");
assert_eq!(xf * Point::new(0.0, 1.0), page(60.0, 50.0), "the next row is to its right");
// Any rotation and scale: the same formula.
let (s, c) = 30f64.to_radians().sin_cos();
let m = [3.0 * c, 3.0 * s, -2.0 * s, 2.0 * c, 40.0, 60.0];
let matrix = format!("[ {} ]", m.map(|v| v.to_string()).join(" "));
let doc = read(&image(&matrix, (2, 1), ("DeviceRGB", 3, 0), &[255, 0, 0, 0, 0, 255], 6)).unwrap().doc;
let xf = pixels(&doc).1;
for (u, v) in [(0.0, 0.0), (2.0, 0.0), (0.0, 1.0), (2.0, 1.0)] {
let want = page(m[0] * u - m[2] * v + m[4], m[1] * u - m[3] * v + m[5]);
let got = xf * Point::new(u, v);
assert!((got - want).hypot() < 1e-9, "pixel ({u}, {v}): {got:?}, want {want:?}");
}
}
/// CMYK images keep their ink amounts, as the file's PDF part keeps them, and an alpha channel
/// becomes their opacity mask: a grayscale image over the image.
#[test]
fn cmyk_images_keep_their_ink_amounts() {
// 3 × 1: cyan, 100% black, then a mix.
let inks = [255, 0, 0, 0, 0, 0, 0, 255, 12, 34, 56, 78];
let doc = read(&image("[ 10 0 0 10 50 90 ]", (3, 1), ("DeviceCMYK", 4, 0), &inks, 14)).unwrap().doc;
let a = art(&doc);
let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) };
let blob = &doc.images[&im.key];
assert!(blob.is_cmyk(), "{}", blob.mime);
assert_eq!(blob.cmyk().map(|i| (i.width, i.height, i.data)), Some((3, 1, inks.to_vec())));
assert!(a[0].extras().mask.is_none(), "no alpha channel, no mask");
// The same samples with an alpha channel.
let data = [&inks[..], &[255, 128, 0]].concat();
let doc = read(&image("[ 10 0 0 10 50 90 ]", (3, 1), ("DeviceCMYK", 4, 1), &data, 17)).unwrap().doc;
let a = art(&doc);
let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) };
assert_eq!(doc.images[&im.key].cmyk().map(|i| i.data), Some(inks.to_vec()), "the inks are the same");
let m = a[0].extras().mask.clone().expect("the alpha channel is an opacity mask");
assert!(m.clip);
let NodeKind::Image(mi) = &m.art.kind else { panic!("{:?}", m.art.kind) };
assert_eq!((mi.width, mi.height, mi.xf), (3, 1, im.xf), "the mask covers the image");
let gray = image::load_from_memory(&doc.images[&mi.key].bytes).unwrap().to_luma8();
assert_eq!(gray.into_raw(), [255, 128, 0]);
// An alpha channel that hides nothing adds no mask.
let data = [&inks[..], &[255; 3]].concat();
let doc = read(&image("[ 10 0 0 10 50 90 ]", (3, 1), ("DeviceCMYK", 4, 1), &data, 17)).unwrap().doc;
let a = art(&doc);
assert!(a[0].extras().mask.is_none());
// Hex samples, and a four-channel image whose space the data doesn't name.
let hex: String = inks.iter().map(|b| format!("{b:02x}")).collect();
let text = String::from_utf8(image("[ 1 0 0 1 0 100 ]", (3, 1), ("Other", 4, 0), hex.as_bytes(), 26)).unwrap();
let doc = read(text.replacen("8 4 0 0 1 0 4 4 0 0", "8 4 0 0 0 0 4 4 0 0", 1).as_bytes()).unwrap().doc;
let NodeKind::Image(im) = &art(&doc)[0].kind else { panic!() };
assert_eq!(doc.images[&im.key].cmyk().map(|i| i.data), Some(inks.to_vec()));
}
/// Editing data with one layer (showing or not) holding a placed file: its matrix, the file at
/// `path` in the box 10 20 14 22, and `preview` (what its preview section holds), then `after`.
fn placed(visible: bool, matrix: &str, path: &str, preview: Option<&[u8]>, after: &str) -> Vec<u8> {
let text = stream(&layer("@@").replacen("1 1 1 1", if visible { "1 1 1 1" } else { "0 1 1 1" }, 1));
let (head, tail) = text.split_once("@@").unwrap();
let mut s = format!(
"{head}%AI5_BeginPlace\r%%FileType: 1\r{matrix} 10 20 14 22 4 4 0\r({path})`\r%%IncludeFile:{path}\r\
%AI17_Begin_Content_if_version_gt:24 12\r%AI28_BeginPlacedRelativePath\r()\r%AI28_EndPlacedRelativePath\r%AI17_Alternate_Content\r%AI17_End_Versioned_Content\r\
%AI17_Begin_Content_if_version_gt:24 5\r"
)
.into_bytes();
if let Some(p) = preview {
s.extend_from_slice(b"%AI26_BeginPlacedObjectPreview\r");
s.extend_from_slice(p);
s.extend_from_slice(b"\r%AI26_EndPlacedObjectPreview\r");
}
s.extend_from_slice(format!("%AI17_End_Versioned_Content\r~\r%AI5_EndPlace\r{after}{tail}").as_bytes());
s
}
/// [`placed`]'s file with its placed file written as the copy of an object on `%_` lines (the
/// editable object whose drawn look comes before it): the lines from `%AI5_BeginPlace` to
/// `%AI5_EndPlace` start with `%_`, the versioned content's markers aside. The preview's header
/// line does too; its samples follow it raw.
fn hide_placed(data: &[u8]) -> Vec<u8> {
let find = |w: &[u8]| data.windows(w.len()).position(|x| x == w).unwrap();
let (from, to) = (find(b"%AI5_BeginPlace"), find(b"%AI5_EndPlace\r") + 14);
let mut out = data[..from].to_vec();
for line in data[from..to].split(|b| *b == b'\r') {
if !line.is_empty() && !line.starts_with(b"%AI17_") {
out.extend_from_slice(b"%_");
}
out.extend_from_slice(line);
out.push(b'\r');
}
out.pop();
out.extend_from_slice(&data[to..]);
out
}
#[test]
fn a_placed_file_on_commented_lines_is_read() {
// Its `%_%AI5_BeginPlace` was dropped as a comment after `%_`, so the `~` that ends it was an
// operator the reader didn't know, and the whole file came in as its PDF part; and its
// preview, whose header starts with `%_`, wasn't read.
let preview = [&b"2 1 12 32 17 "[..], &[255, 255, 0, 0, 128, 0, 64, 0, 9, 9, 9, 9]].concat();
let data = hide_placed(&placed(true, "[5 0 0 -5 0 200]", "/pics/photo.png", Some(&preview), ""));
assert!(data.windows(18).any(|w| w == b"%_%AI5_BeginPlace\r"), "{}", String::from_utf8_lossy(&data));
let r = read(&data).unwrap();
let a = art(&r.doc);
let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) };
assert_eq!((im.link.as_ref().map(|l| l.path.as_str()), im.width, im.height), (Some("/pics/photo.png"), 2, 1));
let (img, _) = pixels(&r.doc);
assert_eq!((img.get_pixel(0, 0).0, img.get_pixel(1, 0).0), ([255, 0, 0, 255], [0, 127, 0, 128]), "its preview");
}
#[test]
fn hidden_placed_file_ended_by_comment_stays_hidden() {
let mut data = hide_placed(&placed(true, "[5 0 0 -5 0 200]", "/pics/photo.png", None, ""));
let end = data.windows(4).position(|w| w == b"%_~\r").unwrap();
data.drain(end..end + 4);
let r = read(&data).unwrap();
assert!(art(&r.doc).is_empty());
assert!(r.unread.is_empty(), "hidden placement has no shown placeholder");
assert!(r.hidden_unread.contains("placed files"));
}
#[test]
fn previewless_placement_records_box_corner_as_alternative() {
let mut data = placed(true, "[1 0 0 -1 -7970 8070]", "missing.png", None, "");
let at = data.windows(15).position(|w| w == b"%AI5_BeginLayer").unwrap();
data.splice(at..at, *b"%AI3_TemplateBox: 100 50 100 50\n");
let r = read(&data).unwrap();
assert_unread(&r, "placed files");
assert_eq!(r.unread_elsewhere.len(), 1);
assert_eq!(r.unread_elsewhere[0].0, r.unread[0].0);
assert_ne!(r.unread_elsewhere[0].1, r.doc.node(r.unread[0].0).unwrap().visual_bounds().unwrap());
}
/// A previewless locked placement keeps its edit lock on the page-art placeholder.
#[test]
fn previewless_placement_placeholder_inherits_lock() {
let mut data = placed(true, "[5 0 0 -5 0 200]", "missing.png", None, "");
let at = data.windows(15).position(|w| w == b"%AI5_BeginPlace").unwrap();
data.splice(at..at, *b"1 A\n");
let result = read(&data).unwrap();
assert_unread(&result, "placed files");
let node = result.doc.node(result.unread[0].0).unwrap();
assert!(node.locked);
assert!(node.visible);
}
#[test]
fn placed_files_are_linked_images_showing_their_preview() {
// A 2×1 preview (premultiplied ARGB, a padded row) of a file of 4×2 pixels.
let preview = [&b"2 1 12 32 17 "[..], &[255, 255, 0, 0, 128, 0, 64, 0, 9, 9, 9, 9]].concat();
let name = "%_/ArtDictionary :\r%_(Photo) /String (AIArtName) ,\r%_;\r";
let r = read(&placed(true, "[5 0 0 -5 0 200]", "/pics/photo.png", Some(&preview), name)).unwrap();
let a = art(&r.doc);
let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) };
assert_eq!((im.link.as_ref().map(|l| l.path.as_str()), im.width, im.height), (Some("/pics/photo.png"), 2, 1));
assert_eq!(a[0].name.as_deref(), Some("Photo"));
assert!(r.doc.images[&im.key].is_proxy(), "the preview is what a missing link shows");
let (img, xf) = pixels(&r.doc);
assert_eq!((img.get_pixel(0, 0).0, img.get_pixel(1, 0).0), ([255, 0, 0, 255], [0, 127, 0, 128]));
// Without a template box the pixels start at the box's corner, the first row the top one:
// (10, 20) is at (50, 100) in art space, the page's top.
assert_eq!((xf * Point::ZERO, xf * Point::new(2.0, 1.0)), (Point::new(50.0, 0.0), Point::new(70.0, 10.0)));
assert!(r.warnings.iter().any(|w| w.contains("low-resolution preview")), "{:?}", r.warnings);
// With a template box, the pixels start at the canvas point of art space's origin (8091.5,
// 8241.5 here), wherever the box is.
let mut data = placed(true, "[5 0 0 -5 -40407.5 41307.5]", "a.png", Some(&preview), "");
let at = data.windows(15).position(|w| w == b"%AI5_BeginLayer").unwrap();
data.splice(at..at, *b"%AI3_TemplateBox: 100 50 100 50\n");
let xf = pixels(&read(&data).unwrap().doc).1;
assert_eq!((xf * Point::ZERO, xf * Point::new(2.0, 1.0)), (Point::new(50.0, 0.0), Point::new(70.0, 10.0)));
// Turned a quarter: the box's rows run along x.
let r = read(&placed(true, "[0 5 5 0 0 0]", "a.png", Some(&preview), "")).unwrap();
let xf = pixels(&r.doc).1;
assert_eq!((xf * Point::ZERO, xf * Point::new(2.0, 1.0)), (Point::new(100.0, 50.0), Point::new(110.0, 30.0)));
// A placed file with a box but no usable preview is marked for page-art replacement.
let cut = &preview[..preview.len() - 6];
let huge = b"70000 70000 280000 32 17 \0\0\0\0";
let overflow = b"1 2 18446744073709551615 32 17 \0\0\0\0\0\0\0\0";
for (matrix, path, preview) in [
("[5 0 0 -5 0 200]", "a.png", None),
("[5 0 0 -5 0 200]", "a.png", Some(cut)),
("[5 0 0 -5 0 200]", "a.png", Some(&b"2 1 2 8 17 \0\0"[..])),
("[5 0 0 -5 0 200]", "a.png", Some(&huge[..])),
("[5 0 0 -5 0 200]", "a.png", Some(&overflow[..])),
("[5 0 0 -5 0 200]", "a.png", Some(&b"2 1"[..])),
("[5 0 0 -5 0 200]", "", Some(&preview[..])),
("[0 0 0 0 0 0]", "a.png", Some(&preview[..])),
("[1 2 3]", "a.png", Some(&preview[..])),
] {
let result = read(&placed(true, matrix, path, preview, ""));
if matrix == "[0 0 0 0 0 0]" || matrix == "[1 2 3]" {
let e = result.err().unwrap_or_else(|| panic!("expected missing bounds for {matrix} {path}"));
assert!(e.contains("placed files"), "{matrix} {path} {preview:?}: {e}");
} else {
assert_unread(&result.unwrap(), "placed files");
}
}
// On a layer that doesn't show, such a file is left out of it.
let r = read(&placed(false, "[5 0 0 -5 0 200]", "a.png", None, "")).unwrap();
assert!(r.hidden_unread.contains("placed files") && art(&r.doc).is_empty());
// `~` ends nothing else.
assert!(read(stream(&layer("~\n")).as_bytes()).unwrap_err().contains("`~`"));
}
#[test]
fn text_objects_are_slots_named_after_their_story() {
let words = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n3 /StoryIndex ,\n;\n%_/ArtDictionary :\n%_/XMLUID : (Title) ; (AI10_ArtUID) ,\n%_;\n";
let r = read_ok(&layer(&format!("1 Xw\n{words}0 Xw\n")));
let a = art(&r.doc);
assert_eq!(slot_of(a[0].name.as_deref()), Some(Some(3)));
assert!(!a[0].visible);
assert_eq!(r.slot_names.get(&a[0].id).map(String::as_str), Some("Title"));
// A copy on `%_` lines of a story that has a plain text object goes; one without stays.
let commented = |story: u32| -> String { format!("/AI11Text :\n{story} /StoryIndex ,\n;\n").lines().map(|l| format!("%_{l}\n")).collect() };
let r = read_ok(&layer(&format!("/AI11Text :\n0 /StoryIndex ,\n;\n{}{}", commented(0), commented(1))));
let stories: Vec<_> = art(&r.doc).iter().map(|n| slot_of(n.name.as_deref())).collect();
assert_eq!(stories, [Some(Some(0)), Some(Some(1))]);
// A text object with an appearance: its drawn look (a group whose type is a story of the
// undo-free text document, which counts its own), then the object on `%_` lines, which is
// the type.
let look = "0 Ae\nu\n/AI11Text :\n1 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\nU\n0 0 Xd\n6 () XW\n";
let own: String = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n7 /StoryIndex ,\n;\n".lines().map(|l| format!("%_{l}\n")).collect();
let plain = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n";
let r = read_ok(&layer(&format!("{look}{own}1 (Style) XW\n{plain}")));
let stories: Vec<_> = art(&r.doc).iter().map(|n| slot_of(n.name.as_deref())).collect();
assert_eq!(stories, [Some(Some(7)), Some(Some(0))], "the look's story 0 isn't the text document's");
// What else the look draws stays, under the type.
let look = look.replace("U\n", &format!("{RED}{}f\nU\n", rect_path(0.0, 0.0, 10.0, 10.0)));
let r = read_ok(&layer(&format!("{look}{own}1 (Style) XW\n")));
let a = art(&r.doc);
assert_eq!((a.len(), a[0].children().map(Vec::len), slot_of(a[1].name.as_deref())), (2, Some(1), Some(Some(7))));
}
/// A file in the legacy format (version 7) whose layer holds `art`, crop marks 0 0 200 100.
fn type_stream7(art: &str) -> String {
format!(
"%!PS-Adobe-3.0\n%%Creator: test\n%AI5_FileFormat 3.0\n%AI3_Cropmarks: 0 0 200 100\n%%EndComments\n%%BeginProlog\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n{}\n%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n",
layer(art)
)
}
/// The text objects of a legacy-format file whose layer holds `art`.
fn legacy_texts(art: &str) -> Vec<vectorcraft_doc::TextObject> {
let doc = match read(type_stream7(art).as_bytes()) {
Ok(r) => r.doc,
Err(e) => panic!("{e}"),
};
art_texts(&doc)
}
fn art_texts(doc: &Document) -> Vec<vectorcraft_doc::TextObject> {
art(doc).iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some((**t).clone()) } else { None }).collect()
}
/// Point type at (10, 50): `attrs`, then the characters `text`.
fn point_type(attrs: &str, text: &str) -> String {
format!("0 To\n1 0 0 1 10 50 0 Tp\nTP\n0 Tr\n0 0 0 1 k\n/_Helvetica 12 Tf\n{attrs}\n({text}) Tx\nTO\n")
}
/// `TC` and `TW` (§15.4.7) set the paragraph's letter and word spacing, `Tq` its hanging
/// punctuation and `Th` (§22) its hyphenation; each paragraph keeps the values in force at its
/// first character, and they last into the next text object.
#[test]
fn legacy_type_keeps_its_spacing_hanging_punctuation_and_hyphenation() {
let two_paragraphs =
point_type("10 15 20 TC\n70 110 140 TW\n1 Tq\n1 1 2 3 4 Th", "One\\r").replace("TO\n", "0 0 5 TC\n0 Tq\n0 0 4 5 6 Th\n(Two) Tx\nTO\n");
let texts = legacy_texts(&format!("{two_paragraphs}{}", point_type("", "Three")));
assert_eq!(texts.len(), 2);
let one = texts[0].para_at(0);
assert_eq!(one.justification.letter_spacing, [10.0, 15.0, 20.0]);
assert_eq!(one.justification.word_spacing, [70.0, 110.0, 140.0]);
assert!(one.roman_hanging && one.hyphenate);
let h = &one.hyphenation;
assert_eq!((h.after_first, h.before_last, h.hyphen_limit), (2, 3, 4));
assert_eq!(h.words_longer_than, vectorcraft_doc::text::HyphenationOptions::default().words_longer_than, "no operand sets it");
let two = texts[0].para_at(1);
assert_eq!(two.justification.letter_spacing, [0.0, 0.0, 5.0]);
assert_eq!(two.justification.word_spacing, [70.0, 110.0, 140.0], "word spacing stays");
assert!(!two.roman_hanging && !two.hyphenate);
// Without the limit flag, any number of hyphens in a row.
assert_eq!((two.hyphenation.after_first, two.hyphenation.before_last, two.hyphenation.hyphen_limit), (4, 5, 0));
// The next text object keeps them.
assert_eq!(texts[1].para_at(0), two);
}
/// A legacy attribute whose operands are missing, are not numbers, or are flags other than 0 and
/// 1 leaves the attribute as it was, and operands are read by their place.
#[test]
fn legacy_type_attributes_with_bad_operands_stay_as_they_were() {
let set = "10 15 20 TC\n70 110 140 TW\n1 Tq\n1 1 2 3 4 Th\n2 Ta\n0 TA\n5 6 7 Ti";
let bad = "/x 15 20 TC\n110 140 TW\n2 Tq\n1 2 2 3 4 Th\n1 1 (2) 3 4 Th\n/x Ta\n2 TA\n5 /x 7 Ti";
let texts = legacy_texts(&point_type(&format!("{set}\n{bad}"), "Hi"));
let p = texts[0].para_at(0);
assert_eq!(p.justification.letter_spacing, [10.0, 15.0, 20.0]);
assert_eq!(p.justification.word_spacing, [70.0, 110.0, 140.0]);
assert!(p.roman_hanging && p.hyphenate);
assert_eq!((p.hyphenation.after_first, p.hyphenation.before_last, p.hyphenation.hyphen_limit), (2, 3, 4));
assert_eq!(p.justify, vectorcraft_doc::Justify::Right);
assert_eq!((p.first_line_indent, p.left_indent, p.right_indent), (5.0, 6.0, 7.0));
assert_eq!(texts[0].runs[0].style.kerning, Some(0.0), "kerning stays off");
// A scale or leading pair with a bad operand leaves both as they were; one operand is the
// horizontal scale (or the leading) of the format before version 7.
let texts = legacy_texts(&point_type("50 80 Tz\n14 3 Tl\n/x 120 Tz\n/x 20 Tl", "Hi"));
let st = &texts[0].runs[0].style;
assert_eq!((st.h_scale, st.v_scale, st.leading), (50.0, 80.0, Some(14.0)));
assert_eq!(texts[0].para_at(0).space_before, 3.0);
let st = &legacy_texts(&point_type("50 80 Tz\n120 Tz", "Hi"))[0].runs[0].style;
assert_eq!((st.h_scale, st.v_scale), (120.0, 100.0));
// A start point that isn't a number leaves the matrix.
let good = &legacy_texts(&point_type("", "Hi"))[0];
let bad = &legacy_texts(&point_type("", "Hi").replace("10 50 0 Tp", "10 50 /x Tp"))[0];
assert_eq!(bad.xf, good.xf);
}
/// Spacing is kept to the ranges the Justification options take, a Minimum, Desired and Maximum
/// out of order go back to their defaults, and hyphenation's letter counts and limit to the ranges
/// `text.setFormat` takes.
#[test]
fn legacy_type_spacing_and_hyphenation_are_kept_in_range() {
let texts = legacy_texts(&point_type("-500 0 900 TC\n0 2000 5000 TW\n1 1 0 40 99 Th", "Hi"));
let p = texts[0].para_at(0);
assert_eq!(p.justification.letter_spacing, [-100.0, 0.0, 500.0]);
assert_eq!(p.justification.word_spacing, [0.0, 1000.0, 1000.0]);
assert_eq!((p.hyphenation.after_first, p.hyphenation.before_last, p.hyphenation.hyphen_limit), (1, 15, 25));
let texts = legacy_texts(&point_type("5 0 0 TC\n200 100 80 TW", "Hi"));
let j = &texts[0].para_at(0).justification;
let d = vectorcraft_doc::text::JustificationOptions::default();
assert_eq!((j.letter_spacing, j.word_spacing), (d.letter_spacing, d.word_spacing));
}
/// A file's legacy type is read up to its allowance of characters, a single long run included.
#[test]
fn one_long_run_of_legacy_type_stops_at_the_allowance() {
// The reader's allowance, `MAX_CHARS`.
const ALLOWANCE: usize = 1 << 22;
let long = "a".repeat(ALLOWANCE + 10);
let texts = legacy_texts(&point_type("", &long));
assert_eq!(texts[0].plain_text().chars().count(), ALLOWANCE);
}
#[test]
fn what_it_doesnt_read_is_an_error_where_it_shows() {
let e = read(stream(&layer("1 2 frobnicate\n")).as_bytes()).unwrap_err();
assert!(e.contains("`frobnicate`"), "{e}");
let r = read(stream(&layer(&format!("(Jive) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(0.0, 0.0, 1.0, 1.0)))).as_bytes()).unwrap();
assert_unread(&r, "pattern fills");
let e = read(stream(&layer("/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n")).as_bytes()).unwrap_err();
assert!(e.contains("symbols"), "{e}");
// A mask with no owner must still be reported (#2004).
let mask = "%_/ArtDictionary :\n%_/XMLUID : (M) ; (AI10_ArtUID) ,\n%_/Mask :\n%_1 /Bool (Linked) ,\n%_0 /Bool (Disabled) ,\n%_X=\n\
%_0 0 0 1 k\n%_0 0 m\n%_5 0 L\n%_5 5 L\n%_0 5 L\n%_0 0 L\n%_f\n%_X+\n%_ (Art) ,\n%_1 /Bool (Clipping) ,\n%_0 /Bool (Inverted) ,\n%_; (AI9_Mask) ,\n%_;\n";
let e = read(stream(&layer(mask)).as_bytes()).unwrap_err();
assert!(e.contains("opacity masks"), "{e}");
// A mask that is disabled changes nothing: the art reads.
read_ok(&layer(&mask.replace("%_0 /Bool (Disabled)", "%_1 /Bool (Disabled)")));
// On a layer that doesn't show, it is left out of it.
let hidden = "%AI5_BeginLayer\n0 1 1 1 0 0 0 1 255 79 79 0 50 0 Lb\n(Off) Ln\n1 2 frobnicate\n(J) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\nLB\n%AI5_EndLayer--\n";
let r = read_ok(hidden);
assert_eq!(r.hidden_unread.iter().cloned().collect::<Vec<_>>(), ["`frobnicate`", "pattern fills"]);
// Unbalanced containers, a layer that doesn't end, no layers.
assert!(read(stream(&layer("U\n")).as_bytes()).is_err());
assert!(read(stream("%AI5_BeginLayer\n(L) Ln\n0 Ae\nu\n").as_bytes()).is_err());
assert!(read(stream("").as_bytes()).is_err());
// The setup's procedures and what follows the page don't count.
read_ok(&format!("Adobe_level2_AI5 /initialize get exec\n{}", layer("")));
}
#[test]
fn unresolved_pattern_reset_before_group_path_keeps_live_art() {
for (operator, draw, reset) in [("p", "f", "0 0 0 1 k"), ("P", "S", "0 0 0 1 K")] {
let body = format!("u\n(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n{reset}\n{}{draw}\nU\n", rect_path(0.0, 0.0, 4.0, 4.0),);
let result = read_ok(&layer(&body));
assert!(result.unread.is_empty(), "{operator}: {:?}", result.unread);
let children = art(&result.doc);
assert_eq!(children.len(), 1, "{operator}: group count");
let group_art = children[0].children().expect("group children");
assert_eq!(group_art.len(), 1, "{operator}: live path count");
assert!(matches!(group_art[0].kind, NodeKind::Path { .. }));
}
}
#[test]
fn unresolved_pattern_in_dictionary_does_not_mark_following_path_unread() {
for (operator, draw) in [("p", "f"), ("P", "S")] {
let body = format!("/Unused :\n(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n;\n{}{draw}\n", rect_path(0.0, 0.0, 4.0, 4.0),);
let result = read_ok(&layer(&body));
assert!(result.unread.is_empty(), "{operator}: {:?}", result.unread);
let children = art(&result.doc);
assert_eq!(children.len(), 1, "{operator}: live path count");
assert!(matches!(children[0].kind, NodeKind::Path { .. }));
}
}
#[test]
fn color_in_dictionary_does_not_clear_outer_unresolved_pattern() {
for (operator, draw, color) in [("p", "f", "0 0 0 1 k"), ("P", "S", "0 0 0 1 K")] {
let body = format!("(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n/Unused :\n{color}\n;\n{}{draw}\n", rect_path(0.0, 0.0, 4.0, 4.0),);
let result = read_ok(&layer(&body));
assert_unread(&result, "pattern fills");
assert_eq!(art(&result.doc).len(), 1, "{operator}: unread path placeholder");
}
}
#[test]
fn gradient_instance_after_unresolved_pattern_is_read() {
let defs =
"%AI5_BeginGradient: (G)\n(G) 0 2 Bd\n[\n<00FF>\n0\n%_0 0 0 0 1 0 0 2 1 6 50 0 Bs\n%_0 0 0 0 0 0 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
for (operator, flag, draw) in [("p", "", "f"), ("P", "1 ", "S")] {
let body = format!(
"(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n{}{draw}\n{}{flag}Bb\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n10 0 0 10 100 50 Xm\n{draw}\n0 BB\n",
rect_path(0.0, 0.0, 4.0, 4.0),
rect_path(10.0, 0.0, 14.0, 4.0),
);
let result = read(stream(&format!("{defs}{}", layer(&body))).as_bytes()).unwrap();
assert_unread(&result, "pattern fills");
let a = art(&result.doc);
assert_eq!(a.len(), 2, "{operator}");
assert_eq!(result.unread[0].0, a[0].id, "{operator}: only the pattern's path is unread");
}
}
#[test]
fn unresolved_pattern_in_mask_art_stays_unread() {
let mask_path = rect_path(0.0, 0.0, 4.0, 4.0).lines().map(|line| format!("%_{line}\n")).collect::<String>();
let patterned = format!("%_(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{mask_path}%_f\n");
// A placed file with no preview, and a symbol whose definition the file doesn't have.
let placed = "%_%AI5_BeginPlace\n%_%%FileType: 1\n%_[5 0 0 -5 0 200] 10 20 14 22 4 4 0\n%_(missing.png)`\n%_%%IncludeFile:missing.png\n%_~\n%_%AI5_EndPlace\n";
let symbol = "%_/SymbolInstance :\n%_(Absent) /SymbolRef ,\n%_;\n";
for (art_, kind) in [
(patterned.as_str(), "pattern fills"),
("%_/Mesh X!\n%_/End X!\n", "gradient meshes it can't read"),
(placed, "placed files"),
(symbol, "symbols"),
("%_1 2 frobnicate\n", "`frobnicate`"),
] {
let mask = format!(
"%_/ArtDictionary :\n%_/Mask :\n%_X=\n{art_}%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_1 /Bool (Linked) ,\n%_; (AI9_Mask) ,\n%_;\n"
);
let masked = format!("{RED}{}f\n{mask}", rect_path(0.0, 0.0, 4.0, 4.0));
let e = read(stream(&layer(&masked)).as_bytes()).unwrap_err();
assert!(e.contains(kind), "{e}");
// In a group, the group is the unread object.
let result = read_ok(&layer(&format!("u\n{masked}U\n")));
assert_unread(&result, kind);
assert!(result.hidden_unread.is_empty(), "{kind}: {:?}", result.hidden_unread);
// A disabled mask doesn't change how its object looks: what it holds is left out.
let disabled = masked.replace("%_1 /Bool (Linked) ,\n", "%_1 /Bool (Linked) ,\n%_1 /Bool (Disabled) ,\n");
for body in [disabled.clone(), format!("u\n{disabled}U\n")] {
let result = read_ok(&layer(&body));
assert!(result.unread.is_empty(), "{kind}: {:?}", result.unread);
assert!(result.hidden_unread.contains(kind), "{kind}: {:?}", result.hidden_unread);
}
}
// Operators a mask's art doesn't know are kept up to 16.
let ops = (0..40).map(|i| format!("%_op{i}\n")).collect::<String>();
let mask = format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{ops}%_X+\n%_(Art) ,\n%_1 /Bool (Linked) ,\n%_; (AI9_Mask) ,\n%_;\n");
let result = read_ok(&layer(&format!("u\n{RED}{}f\n{mask}U\n", rect_path(0.0, 0.0, 4.0, 4.0))));
assert_eq!(result.unread.len(), 1);
assert_eq!(result.unread[0].1.len(), 16);
}
#[test]
fn hidden_unread_art_beside_drawn_look_needs_no_page_replacement() {
let hidden_path = rect_path(0.0, 0.0, 4.0, 4.0).lines().map(|line| format!("%_{line}\n")).collect::<String>();
for (operator, draw, reset) in [("p", "f", "0 0 0 1 k"), ("P", "S", "0 0 0 1 K")] {
let body = format!(
"u\n%_(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n{hidden_path}%_{draw}\n{reset}\n{}{draw}\nU\n",
rect_path(10.0, 0.0, 14.0, 4.0),
);
let result = read_ok(&layer(&body));
assert!(result.unread.is_empty(), "{operator}: {:?}", result.unread);
assert!(result.hidden_unread.contains("pattern fills"));
let children = art(&result.doc);
let group_art = children[0].children().expect("group children");
assert_eq!(group_art.len(), 2);
assert!(matches!(group_art[1].kind, NodeKind::Path { .. }));
}
}
#[test]
fn unknown_operator_on_hidden_line_does_not_mark_drawn_group_unread() {
let body = format!("u\n%_1 2 frobnicate\n{RED}{}f\nU\n", rect_path(10.0, 0.0, 14.0, 4.0));
let result = read_ok(&layer(&body));
assert!(result.unread.is_empty(), "{:?}", result.unread);
assert!(result.hidden_unread.contains("`frobnicate`"));
assert_eq!(art(&result.doc)[0].children().expect("group children").len(), 1);
}
/// A commented closing delimiter does not hide a visible group's unread content.
#[test]
fn visible_group_closed_on_hidden_line_stays_unread() {
let body = format!("u\n1 2 frobnicate\n{RED}{}f\n%_U\n", rect_path(10.0, 0.0, 14.0, 4.0));
let result = read_ok(&layer(&body));
assert_eq!(result.unread.len(), 1);
assert!(result.unread[0].1.contains(&"`frobnicate`".to_string()));
assert!(!result.hidden_unread.contains("`frobnicate`"));
}
/// `Xw` hides unread objects even when their operators are on ordinary lines.
#[test]
fn xw_hidden_unread_keeps_visible_group_sibling() {
for unread in
["1 2 frobnicate\n", "(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n0 0 m\n4 0 L\n4 4 L\nf\n", "/SymbolInstance :\n(Absent) /SymbolRef ,\n;\n"]
{
let body = format!("u\n1 Xw\n{unread}0 Xw\n0 0 0 1 k\n{}f\nU\n", rect_path(10.0, 0.0, 14.0, 4.0));
let result = read_ok(&layer(&body));
assert!(result.unread.is_empty(), "{unread}: {:?}", result.unread);
assert!(!result.hidden_unread.is_empty(), "{unread}");
let children = art(&result.doc);
let group_art = children[0].children().expect("group children");
assert_eq!(group_art.iter().filter(|node| node.visible).count(), 1, "{unread}");
}
let mut data = placed(true, "[5 0 0 -5 0 200]", "missing.png", None, "0 Xw\n");
let at = data.windows(15).position(|w| w == b"%AI5_BeginPlace").unwrap();
data.splice(at..at, *b"1 Xw\n");
let result = read(&data).unwrap();
assert!(result.unread.is_empty());
assert!(result.hidden_unread.contains("placed files"));
}
#[test]
fn hidden_unread_symbol_does_not_mark_drawn_group_unread() {
let body = format!("u\n%_/SymbolInstance :\n%_(Absent) /SymbolRef ,\n%_;\n{RED}{}f\nU\n", rect_path(10.0, 0.0, 14.0, 4.0));
let result = read_ok(&layer(&body));
assert!(result.unread.is_empty(), "{:?}", result.unread);
assert!(result.hidden_unread.contains("symbols"));
assert_eq!(art(&result.doc)[0].children().expect("group children").len(), 1);
}
#[test]
fn unresolved_pattern_paint_persists_past_plain_group_until_replaced() {
for (operator, draw, reset) in [("p", "f", "0 0 0 1 k"), ("P", "S", "0 0 0 1 K")] {
let body = format!(
"u\n(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n{}{draw}\nU\n{}{draw}\n{reset}\n{}{draw}\n",
rect_path(0.0, 0.0, 4.0, 4.0),
rect_path(10.0, 0.0, 14.0, 4.0),
rect_path(20.0, 0.0, 24.0, 4.0),
);
let result = read_ok(&layer(&body));
assert_eq!(result.unread.len(), 2, "{operator}: {:?}", result.unread);
assert_eq!(art(&result.doc).len(), 3);
}
}
#[test]
fn unresolved_pattern_paint_inside_clip_restores_at_q() {
for (operator, draw, reset) in [("p", "f", "0 0 0 1 k"), ("P", "S", "0 0 0 1 K")] {
let body = format!(
"q\n(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\n{}{draw}\nQ\n{}{draw}\n",
rect_path(0.0, 0.0, 4.0, 4.0),
rect_path(10.0, 0.0, 14.0, 4.0),
);
let result = read_ok(&layer(&body));
assert_eq!(result.unread.len(), 1, "{operator}: {:?}", result.unread);
assert_eq!(art(&result.doc).len(), 2);
let body = format!(
"(Absent) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] {operator}\nq\n{reset}\n{}{draw}\nQ\n{}{draw}\n",
rect_path(0.0, 0.0, 4.0, 4.0),
rect_path(10.0, 0.0, 14.0, 4.0),
);
let result = read_ok(&layer(&body));
assert_eq!(result.unread.len(), 1, "{operator}: {:?}", result.unread);
assert_eq!(result.unread[0].0, art(&result.doc)[1].id);
}
}
#[test]
fn sections_it_skips() {
let body = "%AI5_Begin_NonPrinting\n%AI14_BeginSymbol\n0 Ae\nu\nfrobnicate\n%AI10_EndSymbol\n%AI3_BeginPattern: (P)\nU\nU\n%AI3_EndPattern\n%AI5_End_NonPrinting--\n\
%AI17_Begin_Content_if_version_gt:24 4\n%AI17_Alternate_Content\n%AI17_Begin_Content_if_version_gt:23 1\nfrobnicate\n%AI17_End_Versioned_Content\nfrobnicate\n%AI17_End_Versioned_Content\n\
%_/Document :\n%_/Binary : /ASCII85Decode ,\n%_(]<[%\n%;;;~>\n%_; (Profile) ,\n%_;\n";
let doc = read_ok(&format!("{body}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")))).doc;
assert_eq!(art(&doc).len(), 1);
}
/// A filled square at x = `10 × i` (5 pt wide), to tell the alternatives of versioned content apart.
fn alt_square(i: u32) -> String {
let x = f64::from(i) * 10.0;
rect_path(x, 0.0, x + 5.0, 5.0) + "f\n"
}
/// The left edges of the layer's art, in order: which [`alt_square`]s came in.
fn squares(doc: &Document) -> Vec<f64> {
art(doc).iter().filter_map(|n| n.geometric_bounds()).map(|b| b.x0.round()).collect()
}
/// Versioned content holds alternatives of the same art for newer and older versions (newer
/// files write a Dimension tool group, then the same group again for a version in between, then
/// plain art): only the first alternative is read, so the art comes in once, however many
/// alternatives follow and whatever versioned content is nested in them.
#[test]
fn versioned_content_reads_one_alternative() {
let (begin, between, alternate, end) = (
"%AI17_Begin_Content_if_version_gt:24 11\n",
"%AI17_Alternate_Content_if_version_gt:24 10\n",
"%AI17_Alternate_Content\n",
"%AI17_End_Versioned_Content\n",
);
let (a, b, c) = (alt_square(1), alt_square(2), alt_square(3));
let cases = [
("one alternative", format!("{begin}{a}{end}"), vec![10.0]),
("two alternatives", format!("{begin}{a}{alternate}{b}{end}"), vec![10.0]),
("three alternatives", format!("{begin}{a}{between}{b}{alternate}{c}{end}"), vec![10.0]),
// Blocks nested in the first alternative read their own first; those in a later one aren't read.
(
"nested",
format!(
"{begin}{a}{begin}{b}{alternate}{c}{end}{between}{}{begin}{}{alternate}{}{end}{alternate}{}{end}",
alt_square(4),
alt_square(5),
alt_square(6),
alt_square(7)
),
vec![10.0, 20.0],
),
// Art after the block is read as usual.
("art after it", format!("{begin}{a}{between}{b}{alternate}{c}{end}{}", alt_square(8)), vec![10.0, 80.0]),
];
for (case, body, want) in cases {
let s = read_ok(&layer(&body));
assert_eq!(squares(&s.doc), want, "{case}");
assert!(!s.warnings.iter().any(|w| w == read::VERSIONED_UNENDED), "{case}: {:?}", s.warnings);
}
}
/// Versioned content whose end is missing is read whole, with a note, rather than losing the rest
/// of the file.
#[test]
fn versioned_content_that_does_not_end_is_read_with_a_note() {
let body = format!("%AI17_Begin_Content_if_version_gt:24 11\n{}%AI17_Alternate_Content_if_version_gt:24 10\n{}", alt_square(1), alt_square(2));
let s = read_ok(&layer(&body));
assert_eq!(squares(&s.doc), [10.0, 20.0]);
assert!(s.warnings.iter().any(|w| w == read::VERSIONED_UNENDED), "{:?}", s.warnings);
}
/// Non-native art (a placed PDF's content) keeps its PDF as ASCII85 in comment lines after
/// `/Data ,`. ASCII85 has `_`, so a line may start with `%_`, which otherwise reads as hidden
/// tokens, and a `(` in it would swallow the rest of the layer (a letterhead saved by Illustrator
/// 28 wouldn't open: "it ends inside a layer"). Data that isn't a PDF is left out with a note.
#[test]
fn non_native_art_that_is_not_a_pdf_is_left_out_with_a_note() {
let foreign = "/ForeignObject :\n1 /Version ,\n2 0 0 2 10 20 /RTransform ,\n10 20 /Origin ,\n1 2 5 8 /Bounds ,\n/Data ,\n%,u@!!/MSk8%41#ocdN=10d&\n%_O%*mGn(RduU.]4`[u[.f\n%_;(]<[%\n%1H@<P2`V<S,pbuU7L]\\~>\n;\n0 0 Xd\n6 () XW\n0 Ae\n";
let s = read_ok(&layer(&format!("{foreign}{}", rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")));
assert_eq!(art(&s.doc).len(), 1);
assert!(s.warnings.iter().any(|w| w.starts_with(read::NON_NATIVE_ART_UNREAD)), "{:?}", s.warnings);
}
/// A PDF with one page of `size` points holding `content`.
fn tiny_pdf(size: f64, content: &str) -> Vec<u8> {
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {size} {size}] /Contents 4 0 R >>"),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len() + 1),
];
let mut pdf = b"%PDF-1.4\n".to_vec();
let mut offsets = vec![];
for (i, o) in objects.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for o in offsets {
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
pdf
}
/// `bytes` as ASCII85 in comment lines of 60 characters, ending with `~>`.
fn ascii85_comment_lines(bytes: &[u8]) -> String {
let mut text = String::new();
for chunk in bytes.chunks(4) {
let mut word = [0u8; 4];
word[..chunk.len()].copy_from_slice(chunk);
let mut v = u32::from_be_bytes(word);
let mut digits = [0u8; 5];
for d in digits.iter_mut().rev() {
*d = (v % 85) as u8 + b'!';
v /= 85;
}
text.extend(digits.iter().take(chunk.len() + 1).map(|d| char::from(*d)));
}
let mut lines = String::new();
for line in text.as_bytes().chunks(60) {
lines.push('%');
lines.push_str(std::str::from_utf8(line).unwrap());
lines.push('\n');
}
lines.push_str("%~>\n");
lines
}
/// The PDF of non-native art is drawn by the PDF importer, its art fitted to the object's box:
/// `/Bounds` in a space measured from `/Origin` with y down, then `/RTransform`. Two squares at
/// 1..5 × 2..8 of a 10-point page, blue below red: bounds (1, 2)–(5, 8) → from the origin
/// (10, 20), y down, (11, 18)–(15, 12) → twice that plus (100, 50): x 122..130, y 74..86 in art
/// space, which is y 14..26 in the document (crop marks 100 high). That placement mirrors (`2 0 0 2`
/// with the origin's flip), so the PDF's bottom (blue) is the top of the box.
#[test]
fn non_native_art_is_drawn_from_its_pdf_fitted_to_its_bounds() {
let pdf = tiny_pdf(10.0, "0 0 1 rg 1 2 4 3 re f 1 0 0 rg 1 5 4 3 re f");
let foreign = format!(
"q\n/ForeignObject :\n1 /Version ,\n2 0 0 2 100 50 /RTransform ,\n10 20 /Origin ,\n1 2 5 8 /Bounds ,\n/Data ,\n{};\n0 0 Xd\n6 () XW\n0 Ae\n{}h\nW\nn\nQ\n",
ascii85_comment_lines(&pdf),
rect_path(120.0, 70.0, 130.0, 90.0)
);
let s = read_ok(&layer(&foreign));
assert!(s.warnings.is_empty(), "{:?}", s.warnings);
let objects = art(&s.doc);
assert_eq!(objects.len(), 1, "{objects:?}");
// A clipping group: the clip path first, then the non-native art's group.
let NodeKind::Group { children, clip: true } = &objects[0].kind else { panic!("{:?}", objects[0].kind) };
assert_eq!(children.len(), 2);
let group = &children[1];
assert_eq!(group.name.as_deref(), Some(read::NON_NATIVE_ART));
let bounds = group.geometric_bounds().unwrap();
for (got, want) in [(bounds.x0, 122.0), (bounds.y0, 14.0), (bounds.x1, 130.0), (bounds.y1, 26.0)] {
assert!((got - want).abs() < 1e-6, "{bounds:?}");
}
let paths = group.children().unwrap();
assert_eq!(paths.len(), 2);
let fill =
|n: &Node| n.appearance.items.iter().find_map(|it| if let AppearanceItem::Fill(f) = it { Some(f.paint.clone()) } else { None }).unwrap();
let (blue, red) = (fill(&paths[0]), fill(&paths[1]));
assert!(matches!(&blue, Paint::Solid { color, swatch: None, .. } if color.to_rgba8(1.0) == [0, 0, 255, 255]), "{blue:?}");
assert!(matches!(&red, Paint::Solid { color, swatch: None, .. } if color.to_rgba8(1.0) == [255, 0, 0, 255]), "{red:?}");
let (b, r) = (paths[0].geometric_bounds().unwrap(), paths[1].geometric_bounds().unwrap());
assert!((b.y0 - 14.0).abs() < 1e-6 && (b.y1 - 20.0).abs() < 1e-6, "blue {b:?}");
assert!((r.y0 - 20.0).abs() < 1e-6 && (r.y1 - 26.0).abs() < 1e-6, "red {r:?}");
// Its ids are this document's.
assert_ne!(group.id, paths[0].id);
assert_ne!(paths[0].id, paths[1].id);
}
/// The usual placement, `/RTransform` `1 0 0 -1 …`, doesn't mirror (with the origin's flip its
/// determinant is positive), so the art shows upright, as the file's own PDF part draws it: the
/// PDF's top (red) at the top. Bounds
/// (1, 2)–(5, 8) from the origin (10, 20), y down, → (11, 18)–(15, 12) → x 111..115, y 32..38 in
/// art space, y 62..68 in the document (crop marks 100 high).
#[test]
fn non_native_art_placed_without_a_mirror_stays_upright() {
let pdf = tiny_pdf(10.0, "0 0 1 rg 1 2 4 3 re f 1 0 0 rg 1 5 4 3 re f");
let foreign = format!(
"q\n/ForeignObject :\n1 /Version ,\n1 0 0 -1 100 50 /RTransform ,\n10 20 /Origin ,\n1 2 5 8 /Bounds ,\n/Data ,\n{};\n0 0 Xd\n6 () XW\n0 Ae\n{}h\nW\nn\nQ\n",
ascii85_comment_lines(&pdf),
rect_path(110.0, 30.0, 116.0, 40.0)
);
let s = read_ok(&layer(&foreign));
assert!(s.warnings.is_empty(), "{:?}", s.warnings);
let objects = art(&s.doc);
let NodeKind::Group { children, clip: true } = &objects[0].kind else { panic!("{:?}", objects[0].kind) };
let group = &children[1];
let bounds = group.geometric_bounds().unwrap();
for (got, want) in [(bounds.x0, 111.0), (bounds.y0, 62.0), (bounds.x1, 115.0), (bounds.y1, 68.0)] {
assert!((got - want).abs() < 1e-6, "{bounds:?}");
}
let paths = group.children().unwrap();
let (blue, red) = (paths[0].geometric_bounds().unwrap(), paths[1].geometric_bounds().unwrap());
assert!((red.y0 - 62.0).abs() < 1e-6 && (red.y1 - 65.0).abs() < 1e-6, "red {red:?}");
assert!((blue.y0 - 65.0).abs() < 1e-6 && (blue.y1 - 68.0).abs() < 1e-6, "blue {blue:?}");
}
#[test]
fn cmyk_documents() {
let doc = read_ok(&format!("%AI9_ColorModel: 2\n{}", layer(""))).doc;
assert_eq!(doc.color_mode, ColorMode::Cmyk);
}
#[test]
fn hostile_data_ends_without_panics() {
let deep = "0 Ae\nu\n".repeat(300) + &"U\n".repeat(300);
assert!(read(stream(&layer(&deep)).as_bytes()).unwrap_err().contains("nested too deeply"));
let keys = "/X :\n".repeat(300);
assert!(read(stream(&layer(&keys)).as_bytes()).is_err());
for junk in [
&b"%AI5_BeginLayer\n(\xff\xfe"[..],
b"%AI5_BeginLayer\n<zz",
b"]]]] [ [ ; , : : ;",
b"1e308 1e308 m 1e308 -1e308 L f",
b"%%BeginData: 5\rXI\n",
] {
let _ = read(junk);
}
// Images with absurd sizes are left out.
let r = read(&image("[ 1 0 0 1 0 0 ]", (1 << 20, 1 << 20), ("DeviceRGB", 3, 0), &[0; 12], 12)).unwrap();
assert!(art(&r.doc).is_empty());
}
fn zstd(data: &[u8]) -> Vec<u8> {
ruzstd::encoding::compress_to_vec(data, ruzstd::encoding::CompressionLevel::Fastest)
}
/// An EPS whose page draws a line, and the editing data `packed` after it (`marker`: its codec).
fn eps(marker: &str, packed: &[u8]) -> Vec<u8> {
let lines: String = crate::ps::ascii85(packed).lines().map(|l| format!("%{l}\n")).collect();
format!(
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n%%EndComments\n0 0 moveto 10 10 lineto stroke\nshowpage\n%%EOF\n\
%AI9_PrivateDataBegin\n%!PS-Adobe-3.0 EPSF-3.0\n%AI3_Cropmarks: 0 0 200 100\n%AI5_FileFormat 14.0\n{marker}\n{lines}%AI9_PrivateDataEnd\n"
)
.into_bytes()
}
#[test]
fn eps_containers() {
let body = stream(&layer(&(rect_path(0.0, 0.0, 10.0, 10.0) + "f\n")));
let body = body.as_bytes();
for file in [eps("%AI24_DataStream", &zstd(body)), eps("%AI9_DataStream", &crate::ps::deflate(body))] {
let (ps, _) = crate::sections(&file).unwrap();
let data = eps_data(ps).unwrap().unwrap();
assert!(data.starts_with(b"%!PS-Adobe-3.0 EPSF-3.0\n%AI3_Cropmarks"), "the header comes first");
assert!(data.ends_with(body));
assert_eq!(read(&data).unwrap().doc.layers.len(), 1);
}
// Damaged data says why.
let (ps, _) = crate::sections(&eps("%AI24_DataStream", b"not zstd")).map(|(p, t)| (p.to_vec(), t.map(<[u8]>::to_vec))).unwrap();
assert!(eps_data(&ps).unwrap().unwrap_err().contains("Zstandard"));
// Data that isn't compressed is used as it is.
let plain = format!("%!PS-Adobe-3.0\n%%EOF\n%AI9_PrivateDataBegin\n{}%AI9_PrivateDataEnd\n", stream(&layer("")));
assert!(read(&eps_data(plain.as_bytes()).unwrap().unwrap()).is_ok());
// An EPS without editing data has none.
let other = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 10 10\n%%EndComments\n0 0 moveto 10 10 lineto stroke\nshowpage\n";
assert!(eps_data(other).is_none());
}
#[test]
fn ai_private_data() {
let body = stream(&layer(""));
let body = body.as_bytes();
let header = b"%%BoundingBox: 0 0 1 1\r\n";
for raw in [
[&b"%AI24_ZStandard_Data"[..], &zstd(body)].concat(),
[&header[..], b"%AI12_CompressedData", &crate::ps::deflate(body)].concat(),
body.to_vec(),
] {
let data = decode_private(&raw).unwrap();
assert!(data.ends_with(b"%%EOF\n"));
assert!(read(&data).is_ok());
}
assert!(decode_private(b"%AI12_CompressedDataxx").is_err());
}
const G_DEFS: &str = "%AI5_BeginGradient: (G)\n(G) 1 2 Bd\n[\n<00FF>\n0\n0 0 0 0 1 0 0 2 1 6 %_BS\n\
%_0 0 0 0 1 0 0 2 1 6 40 0 Bs\n%_0 0 0 0 0 0 1 2 0.5 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
/// A radial gradient instance whose highlight line is `bh`, on a rectangle.
fn highlighted(bh: &str) -> String {
let art_ = format!("{RED}{}Bb\n{bh} Bh\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n50 0 0 -50 100 50 Bm\nf\n0 BB\n", rect_path(50.0, 0.0, 150.0, 100.0));
format!("{G_DEFS}{}", layer(&art_))
}
#[test]
fn an_undefined_highlight_is_a_gradient_without_one() {
// `--` stands for a number the writer has no value for, here the highlight's point.
let none = read_ok(&highlighted("-- -- 269.6 0.87")).doc;
let zero = read_ok(&highlighted("0 0 269.6 0.87")).doc;
let Paint::Gradient(g) = fill(&art(&none)[0]) else { panic!("{:?}", fill(&art(&none)[0])) };
let Paint::Gradient(z) = fill(&art(&zero)[0]) else { panic!("{:?}", fill(&art(&zero)[0])) };
assert_eq!(g.gradient.kind, GradientKind::Radial);
assert_eq!(g.geom, z.geom, "the same gradient as one with the highlight at the centre");
}
#[test]
fn an_undefined_number_anywhere_else_is_still_an_operator_the_reader_doesnt_know() {
let err = read(stream(&layer(&format!("{RED}-- 5 m\n0 10 L\n10 10 L\nf\n"))).as_bytes()).unwrap_err();
assert!(err.contains("operators VectorCraft doesn't know (`--`)"), "{err}");
}
#[test]
fn a_linked_images_colour_space_operator_is_ignored() {
let art_ = format!("{RED}1 /DeviceGray XC\n{}f\n", rect_path(0.0, 0.0, 10.0, 10.0));
assert_eq!(art(&read_ok(&layer(&art_)).doc).len(), 1);
}
/// Editing data past the old 256 MiB limit still opens on a 64-bit build (#3510): a card file with
/// embedded rasters unpacks to over 300 MiB.
#[cfg(target_pointer_width = "64")]
#[test]
fn editing_data_past_256_mib_is_read() {
let mut body = stream(&layer("")).into_bytes();
// Comment lines the reader skips, so the data stays valid while it grows past the old limit.
let filler = vec![b'%'; (257 << 20) - body.len()];
body.extend_from_slice(&filler);
let raw = [&b"%AI12_CompressedData"[..], &crate::ps::deflate(&body)].concat();
assert!(raw.len() < 1 << 20, "zeros compress");
let data = decode_private(&raw).unwrap();
assert_eq!(data.len(), body.len());
assert_eq!(too_large(), format!("it decompresses to more than {} MB", max_data() >> 20));
}
/// The limit is an eighth of installed memory, never under the 512 MiB every 64-bit build read
/// before and never over 2 GiB (#3510).
#[cfg(target_pointer_width = "64")]
#[test]
fn the_editing_data_limit_follows_installed_memory() {
const GIB: u64 = 1 << 30;
assert_eq!(data_limit(None), 512 << 20, "unknown memory keeps the old limit");
assert_eq!(data_limit(Some(2 * GIB)), 512 << 20);
assert_eq!(data_limit(Some(4 * GIB)), 512 << 20);
assert_eq!(data_limit(Some(8 * GIB)), GIB);
assert_eq!(data_limit(Some(12 * GIB)), 3 * GIB / 2);
assert_eq!(data_limit(Some(16 * GIB)), 2 * GIB);
assert_eq!(data_limit(Some(256 * GIB)), 2 * GIB, "capped, so a tiny file can't take all of a big machine");
assert!((MIN_DATA..=MAX_DATA).contains(&max_data()));
}
/// On a machine with 8 GiB or more, editing data past the old 512 MiB limit opens with its layers
/// (#3510, #4628): a layered file whose rasters unpack to 600 MiB.
#[cfg(target_pointer_width = "64")]
#[test]
fn editing_data_past_512_mib_is_read_where_memory_allows() {
if max_data() < 601 << 20 {
return;
}
let mut body = stream(&format!("{}{}", layer(&rect_path(0.0, 0.0, 10.0, 10.0)), layer(&rect_path(20.0, 20.0, 30.0, 30.0)))).into_bytes();
let filler = vec![b'%'; (600 << 20) - body.len()];
let at = body.windows(14).position(|w| w == b"%%PageTrailer\n").unwrap();
body.splice(at..at, filler);
let raw = [&b"%AI12_CompressedData"[..], &crate::ps::deflate(&body)].concat();
let data = decode_private(&raw).unwrap();
assert_eq!(data.len(), body.len());
drop(body);
let r = read(&data).unwrap();
assert_eq!(r.doc.layers.len(), 2, "both layers are read");
}
/// Past the limit the data is still refused, so the file opens as its PDF part with a note.
#[test]
fn editing_data_past_the_limit_is_refused() {
let raw = [&b"%AI12_CompressedData"[..], &crate::ps::deflate(b"%%")].concat();
assert!(Codec::Zlib.decode(raw.get(20..).unwrap(), 1).is_err());
assert!(Codec::Zlib.decode(raw.get(20..).unwrap(), 2).is_ok());
}
#[test]
fn many_zstandard_frames_are_read_in_one_pass_and_capped() {
let frame = zstd(b" ");
let frames = |n: usize| -> Vec<u8> { [&b"%AI24_ZStandard_Data"[..], &frame.repeat(n), &[0; 64]].concat() };
let started = std::time::Instant::now();
assert_eq!(decode_private(&frames(4000)).unwrap().len(), 4000);
assert!(decode_private(&frames(50_000)).unwrap_err().contains("Zstandard"));
assert!(started.elapsed() < std::time::Duration::from_secs(20), "{:?}", started.elapsed());
}
/// A Zstandard frame that ends in a zero byte keeps it: only the zeros after the last frame are
/// padding.
#[test]
fn a_zstandard_frame_ending_in_a_zero_byte_is_read_whole() {
// Magic number, a single-segment header with a 1-byte content size of 3, and one raw block
// (last, 3 bytes) holding `ab` and a zero byte.
let frame = [0x28, 0xB5, 0x2F, 0xFD, 0x20, 3, 0x19, 0, 0, b'a', b'b', 0];
for padding in [0, 64] {
let raw = [&b"%AI24_ZStandard_Data"[..], &frame, &frame, &vec![0; padding]].concat();
assert_eq!(decode_private(&raw).unwrap(), b"ab\0ab\0");
}
}
#[test]
fn closing_brackets_without_their_opening_dont_rescan_the_stack() {
let n = 200_000;
let body = format!("{}\n{}\n", "1 ".repeat(n), "] ".repeat(n));
let started = std::time::Instant::now();
let _ = read(stream(&layer(&body)).as_bytes());
assert!(started.elapsed() < std::time::Duration::from_secs(20), "{:?}", started.elapsed());
}
#[test]
fn artboards_after_what_stops_the_reading() {
let board = |name: &str, x: i32| {
format!(
"%_/Dictionary :\n%_/FillStyle : 0 1 0 0 0 Xy\n%_/Def ; \n%_ (EachArtboardColor) ,\n%_{x} 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_({name}) /UnicodeString (Name) ,\n%_{} 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n",
x + 100
)
};
let array = format!("%_/Array :\n{}{}%_; (ArtboardArray) ,\n", board("one", 0), board("two", 200));
// Art in the document's own data that the reader can't follow comes before the artboards.
let data = format!("%_/Document :\n%_/Dictionary :\n%_/Array :\n%_/Dictionary :\nLB\n{array}%_; /NotRecorded ,\n%_;\n");
let boards: Vec<_> = super::artboards(data.as_bytes()).into_iter().map(|(n, r)| (n, r.x0)).collect();
assert_eq!(boards, [("one".to_string(), 0.0), ("two".to_string(), 200.0)]);
}
#[test]
fn a_compound_clipping_path_with_a_fill_paints_once_under_its_clip() {
// GUARDS: the painted copies of a compound's clipping subpaths went into the compound itself,
// doubling each subpath, so under even-odd the clip cancelled to nothing.
let art_ = format!(
"0 Ae\nq\n{RED}{}f\n1 XR\n*u\n0 0 0 0 0 0 1 Xa\n{}W\nf\n{}W\nf\n*U\nQ\n",
rect_path(0.0, 0.0, 100.0, 100.0),
rect_path(10.0, 10.0, 40.0, 40.0),
rect_path(50.0, 10.0, 80.0, 40.0),
);
let doc = read_ok(&layer(&art_)).doc;
let NodeKind::Group { children, clip: true } = &art(&doc)[0].kind else { panic!("a clipping group") };
assert_eq!(children.len(), 3, "{children:#?}");
let NodeKind::Compound { children: clip, rule } = &children[0].kind else { panic!("{:?}", children[0].kind) };
assert_eq!(*rule, vectorcraft_geom::FillRule::EvenOdd);
assert_eq!(clip.len(), 2);
assert!(clip.iter().all(|c| matches!(c.kind, NodeKind::Path { clipping: true, .. })));
let NodeKind::Compound { children: painted, .. } = &children[1].kind else { panic!("{:?}", children[1].kind) };
assert_eq!((painted.len(), fill(&children[1]).color()), (2, Some(Color::rgb(0.0, 0.0, 1.0))));
assert_eq!(fill(&children[2]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
}
#[test]
fn a_radial_gradient_without_xm_is_its_bm_circle_else_its_bg_circle() {
// GUARDS: files write `Bg` as the unit circle and place the radial with `Bm`; reading `Bg` alone collapses it.
let defs =
"%AI5_BeginGradient: (R)\n(R) 1 2 Bd\n[\n<00FF>\n0\n%_0 0 0 0 1 0 0 2 1 6 50 0 Bs\n%_0 0 0 0 0 0 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
let geom = |instance: &str| {
let doc = read_ok(&format!("{defs}{}", layer(&format!("{}Bb\n10 0 0 0.5 Bh\n{instance}f\n0 BB\n", rect_path(0.0, 0.0, 200.0, 100.0))))).doc;
let Paint::Gradient(g) = fill(&art(&doc)[0]) else { panic!() };
g.geom.unwrap()
};
let placed = geom("1 (R) 0 0 0 1 1 0 0 1 0 0 Bg\n50 0 0 -50 100 50 Bm\n");
assert_eq!((placed.start, placed.end, placed.focal), (Point::new(100.0, 50.0), Point::new(150.0, 50.0), Some(Point::new(110.0, 50.0))));
let own = geom("1 (R) 60 50 0 20 1 0 0 1 0 0 Bg\n");
assert_eq!((own.start, own.end, own.focal), (Point::new(60.0, 50.0), Point::new(80.0, 50.0), Some(Point::new(70.0, 50.0))));
}
/// Two art styles: `Blue`, a blue fill and a drop shadow; `Lit`, the object's gradient and an outer glow.
const ART_STYLES: &str = "%AI9_BeginArtStyles\n\
/KnownStyle :\n(Blue) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n0 0 0 0 0 0 1 Xa\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n\
/BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n1 /Int (blnd) ,\n0.75 /Real (opac) ,\n5 /Real (blur) ,\n\
0 /Int (csrc) ,\n/FillStyle : 0 O\n0 0 0 1 0 0 0 Xa\n0 1 Xd\n/Def ;\n (sclr) ,\n3 /Real (horz) ,\n4 /Real (vert) ,\n; /Dict ;\n /Part ,\n;\n\
/KnownStyle :\n(Lit) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n1 (G) 0 0 0 1 1 0 0 1 0 0 Bg\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n\
/BasicFilter :\n(Adobe Outer Glow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n2 /Int (blnd) ,\n0.5 /Real (opac) ,\n2 /Real (blur) ,\n\
/FillStyle : 0 O\n0 0 0 0 1 1 0 Xa\n0 1 Xd\n/Def ;\n (sclr) ,\n; /Dict ;\n /Part ,\n;\n\
%AI9_EndArtStyles\n";
#[test]
fn an_object_with_a_style_this_reads_comes_in_as_itself() {
let defs =
"%AI5_BeginGradient: (G)\n(G) 0 2 Bd\n[\n<00FF>\n0\n%_0 0 0 0 1 0 0 2 1 6 50 0 Bs\n%_0 0 0 0 0 0 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_");
let art_ = format!(
"0 Ae\nu\n{RED}{}f\nU\n%_Bb\n%_1 (G) 0 0 0 1 1 0 0 1 0 0 Bg\n%_10 0 0 10 5 5 Xm\n%_{hidden_rect}f\n%_0 BB\n1 (Lit) XW\n",
rect_path(0.0, 0.0, 10.0, 10.0)
);
let r = read_ok(&format!("{ART_STYLES}{defs}{}", layer(&art_)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look");
assert!(matches!(fill(&a[0]), Paint::Gradient(g) if g.swatch.as_deref() == Some("G")));
let effects: Vec<_> = a[0].appearance.effects.iter().map(|e| (e.id.as_str(), e.params["opacity"].as_f64())).collect();
assert_eq!(effects, [("stylize.outerGlow", Some(50.0))]);
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
}
#[test]
fn an_object_with_a_feather_style_comes_in_with_a_live_effect() {
let feather_style = "%AI9_BeginArtStyles\n\
/KnownStyle :\n(Soft) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n0 0 0 0 0 0 1 Xa\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n\
/BasicFilter :\n(Adobe Fuzzy Mask) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n8 /Real (Radius) ,\n; /Dict ;\n /Part ,\n;\n\
%AI9_EndArtStyles\n";
let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_");
let art_ = format!("0 Ae\nu\n{RED}{}f\nU\n%_Bb\n%_{hidden_rect}f\n%_0 BB\n1 (Soft) XW\n", rect_path(0.0, 0.0, 10.0, 10.0));
let r = read_ok(&format!("{feather_style}{}", layer(&art_)));
let a = art(&r.doc);
assert_eq!(a.len(), 1);
let [feather] = a[0].appearance.effects.as_slice() else { panic!("{:#?}", a[0].appearance) };
assert_eq!((feather.id.as_str(), feather.params["radius"].as_f64()), ("stylize.feather", Some(8.0)));
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
}
#[test]
fn an_object_with_convert_to_shape_comes_in_as_itself_with_the_effect_live() {
// GUARDS: `Adobe Shape Effects` wasn't read, so the object came in as its drawn look.
let styles = |keys: &str| {
format!(
"%AI9_BeginArtStyles\n\
/KnownStyle :\n(Boxed) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/BasicFilter :\n(Adobe Shape Effects) 1 0 /Filter ,\n1 /Visible ,\n(ShapeEffect.aip) /PluginFileName ,\n(Shape) /Title ,\n\
/Dictionary : /NotRecorded ,\n(Rectangle) /UnicodeString (DisplayString) ,\n{keys}; /Dict ;\n /Part ,\n\
/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n0 0 0 0 0 0 1 Xa\n0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n;\n\
%AI9_EndArtStyles\n"
)
};
let hidden_rect = rect_path(20.0, 20.0, 90.0, 60.0).replace('\n', "\n%_");
// The drawn look is the shape: 20 pt wider on each side and 6 pt taller.
let art_ = format!("0 Ae\nu\n{RED}{}f\nU\n%_Bb\n%_{hidden_rect}f\n%_0 BB\n1 (Boxed) XW\n", rect_path(0.0, 14.0, 110.0, 66.0));
let keys = "0 /Int (Shape) ,\n150 /Real (AbsWidth) ,\n0 /Real (Absolute) ,\n40 /Real (AbsHeight) ,\n20 /Real (RelWidth) ,\n15 /Real (CornerRadius) ,\n6 /Real (RelHeight) ,\n";
let r = read_ok(&format!("{}{}", styles(keys), layer(&art_)));
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look: {a:#?}");
let b = a[0].geometric_bounds().unwrap();
assert_eq!((b.width(), b.height()), (70.0, 40.0), "the object keeps its own path");
assert_eq!(fill(&a[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0)));
let [e] = a[0].appearance.effects.as_slice() else { panic!("{:?}", a[0].appearance.effects) };
assert_eq!(
(e.id.as_str(), e.params["relative"].as_bool(), e.params["extraW"].as_f64(), e.params["extraH"].as_f64()),
("convertToShape.rectangle", Some(true), Some(40.0), Some(12.0))
);
// A shape that isn't read: the drawn look.
let r = read_ok(&format!("{}{}", styles(&keys.replace("0 /Int (Shape)", "7 /Int (Shape)")), layer(&art_)));
let a = art(&r.doc);
assert!(matches!(a[0].kind, NodeKind::Group { .. }), "{a:#?}");
assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
}
#[test]
fn a_bare_comment_line_in_a_prolog_leaves_its_end_alone() {
// GUARDS: a bare `%` line's empty marker matched the empty begin marker of a section that has none.
let r = read_ok(&format!("%%BeginProlog\n%\n%%EndProlog\n{}", layer(&format!("{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0)))));
assert_eq!(art(&r.doc).len(), 1);
}
#[test]
fn a_styled_object_inside_a_drawn_look_survives_it() {
// GUARDS: an adopted styled object was read on `%_` lines; while its id stayed among those, the
// enclosing look's strip removed it.
let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_");
let art_ = format!(
"0 Ae\nu\n0 Ae\nu\n{RED}{}f\nU\n%_{hidden_rect}f\n1 (Blue) XW\nU\n%_0 Ae\n%_u\n%_{hidden_rect}f\n%_U\n1 (Outer) XW\n",
rect_path(0.0, 0.0, 10.0, 10.0)
);
let r = read_ok(&format!("{ART_STYLES}{}", layer(&art_)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
let inner = a[0].children().unwrap();
assert_eq!(inner.len(), 1, "{inner:#?}");
assert_eq!(fill(&inner[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0)));
let shadow = &inner[0].appearance.effects[0];
assert_eq!((shadow.id.as_str(), shadow.params["x"].as_f64(), shadow.params["y"].as_f64()), ("stylize.dropShadow", Some(3.0), Some(4.0)));
}
/// An object whose style's Drop Shadow has Darkness (`csrc` 1, `dark` 50) comes in as itself with
/// that shadow, not as its drawn look.
#[test]
fn an_object_with_a_darkness_shadow_comes_in_as_itself() {
let styles = ART_STYLES.replace("0 /Int (csrc) ,\n", "1 /Int (csrc) ,\n50 /Real (dark) ,\n");
let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_");
let art_ = format!("0 Ae\nu\n{RED}{}f\nU\n%_{hidden_rect}f\n1 (Blue) XW\n", rect_path(0.0, 0.0, 10.0, 10.0));
let r = read_ok(&format!("{styles}{}", layer(&art_)));
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look: {a:#?}");
assert_eq!(fill(&a[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0)));
let [shadow] = a[0].appearance.effects.as_slice() else { panic!("{:?}", a[0].appearance.effects) };
assert_eq!(
(shadow.id.as_str(), shadow.params["shade"].as_str(), shadow.params["darkness"].as_f64(), shadow.params["x"].as_f64()),
("stylize.dropShadow", Some("darkness"), Some(50.0), Some(3.0))
);
}
/// A styled object read in place of its drawn look keeps the transparency written on the look (the
/// object's own, after the look's group ends): here 60 % in Multiply.
#[test]
fn a_styled_object_keeps_the_transparency_written_on_its_look() {
let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_");
// The object on `%_` lines sets its own paths' transparency back to normal, as files do.
let art_ =
format!("0 Ae\nu\n{RED}{}f\nU\n1 0.6 0 2 0 Xy\n6 () XW\n%_0 1 0 0 0 Xy\n%_{hidden_rect}f\n1 (Blue) XW\n", rect_path(0.0, 0.0, 10.0, 10.0));
let r = read_ok(&format!("{ART_STYLES}{}", layer(&art_)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert_eq!(fill(&a[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0)));
assert_eq!((a[0].opacity, a[0].blend), (0.6, BlendMode::Multiply));
}
/// An envelope is written on `%_` lines as a group holding the mesh that shapes it, with its
/// distortion in the group's dictionary; the distorted art follows on plain lines. Only that art
/// is drawn, as in the file's PDF part, not the envelope's mesh (#2405).
#[test]
fn an_envelope_mesh_is_not_read_as_art() {
let corner = |x: i32, y: i32, next: (i32, i32), prev: (i32, i32)| {
format!("%_[0.82 0.83 0.83 1 {x} {y} {} {} 1 {} {} 1] /N X#\n", next.0, next.1, prev.0, prev.1)
};
let mesh = format!(
"%_/Mesh X!\n%_(DeviceRGB) /CS X#\n%_[0 0] /P X#\n{}{}{}{}%_0 /E X#\n%_/End X!\n",
corner(0, 0, (0, 10), (10, 0)),
corner(0, 40, (10, 40), (0, 30)),
corner(40, 40, (40, 30), (30, 40)),
corner(40, 0, (30, 0), (40, 10)),
);
let envelope = |dictionary: &str| {
format!(
"%_0 Ae\n%_u\n{mesh}%_0 0 Xd\n%_6 () XW\n%_U\n%_/ArtDictionary :\n{dictionary}%_;\n%_\n%_0 0 Xd\n%_6 () XW\n{RED}{}f\n",
rect_path(0.0, 0.0, 40.0, 40.0)
)
};
let has_mesh = |n: &Node| -> bool {
fn walk(n: &Node) -> bool {
matches!(n.kind, NodeKind::Mesh(_)) || n.children().is_some_and(|c| c.iter().any(|k| walk(k)))
}
walk(n)
};
let warped = envelope("%_(Warp: Arch) /UnicodeString (DisplayString) ,\n%_0.27 /Real (DeformValue) ,\n%_1 /Int (AIDeformStyle) ,\n");
let doc = read_ok(&layer(&warped)).doc;
let a = art(&doc);
assert!(!a.iter().any(|n| has_mesh(n)), "{a:#?}");
assert_eq!(a.len(), 1, "{a:#?}");
assert_eq!(fill(&a[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
// A mesh in a group with no distortion is still art.
let doc = read_ok(&layer(&envelope(""))).doc;
assert!(art(&doc).iter().any(|n| has_mesh(n)), "{:#?}", art(&doc));
}
/// An envelope whose contents are being edited is written the other way round: its contents, a
/// group on `%_` lines, then its dictionary, which holds the mesh that shapes them. The contents
/// aren't drawn as they are, only the distorted art on the plain lines after them.
#[test]
fn an_envelope_s_contents_being_edited_are_not_read_as_art() {
let corner = |x: i32, y: i32, next: (i32, i32), prev: (i32, i32)| {
format!("%_[0.82 0.83 0.83 1 {x} {y} {} {} 1 {} {} 1] /N X#\n", next.0, next.1, prev.0, prev.1)
};
let mesh = format!(
"%_/Mesh X!\n%_(DeviceRGB) /CS X#\n%_[0 0] /P X#\n{}{}{}{}%_0 /E X#\n%_/End X!\n%_0 0 Xd\n%_6 () XW\n",
corner(0, 0, (0, 10), (10, 0)),
corner(0, 40, (10, 40), (0, 30)),
corner(40, 40, (40, 30), (30, 40)),
corner(40, 0, (30, 0), (40, 10)),
);
let contents: String = rect_path(0.0, 0.0, 80.0, 20.0).lines().map(|l| format!("%_{l}\n")).collect();
let body = format!(
"%_u\n%_u\n%_0 0 1 0 0 0 1 Xa\n{contents}%_f\n%_U\n%_9 () XW\n%_U\n%_/ArtDictionary :\n%_1 /Bool (AIDeformEditModeKey) ,\n\
%_3 /Int (AIDeformStyle) ,\n%_X=\n{mesh}%_X+\n%_ (AIDeformArtKey) ,\n%_;\n%_\n%_9 () XW\n{RED}{}f\n",
rect_path(0.0, 0.0, 40.0, 40.0)
);
let doc = read_ok(&layer(&body)).doc;
let a = art(&doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert_eq!(fill(&a[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
}
/// `art` on `%_` lines.
fn commented(art: &str) -> String {
art.lines().map(|l| format!("%_{l}\n")).collect()
}
/// Envelope Options as new envelopes have them (Distort Appearance, Anti-Alias, Clipping Mask),
/// Fidelity 50, the contents not being edited.
const ENVELOPE_KEYS: &str = "131073 /Int (AIDeformOptionsKey) ,\n50 /Int (AIDeformFidelityKey) ,\n0 /Bool (AIDeformEditModeKey) ,\n";
/// An envelope as the editing data writes it: on `%_` lines a group holding `before`, then the
/// dictionary with `keys`, the art `block` in its `AIDeformArtKey` block and the kind `kind`; then
/// `drawn` in a group on plain lines, and the plugin's `XP`.
fn envelope_parts(before: &str, keys: &str, block: &str, kind: u32, drawn: &str) -> String {
format!(
"{}%_/ArtDictionary :\n{}%_X=\n{}%_X+\n%_ (AIDeformArtKey) ,\n%_{kind} /Int (AIDeformStyle) ,\n%_;\n%_\n%_9 () XW\nu\n{drawn}U\n9 () XW\n(Adobe Deform Plugin) 1 0 0 XP\n",
commented(&format!("u\n{before}U\n")),
commented(keys),
commented(block),
)
}
/// An envelope of `content` (in the plain group the editing data puts round it) shaped by the mesh
/// `shape`.
fn envelope_of(shape: &str, keys: &str, content: &str, kind: u32, drawn: &str) -> String {
envelope_parts(shape, keys, &format!("u\n{content}U\n9 () XW\n"), kind, drawn)
}
/// A grid point's place in art space and its handle towards a neighbour.
type MeshAt<'a> = &'a dyn Fn((usize, usize)) -> (f64, f64);
type MeshHandle<'a> = &'a dyn Fn((usize, usize), (usize, usize)) -> (f64, f64);
/// A grey `/Mesh` of `cols`×`rows` patches (`/P [i j]`, `i` along x, column by column), each
/// corner of patch `(i, j)` at `at`, from the one nearest the origin up, across and down, with its
/// handles towards the next corner and the previous one at `handle`; `anchor`, written after the
/// second corner of the first patch, puts an anchor on its second edge.
fn mesh_text(cols: usize, rows: usize, at: MeshAt<'_>, handle: MeshHandle<'_>, anchor: Option<[f64; 6]>) -> String {
let mut s = format!("/Mesh X!\n2 /Version X#\n/Cartesian /Type X#\n[{cols} {rows}] /Size X#\n/Data X#\n/DeviceGray /CS X#\n");
for i in 0..cols {
for j in 0..rows {
s += &format!("[{i} {j}] /P X#\n");
let (a, b, c, d) = ((i, j), (i, j + 1), (i + 1, j + 1), (i + 1, j));
for (k, (p, next, prev)) in [(a, b, d), (b, c, a), (c, d, b), (d, a, c)].into_iter().enumerate() {
let ((x, y), (nx, ny), (px, py)) = (at(p), handle(p, next), handle(p, prev));
s += &format!("[0.8 1 {x} {y} {nx} {ny} 1 {px} {py} 1] /N X#\n");
if let (1, Some([x, y, ax, ay, bx, by])) = (k, anchor.filter(|_| (i, j) == (0, 0))) {
s += &format!("[{x} {y} {ax} {ay} {bx} {by} 1] /A X#\n");
}
}
s += "/E X#\n";
}
}
s + "/End X!\n"
}
/// A grid in art space, its points `step` apart from (40, 20), handles a third of the way.
fn straight_mesh(cols: usize, rows: usize, step: (f64, f64)) -> String {
let at = move |(i, j): (usize, usize)| (40.0 + i as f64 * step.0, 20.0 + j as f64 * step.1);
let third = move |p: (usize, usize), q: (usize, usize)| {
let ((x, y), (u, v)) = (at(p), at(q));
(x + (u - x) / 3.0, y + (v - y) / 3.0)
};
mesh_text(cols, rows, &at, &third, None)
}
/// The red rectangle 40 20 160 80 in art space, which is the same box in the document's.
fn envelope_content() -> String {
format!("{RED}{}f\n", rect_path(40.0, 20.0, 160.0, 80.0))
}
/// What envelope `kind` draws of [`envelope_content`]: the rectangle's edges, many points to a
/// side, mapped as VectorCraft maps them, as a red path in art space.
fn drawn_content(kind: &vectorcraft_doc::live::EnvelopeKind) -> String {
let rect = Rect::new(40.0, 20.0, 160.0, 80.0);
let map = vectorcraft_doc::live::envelope_mapper(kind, rect);
let corners = [Point::new(40.0, 20.0), Point::new(160.0, 20.0), Point::new(160.0, 80.0), Point::new(40.0, 80.0)];
let mut s = RED.to_string();
for (k, a) in corners.iter().enumerate() {
let b = corners[(k + 1) % 4];
for t in 0..64 {
let q = map(a.lerp(b, f64::from(t) / 64.0));
s += &format!("{} {} {}\n", q.x, 100.0 - q.y, if k == 0 && t == 0 { "m" } else { "L" });
}
}
s + "f\n"
}
/// The live envelope `n` is: its content, kind, fidelity, editing and options.
fn live_envelope(n: &Node) -> (&[Arc<Node>], &vectorcraft_doc::live::EnvelopeKind, f64, bool, vectorcraft_doc::live::EnvelopeOptions) {
match &n.kind {
NodeKind::Envelope { content, kind, fidelity, editing, options, frame } => {
assert_eq!(**frame, kurbo::Affine::IDENTITY);
(content, kind, *fidelity, *editing, *options)
}
other => panic!("not a live envelope: {other:#?}"),
}
}
/// A warp's dictionary keys: style number `style`, bend, horizontal and vertical distortion as
/// fractions, and the orientation (vertical with `rotate` 1).
fn warp_keys(style: u32, bend: f64, h: f64, v: f64, rotate: u32) -> String {
format!(
"{ENVELOPE_KEYS}{style} /Int (DeformStyle) ,\n{h} /Real (DeformHoriz) ,\n{bend} /Real (DeformValue) ,\n{v} /Real (DeformVert) ,\n{rotate} /Bool (Rotate) ,\n(Warp) /UnicodeString (DisplayString) ,\n"
)
}
/// An envelope made with a warp opens as that live envelope of its contents, its style, bend,
/// distortions (fractions in the file) and orientation read; the warp's drawn art goes.
#[test]
fn a_warp_envelope_opens_live() {
use vectorcraft_doc::live::EnvelopeKind;
for (style, bend, h, v, rotate, id) in
[(1, 0.5, 0.0, 0.0, 0, "arc"), (1, 0.5, -0.2, 0.3, 1, "arc"), (8, 0.5, 0.0, 0.0, 0, "flag"), (15, 0.5, 0.0, 0.0, 0, "twist")]
{
let want = EnvelopeKind::Warp { style: id.into(), bend: bend * 100.0, h: h * 100.0, v: v * 100.0, horizontal: rotate == 0 };
let body =
envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(style, bend, h, v, rotate), &envelope_content(), 1, &drawn_content(&want));
let r = read_ok(&layer(&body));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
let (content, kind, fidelity, editing, options) = live_envelope(&a[0]);
let EnvelopeKind::Warp { style: s, bend: b, h: dh, v: dv, horizontal } = kind else { panic!("{kind:?}") };
assert_eq!((s.as_str(), *horizontal), (id, rotate == 0));
assert!([(*b, bend), (*dh, h), (*dv, v)].iter().all(|(got, f)| (got - f * 100.0).abs() < 1e-9), "{kind:?}");
assert_eq!((fidelity, editing, options), (50.0, false, vectorcraft_doc::live::EnvelopeOptions::NEW));
assert_eq!(content.len(), 1, "{content:#?}");
assert!(matches!(content[0].kind, NodeKind::Path { .. }), "{content:#?}");
assert_eq!(fill(&content[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
}
/// Every warp style number reads as its style, in Effect › Warp's order.
#[test]
fn warp_envelopes_read_every_style() {
use vectorcraft_doc::live::EnvelopeKind;
let ids = [
"arc",
"arcLower",
"arcUpper",
"arch",
"bulge",
"shellLower",
"shellUpper",
"flag",
"wave",
"fish",
"rise",
"fisheye",
"inflate",
"squeeze",
"twist",
];
for (n, id) in ids.iter().enumerate() {
let want = EnvelopeKind::Warp { style: (*id).into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
let keys = warp_keys(n as u32 + 1, 0.3, 0.0, 0.0, 0);
let r = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &keys, &envelope_content(), 1, &drawn_content(&want))));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{id}: {a:#?}");
assert_eq!(live_envelope(&a[0]).1, &want, "{id}");
}
}
/// An envelope made with a mesh opens as a live mesh envelope: its rows and columns, its points
/// row by row from the top left, and each point's four handles, a moved point and an uneven handle
/// included.
#[test]
fn a_mesh_envelope_opens_live_with_its_points_and_handles() {
use vectorcraft_doc::live::{EnvelopeKind, H_DOWN, H_LEFT, H_RIGHT, H_UP};
// 3 columns 40 apart and 2 rows 30 apart from (40, 20); the inner point (1, 1) moved, and its
// handle towards (2, 1) longer and off the line.
let at = |(i, j): (usize, usize)| if (i, j) == (1, 1) { (90.0, 55.0) } else { (40.0 + i as f64 * 40.0, 20.0 + j as f64 * 30.0) };
let handle = |p: (usize, usize), q: (usize, usize)| {
if (p, q) == ((1, 1), (2, 1)) {
return (108.0, 62.0);
}
let ((x, y), (u, v)) = (at(p), at(q));
(x + (u - x) / 3.0, y + (v - y) / 3.0)
};
let shape = mesh_text(3, 2, &at, &handle, None);
// Row r of the envelope is row 2 - r of the file (art space has y up).
let doc_pt = |(x, y): (f64, f64)| Point::new(x, 100.0 - y);
let mut points = vec![];
let mut handles = vec![];
for r in 0..=2usize {
for c in 0..=3usize {
let g = (c, 2 - r);
let p = doc_pt(at(g));
points.push(p);
let mut h = [kurbo::Vec2::ZERO; 4];
let towards = |q: Option<(usize, usize)>| q.filter(|q| q.0 <= 3 && q.1 <= 2).map_or(kurbo::Vec2::ZERO, |q| doc_pt(handle(g, q)) - p);
h[H_RIGHT] = towards(Some((g.0 + 1, g.1)));
h[H_LEFT] = towards(g.0.checked_sub(1).map(|i| (i, g.1)));
h[H_UP] = towards(Some((g.0, g.1 + 1)));
h[H_DOWN] = towards(g.1.checked_sub(1).map(|j| (g.0, j)));
handles.push(h);
}
}
let want = EnvelopeKind::Mesh { rows: 2, cols: 3, points: points.clone(), handles: handles.clone() };
let r = read_ok(&layer(&envelope_of(&shape, ENVELOPE_KEYS, &envelope_content(), 2, &drawn_content(&want))));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
let EnvelopeKind::Mesh { rows, cols, points: got, handles: got_h } = live_envelope(&a[0]).1 else { panic!("{a:#?}") };
assert_eq!((*rows, *cols, got.len(), got_h.len()), (2, 3, 12, 12));
for (k, (p, q)) in got.iter().zip(&points).enumerate() {
assert!((*p - *q).hypot() < 1e-6, "point {k}: {p:?}, not {q:?}");
}
// The moved inner point (row 1, column 1): each of its handles, the uneven one towards the right.
let inner = 4 + 1;
assert!((got[inner] - Point::new(90.0, 45.0)).hypot() < 1e-6, "{:?}", got[inner]);
for slot in [H_RIGHT, H_LEFT, H_DOWN, H_UP] {
assert!((got_h[inner][slot] - handles[inner][slot]).hypot() < 1e-6, "slot {slot}: {:?} vs {:?}", got_h[inner], handles[inner]);
}
assert!((got_h[inner][H_RIGHT] - kurbo::Vec2::new(18.0, -7.0)).hypot() < 1e-6, "{:?}", got_h[inner]);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
/// An envelope made with a top object opens as a live envelope whose top path is the edge of its
/// one-patch mesh, an anchor on the edge included; its contents stay the rectangle.
#[test]
fn a_top_object_envelope_opens_live_with_its_outline() {
use vectorcraft_doc::live::EnvelopeKind;
// Corners (40, 20) (40, 80) (160, 80) (160, 20); the top edge bows up through an anchor at (100, 95).
let at = |(i, j): (usize, usize)| (40.0 + i as f64 * 120.0, 20.0 + j as f64 * 60.0);
let handle = |p: (usize, usize), q: (usize, usize)| match (p, q) {
((0, 1), (1, 1)) => (60.0, 88.0),
((1, 1), (0, 1)) => (140.0, 88.0),
_ => {
let ((x, y), (u, v)) = (at(p), at(q));
(x + (u - x) / 3.0, y + (v - y) / 3.0)
}
};
let shape = mesh_text(1, 1, &at, &handle, Some([100.0, 95.0, 70.0, 95.0, 130.0, 95.0]));
let mut bp = kurbo::BezPath::new();
bp.move_to((40.0, 20.0));
bp.curve_to((60.0, 12.0), (70.0, 5.0), (100.0, 5.0));
bp.curve_to((130.0, 5.0), (140.0, 12.0), (160.0, 20.0));
bp.curve_to((160.0, 40.0), (160.0, 60.0), (160.0, 80.0));
bp.curve_to((120.0, 80.0), (80.0, 80.0), (40.0, 80.0));
bp.curve_to((40.0, 60.0), (40.0, 40.0), (40.0, 20.0));
bp.close_path();
let want = EnvelopeKind::TopObject { path: vectorcraft_geom::PathData::from_bezpath(&bp) };
let r = read_ok(&layer(&envelope_of(&shape, ENVELOPE_KEYS, &envelope_content(), 3, &drawn_content(&want))));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
let (content, kind, ..) = live_envelope(&a[0]);
let EnvelopeKind::TopObject { path } = kind else { panic!("{kind:?}") };
let anchors: Vec<Point> = path.subpaths.iter().flat_map(|s| s.anchors.iter().map(|a| a.p)).collect();
for p in [(40.0, 20.0), (100.0, 5.0), (160.0, 20.0), (160.0, 80.0), (40.0, 80.0)] {
assert!(anchors.iter().any(|q| (*q - Point::from(p)).hypot() < 1e-6), "{p:?} not in {anchors:?}");
}
// The curve through the anchor: its handles there run level.
let mid = path.subpaths.iter().flat_map(|s| s.anchors.iter()).find(|a| (a.p - Point::new(100.0, 5.0)).hypot() < 1e-6).unwrap();
assert!((mid.h_in.y - 5.0).abs() < 1e-6 && (mid.h_out.y - 5.0).abs() < 1e-6, "{mid:?}");
assert_eq!(content.len(), 1, "{content:#?}");
assert_eq!(fill(&content[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
/// Envelope Options come from the options word, Fidelity from its own key.
#[test]
fn an_envelope_reads_its_options_and_fidelity() {
use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions, PreserveShape};
let shape = straight_mesh(1, 1, (120.0, 60.0));
// A flat 1 × 1 mesh over the box draws the box as it is.
let drawn = drawn_content(&EnvelopeKind::Mesh {
rows: 1,
cols: 1,
points: vec![Point::new(40.0, 20.0), Point::new(160.0, 20.0), Point::new(40.0, 80.0), Point::new(160.0, 80.0)],
handles: vec![],
});
for (word, fidelity, options) in [
(
65536,
80,
EnvelopeOptions {
anti_alias: false,
preserve_shape: PreserveShape::Transparency,
distort_appearance: false,
distort_linear_gradients: false,
distort_pattern_fills: false,
},
),
(
0x20007,
100,
EnvelopeOptions {
anti_alias: true,
preserve_shape: PreserveShape::ClippingMask,
distort_appearance: true,
distort_linear_gradients: true,
distort_pattern_fills: true,
},
),
(
0x20002,
0,
EnvelopeOptions {
anti_alias: true,
preserve_shape: PreserveShape::ClippingMask,
distort_appearance: false,
distort_linear_gradients: false,
distort_pattern_fills: true,
},
),
] {
let keys = format!("{word} /Int (AIDeformOptionsKey) ,\n{fidelity} /Int (AIDeformFidelityKey) ,\n0 /Bool (AIDeformEditModeKey) ,\n");
let r = read_ok(&layer(&envelope_of(&shape, &keys, &envelope_content(), 2, &drawn)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{word}: {a:#?}");
let (_, _, got_fidelity, _, got) = live_envelope(&a[0]);
assert_eq!((got_fidelity, got), (f64::from(fidelity), options), "{word}");
}
}
/// An envelope's contents keep their groups: a group of two rectangles stays that group.
#[test]
fn an_envelope_s_contents_keep_their_groups() {
use vectorcraft_doc::live::EnvelopeKind;
let group = format!(
"u\n{RED}{}f\n{}f\nU\n/ArtDictionary :\n(Pair) /String (AIArtName) ,\n;\n",
rect_path(40.0, 20.0, 90.0, 80.0),
rect_path(110.0, 20.0, 160.0, 80.0)
);
let mut drawn = RED.to_string();
drawn += &format!("{}f\n{}f\n", rect_path(40.0, 20.0, 90.0, 80.0), rect_path(110.0, 20.0, 160.0, 80.0));
let r = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), ENVELOPE_KEYS, &group, 2, &drawn)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
let (content, kind, ..) = live_envelope(&a[0]);
assert!(matches!(kind, EnvelopeKind::Mesh { rows: 1, cols: 1, .. }), "{kind:?}");
assert_eq!(content.len(), 1, "{content:#?}");
assert_eq!(content[0].name.as_deref(), Some("Pair"));
let kids = content[0].children().unwrap();
assert_eq!(kids.len(), 2, "{kids:#?}");
assert!(kids.iter().all(|k| matches!(k.kind, NodeKind::Path { .. })), "{kids:#?}");
}
/// While an envelope's contents are edited, the contents come first and the mesh is in the
/// dictionary: the envelope opens live all the same, in Edit Contents.
#[test]
fn an_envelope_whose_contents_are_edited_opens_live_in_edit_contents() {
use vectorcraft_doc::live::EnvelopeKind;
let at = |(i, j): (usize, usize)| (40.0 + i as f64 * 120.0, 20.0 + j as f64 * 60.0);
let handle = |p: (usize, usize), q: (usize, usize)| match (p, q) {
((0, 1), (1, 1)) => (70.0, 95.0),
((1, 1), (0, 1)) => (130.0, 95.0),
_ => {
let ((x, y), (u, v)) = (at(p), at(q));
(x + (u - x) / 3.0, y + (v - y) / 3.0)
}
};
let mesh = mesh_text(1, 1, &at, &handle, None);
let mut bp = kurbo::BezPath::new();
bp.move_to((40.0, 20.0));
bp.curve_to((70.0, 5.0), (130.0, 5.0), (160.0, 20.0));
bp.curve_to((160.0, 40.0), (160.0, 60.0), (160.0, 80.0));
bp.curve_to((120.0, 80.0), (80.0, 80.0), (40.0, 80.0));
bp.curve_to((40.0, 60.0), (40.0, 40.0), (40.0, 20.0));
bp.close_path();
let want = EnvelopeKind::TopObject { path: vectorcraft_geom::PathData::from_bezpath(&bp) };
let keys = ENVELOPE_KEYS.replace("0 /Bool (AIDeformEditModeKey)", "1 /Bool (AIDeformEditModeKey)");
let body = envelope_parts(&envelope_content(), &keys, &format!("0 g\n{mesh}"), 3, &drawn_content(&want));
let r = read_ok(&layer(&body));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
let (content, kind, _, editing, _) = live_envelope(&a[0]);
assert!(editing);
assert!(matches!(kind, EnvelopeKind::TopObject { .. }), "{kind:?}");
assert_eq!(content.len(), 1, "{content:#?}");
assert!(matches!(content[0].kind, NodeKind::Path { .. }), "{content:#?}");
assert_eq!(fill(&content[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
}
/// An envelope that doesn't read (a warp style number past the list, a mesh envelope whose shape
/// isn't a mesh, one whose plugin never closes it) keeps its drawn art, with one note; the art
/// round it is read as ever.
#[test]
fn an_envelope_that_doesnt_read_keeps_its_drawn_art() {
let blue = "0 0 1 1 0 0 1 Xa\n";
let drawn = format!("{blue}{}f\n", rect_path(40.0, 20.0, 160.0, 80.0));
let mesh = straight_mesh(1, 1, (120.0, 60.0));
let cases = [
envelope_of(&mesh, &warp_keys(99, 0.5, 0.0, 0.0, 0), &envelope_content(), 1, &drawn),
envelope_of(&format!("{}f\n", rect_path(40.0, 20.0, 160.0, 80.0)), ENVELOPE_KEYS, &envelope_content(), 2, &drawn),
envelope_of(&mesh, ENVELOPE_KEYS, &envelope_content(), 7, &drawn),
envelope_of(&mesh, &ENVELOPE_KEYS.replace("131073", "131081"), &envelope_content(), 2, &drawn),
envelope_of(&mesh, ENVELOPE_KEYS, &envelope_content(), 2, &drawn).replace("(Adobe Deform Plugin) 1 0 0 XP\n", ""),
];
for case in cases {
let body = format!("{RED}{}f\n{case}{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0), rect_path(190.0, 0.0, 200.0, 10.0));
let r = read_ok(&layer(&body));
let a = art(&r.doc);
assert_eq!(a.len(), 3, "{case}\n{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Path { .. }) && matches!(a[2].kind, NodeKind::Path { .. }), "{a:#?}");
let NodeKind::Group { children, .. } = &a[1].kind else { panic!("{a:#?}") };
assert_eq!(fill(&children[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0)), "{case}");
assert_eq!(r.warnings, [super::envelope::DRAWN], "{case}");
}
}
/// An envelope whose contents don't come out as its drawn art (its warp bends them another way)
/// keeps the drawn art.
#[test]
fn an_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() {
use vectorcraft_doc::live::EnvelopeKind;
let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true };
let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(1, 0.5, 0.0, 0.0, 0), &envelope_content(), 1, &drawn_content(&other));
let r = read_ok(&layer(&body));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Group { .. }), "{a:#?}");
assert_eq!(r.warnings, [super::envelope::DRAWN]);
}
/// An envelope that comes out close to its drawn art, but with more of the page differing than the
/// page check can take from the live envelopes (its drawn art 2 pt to the side on a 200 × 100
/// page), keeps the drawn art, so the file still opens from its layers.
#[test]
fn an_envelope_that_differs_from_its_drawn_art_over_the_page_keeps_it() {
let shifted = format!("{RED}{}f\n", rect_path(42.0, 20.0, 162.0, 80.0));
let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), ENVELOPE_KEYS, &envelope_content(), 2, &shifted);
let r = read_ok(&layer(&body));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Group { .. }), "{a:#?}");
assert_eq!(r.warnings, [super::envelope::DRAWN]);
}
/// An envelope whose drawn art holds an object the reader can't read keeps that drawn art, which
/// the page's art replaces.
#[test]
fn an_envelope_whose_drawn_art_holds_an_unread_object_keeps_it() {
let flat = vectorcraft_doc::live::EnvelopeKind::Mesh {
rows: 1,
cols: 1,
points: vec![Point::new(40.0, 20.0), Point::new(160.0, 20.0), Point::new(40.0, 80.0), Point::new(160.0, 80.0)],
handles: vec![],
};
let drawn = format!("{}frobnicate\n", drawn_content(&flat));
let r = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), ENVELOPE_KEYS, &envelope_content(), 2, &drawn)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Group { .. }), "{a:#?}");
assert_eq!(r.unread.iter().map(|(id, _)| *id).collect::<Vec<_>>(), [a[0].id]);
assert_eq!(r.warnings, [super::envelope::DRAWN]);
}
/// A live envelope takes its drawn art's own properties: its name here.
#[test]
fn a_live_envelope_keeps_its_drawn_art_s_name() {
let flat = vectorcraft_doc::live::EnvelopeKind::Mesh {
rows: 1,
cols: 1,
points: vec![Point::new(40.0, 20.0), Point::new(160.0, 20.0), Point::new(40.0, 80.0), Point::new(160.0, 80.0)],
handles: vec![],
};
let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), ENVELOPE_KEYS, &envelope_content(), 2, &drawn_content(&flat))
.replace("U\n9 () XW\n(Adobe Deform Plugin)", "U\n/ArtDictionary :\n(Bent) /String (AIArtName) ,\n;\n9 () XW\n(Adobe Deform Plugin)");
let r = read_ok(&layer(&body));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
live_envelope(&a[0]);
assert_eq!(a[0].name.as_deref(), Some("Bent"));
}
/// The same envelope beside the page opens live: what differs off the page isn't counted.
#[test]
fn an_envelope_off_the_page_opens_live_when_it_draws_close_to_its_drawn_art() {
let at = |(i, j): (usize, usize)| (300.0 + i as f64 * 120.0, 20.0 + j as f64 * 60.0);
let third = |p: (usize, usize), q: (usize, usize)| {
let ((x, y), (u, v)) = (at(p), at(q));
(x + (u - x) / 3.0, y + (v - y) / 3.0)
};
let content = format!("{RED}{}f\n", rect_path(300.0, 20.0, 420.0, 80.0));
let shifted = format!("{RED}{}f\n", rect_path(302.0, 20.0, 422.0, 80.0));
let r = read_ok(&layer(&envelope_of(&mesh_text(1, 1, &at, &third, None), ENVELOPE_KEYS, &content, 2, &shifted)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
live_envelope(&a[0]);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
#[test]
fn a_gradient_mesh_reads_as_a_patch() {
let corner = |rgb: &str, x: i32, y: i32, next: (i32, i32), prev: (i32, i32)| {
format!("%_[{rgb} 1 {x} {y} {} {} 1 {} {} 1] /N X#\n", next.0, next.1, prev.0, prev.1)
};
let mesh = |space: &str| {
format!(
"/Mesh X!\n%_({space}) /CS X#\n%_[0 0] /P X#\n{}{}{}{}%_0 /E X#\n/End X!\n",
corner("1 0 0", 0, 0, (0, 10), (10, 0)),
corner("0 1 0", 0, 40, (10, 40), (0, 30)),
corner("0 0 1", 40, 40, (40, 30), (30, 40)),
corner("1 1 1", 40, 0, (30, 0), (40, 10)),
)
};
let doc = read_ok(&layer(&mesh("DeviceRGB"))).doc;
let NodeKind::Mesh(m) = &art(&doc)[0].kind else { panic!("{:?}", art(&doc)[0].kind) };
let colors: Vec<_> = m.points.iter().map(|p| p.color).collect();
assert_eq!(colors, [Color::rgb(1.0, 0.0, 0.0), Color::rgb(1.0, 1.0, 1.0), Color::rgb(0.0, 1.0, 0.0), Color::rgb(0.0, 0.0, 1.0)]);
use vectorcraft_doc::live::{H_DOWN, H_RIGHT};
let first = &m.points[0];
assert_eq!(
(first.p, first.handles[H_DOWN], first.handles[H_RIGHT]),
(Point::new(0.0, 100.0), kurbo::Vec2::new(0.0, -10.0), kurbo::Vec2::new(10.0, 0.0))
);
let e = read(stream(&layer(&mesh("Separation"))).as_bytes()).unwrap_err();
assert!(e.contains("gradient meshes"), "{e}");
}
/// Both mesh-corner layouts must keep colour, position and handles, rather than guessing
/// opacity from a coordinate. Unsupported weights and truncated corners remain errors.
#[test]
fn mesh_corners_with_and_without_opacity() {
let geometry = ["10 20 10 25 1 15 20 1", "10 50 15 50 1 10 45 1", "40 50 40 45 1 35 50 1", "40 20 35 20 1 40 25 1"];
for (space, colour) in [("DeviceRGB", "0.2 0.4 0.6"), ("DeviceCMYK", "0.1 0.2 0.3 0.4"), ("DeviceGray", "0.5")] {
let mesh = |opacity: &str| {
let corners: String = geometry.iter().map(|g| format!("%_[{colour} {opacity}{g}] /N X#\n")).collect();
format!("/Mesh X!\n%_/{space} /CS X#\n{corners}%_/E X#\n/End X!\n")
};
let old = art(&read_ok(&layer(&mesh(""))).doc);
let new = art(&read_ok(&layer(&mesh("1 "))).doc);
assert_eq!(old[0].kind, new[0].kind);
let translucent = art(&read_ok(&layer(&mesh("0.25 "))).doc);
let NodeKind::Mesh(m) = &translucent[0].kind else { panic!("mesh") };
assert!(m.points.iter().all(|p| p.opacity == 0.25));
assert_eq!(m.points[0].p, Point::new(10.0, 80.0));
for broken in [mesh("").replace("10 25 1", "10 25 0.5"), mesh("").replace("15 20 1]", "15 20]")] {
assert!(read(stream(&layer(&broken)).as_bytes()).unwrap_err().contains("gradient meshes"));
}
}
}
#[test]
fn vector_opacity_mask_keeps_its_art_and_does_not_paint_as_a_sibling() {
let owner = format!("{RED}{}f\n", rect_path(10.0, 20.0, 50.0, 60.0));
let mask = "%_/ArtDictionary :\n%_/Mask :\n%_1 /Bool (Linked) ,\n%_X=\n%_u\n%_1 g\n%_10 20 m\n%_10 60 L\n%_30 60 L\n%_30 20 L\n%_f\n%_U\n%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_0 /Bool (Inverted) ,\n%_0 /Bool (Disabled) ,\n%_; (AI9_Mask) ,\n%_;\n";
let following = format!("{}f\n", rect_path(70.0, 20.0, 80.0, 30.0));
for (invert, disabled, linked) in [(false, false, true), (true, false, false), (false, true, true)] {
let mask = mask
.replace("0 /Bool (Inverted)", &format!("{} /Bool (Inverted)", u8::from(invert)))
.replace("0 /Bool (Disabled)", &format!("{} /Bool (Disabled)", u8::from(disabled)))
.replace("1 /Bool (Linked)", &format!("{} /Bool (Linked)", u8::from(linked)));
let r = read_ok(&layer(&format!("{owner}{mask}{following}")));
assert!(r.warnings.is_empty());
let a = art(&r.doc);
assert_eq!(a.len(), 2);
assert_eq!(fill(&a[1]).color(), Some(Color::rgb(1.0, 0.0, 0.0)), "mask colour must not leak");
let m = a[0].extras().mask.as_ref().unwrap();
assert_eq!((m.clip, m.invert, m.disabled, m.linked), (true, invert, disabled, linked));
let mask_path = &m.art.children().unwrap()[0].children().unwrap()[0];
assert!(matches!(mask_path.kind, NodeKind::Path { .. }));
assert_eq!(mask_path.geometric_bounds(), Some(Rect::new(10.0, 40.0, 30.0, 80.0)));
assert_ne!(mask_path.id, a[0].id);
}
for broken in [mask.replace("%_X+\n", ""), mask.replace("%_U\n", ""), mask.replace("%_f", "%_unknownMaskOperator")] {
assert!(read(stream(&layer(&format!("{owner}{broken}"))).as_bytes()).is_err());
}
}
/// `9 () XW` also follows legacy compound member groups. The final gradient paints
/// every member; the initial black paint is a placeholder, not a separate black object.
#[test]
fn winding_declared_compound_members_keep_the_final_gradient() {
let defs = "%AI5_BeginGradient: (G)\n(G) 0 2 Bd\n0 0 0 0 1 0 0 2 50 0 Bs\n0 0 0 0 0 0 1 2 50 100 Bs\nBD\n%AI5_EndGradient\n";
let body = format!(
"*u\nu\n1 D\n0 g\n{}f\n0 D\n{}Bb\n1 (G) 0 0 0 100 1 0 0 1 0 0 Bg\nf\n0 BB\nU\n9 () XW\n*U\n",
rect_path(10.0, 10.0, 80.0, 80.0),
rect_path(30.0, 30.0, 50.0, 50.0)
);
let r = read_ok(&format!("{defs}{}", layer(&body)));
let a = art(&r.doc);
assert_eq!(a.len(), 1);
let NodeKind::Compound { children, .. } = &a[0].kind else { panic!("compound, not separately painted look") };
assert_eq!(children.len(), 2);
assert!(matches!(fill(&a[0]), Paint::Gradient(g) if g.gradient.stops.len() == 2));
assert!(children.iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
// Without winding declarations this is still an independently painted appearance group.
let r = read_ok(&format!("{defs}{}", layer(&body.replace("1 D\n", "").replace("0 D\n", ""))));
assert!(matches!(art(&r.doc)[0].kind, NodeKind::Group { .. }));
}
/// #779: path guides from Illustrator's `(op) *` operator (AI7 §6.1) import as guide paths.
#[test]
fn path_guides_from_the_star_operator() {
let line = |x0: f64, y0: f64, x1: f64, y1: f64| format!("{x0} {y0} m\n{x1} {y1} L\n(N) *\n");
let body = format!("{RED}{}f\n{}{}", rect_path(0.0, 0.0, 50.0, 50.0), line(10.0, 20.0, 90.0, 20.0), "(N) *\n15 10 m\n15 80 L\n(N) *\n",);
let kids = art(&read_ok(&layer(&body)).doc);
assert_eq!(kids.len(), 3);
let guides = kids.iter().filter(|n| matches!(n.kind, NodeKind::Path { guide: true, .. })).count();
assert_eq!(guides, 2, "the two construction lines are guide paths");
assert!(matches!(kids[0].kind, NodeKind::Path { guide: false, .. }));
for g in kids.iter().filter(|n| matches!(n.kind, NodeKind::Path { guide: true, .. })) {
assert!(g.appearance.items.is_empty(), "an (N) guide is non-printing geometry with no paint");
}
}
/// Legacy version 7 writes `(N) *` before and after the path (§6).
#[test]
fn path_guides_wrapped_before_and_after_the_path() {
let body = "(N) *\n20 30 m\n80 30 L\n(N) *\n";
let kids = art(&read_ok(&layer(body)).doc);
assert_eq!(kids.len(), 1);
assert!(matches!(kids[0].kind, NodeKind::Path { guide: true, .. }));
}
/// A stroked guide keeps the stroke for display but stays a guide path.
#[test]
fn stroked_path_guides_from_the_star_operator() {
let body = "1 w\n0 0 0 1 k\n10 10 m\n90 10 L\n(S) *\n";
let kids = art(&read_ok(&layer(body)).doc);
assert_eq!(kids.len(), 1);
let n = &kids[0];
assert!(matches!(n.kind, NodeKind::Path { guide: true, .. }));
assert_eq!(n.appearance.items.len(), 1);
assert!(matches!(n.appearance.items[0], AppearanceItem::Stroke(_)));
}
/// [`stream`] with a template box centred on (306.5, 395.5): the canvas runs 8,191.5 points each
/// way from there.
fn canvas_stream(body: &str) -> String {
stream(body).replacen("%%EndComments", "%AI3_TemplateBox: 306.5 395.5 306.5 395.5\n%%EndComments", 1)
}
/// The straight guide path from (`x0`, `y0`) to (`x1`, `y1`), `(op) *`.
fn guide_line(x0: f64, y0: f64, x1: f64, y1: f64, op: &str) -> String {
format!("{x0} {y0} m\n{x1} {y1} L\n({op}) *\n")
}
/// #779: a straight `(N) *` guide path across the whole canvas, directly on a layer or sublayer, is
/// a ruler guide on that layer, at its place in the document; it leaves no path behind.
#[test]
fn canvas_wide_guide_paths_are_ruler_guides_of_their_layer() {
let body = format!(
"{}{}{RED}{}f\n%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Sub) Ln\n{}LB\n%AI5_EndLayer--\n",
guide_line(-7885.0, 40.0, 8498.0, 40.0, "N"),
guide_line(25.0, 8587.0, 25.0, -7796.0, "N"),
rect_path(0.0, 0.0, 10.0, 10.0),
guide_line(-7885.0, 90.0, 8498.0, 90.0, "N"),
);
let doc = read(canvas_stream(&layer(&body)).as_bytes()).unwrap().doc;
let top = &doc.layers[0];
let kids = top.children().unwrap();
assert_eq!(kids.len(), 2, "the square and the sublayer");
assert!(matches!(kids[0].kind, NodeKind::Path { guide: false, .. }));
let sub = &kids[1];
assert!(sub.children().unwrap().is_empty());
let want = [
Guide { vertical: false, pos: 60.0, artboard: None, layer: Some(top.id) },
Guide { vertical: true, pos: 25.0, artboard: None, layer: Some(top.id) },
Guide { vertical: false, pos: 10.0, artboard: None, layer: Some(sub.id) },
];
assert_eq!(doc.guides, want);
}
/// #779: a straight `(N) *` guide path from edge to edge of one artboard is a ruler guide of that
/// artboard.
#[test]
fn artboard_wide_guide_paths_are_ruler_guides_of_their_artboard() {
// The crop marks' artboard, 0 0 200 100.
let doc = read(canvas_stream(&layer(&guide_line(0.0, 30.0, 200.0, 30.0, "N"))).as_bytes()).unwrap().doc;
assert_eq!(doc.guides, [Guide { vertical: false, pos: 70.0, artboard: Some(1), layer: Some(doc.layers[0].id) }]);
assert!(art(&doc).is_empty());
// The second of two artboards: art space x 400 to 600, y -150 to 0.
let boards = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\
%_/Dictionary :\n%_0 200 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_300 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\
%_/Dictionary :\n%_400 0 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_600 -150 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\
%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n";
let doc = read_ok(&format!("{boards}{}", layer(&guide_line(500.0, 0.0, 500.0, -150.0, "N")))).doc;
assert_eq!(doc.artboards[1].id, 2);
assert_eq!(doc.guides, [Guide { vertical: true, pos: 500.0, artboard: Some(2), layer: Some(doc.layers[0].id) }]);
}
/// Guide paths that aren't ruler guides stay guide paths: `(S) *` ones (guides made from objects),
/// even across the whole canvas or an artboard, short ones, slanted ones, and ones in a group.
#[test]
fn other_guide_paths_stay_guide_paths() {
let cases = [
guide_line(-7885.0, 40.0, 8498.0, 40.0, "S"),
guide_line(20.0, 40.0, 80.0, 40.0, "S"),
guide_line(20.0, 40.0, 80.0, 40.0, "N"),
guide_line(0.0, 30.0, 200.0, 30.0, "S"),
guide_line(-7885.0, -7796.0, 8498.0, 8587.0, "N"),
format!("u\n{}U\n", guide_line(-7885.0, 40.0, 8498.0, 40.0, "N")),
format!("1 Xw\n{}0 Xw\n", guide_line(-7885.0, 40.0, 8498.0, 40.0, "N")),
"-7885 40 m\n306 40 L\n8498 40 L\n(N) *\n".to_string(),
];
for body in cases {
let doc = read(canvas_stream(&layer(&body)).as_bytes()).unwrap().doc;
assert!(doc.guides.is_empty(), "{body}");
assert_eq!(art(&doc).len(), 1, "{body}");
}
}
/// The layer whose `Lb` has `ops` as its options (the first one in the file).
fn layer_with(ops: &str) -> Node {
let doc = read_ok(&format!("%AI5_BeginLayer\n{ops} Lb\n(L) Ln\nLB\n%AI5_EndLayer--\n")).doc;
Node::clone(&doc.layers[0])
}
/// #779: the template flag four places after the colour index makes a template layer; locking,
/// printing and dimming keep their own flags. In thirteen and fourteen operands the flag before
/// the colour index and the last one change nothing.
#[test]
fn the_template_flag_of_a_layer() {
let l = layer_with("1 1 0 0 1 0 0 4 255 79 255 1 50 0");
let NodeKind::Layer { template, printable, dim_images, color, .. } = l.kind else { panic!() };
assert!(template && !printable && l.locked);
assert_eq!((dim_images, color), (Some(50), LayerColor::Preset(4)));
for ops in ["1 1 1 1 1 0 1 5 1 2 3 1 30 0", "1 1 1 1 1 0 0 5 1 2 3 1 30 1", "1 1 1 1 1 0 1 5 1 2 3 1 30"] {
let l = layer_with(ops);
let NodeKind::Layer { template, printable, dim_images, color, .. } = l.kind else { panic!() };
assert!(template && printable && !l.locked, "{ops}");
assert_eq!((dim_images, color), (Some(30), LayerColor::Preset(5)), "{ops}");
}
let l = layer_with("1 1 1 1 0 0 1 0 79 128 255 0 50 0");
assert!(matches!(l.kind, NodeKind::Layer { template: false, .. }));
}
/// The colour index is one place earlier in eleven and twelve operands, as in ten, and the twelfth
/// is the dimming percentage; with nine it is one place earlier still.
#[test]
fn layer_options_of_nine_to_twelve_operands() {
let options = |l: Node| match l.kind {
NodeKind::Layer { template, dim_images, color, .. } => (template, dim_images, color),
_ => panic!(),
};
assert_eq!(options(layer_with("1 1 1 1 1 0 3 1 2 3 1")), (true, Some(50), LayerColor::Preset(3)));
assert_eq!(options(layer_with("1 1 1 1 1 0 3 1 2 3 0 25")), (false, Some(25), LayerColor::Preset(3)));
assert_eq!(options(layer_with("1 1 1 1 1 0 -1 10 20 30 1 25")), (true, Some(25), LayerColor::Custom([10, 20, 30])));
assert_eq!(options(layer_with("1 1 1 1 0 0 3 1 2 3")), (false, None, LayerColor::Preset(3)));
assert_eq!(options(layer_with("1 1 1 1 0 2 1 2 3")), (false, None, LayerColor::Preset(2)));
}
/// A blend's record as the editing data writes it after `(Adobe Path Blends) 1 0 n XP`: `DCBA`,
/// version 1, then (as far as the reader reads it) the number of keys at word 10 and a record of
/// six words per key starting with its id. Hand-written; the other words are zero.
fn blend_record(ids: &[char]) -> String {
let mut words: Vec<u32> = vec![u32::from_le_bytes(*b"DCBA"), 1, 0, 0, 0, 0, 0, 0, 0, 0, ids.len() as u32];
for id in ids {
words.extend([*id as u32, 0, 0, 0, 0, 0]);
}
let hex: String = words.iter().flat_map(|w| w.to_le_bytes()).map(|b| format!("{b:02X}")).collect();
let lines: Vec<String> = hex.as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
format!("(Adobe Path Blends) 1 0 {} XP\n{}\n", hex.len() / 2, lines.join("\n"))
}
/// A square `s` wide with its lower left at (x, y), filled with RGB `rgb`, as `%_` lines when
/// `hidden`.
fn square(x: f64, y: f64, s: f64, rgb: [f64; 3], hidden: bool) -> String {
let p = if hidden { "%_" } else { "" };
format!("{p}0 0 0 0 {} {} {} Xa\n{p}{x} {y} m\n{p}{x} {} L\n{p}{} {} L\n{p}{} {y} L\n{p}f\n", rgb[0], rgb[1], rgb[2], y + s, x + s, y + s, x + s)
}
/// The dictionary naming a blend part: type `kind` (`K`, `S`) and, for a key, `id`.
fn blend_part(kind: &str, id: Option<&str>) -> String {
let id = id.map(|i| format!("%_({i}) /String (ShapeBlendPathID) ,\n")).unwrap_or_default();
format!("%_/ArtDictionary :\n{id}%_({kind}) /String (ShapeBlendPathType) ,\n%_;\n")
}
/// A blend of two 20 pt squares, red on the left (key `A`) and blue on the right (`B`), along
/// `spine` (path operators, `%_` lines), with its drawn steps (three between the keys) and the
/// record `record`.
fn blend_file(spine: &str, ids: [Option<&str>; 2], record: &str) -> String {
let (red, blue) = ([1.0, 0.0, 0.0], [0.0, 0.0, 1.0]);
let mut art = String::from("%_0 Ae\n%_u\n");
art += &square(0.0, 40.0, 20.0, red, true);
art += &blend_part("K", ids[0]);
art += &square(100.0, 40.0, 20.0, blue, true);
art += &blend_part("K", ids[1]);
art += spine;
art += "%_N\n";
art += &blend_part("S", None);
art += "%_U\n%_9 () XW\nu\n";
for i in 0..5 {
let t = i as f64 / 4.0;
art += &square(t * 100.0, 40.0, 20.0, [1.0 - t, 0.0, t], false);
}
art += "U\n9 () XW\n0 Ae\n";
art += record;
art
}
/// The blend read from `body`: the first object of the layer.
fn read_blend(body: &str) -> (Arc<Node>, Vec<String>) {
let s = read_ok(&layer(body));
let first = art(&s.doc).into_iter().next().expect("an object");
(first, s.warnings)
}
/// Two keys, a straight spine and a record: one live blend of the two keys, three steps between
/// them (as drawn), its colours running from red to blue, the drawn steps and the blend's own art
/// gone.
#[test]
fn a_blend_comes_in_as_a_live_blend() {
let body = blend_file("%_10 50 m\n%_110 50 L\n", [Some("A"), Some("B")], &blend_record(&['A', 'B']));
let s = read_ok(&layer(&body));
let objects = art(&s.doc);
assert_eq!(objects.len(), 1, "one object: {objects:?}");
let NodeKind::Blend { children, spec } = &objects[0].kind else { panic!("not a blend: {:?}", objects[0].kind) };
assert_eq!(children.len(), 2);
assert_eq!(spec.spacing, vectorcraft_doc::live::BlendSpacing::Steps(3));
let laid = vectorcraft_doc::blend::blend_expand(children, spec);
assert_eq!(laid.len(), 5);
let middle = laid.iter().find(|n| n.geometric_bounds().is_some_and(|b| (b.center().x - 60.0).abs() < 0.5)).expect("the middle step");
let Paint::Solid { color, .. } = fill(middle) else { panic!("solid") };
let [r, _, b] = color.to_rgb();
assert!((r - 0.5).abs() < 0.02 && (b - 0.5).abs() < 0.02, "{r} {b}");
assert!(!s.warnings.iter().any(|w| w == super::blend::DRAWN), "{:?}", s.warnings);
}
/// A spine written from the right key to the left one keeps the keys in their order on it (the
/// red key first), sitting on its last and first anchors.
#[test]
fn a_blends_spine_written_backwards_keeps_its_keys_order() {
let (blend, warnings) = read_blend(&blend_file("%_110 50 m\n%_10 50 L\n", [Some("A"), Some("B")], &blend_record(&['A', 'B'])));
let NodeKind::Blend { children, spec } = &blend.kind else { panic!("a live blend: {:?} {warnings:?}", blend.kind) };
assert!(matches!(fill(&children[0]), Paint::Solid { color, .. } if color.to_rgb()[0] > 0.9), "the first key is the red one");
assert_eq!(spec.key_anchors, vec![1, 0], "the red key on the spine's end, the blue one on its start");
}
/// A curved spine (a line, a curve and a line) is read with its points exactly; a blend whose drawn
/// steps don't follow it (they are drawn on a straight line here) keeps its drawn steps.
#[test]
fn a_curved_spine_keeps_its_points() {
let spine = "%_110 50 m\n%_95 45 l\n%_80 20 40 20 25 45 c\n%_10 50 L\n";
let s = read_ok(&layer(&format!("%_u\n{spine}%_N\n%_U\n")));
let mut found = None;
for n in art(&s.doc) {
n.walk(&mut |c| {
if let NodeKind::Path { path, .. } = &c.kind {
found = Some(path.clone());
}
});
}
let path = found.expect("the spine is read");
let a = &path.subpaths[0].anchors;
assert_eq!(a.len(), 4);
assert!((a[1].h_out.x - 80.0).abs() < 1e-9 && (a[2].h_in.x - 40.0).abs() < 1e-9 && (a[2].p.x - 25.0).abs() < 1e-9, "{a:?}");
let (blend, warnings) = read_blend(&blend_file(spine, [Some("A"), Some("B")], &blend_record(&['A', 'B'])));
assert!(!matches!(blend.kind, NodeKind::Blend { .. }), "steps drawn off the spine aren't made live");
assert_eq!(blend.children().map(|c| c.len()), Some(5));
assert!(warnings.iter().any(|w| w == super::blend::DRAWN));
}
/// Steps with a gradient fill can't be compared: the blend keeps its drawn steps.
#[test]
fn a_blend_whose_steps_it_cant_compare_keeps_its_drawn_steps() {
let body = blend_file("%_10 50 m\n%_110 50 L\n", [Some("A"), Some("B")], &blend_record(&['A', 'B']));
// The drawn squares' fill made a gradient's.
let gradient = "%AI5_BeginGradient: (g)\n(g) 0 2 Bd\n[\n1 0 0 0 1 %_Bs\n0 0 0 1 0 %_Bs\nBD\n%AI5_EndGradient\n";
let drawn_from = body.find("\nu\n").expect("the drawn group") + 1;
let (head, tail) = body.split_at(drawn_from);
let tail = tail.replacen("Xa\n", "Xa\n0 1 0 0 1 0 0 1 0 0 Bb\n(g) 0 0 0 1 1 0 0 1 0 0 Bg\n", 5).replace("\nf\n", "\nf\n0 BB\n");
let (blend, _) = read_blend(&format!("{gradient}{head}{tail}"));
assert!(!matches!(blend.kind, NodeKind::Blend { .. }), "{:?}", blend.kind);
}
/// The blend's own art (its keys and spine) isn't left drawn when what follows it isn't a group of
/// drawn steps.
#[test]
fn a_blends_own_art_goes_when_its_steps_arent_a_group() {
let body = blend_file("%_10 50 m\n%_110 50 L\n", [Some("A"), Some("B")], &blend_record(&['A', 'B']));
// The drawn steps as one square, not a group.
let at = body.find("\nu\n").expect("the drawn group") + 1;
let end = body.find("U\n9 () XW\n0 Ae\n").expect("its end");
let body = format!("{}{}{}", &body[..at], square(0.0, 40.0, 20.0, [1.0, 0.0, 0.0], false), &body[end + 2..]);
let s = read_ok(&layer(&body));
let objects = art(&s.doc);
assert_eq!(objects.len(), 1, "the drawn square alone: {objects:?}");
assert!(matches!(objects[0].kind, NodeKind::Path { .. }));
assert!(s.warnings.iter().any(|w| w == super::blend::DRAWN));
}
/// A record that isn't one, a key with no id, or ids the record doesn't list: the drawn steps
/// stay (with a note), without the blend's own art drawn over them.
#[test]
fn a_blend_it_cant_read_keeps_its_drawn_steps() {
let straight = "%_10 50 m\n%_110 50 L\n";
let garbled = "(Adobe Path Blends) 1 0 16 XP\n%00112233445566778899AABBCCDDEEFF\n";
let short = "(Adobe Path Blends) 1 0 92 XP\n%44434241\n";
for body in [
blend_file(straight, [Some("A"), Some("B")], garbled),
blend_file(straight, [Some("A"), Some("B")], short),
blend_file(straight, [Some("A"), None], &blend_record(&['A', 'B'])),
blend_file(straight, [Some("A"), Some("B")], &blend_record(&['X', 'Y'])),
] {
let s = read_ok(&layer(&body));
let objects = art(&s.doc);
assert_eq!(objects.len(), 1, "only the drawn steps: {objects:?}");
assert_eq!(objects[0].children().map(|c| c.len()), Some(5), "the five drawn squares");
assert!(s.warnings.iter().any(|w| w == super::blend::DRAWN), "{:?}", s.warnings);
}
}
/// A blend's transparency (here knockout on, as blends are written) is the state block after its
/// record, as a group's follows its end: the live blend's, or its drawn steps' where they stay. It
/// is the blend's own, not the next object's.
#[test]
fn a_blends_transparency_follows_its_record() {
let state = "0 0.6 0 1 0 Xy\n0 0 Xd\n6 () XW\n";
let next = format!("0 1 0 0 0 Xy\n{RED}{}f\n", rect_path(150.0, 40.0, 170.0, 60.0));
let straight = "%_10 50 m\n%_110 50 L\n";
let garbled = "(Adobe Path Blends) 1 0 16 XP\n%00112233445566778899AABBCCDDEEFF\n".to_string();
for (record, live) in [(blend_record(&['A', 'B']), true), (garbled, false)] {
let body = format!("{}{state}{next}", blend_file(straight, [Some("A"), Some("B")], &record));
let a = art(&read_ok(&layer(&body)).doc);
assert_eq!(a.len(), 2, "{a:?}");
assert_eq!(matches!(a[0].kind, NodeKind::Blend { .. }), live, "{:?}", a[0].kind);
assert_eq!((a[0].opacity, a[0].knockout), (0.6, Knockout::On), "live: {live}");
assert_eq!(a[1].opacity, 1.0, "the next object's own state");
}
// A layer whose last object is a blend: a state block that ends `7 () XW` before `LB` is the
// layer's, and the blend keeps the state it had.
let body = format!("{}1 0.35 0 0 0 Xy\n0 0 Xd\n7 () XW\n", blend_file(straight, [Some("A"), Some("B")], &blend_record(&['A', 'B'])));
let doc = read_ok(&layer(&body)).doc;
assert_eq!((doc.layers[0].opacity, doc.layers[0].blend), (0.35, BlendMode::Multiply), "the layer's state");
let a = art(&doc);
assert!(matches!(a[0].kind, NodeKind::Blend { .. }), "{:?}", a[0].kind);
assert_eq!((a[0].opacity, a[0].blend), (1.0, BlendMode::Normal), "the blend's own state");
}
/// The blend is live: moving a key moves the steps between it and the other key with it.
#[test]
fn moving_a_blends_key_moves_its_steps() {
let body = blend_file("%_10 50 m\n%_110 50 L\n", [Some("A"), Some("B")], &blend_record(&['A', 'B']));
let (blend, _) = read_blend(&body);
let NodeKind::Blend { children, spec } = &blend.kind else { panic!("a blend") };
let middle = |keys: &[Arc<Node>]| {
let laid = vectorcraft_doc::blend::blend_expand(keys, spec);
let xs: Vec<f64> = laid.iter().filter_map(|n| n.geometric_bounds()).map(|b| b.center().y).collect();
xs.iter().sum::<f64>() / xs.len() as f64
};
let before = middle(children);
let mut keys = children.clone();
let mut moved = (*keys[1]).clone();
moved.transform(vectorcraft_geom::Affine::translate((0.0, 40.0)), true);
keys[1] = Arc::new(moved);
let after = middle(&keys);
assert!((after - before - 20.0).abs() < 1.0, "the steps follow: {before} -> {after}");
}
/// A record line longer than the record says is read only as far as its length: the digits after
/// it don't spoil it.
#[test]
fn a_blend_record_is_read_to_its_length() {
let record = blend_record(&['A', 'B']);
let longer = record.trim_end().to_string() + "DEADBEEF\n";
let (blend, warnings) = read_blend(&blend_file("%_10 50 m\n%_110 50 L\n", [Some("A"), Some("B")], &longer));
assert!(matches!(blend.kind, NodeKind::Blend { .. }), "{warnings:?}");
}
/// An object's dictionary with the text wrap `entries` (each a `values (key) ,` line).
fn wrap_dictionary(entries: &[&str]) -> String {
let lines: String = entries.iter().map(|e| format!("%_{e}\n")).collect();
format!("%_/ArtDictionary :\n{lines}%_;\n%_\n")
}
/// The wrap outline a file may keep beside the options: art, then its key.
const WRAP_PATH: &str = "X=\n%_4 As\n%_0 0 m\n%_0 20 L\n%_20 20 L\n%_20 0 L\n%_f\n%_X+\n%_ (AI11TextWrapPath) ,";
fn wrap_of(n: &Node) -> Option<vectorcraft_doc::TextWrap> {
n.extras().wrap
}
#[test]
fn wrap_dictionary_path() {
let dict = wrap_dictionary(&[WRAP_PATH, "0 /Bool (AI11TextWrapInvert) ,", "12.5 /Real (AI11TextWrapOffset) ,"]);
let r = read_ok(&layer(&format!("{RED}{}f\n{dict}", rect_path(10.0, 10.0, 50.0, 50.0))));
let kids = art(&r.doc);
assert_eq!(kids.len(), 1, "the kept outline isn't art: {:?}", r.warnings);
assert_eq!(wrap_of(&kids[0]), Some(vectorcraft_doc::TextWrap { offset: 12.5, invert: false }));
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
#[test]
fn wrap_dictionary_invert_and_default() {
let inverted = wrap_dictionary(&["12.5 /Real (AI11TextWrapOffset) ,", "1 /Bool (AI11TextWrapInvert) ,"]);
let default = wrap_dictionary(&["0 /Bool (AI11TextWrapInvert) ,"]);
let plain = wrap_dictionary(&["(Plain) /String (AIArtName) ,"]);
let r = read_ok(&layer(&format!(
"{RED}{}f\n{inverted}{}f\n{default}{}f\n{plain}",
rect_path(0.0, 0.0, 10.0, 10.0),
rect_path(20.0, 0.0, 30.0, 10.0),
rect_path(40.0, 0.0, 50.0, 10.0)
)));
let kids = art(&r.doc);
assert_eq!(kids.len(), 3);
assert_eq!(wrap_of(&kids[0]), Some(vectorcraft_doc::TextWrap { offset: 12.5, invert: true }));
assert_eq!(wrap_of(&kids[1]), Some(vectorcraft_doc::TextWrap::default()), "6 pt when the offset isn't given");
assert_eq!(wrap_of(&kids[2]), None, "an object without wrap options isn't a wrap object");
assert_eq!(kids[2].name.as_deref(), Some("Plain"));
}
#[test]
fn wrap_dictionary_bad_offset() {
let offsets = ["nan /Real", "1e308 /Real", "(far) /String", "2000 /Real", "-2000 /Real"];
let mut body = RED.to_string();
for (i, offset) in offsets.iter().enumerate() {
let x = 20.0 * i as f64;
body += &format!("{}f\n", rect_path(x, 0.0, x + 10.0, 10.0));
body += &wrap_dictionary(&[&format!("{offset} (AI11TextWrapOffset) ,"), "1 /Bool (AI11TextWrapInvert) ,"]);
}
// An offset that isn't a number, with nothing else of the options, isn't a wrap object.
body += &format!("{}f\n", rect_path(200.0, 0.0, 210.0, 10.0));
body += &wrap_dictionary(&["(far) /String (AI11TextWrapOffset) ,", "7 /Bool (AI11TextWrapInvert) ,"]);
let r = read_ok(&layer(&body));
let offsets: Vec<Option<f64>> = art(&r.doc).iter().map(|n| wrap_of(n).map(|w| w.offset)).collect();
assert_eq!(offsets, [Some(6.0), Some(1000.0), Some(6.0), Some(1000.0), Some(-1000.0), None]);
assert!(art(&r.doc).iter().take(5).all(|n| wrap_of(n).is_some_and(|w| w.invert)));
}
#[test]
fn wrap_dictionary_group() {
let dict = wrap_dictionary(&["6 /Real (AI11TextWrapOffset) ,", "0 /Bool (AI11TextWrapInvert) ,"]);
let r = read_ok(&layer(&format!("u\n{RED}{}f\n{}f\nU\n{dict}", rect_path(0.0, 0.0, 10.0, 10.0), rect_path(20.0, 0.0, 30.0, 10.0))));
let kids = art(&r.doc);
assert_eq!(kids.len(), 1);
assert_eq!(wrap_of(&kids[0]), Some(vectorcraft_doc::TextWrap::default()));
let NodeKind::Group { children, .. } = &kids[0].kind else { panic!("a group: {:?}", kids[0].kind) };
assert_eq!(children.len(), 2);
assert!(children.iter().all(|c| wrap_of(c).is_none() && matches!(c.kind, NodeKind::Path { .. })));
}
/// Wraparound text in the version 7 format (§15.4.2): `*w`, objects, the area text, the objects it
/// wraps around, `*W`.
#[test]
fn legacy_wrap_group() {
let text = format!("1 To\n1 0 0 1 0 0 0 Tp\n{}n\nTP\n0 Tr\n0 0 0 1 k\n/_Helvetica 12 Tf\n(Around) Tx\nTO\n", rect_path(0.0, 0.0, 100.0, 80.0));
// The last object's own dictionary gives its options.
let own = wrap_dictionary(&["3 /Real (AI11TextWrapOffset) ,", "1 /Bool (AI11TextWrapInvert) ,"]);
let body = format!(
"*w\n{RED}{}f\n{text}{}f\n{}f\n{own}*W\n",
rect_path(0.0, 0.0, 5.0, 5.0),
rect_path(30.0, 30.0, 50.0, 50.0),
rect_path(60.0, 30.0, 70.0, 50.0)
);
let r = read_ok(&layer(&body));
let kids = art(&r.doc);
assert_eq!(kids.len(), 1, "{:?}", r.warnings);
let NodeKind::Group { children, clip: false } = &kids[0].kind else { panic!("a group: {:?}", kids[0].kind) };
let wraps: Vec<(bool, bool)> = children.iter().map(|c| (matches!(c.kind, NodeKind::Text(_)), wrap_of(c).is_some())).collect();
assert_eq!(wraps, [(false, true), (true, false), (false, true), (false, true)], "objects before the text are wrap objects too: {:?}", r.warnings);
let none = Some(vectorcraft_doc::TextWrap { offset: 0.0, invert: false });
assert_eq!(wrap_of(&children[0]), none, "no offset");
assert_eq!(wrap_of(&children[2]), none, "no offset");
assert_eq!(wrap_of(&children[3]), Some(vectorcraft_doc::TextWrap { offset: 3.0, invert: true }));
assert!(!r.warnings.iter().any(|w| w.contains("*w") || w.contains("*W")), "{:?}", r.warnings);
}
/// A wraparound group with no type in it: each of its objects is a wrap object, with no offset.
#[test]
fn legacy_wrap_group_without_text() {
let body = format!("*w\n{RED}{}f\n{}f\n*W\n", rect_path(0.0, 0.0, 5.0, 5.0), rect_path(30.0, 30.0, 50.0, 50.0));
let r = read_ok(&layer(&body));
let kids = art(&r.doc);
assert_eq!(kids.len(), 1, "{:?}", r.warnings);
let NodeKind::Group { children, clip: false } = &kids[0].kind else { panic!("a group: {:?}", kids[0].kind) };
let wraps: Vec<_> = children.iter().map(|c| wrap_of(c)).collect();
assert_eq!(wraps, [Some(vectorcraft_doc::TextWrap { offset: 0.0, invert: false }); 2]);
}
/// `*w` and `*W` take no operands, and a wraparound group can't be an element of a compound path.
#[test]
fn legacy_wrap_group_errors() {
let square = format!("{RED}{}f\n", rect_path(0.0, 0.0, 5.0, 5.0));
for (body, error) in [
(format!("1 *w\n{square}*W\n"), "`*w` takes no operands"),
(format!("*w\n{square}0 *W\n"), "`*W` takes no operands"),
(format!("*u\n*w\n{square}*W\n*U\n"), "a wraparound group is inside a compound path"),
(format!("*u\nu\n*w\n{square}*W\nU\n*U\n"), "a wraparound group is inside a compound path"),
] {
let e = read(stream(&layer(&body)).as_bytes()).unwrap_err();
assert!(e.contains(error), "{body:?}: {e}");
}
}
/// The printer's crop marks Illustrator draws after the last layer (`%AI3_BeginCrops`, inside
/// `userdict /AI3_noCropMarks known not { … } if`) aren't art outside the layers: the file reads
/// from its layers, without them (the crop area is `%AI3_Cropmarks`).
#[test]
fn crop_marks_after_the_layers_are_not_art() {
let crops = "%AI3_BeginCrops\nuserdict /AI3_noCropMarks known not {\n0 A\nu\n0 R\n1 1 1 1 ([Registration]) 0 XS\n\
0 J 0 j 0.5 w 4 M []0 d\n0 XR\n-9 -400 m\n-36 -400 L\nS\nU\n} if\n%AI3_EndCrops\n";
let r = read_ok(&format!("{}{crops}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n"))));
assert_eq!(art(&r.doc).len(), 1);
assert_eq!(r.doc.layers.len(), 1);
}
/// The name of an Objects on Path record.
const ALIGN_ON_PATH: &str = "Adobe Align On Path"; // brand-ok: the record's name in the files
/// The ids of an Objects on Path group's path and its four objects.
const ON_PATH_IDS: [&str; 5] = [
"0a6d1e52-0000-4000-8000-000000000001",
"0a6d1e52-0000-4000-8000-000000000002",
"0a6d1e52-0000-4000-8000-000000000003",
"0a6d1e52-0000-4000-8000-000000000004",
"0a6d1e52-0000-4000-8000-000000000005",
];
const OPEN_PATH: &str = "20 20 m\n100 80 L\n180 20 L\nS\n";
const CLOSED_PATH: &str =
"40 50 m\n40 66.57 66.86 80 100 80 c\n133.14 80 160 66.57 160 50 c\n160 33.43 133.14 20 100 20 c\n66.86 20 40 33.43 40 50 c\ns\n";
/// An Objects on Path group: the stroked `path` with the id `ids[0]`, then a 10 × 6 red square for
/// each other id, 50 pt apart from x 20, whose frame is turned `angle` radians.
fn on_path_group(path: &str, ids: &[&str], angle: f64) -> String {
use vectorcraft_testkit::ai::on_path_member;
let (first, rest) = ids.split_first().unwrap();
let mut group = format!("0 Ae\nu\n/ConstraintGroup :\n128 /GroupType ,\n;\n0 0 1 0 0 0 1 XA\n2 w\n{path}{}", on_path_member(first, None, 0.0));
for (i, id) in rest.iter().enumerate() {
let x = 20.0 + 50.0 * i as f64;
group += &format!("{RED}{}f\n{}", rect_path(x, 40.0, x + 10.0, 46.0), on_path_member(id, Some(i), angle));
}
group + "U\n"
}
/// The group the layer of `body` holds first, and its Objects on Path record.
fn on_path(body: &str) -> (Arc<Node>, Option<vectorcraft_doc::objects_on_path::ObjectsOnPath>) {
let doc = read_ok(body).doc;
let g = art(&doc).first().cloned().unwrap();
let rec = g.extras().objects_on_path.as_deref().cloned();
(g, rec)
}
/// An Objects on Path group opens as one, with its path, its objects in their order along the path,
/// Pivot and Rotate, and its art where the file draws it; the same art without the record opens as a
/// plain group.
#[test]
fn an_objects_on_path_group_opens_with_its_path_objects_and_options() {
use vectorcraft_testkit::ai::{on_path_options, on_path_record};
let group = on_path_group(OPEN_PATH, &ON_PATH_IDS, 0.5);
let record = on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &on_path_options(4, 0, std::f64::consts::FRAC_PI_6, ""));
let (g, rec) = on_path(&format!("{record}{}", layer(&group)));
let rec = rec.expect("an Objects on Path group");
let kids = g.children().unwrap();
assert_eq!(kids.len(), 5);
let NodeKind::Path { path, .. } = &kids.iter().find(|c| c.id == rec.path).unwrap().kind else { panic!("the path") };
assert!(path.subpaths.first().is_some_and(|s| !s.closed), "the open path");
let child = |id| kids.iter().find(|c| c.id == id).unwrap();
let xs: Vec<f64> = rec.members.iter().map(|m| child(m.id).geometric_bounds().unwrap().x0).collect();
assert_eq!(xs, [20.0, 70.0, 120.0, 170.0], "the objects in the file's order");
for m in &rec.members {
let center = child(m.id).geometric_bounds().unwrap().center();
assert_eq!((m.angle, m.frame), (-0.5, kurbo::Affine::rotate_about(-0.5, center)), "the frame turned counterclockwise");
}
assert!((rec.rotate - 30.0).abs() < 1e-9, "{}", rec.rotate);
assert_eq!(rec.pivot, 0);
let (plain, none) = on_path(&layer(&group));
assert!(none.is_none());
let kinds = |g: &Node| g.children().unwrap().iter().map(|c| c.kind.clone()).collect::<Vec<_>>();
assert_eq!(kinds(&g), kinds(&plain), "the art stays where it is drawn");
}
/// A group on a closed path opens as an Objects on Path group too.
#[test]
fn an_objects_on_path_group_on_a_closed_path_opens_live() {
use vectorcraft_testkit::ai::{on_path_options, on_path_record};
let record = on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &on_path_options(4, 4, 0.0, ""));
let (g, rec) = on_path(&format!("{record}{}", layer(&on_path_group(CLOSED_PATH, &ON_PATH_IDS, 0.0))));
let rec = rec.expect("an Objects on Path group");
let NodeKind::Path { path, .. } = &g.children().unwrap().iter().find(|c| c.id == rec.path).unwrap().kind else { panic!("the path") };
assert!(path.subpaths.first().is_some_and(|s| s.closed), "the closed path");
assert_eq!((rec.members.len(), rec.pivot, rec.rotate), (4, 4, 0.0));
}
/// A record the group can't be read from, or with options an Objects on Path group doesn't hold,
/// leaves the group a plain group with its art.
#[test]
fn an_objects_on_path_record_that_doesnt_fit_leaves_a_plain_group() {
use vectorcraft_testkit::ai::{on_path_options, on_path_record};
let [p, a, b, c, d] = ON_PATH_IDS;
let options = |more: &str| on_path_options(4, 4, 0.0, more);
let good = options("");
let group = on_path_group(OPEN_PATH, &ON_PATH_IDS, 0.0);
assert!(on_path(&format!("{}{}", on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good), layer(&group))).1.is_some());
let cases: Vec<(&str, String, String)> = vec![
("the path's id is missing", on_path_record(ALIGN_ON_PATH, &["0a6d1e52-ffff", a, b, c, d], &good), group.clone()),
("an object's id is missing", on_path_record(ALIGN_ON_PATH, &[p, a, b, c], &good), group.clone()),
("an id twice", on_path_record(ALIGN_ON_PATH, &[p, a, b, b, d], &good), group.clone()),
("objects out of order", on_path_record(ALIGN_ON_PATH, &[p, b, a, c, d], &good), group.clone()),
("a spacing", on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &options("%_56.5 /Real (Spacing) ,\n")), group.clone()),
("an option this reader doesn't know", on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &options("%_1 /Bool (Flip) ,\n")), group.clone()),
(
"uneven spacing",
on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good.replace("1 /Bool (IsEquiSpaced)", "0 /Bool (IsEquiSpaced)")),
group.clone(),
),
(
"a later start",
on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good.replace("0 /Real (StartRangeFraction)", "0.25 /Real (StartRangeFraction)")),
group.clone(),
),
("a pivot past 8", on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &on_path_options(4, 9, 0.0, "")), group.clone()),
("another constraint", on_path_record("Some Other Constraint", &ON_PATH_IDS, &good), group.clone()),
(
"another version",
on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good).replace("%_1 /Int (Constraint Verison)", "%_2 /Int (Constraint Verison)"),
group.clone(),
),
("an object without its angle", on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good), group.replace("%_(0) /String (BBAccumRotation) ,\n", "")),
(
"another child in the group",
on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good),
group.replace("U\n", &format!("{}f\nU\n", rect_path(0.0, 0.0, 5.0, 5.0))),
),
("a path without its key", on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &good), group.replace("%_1 /Bool (KeyPath) ,\n", "")),
];
for (why, record, group) in cases {
let (g, rec) = on_path(&format!("{record}{}", layer(&group)));
assert!(rec.is_none(), "{why}");
assert!(matches!(g.kind, NodeKind::Group { clip: false, .. }), "{why}");
assert!(g.children().unwrap().len() >= 5, "{why}: the art is kept");
}
}
/// The path and objects of a group are measured once however many records name it: a thousand
/// copies of a record open about as fast as one copy followed by 999 records whose path id is
/// missing, when the group opens live, when its long path has no length, and when its first object
/// is a group of many paths and its last object has no bounds.
#[test]
fn repeated_objects_on_path_records_dont_measure_the_objects_again() {
use vectorcraft_testkit::ai::{on_path_member, on_path_options, on_path_record};
let paths: String = (0..20_000).map(|i| format!("{}f\n", rect_path(f64::from(i % 200), 40.0, f64::from(i % 200) + 1.0, 46.0))).collect();
let big = format!("u\n{RED}{paths}U\n{}", on_path_member(ON_PATH_IDS[1], Some(0), 0.0));
let empty = format!("u\nU\n{}", on_path_member(ON_PATH_IDS[2], Some(1), 0.0));
let still = format!("20 20 m\n{}S\n", "20 20 L\n".repeat(20_000));
let cases = [
("a live group", OPEN_PATH.to_string(), big.clone(), 2, true),
("a path without length", still, big.clone(), 2, false),
("an object without bounds", OPEN_PATH.to_string(), format!("{big}{empty}"), 3, false),
];
for (why, path, objects, n, live) in cases {
let ids = &ON_PATH_IDS[..n];
let group = format!(
"0 Ae\nu\n/ConstraintGroup :\n128 /GroupType ,\n;\n0 0 1 0 0 0 1 XA\n2 w\n{path}{}{objects}U\n",
on_path_member(ids[0], None, 0.0)
);
let record = on_path_record(ALIGN_ON_PATH, ids, &on_path_options(n - 1, 4, 0.0, ""));
let (start, end) = (record.find("%_/Dictionary :\n%_1 /Int").unwrap(), record.find("%_; (Constraint Data)").unwrap());
let copy = &record[start..end];
let unresolved = copy.replace(ids[0], "0a6d1e52-ffff-4000-8000-000000000000");
let one = format!("{}{copy}{}{}", &record[..start], unresolved.repeat(999), &record[end..]);
let many = format!("{}{}{}", &record[..start], copy.repeat(1000), &record[end..]);
let time = |data: &str| {
let started = std::time::Instant::now();
let doc = read_ok(&format!("{data}{}", layer(&group))).doc;
let elapsed = started.elapsed();
let g = art(&doc)[0].clone();
assert_eq!(g.children().unwrap().len(), n, "{why}");
assert_eq!(g.extras().objects_on_path.is_some(), live, "{why}");
elapsed
};
// The fastest of three runs each, taken in turn, so that other tests running at the same
// time weigh on both.
let (mut fastest_one, mut fastest_many) = (std::time::Duration::MAX, std::time::Duration::MAX);
for _ in 0..3 {
fastest_one = fastest_one.min(time(&one));
fastest_many = fastest_many.min(time(&many));
}
assert!(fastest_many < fastest_one * 5, "{why}: one copy: {fastest_one:?}, 1000 copies: {fastest_many:?}");
}
}
/// The copy of the drawn group that follows it as versioned content is skipped: the group opens once,
/// live.
#[test]
fn an_objects_on_path_group_in_versioned_content_opens_once() {
use vectorcraft_testkit::ai::{on_path_options, on_path_record};
let record = on_path_record(ALIGN_ON_PATH, &ON_PATH_IDS, &on_path_options(4, 4, 0.0, ""));
let group = on_path_group(OPEN_PATH, &ON_PATH_IDS, 0.0);
let copy = group.replace("/ConstraintGroup :\n128 /GroupType ,\n;\n", "");
let versioned = format!("%AI17_Begin_Content_if_version_gt:24 14\n{group}%AI17_Alternate_Content\n{copy}%AI17_End_Versioned_Content\n");
let doc = read_ok(&format!("{record}{}", layer(&versioned))).doc;
let kids = art(&doc);
assert_eq!(kids.len(), 1);
assert_eq!(kids[0].children().unwrap().len(), 5);
assert!(kids[0].extras().objects_on_path.is_some());
}