mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 18:27:02 -06:00
Export for Screens, Batch over data sets, Save for Web and the Libraries panel name files after text the person typed: a prefix, a suffix, a name pattern, the name of an artboard, asset, layer, slice, data set or library, or the document's title. The prefix, the suffix, name patterns and Batch's data set names had only /, \ and : replaced, so on Windows a name holding <, >, ", |, ?, * or a control character failed to save, and Save for Web without a path named its files after the document's title as it was. A row folder name could end in a period, which Windows does not allow, and names such as CON, NUL, COM1 or LPT1, which Windows reserves for devices, were written as they were. file_part, which Export for Screens' prefix and suffix and Batch's data set names use, now writes all of those characters as -, and name patterns use it in place of their own copy. A new safe_file_name runs a whole file or folder name through file_part, removes trailing periods and spaces, and adds - after a device name that is the whole name or comes before its first period (CON.png becomes CON-.png). Export for Screens uses it for each file, folder and zip it names, and keeps item names distinct after it. Save for Web uses it for slice images and, without a path, for files named after the title. Library files use it too. This happens on every platform. Other names are written as before, except where a changed name now equals another item's name, which then gets a number added. Docs: docs/mcp.md, docs/target-app-parity.md.
217 lines
11 KiB
Rust
217 lines
11 KiB
Rust
//! Batch exports through Export for Screens (#1935): a file name pattern (the document's and the
|
||
//! item's names, a counter, the date), folders by format, by item or both, layers and the selection
|
||
//! as what is exported, and document formats (VectorCraft, Illustrator, EPS) as rows.
|
||
|
||
use serde_json::{Value, json};
|
||
|
||
use super::*;
|
||
|
||
fn b64(v: &Value) -> Vec<u8> {
|
||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
|
||
}
|
||
|
||
/// A document of `artboards` 40×30 pt artboards side by side with a 20 pt square on the first.
|
||
fn session(artboards: usize) -> Session {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 40, "height": 30, "artboards": artboards})).unwrap();
|
||
s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 20})).unwrap();
|
||
s
|
||
}
|
||
|
||
fn export(s: &mut Session, p: Value) -> Value {
|
||
s.execute("document.exportForScreens", &p).unwrap()
|
||
}
|
||
|
||
fn files(r: &Value) -> Vec<(String, Vec<u8>)> {
|
||
r["files"].as_array().unwrap().iter().map(|f| (f["name"].as_str().unwrap().to_string(), b64(f))).collect()
|
||
}
|
||
|
||
fn names(r: &Value) -> Vec<String> {
|
||
files(r).into_iter().map(|(n, _)| n).collect()
|
||
}
|
||
|
||
/// The request in #1935: SVG, PNG, PDF and PDF with printer's marks for artboards 1–3, named
|
||
/// `COMPANY-Logo-<filename>-####-YYYYMMDD`, sorted into a folder per extension.
|
||
#[test]
|
||
fn the_logo_package_of_the_request() {
|
||
let mut s = session(4);
|
||
let r = export(
|
||
&mut s,
|
||
json!({
|
||
"range": "1-3",
|
||
"name": "COMPANY-Logo-<filename>-####-YYYYMMDD.ext",
|
||
"date": "2026-10-16",
|
||
"folders": "format",
|
||
"formats": [
|
||
{"format": "svg"},
|
||
{"format": "png", "scale": "2x", "suffix": ""},
|
||
{"format": "pdf"},
|
||
{"format": "pdf", "suffix": "-print", "marks": {"trim": true, "registration": true, "colorBars": true, "pageInfo": true}}
|
||
]
|
||
}),
|
||
);
|
||
let n = names(&r);
|
||
assert_eq!(n.len(), 12, "{n:?}");
|
||
for (i, number) in ["0001", "0002", "0003"].iter().enumerate() {
|
||
let stem = format!("COMPANY-Logo-Untitled-1-{number}-20261016");
|
||
for want in [format!("svg/{stem}.svg"), format!("png/{stem}.png"), format!("pdf/{stem}.pdf"), format!("pdf/{stem}-print.pdf")] {
|
||
assert!(n.contains(&want), "artboard {}: {want} in {n:?}", i + 1);
|
||
}
|
||
}
|
||
// The printed PDF has room for its marks: a bigger page than the plain one.
|
||
let f = files(&r);
|
||
let page = |name: &str| {
|
||
let bytes = &f.iter().find(|(n, _)| n == name).unwrap().1;
|
||
let d = vectorcraft_pdf::import(bytes).unwrap();
|
||
let a = d.artboards.first().unwrap().rect;
|
||
(a.width(), a.height())
|
||
};
|
||
let plain = page("pdf/COMPANY-Logo-Untitled-1-0001-20261016.pdf");
|
||
let print = page("pdf/COMPANY-Logo-Untitled-1-0001-20261016-print.pdf");
|
||
assert!(print.0 > plain.0 && print.1 > plain.1, "{plain:?} vs {print:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn folders_by_item_and_by_item_and_format() {
|
||
let mut s = session(2);
|
||
let rows = json!([{"format": "png"}, {"format": "svg"}]);
|
||
let by_item = names(&export(&mut s, json!({"folders": "item", "formats": rows})));
|
||
assert_eq!(by_item, ["Artboard-1/Artboard-1.png", "Artboard-1/Artboard-1.svg", "Artboard-2/Artboard-2.png", "Artboard-2/Artboard-2.svg"]);
|
||
let both = names(&export(&mut s, json!({"folders": "itemFormat", "formats": rows})));
|
||
assert_eq!(both[0], "Artboard-1/png/Artboard-1.png");
|
||
let flat = names(&export(&mut s, json!({"folders": "none", "subfolders": true, "formats": rows})));
|
||
assert_eq!(flat[0], "Artboard-1.png", "folders wins over subfolders");
|
||
// Without folders, subfolders keeps its meaning (a folder per row).
|
||
let rows_mode = names(&export(&mut s, json!({"subfolders": true, "formats": rows})));
|
||
assert_eq!(rows_mode[0], "1x/Artboard-1.png");
|
||
}
|
||
|
||
#[test]
|
||
fn the_counter_counts_items_or_files_from_any_start() {
|
||
let mut s = session(2);
|
||
let rows = json!([{"format": "png"}, {"format": "svg"}]);
|
||
let items = names(&export(&mut s, json!({"name": "logo-###", "counterStart": 9, "formats": rows})));
|
||
assert_eq!(items, ["logo-009.png", "logo-009.svg", "logo-010.png", "logo-010.svg"]);
|
||
let per_file = names(&export(&mut s, json!({"name": "logo-#", "counterPer": "file", "formats": rows})));
|
||
assert_eq!(per_file, ["logo-1.png", "logo-2.svg", "logo-3.png", "logo-4.svg"]);
|
||
// A pattern that names different files alike numbers them instead of dropping one.
|
||
let same = names(&export(&mut s, json!({"name": "logo", "formats": [{"format": "png"}]})));
|
||
assert_eq!(same, ["logo.png", "logo-2.png"]);
|
||
// The item's name, its place and the row's size and suffix.
|
||
let parts = names(&export(
|
||
&mut s,
|
||
json!({"name": "<index>_<name>_<scale><suffix>_<format>", "range": "2", "formats": [{"format": "png", "scale": "2x"}]}),
|
||
));
|
||
assert_eq!(parts, ["1_Artboard 2_2x@2x_png.png"]);
|
||
}
|
||
|
||
/// Export for Screens writes the characters Windows does not allow in a file name (`<`, `>`, `"`,
|
||
/// `|`, `?`, `*`, control characters) as `-` in a pattern's text and in the names its tokens stand
|
||
/// for, on every platform.
|
||
#[test]
|
||
fn a_pattern_writes_the_characters_windows_does_not_allow_as_dashes() {
|
||
let mut s = session(1);
|
||
s.execute("artboard.setProps", &json!({"index": 0, "name": "a|b?\"c\u{7}"})).unwrap();
|
||
let r = export(&mut s, json!({"name": "<name>*<x>", "formats": [{"format": "svg"}]}));
|
||
assert_eq!(names(&r), ["a-b--c---x-.svg"]);
|
||
}
|
||
|
||
#[test]
|
||
fn layers_and_the_selection_are_exported_by_name() {
|
||
let mut s = session(1);
|
||
let first_layer = s.doc().unwrap().doc.layers[0].id.0;
|
||
let top = s.execute("layer.new", &json!({"name": "Dark Mode"})).unwrap()["id"].as_u64().unwrap();
|
||
let circle = s.execute("shape.ellipse", &json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||
let r = export(&mut s, json!({"layers": [first_layer, top], "formats": [{"format": "svg"}]}));
|
||
assert_eq!(names(&r), ["Layer-1.svg", "Dark-Mode.svg"]);
|
||
// The selection: one file of it all, or a file per object.
|
||
let square = s.doc().unwrap().doc.layers[0].children().unwrap()[0].id.0;
|
||
s.execute("select.set", &json!({"ids": [square, circle]})).unwrap();
|
||
assert_eq!(names(&export(&mut s, json!({"selection": "one", "formats": [{"format": "png"}]}))), ["Selection.png"]);
|
||
let each = names(&export(&mut s, json!({"selection": "each", "formats": [{"format": "png"}]})));
|
||
assert_eq!(each.len(), 2, "{each:?}");
|
||
s.execute("select.none", &json!({})).unwrap();
|
||
assert!(s.execute("document.exportForScreens", &json!({"selection": "one"})).is_err(), "nothing selected");
|
||
}
|
||
|
||
/// Document formats write each artboard as a document of its own: one artboard, its art.
|
||
#[test]
|
||
fn document_formats_write_one_artboard_each() {
|
||
let mut s = session(2);
|
||
let r = export(&mut s, json!({"formats": [{"format": "vectorcraft"}, {"format": "ai"}, {"format": "eps"}]}));
|
||
let n = names(&r);
|
||
assert_eq!(n, ["Artboard-1.vectorcraft", "Artboard-1.ai", "Artboard-1.eps", "Artboard-2.vectorcraft", "Artboard-2.ai", "Artboard-2.eps"]);
|
||
let native = &files(&r)[0].1;
|
||
let mut t = Session::new();
|
||
t.execute("document.open", &json!({"name": "a.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(native)})).unwrap();
|
||
assert_eq!(t.doc().unwrap().doc.artboards.len(), 1, "that artboard alone");
|
||
}
|
||
|
||
#[test]
|
||
fn bad_batch_params_are_refused() {
|
||
let mut s = session(1);
|
||
for bad in [
|
||
json!({"folders": "byColour"}),
|
||
json!({"counterPer": "page"}),
|
||
json!({"date": "16/10/2026"}),
|
||
json!({"selection": "some"}),
|
||
json!({"selection": "one", "fullDocument": true}),
|
||
json!({"layers": [99999]}),
|
||
json!({"layers": [1], "assets": [1]}),
|
||
] {
|
||
assert!(s.execute("document.exportForScreens", &bad).is_err(), "{bad}");
|
||
}
|
||
}
|
||
|
||
/// Export presets: the built-in ones, saving (only what a preset keeps), using one under other
|
||
/// params, replacing, deleting, and keeping them with the preferences.
|
||
#[test]
|
||
fn export_presets_save_use_and_delete() {
|
||
let mut s = session(2);
|
||
let list = s.execute("document.exportPresets.list", &json!({})).unwrap();
|
||
let built_in: Vec<&str> =
|
||
list["presets"].as_array().unwrap().iter().filter(|p| p["builtIn"] == true).map(|p| p["name"].as_str().unwrap()).collect();
|
||
assert_eq!(built_in, ["Logo Package", "Print-Ready PDF"]);
|
||
// The Logo Package: SVG, PNG 1x and 2x, PDF and PDF with marks, a folder per format.
|
||
let r = export(&mut s, json!({"exportPreset": "Logo Package", "range": "1"}));
|
||
assert_eq!(
|
||
names(&r),
|
||
[
|
||
"svg/Untitled-1-Artboard 1.svg",
|
||
"png/Untitled-1-Artboard 1.png",
|
||
"png/Untitled-1-Artboard 1@2x.png",
|
||
"pdf/Untitled-1-Artboard 1.pdf",
|
||
"pdf/Untitled-1-Artboard 1-print.pdf"
|
||
]
|
||
);
|
||
|
||
let settings = json!({"name": "ACME-<name>-##", "folders": "item", "formats": [{"format": "jpg", "quality": 80}], "range": "2", "assets": [1]});
|
||
let saved = s.execute("document.exportPresets.save", &json!({"name": "Web JPG", "settings": settings})).unwrap();
|
||
assert_eq!(saved["created"], true);
|
||
assert!(saved["settings"].get("range").is_none() && saved["settings"].get("assets").is_none(), "which art is not kept: {saved}");
|
||
let r = export(&mut s, json!({"exportPreset": "Web JPG", "range": "2"}));
|
||
assert_eq!(names(&r), ["Artboard-2/ACME-Artboard 2-01.jpg"]);
|
||
// The call's own params win over the preset's.
|
||
let r = export(&mut s, json!({"exportPreset": "Web JPG", "range": "2", "folders": "none"}));
|
||
assert_eq!(names(&r), ["ACME-Artboard 2-01.jpg"]);
|
||
// Saving again replaces it; the preferences keep it.
|
||
let again = s.execute("document.exportPresets.save", &json!({"name": "Web JPG", "settings": {"formats": [{"format": "png"}]}})).unwrap();
|
||
assert_eq!(again["created"], false);
|
||
let prefs = serde_json::to_value(&s.prefs).unwrap();
|
||
assert_eq!(prefs["screenExportPresets"][0]["name"], "Web JPG");
|
||
s.execute("document.exportPresets.delete", &json!({"name": "Web JPG"})).unwrap();
|
||
assert!(s.execute("document.exportForScreens", &json!({"exportPreset": "Web JPG"})).is_err());
|
||
// Built-in names stay; bad settings are refused when saved.
|
||
for bad in [
|
||
("document.exportPresets.save", json!({"name": "Logo Package", "settings": {}})),
|
||
("document.exportPresets.save", json!({"name": "", "settings": {}})),
|
||
("document.exportPresets.save", json!({"name": "X", "settings": {"formats": [{"format": "txt"}]}})),
|
||
("document.exportPresets.save", json!({"name": "X", "settings": {"folders": "sideways"}})),
|
||
("document.exportPresets.save", json!({"name": "X"})),
|
||
("document.exportPresets.delete", json!({"name": "Logo Package"})),
|
||
("document.exportPresets.delete", json!({"name": "Never saved"})),
|
||
] {
|
||
assert!(s.execute(bad.0, &bad.1).is_err(), "{bad:?}");
|
||
}
|
||
}
|