mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 11:17:01 -06:00
Issue #1935 asks for batch exports of many logo variations in many formats, each format in its own folder. This builds on Export for Screens, which already writes every chosen artboard in every format row. Names: a file name pattern (`name`) names every file alike. Tokens: <filename> (the document), <name> (the artboard, asset, layer or object), <index>, a run of # (a counter, zero-padded to the run's length, from counterStart; counterPer item or file), YYYYMMDD, YYYY-MM-DD, <date>, <time>, <YYYY>, <MM>, <DD>, <format>, <scale> and <suffix>. A trailing .ext is dropped. Words are not changed: only the date patterns and the bracketed tokens are. `date` sets the day (the default is today, UTC). Different files that a pattern names alike get -2, -3... Without a pattern, two rows with one name still write the first row only, as before. Folders: `folders` puts all files in one folder, a folder per row (as Create Sub-folders did), per extension, per item, or per item and then extension. What is exported: besides artboards, assets and the full document, `layers` (each layer's art alone, sublayers included) and `selection` (one file of the selection, or a file per object). Formats: rows can also be EPS, AI and VectorCraft (a document of the item alone, its one artboard). A PDF row with printer's marks (PDF Print in the dialog: trim, registration, colour bars, page information, suffix -print) sits next to a plain PDF row. Presets: document.exportPresets.list, .save and .delete keep batch export presets with the preferences, and `exportPreset` uses one under the call's own params. Built-in: Logo Package (SVG, PNG 1x and 2x, PDF and print PDF, a folder per format) and Print-Ready PDF. Dialog: Layers and Selection tabs with thumbnails; an Export Preset menu with Save and Delete; Folders; File Name with Insert for the tokens, Start at, Prefix, and an example of the first file's name. The OK button says Export Layer or Export Selection on those tabs. Tests: the pattern tokens, the request's example (SVG, PNG, PDF and print PDF for artboards 1-3, a folder per extension, the print PDF a bigger page), folders by item and both, the counter by item and by file, numbered duplicates, layers and the selection, EPS, AI and VectorCraft per artboard, presets (built-in, saved, used, replaced, deleted, kept with the preferences), bad params, and the dialog (every tab, the example name, a pattern export into folders, the Layers and Selection tabs, presets applied and saved). The three tests that refused EPS and VectorCraft rows now check those files. New labels are translated in all 19 catalogs.
209 lines
11 KiB
Rust
209 lines
11 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_Artboard-1.eps", "Untitled-1_Artboard-2.eps"]);
|
|
for f in files {
|
|
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);
|
|
}
|
|
// 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)));
|
|
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"));
|
|
}
|
|
|
|
#[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"));
|
|
}
|
|
|
|
#[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"));
|
|
}
|