Files
vectorcraft/crates/engine/src/cmd/fileio/tests_epsimport.rs
T
bflatasticandClaude Opus 5.5 0bdce8c633 AI import: groups and nested groups survive
Legacy PostScript .ai files (Illustrator 3-8, and their EPS) open through
the EPS interpreter, which only recorded what was drawn: every group came
in flattened. In a file in Illustrator's format (its %AI comments, or
Illustrator named as the creator) the `u` ... `U` it writes at the top
level now open and close groups, whatever its prolog defines them as, so
groups come in nested as they were, between and inside clipping groups.

clipnest's Clip carries an optional region: without one it is a plain
group, nested in the same chain as the clips (ids given in opening order,
so a chain of clips and groups sorts by id).

Clips nested without end used to nest clipping groups past the stack
(a stack overflow in EPS and EMF/WMF import): clips and groups now nest
at most 128 deep with a warning, and each object's chain counts against
the EPS reader's drawing budget.

PDF-compatible .ai (Illustrator 9+) can't get the same: its PDF content
draws each object on its own and doesn't mark plain groups (only layers,
clipping groups and groups with opacity, blending or a mask, which
already come back); the groups live only in the private data, which is
out of scope. Documented in ROADMAP and docs/mcp.md.

Addresses Discord feedback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:06:57 +07:00

138 lines
7.0 KiB
Rust

//! EPS and PostScript files opened and placed: our own EPS files restore the document they carry,
//! others are read by the PostScript interpreter or come in as their preview.
use serde_json::{Value, json};
use super::tests_svgedit::{comparable, rich};
use super::*;
fn b64(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap_or_else(|| panic!("no dataBase64 in {v}"))).unwrap()
}
fn open(s: &mut Session, name: &str, bytes: &[u8]) -> Result<Value> {
s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)}))
}
/// `bytes` with `from` replaced by `to` (the same length: the preview header's offsets stay).
fn patched(bytes: &[u8], from: &[u8], to: &[u8]) -> Vec<u8> {
assert_eq!(from.len(), to.len());
let at = bytes.windows(from.len()).position(|w| w == from).unwrap_or_else(|| panic!("no {:?}", String::from_utf8_lossy(from)));
let mut out = bytes.to_vec();
out[at..at + to.len()].copy_from_slice(to);
out
}
/// An EPS file of ours as another app would write it: without the document it carries.
fn foreign(eps: &[u8]) -> Vec<u8> {
patched(eps, b"%VectorCraft_BeginData: native", b"%VectorCraft_BeginData: nativx")
}
fn objects(s: &Session) -> usize {
s.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, Vec::len)).sum()
}
#[test]
fn an_eps_file_of_ours_reopens_as_the_same_document() {
let mut s = rich();
let before = comparable(&s.doc().unwrap().doc);
let eps = b64(&s.execute("document.exportEps", &json!({})).unwrap());
let r = open(&mut s, "rich.eps", &eps).unwrap();
assert_eq!((&r["warnings"], &r["restored"], &r["format"]), (&json!([]), &json!(true), &json!("eps")), "{r}");
assert_eq!(comparable(&s.doc().unwrap().doc), before, "the document came back exactly");
assert_eq!(s.doc().unwrap().path, None, "an EPS file is not saved over");
// A document that can't be read: the PostScript is read instead, with a warning.
let at = eps.windows(9).position(|w| w == b"native\n% ").unwrap() + 12;
let mut damaged = eps.clone();
damaged[at] = b'z';
let r = open(&mut s, "damaged.eps", &damaged).unwrap();
assert_eq!((&r["warnings"][0], &r["restored"]), (&json!(load::EDITING_DAMAGED), &json!(false)), "{r}");
assert!(objects(&s) > 0);
}
#[test]
fn other_eps_files_are_read_by_the_postscript_interpreter() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
s.execute("shape.rectangle", &json!({"x": 20, "y": 30, "width": 100, "height": 50})).unwrap();
let eps = foreign(&b64(&s.execute("document.exportEps", &json!({"previewFormat": "none", "cmykPostScript": false})).unwrap()));
let r = open(&mut s, "plain.eps", &eps).unwrap();
assert_eq!((&r["restored"], &r["format"]), (&json!(false), &json!("eps")), "{r}");
let d = &s.doc().unwrap().doc;
// The page is the bounding box: the art's bounds.
assert_eq!(d.artboards[0].rect.size(), vectorcraft_geom::Size::new(101.0, 51.0), "the stroke's half width rounds up to whole points");
assert_eq!(objects(&s), 1);
assert_eq!(s.doc().unwrap().title(), "plain.eps");
}
#[test]
fn an_unreadable_eps_file_comes_in_as_its_preview() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
s.execute("shape.ellipse", &json!({"x": 20, "y": 30, "width": 100, "height": 50})).unwrap();
let eps = foreign(&b64(&s.execute("document.exportEps", &json!({"previewFormat": "tiffColor"})).unwrap()));
// An operator the interpreter doesn't know (another app's own).
let eps = patched(&eps, b"showpage", b"frobnica");
let r = open(&mut s, "art.eps", &eps).unwrap();
let w = r["warnings"][0].as_str().unwrap();
assert!(w.contains("frobnica") && w.contains("preview"), "{r}");
let d = &s.doc().unwrap().doc;
let layer = &d.layers[0];
let image = &layer.children().unwrap()[0];
assert!(matches!(image.kind, vectorcraft_doc::NodeKind::Image(_)), "{:?}", image.kind);
assert_eq!(image.geometric_bounds().unwrap(), d.artboards[0].rect);
}
#[test]
fn postscript_ai_files_and_garbage() {
let mut s = Session::new();
let ps = b"%!PS-Adobe-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n1 0 0 setrgbcolor 10 10 50 50 rectfill\nshowpage\n%%EOF\n";
// A PostScript .ai opens as artwork; Save asks where (the file isn't written back).
let r = open(&mut s, "legacy.ai", ps).unwrap();
assert_eq!((&r["format"], &r["restored"]), (&json!("ai"), &json!(false)), "{r}");
assert_eq!(s.doc().unwrap().path, None);
assert_eq!(objects(&s), 1);
// Illustrator's groups come in as groups, nested as they were.
let ai = b"%!PS-Adobe-3.0
%%Creator: Adobe Illustrator(R) 8.0
%%BoundingBox: 0 0 100 100
%%EndComments
/u {} def /U {} def /L {lineto} def /f {closepath fill} def
u 0 0 moveto 9 0 L 9 9 L f u 20 0 moveto 29 0 L 29 9 L f 40 0 moveto 49 0 L 49 9 L f U U
showpage
%%EOF
";
open(&mut s, "groups.ai", ai).unwrap();
let d = s.doc().unwrap().doc.clone();
let outer = &d.layers[0].children().unwrap()[0];
let inner = outer.children().map(|c| (c.len(), c[1].children().map(Vec::len))).unwrap();
assert!(matches!(outer.kind, vectorcraft_doc::NodeKind::Group { clip: false, .. }), "{:?}", outer.kind);
assert_eq!(inner, (2, Some(2)));
// A .ait one opens as a new untitled document.
let r = open(&mut s, "legacy.ait", ps).unwrap();
assert!(r["title"].as_str().unwrap().starts_with("Untitled"), "{r}");
// Garbage named .eps says what it is.
let e = open(&mut s, "junk.eps", b"not postscript at all").unwrap_err().to_string();
assert!(e.contains("junk.eps") && e.contains("PostScript"), "{e}");
let e = open(&mut s, "junk.eps", &[0xC5, 0xD0, 0xD3, 0xC6, 1, 2, 3]).unwrap_err().to_string();
assert!(e.contains("junk.eps"), "{e}");
}
#[test]
fn eps_files_place_as_one_group_clipped_to_their_bounding_box() {
let mut s = rich();
let eps = b64(&s.execute("document.exportEps", &json!({"useArtboards": true, "artboards": [0]})).unwrap());
s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap();
let r = s.execute("file.place", &json!({"name": "rich.eps", "dataBase64": vectorcraft_format::base64_encode(&eps), "at": [300, 300]})).unwrap();
assert_eq!(r["format"], "eps", "{r}");
assert_eq!(r["ids"].as_array().unwrap().len(), 1);
// The interpreter's reading of the same file places too.
let r = s.execute("file.place", &json!({"name": "other.eps", "dataBase64": vectorcraft_format::base64_encode(&foreign(&eps))})).unwrap();
assert_eq!(r["ids"].as_array().unwrap().len(), 1, "{r}");
let info = s.execute("file.place.info", &json!({"name": "other.eps", "dataBase64": vectorcraft_format::base64_encode(&foreign(&eps))})).unwrap();
assert_eq!(info["format"], "eps");
// EPS is readable, and listed for the open and place dialogs.
assert!(format("eps").is_some_and(|f| f.read && f.write));
assert!(OPEN_EXTS.contains(&"eps") && PLACE_EXTS.contains(&"eps"));
}