Files
vectorcraft/crates/eps/src/import/tests_illustrator.rs
T
dotCooCoo 795cbc060d .ai import: graphs open as their drawn art, with a note
A graph in a legacy .ai, or in a current file's editing data, is written
as `%_Gs`, its data and settings on `%_` lines, its drawn art in a group
with a `%_... Go` tag after each object, and `%_GS`. The reader stopped
on the graph operators as ones it doesn't know, so such a file did not
open through its layers.

The reader now leaves the graph's records out with their operands and
reads the drawn art as usual: the bars, lines and labels come in as the
groups, paths and type they are written as. Paint operators on `%_`
lines between `Gt` and `GT` set nothing. One note per file says the
graphs came in as plain art without their data or settings. A graph
that starts inside another, or doesn't end before its layer does, keeps
the art read so far with a second note. Graph operators outside a graph
stay unknown.
2026-11-02 12:04:24 +07:00

335 lines
18 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.
//! Illustrator files (Illustrator 3–8 and their EPS): the groups they write (`u` … `U`) come in
//! as groups, nested as they were, between and inside clipping groups.
use std::sync::Arc;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::clipnest::MAX_NEST;
use vectorcraft_doc::{LayerColor, LineCap, Node, NodeKind};
use vectorcraft_geom::Rect;
use crate::import::import;
/// A minimal file in Illustrator's format: its header comments, a prolog of our own defining the
/// operators `body` uses (`u` and `U` as `ugroup` and `Ugroup`), and `body`.
fn ai(header: &str, ugroup: &str, body: &str) -> Vec<u8> {
format!(
"%!PS-Adobe-3.0 EPSF-3.0\n{header}%%BoundingBox: 0 0 100 100\n%%EndComments\n%%BeginProlog\n\
/m {{moveto}} def /L {{lineto}} def /f {{closepath fill}} def /S {{stroke}} def /g {{setgray}} def\n\
/u {{{ugroup}}} def /U {{{}}} def\n%%EndProlog\n{body}\nshowpage\n%%EOF\n",
if ugroup.contains("gsave") { "grestore" } else { "" }
)
.into_bytes()
}
/// A legacy Illustrator format header: the reader takes `u` … `U` as groups only in files whose
/// header says they are in that format (`%AI…` comments or the creator, which the format's
/// published specification documents; see the `import` module docs).
const AI8: &str = "%%Creator: Adobe Illustrator(R) 8.0\n%AI5_FileFormat 4.0\n";
/// The editing text of a version 7 file (`%AI5_FileFormat 3`), no prolog: `setup` in its setup,
/// then `body`.
fn stream7(setup: &str, body: &str) -> Vec<u8> {
format!(
"%!PS-Adobe-3.0\n%%Creator: a script\n%%BoundingBox: 0 0 100 100\n%AI5_FileFormat 3\n%AI3_Cropmarks: 0 0 100 100\n%%EndComments\n\
%%BeginProlog\n%%EndProlog\n%%BeginSetup\n{setup}%%EndSetup\n{body}%%PageTrailer\n%%Trailer\n%%EOF\n"
)
.into_bytes()
}
/// A triangle filled at `x`.
fn tri(x: u32) -> String {
format!("{x} {x} m {} {x} L {} {} L f", x + 5, x + 5, x + 5)
}
/// The layer's objects as a tree: `p` a path, `c` a clip path, `g[…]` a group, `k[…]` a clipping
/// group.
fn tree(nodes: &[Arc<Node>]) -> String {
let one = |n: &Arc<Node>| match &n.kind {
NodeKind::Group { children, clip: false } => format!("g[{}]", tree(children)),
NodeKind::Group { children, clip: true } => format!("k[{}]", tree(children)),
NodeKind::Path { clipping: true, .. } => "c".into(),
NodeKind::Path { .. } => "p".into(),
k => format!("{k:?}"),
};
nodes.iter().map(one).collect::<Vec<_>>().join(" ")
}
fn read(header: &str, ugroup: &str, body: &str) -> (String, Vec<String>) {
let r = import(&ai(header, ugroup, body)).unwrap();
(tree(r.document.layers[0].children().unwrap()), r.warnings)
}
#[test]
fn groups_and_nested_groups_come_in_as_groups() {
let body = format!("u {} u {} {} U U {}", tri(10), tri(30), tri(50), tri(70));
assert_eq!(read(AI8, "", &body), ("g[p g[p p]] p".into(), vec![]));
// Illustrator 3's header names it only as the creator; a prolog whose groups save and restore
// the graphics state groups the same.
assert_eq!(read("%%Creator:Adobe Illustrator(TM) 3.2\n", "gsave", &body).0, "g[p g[p p]] p");
// Groups of one object, and groups next to each other.
assert_eq!(read(AI8, "", &format!("u {} U u {} U", tri(10), tri(30))).0, "g[p] g[p]");
}
#[test]
fn groups_nest_with_clipping_groups() {
// A clipping group inside a group, a group inside a clipping group.
let clip = "gsave 0 0 m 50 0 L 50 50 L 0 50 L closepath clip newpath";
let body = format!("u {} {clip} u {} {} U grestore {} U", tri(10), tri(20), tri(30), tri(40));
assert_eq!(read(AI8, "", &body).0, "g[p k[c g[p p]] p]");
// A fill and a stroke of one path in a group stay one object.
let body = "u 10 10 m 20 10 L 20 20 L closepath gsave 0 g fill grestore S U";
let r = import(&ai(AI8, "", body)).unwrap();
let layer = r.document.layers[0].children().unwrap();
assert_eq!(tree(layer), "g[p]");
assert_eq!(layer[0].children().unwrap()[0].appearance.items.len(), 2);
}
#[test]
fn other_postscript_keeps_its_own_u() {
// A program that isn't Illustrator's may name its own procedures `u` and `U`.
let body = format!("u {} {} U", tri(10), tri(30));
assert_eq!(read("%%Creator: some app\n", "", &body).0, "p p");
}
/// A `.ai` in PostScript form as other apps write it (#1027): its header and prolog name the
/// procsets that define its operators (by the DSC resource comments) without including them, so
/// its program can't be run. Its layers come from the program itself: names as they are, the
/// legacy format's ten-operand `Lb`, flatness `i`, the bounding box as the page.
const NAMED_PROLOG: &str = "%!PS-Adobe-3.0\n%%Creator: a CAD app\n%%BoundingBox: 0 0 200 100\n\
%%DocumentNeededResources: procset Adobe_packedarray 2.0 0\n%%+ procset Adobe_IllustratorA_AI3 1.0 0\n\
%AI3_TemplateBox: 288 384 288 384\n%%EndComments\n%%BeginProlog\n%%IncludeResource: procset Adobe_packedarray 2.0 0\n\
Adobe_packedarray /initialize get exec\n%%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 0\n%%EndProlog\n\
%%BeginSetup\nAdobe_IllustratorA_AI3 /initialize get exec\n%%EndSetup\n\
%AI5_BeginLayer\n1 1 1 1 0 0 -1 0 0 0 Lb\n(SECTION::Cut) Ln\n0 A\n0 R\n0 0 0 1 K\n0 i 1 J 1 j 0.8 w 4 M []0 d\n0 D\n\
10 10 m\n90 10 L\n90 90 L\n10 90 L\n10 10 L\nS\nLB\n%AI5_EndLayer--\n\
%AI5_BeginLayer\n1 1 1 1 0 0 -1 191 63 255 Lb\n(SECTION::Hatch) Ln\n0 A\n0 R\n0 0 0 1 K\n0.1 w\n110 10 m\n190 90 L\nS\nLB\n%AI5_EndLayer--\n\
%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\nAdobe_IllustratorA_AI3 /terminate get exec\n%%EOF\n";
#[test]
fn a_postscript_ai_that_names_its_prolog_without_including_it_opens_with_its_layers() {
let r = import(NAMED_PROLOG.as_bytes()).unwrap();
assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings);
let d = &r.document;
let names: Vec<_> = d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect();
assert_eq!(names, ["SECTION::Cut", "SECTION::Hatch"]);
let colors: Vec<_> = d.layers.iter().map(|l| if let NodeKind::Layer { color, .. } = l.kind { Some(color) } else { None }).collect();
assert_eq!(colors, [Some(LayerColor::Custom([0, 0, 0])), Some(LayerColor::Custom([191, 63, 255]))]);
assert_eq!(d.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0), "the page is the bounding box");
let square = &d.layers[0].children().unwrap()[0];
assert_eq!(square.geometric_bounds(), Some(Rect::new(10.0, 10.0, 90.0, 90.0)));
let stroke = square.appearance.stroke().unwrap();
assert_eq!((stroke.width, stroke.cap), (0.8, LineCap::Round));
assert_eq!(tree(d.layers[1].children().unwrap()), "p");
}
/// A `.ai` in PostScript form with a graph on each of two layers (version 7 specification §17): its
/// data on `%_` lines, then its drawn art in a group, each object followed by `%_… Go`.
fn graphs_on_layers() -> String {
let graph = |x: u32| {
format!(
"%_Gs\n%_{x} 10 {} 90 Gb\n%_2 0 0 12 1 1 0 0 2 30 0 Gy\n%_1 1 1 1 Gz\n%_() Gc 5 Gc GC\n%_Gt\n%_0.5 g\n%_GT\n\
u\n0 0 0 1 k\n{x} 10 m\n{} 10 L\n{} 50 L\n{x} 50 L\n{x} 10 L\nf\n%_1 0 0 0 Go\nu\nU\nU\n%_GS\n",
x + 60,
x + 20,
x + 20
)
};
NAMED_PROLOG
.replace("10 10 L\nS\nLB", &format!("10 10 L\nS\n{}LB", graph(100)))
.replace("190 90 L\nS\nLB", &format!("190 90 L\nS\n{}LB", graph(10)))
}
#[test]
fn graphs_open_as_their_drawn_art_on_their_layers() {
let r = import(graphs_on_layers().as_bytes()).unwrap();
assert!(!r.preview, "read through the layers, not a fallback preview");
let d = &r.document;
let names: Vec<_> = d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect();
assert_eq!(names, ["SECTION::Cut", "SECTION::Hatch"]);
assert_eq!(tree(d.layers[0].children().unwrap()), "p g[p g[]]");
assert_eq!(tree(d.layers[1].children().unwrap()), "p g[p g[]]");
let notes: Vec<_> = r.warnings.iter().filter(|w| w.contains("graphs come in as plain art")).collect();
assert_eq!(notes.len(), 1, "{:?}", r.warnings);
assert!(!r.warnings.iter().any(|w| w.contains("doesn't know") || w.contains("couldn't be read")), "{:?}", r.warnings);
}
/// A `.ai` in PostScript form whose layers can't be read either says why, after what stopped its
/// program.
#[test]
fn a_postscript_ai_whose_layers_cant_be_read_says_why() {
let damaged = NAMED_PROLOG.replace("0 D\n", "0 D\n1 2 frobnicate\n");
let e = import(damaged.as_bytes()).unwrap_err();
assert!(e.contains("`Adobe_packedarray`") && e.contains("its layers couldn't be read either") && e.contains("`frobnicate`"), "{e}");
}
/// A `.ai` in PostScript form (read through its layers) whose text carries hyphenation and
/// tab-ruler attributes: `XL` (hyphenation language), `Xb`/`XB` (a tab definition) and `Th`
/// (hyphen control). These keep no geometry, so the reader drops them rather than failing the
/// file the way it did before — the symptom seen on Illustrator 6/8 EPS files whose
/// `Adobe_level2_AI5` program can't be run.
#[test]
fn text_with_hyphenation_and_tab_attributes_opens_instead_of_failing() {
let text = "0 To\n1 0 0 1 10 50 0 Tp\nTP\n0 Tr\n0 0 0 1 k\n/_Helvetica 9 Tf\n\
%_ 0 XL\n36 0 Xb\nXB\n0 1 2 2 99 Th\n0 Ta\n(hi) Tx\nTO\n";
// The operators sit on a visible layer (so they'd be reported as unknown, not quietly skipped
// on a hidden one), beside the square so the layer is never empty.
let src = NAMED_PROLOG.replace("S\nLB\n%AI5_EndLayer--\n%AI5_BeginLayer", &format!("S\n{text}LB\n%AI5_EndLayer--\n%AI5_BeginLayer"));
let r = import(src.as_bytes()).unwrap();
assert!(!r.preview, "read through the layers, not a fallback preview");
assert!(!r.warnings.iter().any(|w| w.contains("doesn't know") || w.contains("couldn't be read")), "{:?}", r.warnings);
// `Th` sets the paragraph's hyphenation: off, 2 letters before and after a hyphen, and the
// limit of 99 hyphens in a row kept to the 25 `text.setFormat` takes.
let mut texts = vec![];
r.document.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
texts.push(t.para_at(0).clone());
}
});
let [p] = texts.as_slice() else { panic!("{texts:?}") };
assert!(!p.hyphenate);
assert_eq!((p.hyphenation.after_first, p.hyphenation.before_last, p.hyphenation.hyphen_limit), (2, 2, 25));
}
/// An Illustrator 3 file of paths alone, as image editors export their paths (storytold/photocraft#6036):
/// no prolog and no layers, the paths unpainted (`n` closes, `N` doesn't), a compound path of two.
const PATHS_ONLY: &str = "%!PS-Adobe-2.0 EPSF-1.2\n%%Creator: an image editor\n%%BoundingBox: 0 0 200 100\n\
%AI3_Cropmarks: 0 0 200 100\n%%EndComments\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n\
%%Note: Outline\n10 10 m\n100 10 L\n50 80 60 90 10 10 C\nn\n\
*u\n20 20 m\n30 20 L\nN\n40 40 m\n50 50 L\n60 40 L\nn\n*U\n%%PageTrailer\n%%Trailer\n%%EOF\n";
#[test]
fn an_illustrator_3_file_without_layers_opens_its_paths_on_one_layer() {
let r = import(PATHS_ONLY.as_bytes()).unwrap();
assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings);
let d = &r.document;
assert_eq!(d.layers.len(), 1);
assert_eq!(d.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0));
let art = d.layers[0].children().unwrap();
assert_eq!(art.len(), 2);
// Art space has y up: (10, 10) is 10 above the artboard's bottom.
let NodeKind::Path { path, .. } = &art[0].kind else { panic!("{:?}", art[0].kind) };
let ends: Vec<_> = path.subpaths[0].anchors.iter().map(|a| (a.p.x, a.p.y)).collect();
assert_eq!(ends, [(10.0, 90.0), (100.0, 90.0)]);
assert!(path.subpaths[0].closed);
assert!(art[0].appearance.items.is_empty(), "unpainted");
let NodeKind::Compound { children, .. } = &art[1].kind else { panic!("{:?}", art[1].kind) };
let closed: Vec<_> = children.iter().map(|c| if let NodeKind::Path { path, .. } = &c.kind { path.is_closed() } else { false }).collect();
assert_eq!(closed, [false, true]);
assert_eq!(art[1].geometric_bounds(), Some(Rect::new(20.0, 50.0, 60.0, 80.0)));
}
/// In an Illustrator 3 file the case of a segment's operator says whether the anchor it ends on is
/// smooth (lower case) or a corner (upper case).
#[test]
fn an_illustrator_3_files_anchors_are_smooth_or_corners_by_their_operators_case() {
use vectorcraft_geom::AnchorKind::{Corner, Smooth};
let src = PATHS_ONLY.replace("10 10 m\n100 10 L\n50 80 60 90 10 10 C\nn\n", "0 0 m\n10 10 20 0 30 0 c\n40 10 50 0 60 0 C\nh\nS\n");
let r = import(src.as_bytes()).unwrap();
assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings);
let art = r.document.layers[0].children().unwrap();
let path = art[0].path_data().unwrap();
let kinds: Vec<_> = path.subpaths[0].anchors.iter().map(|a| a.kind).collect();
assert_eq!(kinds, [Corner, Smooth, Corner]);
let middle = path.subpaths[0].anchors[1];
assert_eq!([(middle.h_in.x, middle.h_in.y), (middle.h_out.x, middle.h_out.y)], [(20.0, 100.0), (40.0, 90.0)]);
}
/// The same program in a file that doesn't say it is in Illustrator's format is PostScript whose
/// operators the reader doesn't know: it is still refused.
#[test]
fn the_same_program_in_other_postscript_is_still_refused() {
let other = PATHS_ONLY.replace("%AI3_Cropmarks: 0 0 200 100\n", "");
let e = import(other.as_bytes()).unwrap_err();
assert!(e.contains("it uses `m`") && !e.contains("layers"), "{e}");
}
/// The editing text of a newer file saved as PostScript (no prolog at all, a non-printing setup):
/// it opens with its sublayers.
#[test]
fn newer_editing_text_as_postscript_opens_with_its_sublayers() {
let layer =
|name: &str, art: &str| format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 -1 79 128 255 0 50 0 Lb\n({name}) Ln\n0 A\n0 Xw\n{art}LB\n%AI5_EndLayer--\n");
let ground = layer("Ground", &(layer("Lawn", "0 0 0 0.3 k\n10 20 m\n70 20 L\n70 80 L\n10 80 L\nf\n") + &layer("Paving", "")));
let text = format!(
"%!PS-Adobe-3.0\n%%Creator: a script\n%%BoundingBox: 0 0 250 100\n%AI5_FileFormat 14.0\n%AI3_Cropmarks: 0 0 250 100\n%%EndComments\n\
%%BeginProlog\n%%EndProlog\n%%BeginSetup\n%AI5_Begin_NonPrinting\nNp\n%AI5_End_NonPrinting--\n%%EndSetup\n{ground}%%PageTrailer\n%%Trailer\n%%EOF\n"
);
let r = import(text.as_bytes()).unwrap();
let d = &r.document;
assert_eq!(d.layers.len(), 1, "{:?}", r.warnings);
let subs: Vec<_> = d.layers[0].children().unwrap().iter().map(|l| l.name.clone().unwrap_or_default()).collect();
assert_eq!(subs, ["Lawn", "Paving"]);
}
/// A file in the legacy format whose prolog is included (here one of our own) runs as its page, and
/// comes in through the layers its program has when they look like the page.
#[test]
fn a_legacy_file_with_its_prolog_comes_in_through_its_layers() {
let prolog = "/Lb {10 {pop} repeat} def /Ln {pop} def /LB {} def /A {pop} def /k {setcmykcolor} def";
let body = format!("%AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n(Back) Ln\n0 A\n0 0 0 1 k\n{} LB\n%AI5_EndLayer--\n", tri(10));
let file = String::from_utf8(ai(AI8, "", &body)).unwrap().replace("%%EndProlog", &format!("{prolog}\n%%EndProlog"));
let r = import(file.as_bytes()).unwrap();
let names: Vec<_> = r.document.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect();
assert_eq!(names, ["Back"], "{:?}", r.warnings);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
// Without the editing data, it is its page.
let page = crate::import::import_with(file.as_bytes(), false).unwrap();
assert_eq!(page.document.layers[0].name.as_deref(), Some("Layer 1"));
}
/// A version 7 palette writes no swatch names (the AI7 spec's §11 example): its swatches come in
/// named by their paint, in its order, in place of the defaults.
#[test]
fn a_version_7_palette_keeps_its_swatches_without_names() {
let setup = "%AI5_BeginGradient: (B&W)\n(B&W) 0 2 Bd\n[\n%_1 0 50 0 Bs\n%_0 0 50 100 Bs\nBD\n%AI5_EndGradient\n\
%AI5_BeginPalette\n0 0 Pb\nPn\nPc\n1 g\nPc\n0 g\nPc\n0 0 0 0 k\nPc\n0.75 g\nPc\n\
Bb\n2 (B&W) 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB\nPc\n0.25 0 0 0 k\nPc\n0 1 1 0 (Spot A) 0 x\nPc\n0.75 g\nPc\nPc\nPB\n%AI5_EndPalette\n";
let body = format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 -1 79 128 255 0 50 0 Lb\n(Art) Ln\n0 A\n0 Xw\n0 0 0 1 k\n{}\nLB\n%AI5_EndLayer--\n", tri(10));
let r = import(&stream7(setup, &body)).unwrap();
let d = &r.document;
assert_eq!(d.layers.len(), 1, "{:?}", r.warnings);
let names: Vec<&str> = d.swatches.iter().map(|s| s.name.as_str()).collect();
assert_eq!(
names,
["[None]", "Gray K=0", "Gray K=100", "C=0 M=0 Y=0 K=0", "Gray K=25", "B&W", "C=25 M=0 Y=0 K=0", "Spot A", "Gray K=25"],
"{:?}",
r.warnings
);
assert!(d.swatch_groups.is_empty());
let spot = d.swatch("Spot A").unwrap();
assert!(spot.spot && spot.global, "{spot:?}");
assert!(d.swatches.iter().filter(|s| s.name != "Spot A").all(|s| !s.spot && !s.global));
assert!(matches!(&d.swatch("B&W").unwrap().paint, Paint::Gradient(g) if g.swatch.as_deref() == Some("B&W")));
assert_eq!(d.swatch("Gray K=0").unwrap().paint.color(), Some(Color::gray(0.0)), "1 g is white");
}
/// A version 8 file's paths keep their notes and Show Center.
#[test]
fn a_legacy_files_paths_keep_their_notes_and_show_center() {
let body = format!(
"%AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n(Notes) Ln\n0 A\n%AI3_Note:first\n%AI5_NoteMore:second line\n1 Ap\n0 0 0 1 k\n{}\n\
%AI3_Note:\n0 Ap\n{}\nLB\n%AI5_EndLayer--\n",
tri(10),
tri(40)
);
let r = import(&ai(AI8, "", &body)).unwrap();
let kids = r.document.layers[0].children().unwrap();
let attrs: Vec<_> = kids.iter().map(|n| n.extras().attrs.as_deref().map(|a| (a.note.clone(), a.show_center))).collect();
assert_eq!(attrs, [Some(("first\nsecond line".into(), Some(true))), None], "{:?}", r.warnings);
assert!(kids[0].shows_center() && !kids[1].shows_center());
}
#[test]
fn unbalanced_and_runaway_groups_are_read_safely() {
// Ends without a beginning end nothing; groups left open hold the rest.
assert_eq!(read(AI8, "", &format!("U U {} u {} u {}", tri(10), tri(20), tri(30))).0, "p g[p g[p]]");
// Groups nest only so deep: the deeper ones' art goes into the innermost group read, and the
// groups around them close where they should.
let n = MAX_NEST + 50;
let body = format!("{} {} {} {}", "u ".repeat(n), tri(10), "U ".repeat(n), tri(30));
let (t, warnings) = read(AI8, "", &body);
assert_eq!(t, format!("{}p{} p", "g[".repeat(MAX_NEST), "]".repeat(MAX_NEST)));
assert!(warnings.iter().any(|w| w.contains("nested more than")), "{warnings:?}");
}