mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 03:37:00 -06:00
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>
122 lines
6.3 KiB
Rust
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"));
|
|
}
|