Files
vectorcraft/crates/eps/src/import/ai/tests.rs
T

1077 lines
59 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};
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}"),
}
}
/// 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 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 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");
}
#[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 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 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));
}
#[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
}
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)
}
#[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 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
}
#[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}");
// An opacity mask (#2004): drawing the layer's art whole would show what the mask hides, so
// the mask is named and the file is read from its PDF part instead.
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);
}
/// 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());
}
#[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 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)));
}
#[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}");
}