Files
vectorcraft/crates/eps/src/import/ai/tests.rs
T
Don Omondi d4745fe046 fix(eps): a layer's state after its last blend stays the layer's
#4757 gives a blend the Xy written after its record. When the blend is a
layer's last object, the layer's own state block (Xy, Xd, 7 () XW, LB)
went to the blend instead, and the layer stayed opaque. The blend's state
is now tentative like a mesh's (#4748): its own 6 () XW confirms it, the
layer's LB gives it to the layer and the blend keeps what it had.

Also records the .ai/EPS audit fixes under G9, the File formats row and M4.
2026-10-31 05:11:04 +07:00

2040 lines
112 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, 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}"),
}
}
#[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);
}
#[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 e = 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_err();
assert!(e.contains("pattern fills"), "{name}: {e}");
}
// 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());
}
#[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 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 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 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)));
// Without a preview it can read (none, cut short, not 32 bits, absurd sizes), without a path or
// a matrix, the file isn't read from its editing data.
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 e = read(&placed(true, matrix, path, preview, "")).unwrap_err();
assert!(e.contains("placed files"), "{matrix} {path} {preview:?}: {e}");
}
// 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))));
}
#[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 e = read(stream(&layer("(Jive) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n")).as_bytes()).unwrap_err();
assert!(e.contains("pattern fills"), "{e}");
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 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());
}
#[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 ,\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 ,\n0.5 /Real (opac) ,\n2 /Real (blur) ,\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 Feather) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n8 /Real (rad) ,\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 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("3 /Real (horz) ,\n", "1 /Int (csrc) ,\n50 /Real (dark) ,\n3 /Real (horz) ,\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));
}
#[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(_)));
}
/// 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:?}");
}
/// 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);
}