Files
vectorcraft/crates/engine/src/cmd/fileio/tests_eps.rs
T
dotCooCoo 4210c2447f EPS: the useArtboards help text separates false from absent
useArtboards: false writes one file of the visible art even when artboards are named. With useArtboards absent, naming artboards writes the same files as useArtboards: true, including the no-art exception for the central file. The option description and the document.exportEps params text now say so, and the EPS test covers both cases.
2026-10-24 14:00:32 -07:00

266 lines
15 KiB
Rust

//! EPS export through the commands: the art's bounds or one file per artboard, flattened
//! transparency, previews, colours, options and the document the file carries.
use serde_json::{Value, json};
use super::*;
fn session(width: f64, height: f64, artboards: usize) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": width, "height": height, "artboards": artboards})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s
}
fn b64(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
}
/// The PostScript of an EPS file (behind its preview header, if any).
fn postscript(bytes: &[u8]) -> String {
String::from_utf8(vectorcraft_eps::sections(bytes).unwrap().0.to_vec()).unwrap()
}
fn dsc(ps: &str, key: &str) -> Option<String> {
let prefix = format!("%%{key}:");
ps.lines().find_map(|l| l.strip_prefix(&prefix)).map(|v| v.trim().to_string())
}
/// The first value of TIFF tag `tag` (a short or a long, in the first directory).
fn tiff_tag(tiff: &[u8], tag: u16) -> Option<u32> {
let u16_at = |at: usize| Some(u16::from_le_bytes(tiff.get(at..at + 2)?.try_into().ok()?));
let dir = u32::from_le_bytes(tiff.get(4..8)?.try_into().ok()?) as usize;
let entry = (0..u16_at(dir)? as usize).map(|i| dir + 2 + i * 12).find(|e| u16_at(*e) == Some(tag))?;
match u16_at(entry + 2)? {
3 => u16_at(entry + 8).map(u32::from),
_ => Some(u32::from_le_bytes(tiff.get(entry + 8..entry + 12)?.try_into().ok()?)),
}
}
fn warnings(r: &Value) -> Vec<String> {
r["warnings"].as_array().unwrap().iter().map(|w| w.as_str().unwrap().to_string()).collect()
}
#[test]
fn eps_covers_the_art_y_up_from_the_first_artboard_with_a_preview_and_the_document() {
let mut s = session(200.0, 200.0, 1);
s.execute("shape.rectangle", &json!({"x": 50, "y": 60, "width": 100, "height": 40})).unwrap();
let r = s.execute("document.export", &json!({"format": "eps"})).unwrap();
assert_eq!(r["format"], "eps");
let bytes = b64(&r);
// The default TIFF preview sits behind the binary header.
let (_, tiff) = vectorcraft_eps::sections(&bytes).unwrap();
let preview = image::load_from_memory_with_format(tiff.unwrap(), image::ImageFormat::Tiff).unwrap();
assert_eq!((preview.width(), preview.height()), (100, 40));
let ps = postscript(&bytes);
assert_eq!(dsc(&ps, "BoundingBox").as_deref(), Some("50 100 150 140"), "60..100 down is 100..140 up");
assert_eq!(dsc(&ps, "LanguageLevel").as_deref(), Some("3"));
assert!(ps.contains("[1 0 0 -1 0 200] cm"));
// RGB documents are written in CMYK by default.
assert!(!ps.contains(" rg\n") && ps.contains(" k\n"), "{ps}");
// The document comes back whole, and so does a thumbnail.
let native = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap();
assert_eq!(native.layers[0].children().unwrap().len(), 1);
assert!(vectorcraft_eps::thumbnail(&bytes).unwrap().starts_with(b"\x89PNG"));
// Without the preview, thumbnail and CMYK: plain PostScript in RGB.
let r = s.execute("document.exportEps", &json!({"previewFormat": "none", "thumbnails": false, "cmykPostScript": false, "level": 2})).unwrap();
let bytes = b64(&r);
assert!(bytes.starts_with(b"%!PS-Adobe-3.0 EPSF-3.0"));
let ps = postscript(&bytes);
assert!(ps.contains(" rg\n") && vectorcraft_eps::thumbnail(&bytes).is_none());
assert_eq!(dsc(&ps, "LanguageLevel").as_deref(), Some("2"));
}
#[test]
fn use_artboards_writes_a_file_per_artboard_named_with_an_underscore() {
let mut s = session(100.0, 100.0, 2);
let second = s.doc().unwrap().doc.artboards[1].rect.x0;
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
s.execute("shape.rectangle", &json!({"x": second + 10.0, "y": 10, "width": 30, "height": 30})).unwrap();
let r = s.execute("document.export", &json!({"format": "eps", "useArtboards": true, "previewFormat": "none"})).unwrap();
let files = r["files"].as_array().unwrap();
let names: Vec<&str> = files.iter().map(|f| f["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Untitled-1.eps", "Untitled-1_Artboard-1.eps", "Untitled-1_Artboard-2.eps"]);
// The central file comes first: the whole document, bounded by the visible art of both artboards.
let central = b64(&files[0]);
assert_eq!(dsc(&postscript(&central), "BoundingBox").as_deref(), Some(format!("10 60 {} 90", second + 40.0).as_str()));
let native = vectorcraft_format::load(&vectorcraft_eps::native(&central).unwrap()).unwrap();
assert_eq!((native.artboards.len(), native.layers[0].children().unwrap().len()), (2, 2));
for f in &files[1..] {
let bytes = b64(f);
let ps = postscript(&bytes);
assert_eq!(dsc(&ps, "BoundingBox").as_deref(), Some("0 0 100 100"));
// Each carries the document with its own artboard.
let native = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap();
assert_eq!(native.artboards.len(), 1);
}
// Named artboards, two or more, get the central file too.
let r = s.execute("document.exportEps", &json!({"artboards": [1, 0], "previewFormat": "none"})).unwrap();
let names: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Untitled-1.eps", "Untitled-1_Artboard-2.eps", "Untitled-1_Artboard-1.eps"]);
// A range names the artboards (useArtboards implied); one file keeps the path's name.
let r = s.execute("document.exportEps", &json!({"range": "2", "previewFormat": "none"})).unwrap();
assert!(r.get("files").is_none());
assert!(postscript(&b64(&r)).contains(&format!("[1 0 0 -1 {} 100] cm", -second)));
// useArtboards: false writes one file of the visible art, even with artboards named.
let r = s.execute("document.exportEps", &json!({"useArtboards": false, "artboards": [0, 1], "previewFormat": "none"})).unwrap();
assert!(r.get("files").is_none());
assert_eq!(dsc(&postscript(&b64(&r)), "BoundingBox").as_deref(), Some(format!("10 60 {} 90", second + 40.0).as_str()));
assert!(s.execute("document.exportEps", &json!({"artboard": 5})).is_err());
// Without art there is nothing to bound.
let mut empty = session(100.0, 100.0, 1);
assert!(empty.execute("document.exportEps", &json!({})).unwrap_err().to_string().contains("nothing to export"));
// Artboards without art still get their files, but no central file.
let mut empty = session(100.0, 100.0, 2);
let r = empty.execute("document.exportEps", &json!({"useArtboards": true, "previewFormat": "none"})).unwrap();
assert_eq!(r["files"].as_array().map(Vec::len), Some(2), "{r}");
// Named artboards without useArtboards get the same files.
let r = empty.execute("document.exportEps", &json!({"range": "1-2", "previewFormat": "none"})).unwrap();
assert_eq!(r["files"].as_array().map(Vec::len), Some(2), "{r}");
}
#[test]
fn use_artboards_writes_the_central_file_beside_the_artboard_files() {
let mut s = session(100.0, 100.0, 2);
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
let dir = std::env::temp_dir().join(format!("vc-epsboards-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("logo.eps").to_string_lossy().to_string();
s.execute("document.exportEps", &json!({"path": path, "useArtboards": true, "previewFormat": "none"})).unwrap();
let mut written: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
written.sort();
assert_eq!(written, ["logo.eps", "logo_Artboard-1.eps", "logo_Artboard-2.eps"]);
let central = std::fs::read(&path).unwrap();
assert_eq!(dsc(&postscript(&central), "BoundingBox").as_deref(), Some("10 60 40 90"));
let _ = std::fs::remove_dir_all(dir);
// Export for Screens still writes one EPS per artboard, with no central file.
let rows = json!({"formats": [{"format": "eps", "useArtboards": true}], "settings": {"eps": {"useArtboards": true}}});
let r = s.execute("document.exportForScreens", &rows).unwrap();
assert_eq!(r["files"].as_array().map(Vec::len), Some(2), "{r}");
}
#[test]
fn transparency_is_flattened_with_the_flattener_preset() {
let mut s = session(200.0, 200.0, 1);
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 100, "height": 100})).unwrap();
s.execute("select.none", &json!({})).unwrap();
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 60, "y": 60, "width": 100, "height": 100})).unwrap();
s.execute("transparency.set", &json!({"opacity": 50})).unwrap();
let opts = json!({"previewFormat": "none", "thumbnails": false, "cmykPostScript": false});
let r = s.execute("document.exportEps", &opts).unwrap();
let w = warnings(&r);
assert!(w.iter().any(|w| w.contains("flattened")), "{w:?}");
assert!(!w.iter().any(|w| w.contains("written opaque")), "nothing transparent reaches the writer: {w:?}");
let ps = postscript(&b64(&r));
// Flat regions: red alone, the half-transparent blue over red, and over white.
let colours: Vec<&str> = ps.lines().filter(|l| l.ends_with(" rg")).collect();
assert_eq!(colours, ["0.5 0.5 1 rg", "1 0 0 rg", "0.5 0 0.5 rg"], "{ps}");
// The document is left as it was, and reopens with its transparency.
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.layers[0].children().unwrap().len(), 2);
let native = vectorcraft_format::load(&vectorcraft_eps::native(&b64(&r)).unwrap()).unwrap();
assert!(native.layers[0].children().unwrap().iter().any(|c| c.opacity < 1.0));
// A saved preset by name; an unknown one is refused.
s.execute("flattener.presets.save", &json!({"name": "All Raster", "balance": 0})).unwrap();
let r = s.execute("document.exportEps", &json!({"flattenerPreset": "All Raster", "previewFormat": "none"})).unwrap();
assert!(postscript(&b64(&r)).contains("image"), "rasterized");
let e = s.execute("document.exportEps", &json!({"flattenerPreset": "Nope"})).unwrap_err().to_string();
assert!(e.contains("preset"), "{e}");
}
#[test]
fn overprints_are_kept_or_discarded() {
let mut s = session(100.0, 100.0, 1);
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap()["id"].as_u64().unwrap();
s.execute("attributes.set", &json!({"ids": [id], "overprintFill": true})).unwrap();
let kept = postscript(&b64(&s.execute("document.exportEps", &json!({"previewFormat": "none"})).unwrap()));
assert!(kept.contains("true op\n"), "{kept}");
let dropped = postscript(&b64(&s.execute("document.exportEps", &json!({"previewFormat": "none", "overprints": "discard"})).unwrap()));
assert!(!dropped.contains("true op"));
}
#[test]
fn placed_images_are_written_and_previews_are_black_and_white_on_request() {
let mut s = super::tests_svg::image_session();
let r = s.execute("document.exportEps", &json!({"previewFormat": "tiffBw", "level": 2})).unwrap();
let bytes = b64(&r);
let ps = postscript(&bytes);
assert!(ps.contains("/RunLengthDecode filter >> image"), "{ps}");
let tiff = vectorcraft_eps::sections(&bytes).unwrap().1.unwrap();
// A bilevel TIFF: one sample of one bit, white is zero.
assert!(tiff.starts_with(b"II*\0"));
assert_eq!((tiff_tag(tiff, 258), tiff_tag(tiff, 277), tiff_tag(tiff, 262)), (Some(1), Some(1), Some(0)));
let l3 = postscript(&b64(&s.execute("document.exportEps", &json!({"previewFormat": "none"})).unwrap()));
assert!(l3.contains("/FlateDecode filter >> image"));
}
/// Two placed images whose pixel data can't be decoded are left out of the EPS, and `warnings`
/// holds the note raster exports give, once, without naming either image's key.
#[test]
fn images_that_cant_be_decoded_give_the_raster_note_once() {
const NOTE: &str = "an image couldn't be decoded and is missing from the picture";
let mut s = session(200.0, 100.0, 1);
crate::tests_menucmds::place_undecodable_png(&mut s);
crate::tests_menucmds::place_undecodable_png_sized(&mut s, 32, 16);
let keys: Vec<String> = s.doc().unwrap().doc.images.keys().cloned().collect();
assert_eq!(keys.len(), 2);
let r = s.execute("document.exportEps", &json!({"previewFormat": "none"})).unwrap();
let w = warnings(&r);
assert_eq!(w.iter().filter(|w| *w == NOTE).count(), 1, "{w:?}");
assert!(!w.iter().any(|w| keys.iter().any(|k| w.contains(k.as_str()))), "{w:?}");
assert!(!postscript(&b64(&r)).contains("filter >> image"), "no image is written");
}
#[test]
fn bad_eps_options_are_refused_and_formats_list_eps() {
let mut s = session(100.0, 100.0, 1);
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
for p in [
json!({"level": 1}),
json!({"level": "4"}),
json!({"previewFormat": "pict"}),
json!({"overprints": "simulate"}),
json!({"flattener": {"balance": 400}}),
json!({"thumbnails": "yes"}),
] {
let e = s.execute("document.exportEps", &p).unwrap_err().to_string();
assert!(e.contains("EPS"), "{p}: {e}");
}
// Export for Screens writes an EPS per artboard (a document of that artboard alone).
let r = s.execute("document.exportForScreens", &json!({"formats": [{"format": "eps"}]})).unwrap();
assert!(r["files"][0]["name"].as_str().unwrap().ends_with(".eps"), "{r}");
let r = s.execute("document.formats", &json!({})).unwrap();
let eps = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "eps").unwrap().clone();
// Readable too since EPS import (tests_epsimport.rs).
assert_eq!((eps["read"].as_bool(), eps["write"].as_bool(), eps["mime"].as_str()), (Some(true), Some(true), Some("application/postscript")));
for o in [
"level",
"previewFormat",
"transparentPreview",
"overprints",
"flattenerPreset",
"flattener",
"embedFonts",
"includeLinkedFiles",
"thumbnails",
"cmykPostScript",
"compatibleGradients",
"selectedOnly",
"useArtboards",
] {
assert!(eps["options"].get(o).is_some(), "{o}");
}
}
#[test]
fn selected_only_writes_the_selection() {
let mut s = session(200.0, 200.0, 1);
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
let b = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [b]})).unwrap();
let ps = postscript(&b64(&s.execute("document.exportEps", &json!({"selectedOnly": true, "previewFormat": "none"})).unwrap()));
assert_eq!(dsc(&ps, "BoundingBox").as_deref(), Some("100 80 150 100"));
}