Files
vectorcraft/crates/engine/src/cmd/fileio/tests_epsimport.rs
T
bflatasticandClaude Opus 5.5 6752b22783 M4.91: EPS and PostScript files open and place
document.open and file.place read .eps files and PostScript by any name
(PostScript .ai opens as artwork, .ait as a new untitled document). An EPS
file VectorCraft wrote restores the document it carries (restored: true);
other files run through a small clean-room PostScript interpreter (paths,
colours and spot inks, gsave/grestore, concat, line state, bind def,
procedures, loops and dictionaries, clips, shadings, images through the
decoding filters, type as point type), and fall back to their TIFF preview
with a warning when the program can't be read. Operation, nesting, memory
and path budgets keep hostile files from hanging or exhausting memory.

The clipping-group nesting the metafile reader used moves to
vectorcraft_doc::clipnest, shared by both readers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:51:54 +07:00

122 lines
6.3 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);
// 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"));
}