mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 20:58:00 -06:00
VectorCraft does not run scripts written for the reference app, and File › Scripts held only the disabled Other Script…. File › Scripts › Check Script… picks a .jsx, .jsxinc or .jsxbin file, reads it without running any of it, and shows a report: what the script needs (the script language's own objects, the document model, files, menu commands, dialogs, or features only the reference app has), what it would do outside the document, the menu-command strings it passes, the document-model member chains and type names it uses, its directives, the files it includes, and the problems that would stop it loading. Copy Report copies the report as JSON. File › Open and drag-and-drop show the report for those files too. - vectorcraft-scriptcheck, a new Standalone crate with no dependencies, holds the checker: a lexer for ECMAScript 3 with E4X literals and the #name and //@name directive lines, and an analysis of the tokens that follows variables, function scopes and the files a script includes. It never recurses: open brackets, the levels of an XML literal and the function bodies being read are kept on stacks capped at 4,096. It reads at most 8 MiB of a file and 32 MiB with the included files, follows 64 included files 16 deep, looks up at most 1,024 paths for them, keeps 1,000 entries in a list, tracks 1,000 variables holding member chains and 1,000 holding File objects, and cuts echoed text to 120 characters. A compiled script is reported as compiled and isn't read. - script.check (engine, a query) takes path, data or dataBase64. With a path and includes: true, it reads the files the #include lines name from inside the script's folder, through the automation read root, and when the script or one of those files assigns a member chain or a File object to a variable, the check treats the variable as holding it in every file. It doesn't follow absolute names, names, folders or links that lead out of the folder, names or folders longer than 4,096 bytes, or JSINCLUDE, and it reads only regular files. - ui.checkScript shows the report (dialog scriptCheck). File › Open lists Scripts (.jsx, .jsxinc, .jsxbin) and sends a script's path to script.check, which reads at most 8 MiB of it. The language rules come from ECMA-262 3rd edition and ECMA-357 2nd edition. The directive, platform and document-model root names come from the public JavaScript Tools Guide and scripting guide pages that catalog.rs cites by URL, and executeMenuCommand, doScript, loadAction, unloadAction and selectTool come from the public forum posts cited there; only names are used. Every script in the tests is written from scratch with invented names. Docs: docs/mcp.md, docs/target-app-parity.md.
489 lines
22 KiB
Rust
489 lines
22 KiB
Rust
use std::io::Cursor;
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_doc::NodeKind;
|
||
|
||
use super::*;
|
||
use crate::cmd::parse_range;
|
||
|
||
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
|
||
}
|
||
|
||
fn tmp_dir(tag: &str) -> std::path::PathBuf {
|
||
let d = std::env::temp_dir().join(format!("vc-fileio-{tag}-{}", std::process::id()));
|
||
std::fs::create_dir_all(&d).unwrap();
|
||
d
|
||
}
|
||
|
||
fn b64(v: &Value) -> Vec<u8> {
|
||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
|
||
}
|
||
|
||
fn open(s: &mut Session, name: &str, bytes: &[u8]) -> Value {
|
||
s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)}))
|
||
.unwrap_or_else(|e| panic!("{name}: {e}"))
|
||
}
|
||
|
||
/// A `w`×`h` image of one colour in `format`.
|
||
fn image_bytes(w: u32, h: u32, format: image::ImageFormat) -> Vec<u8> {
|
||
let rgba = image::RgbaImage::from_pixel(w, h, image::Rgba([200, 30, 40, 255]));
|
||
let img = if format == image::ImageFormat::Jpeg {
|
||
image::DynamicImage::ImageRgb8(image::DynamicImage::ImageRgba8(rgba).to_rgb8())
|
||
} else {
|
||
rgba.into()
|
||
};
|
||
let mut out = Vec::new();
|
||
img.write_to(&mut Cursor::new(&mut out), format).unwrap();
|
||
out
|
||
}
|
||
|
||
fn image_of(s: &Session) -> vectorcraft_doc::ImageObject {
|
||
let doc = &s.doc().unwrap().doc;
|
||
match &doc.layers[0].children().unwrap()[0].kind {
|
||
NodeKind::Image(im) => (**im).clone(),
|
||
other => panic!("expected an image, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn opens_every_readable_format() {
|
||
let mut s = session(200.0, 100.0, 1);
|
||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
|
||
let native = b64(&s.execute("document.serialize", &json!({})).unwrap());
|
||
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string();
|
||
let pdf = b64(&s.execute("document.serialize", &json!({"format": "pdf"})).unwrap());
|
||
let svgz = vectorcraft_svg::compress(&svg);
|
||
|
||
for (name, bytes, format) in [
|
||
("a.vectorcraft", native.clone(), "vectorcraft"),
|
||
("a.drawcraft", native, "vectorcraft"),
|
||
("a.svg", svg.into_bytes(), "svg"),
|
||
("a.svgz", svgz, "svgz"),
|
||
("a.pdf", pdf.clone(), "pdf"),
|
||
("a.ai", pdf, "ai"),
|
||
] {
|
||
let r = open(&mut s, name, &bytes);
|
||
assert_eq!(r["format"], format, "{name}");
|
||
assert_eq!(r["title"], if format == "vectorcraft" { "Untitled-1" } else { name }, "{name}");
|
||
assert!(s.doc().unwrap().doc.node_count() >= 2, "{name}: the rectangle came through");
|
||
}
|
||
for (name, kind) in [
|
||
("p.png", image::ImageFormat::Png),
|
||
("p.jpg", image::ImageFormat::Jpeg),
|
||
("p.gif", image::ImageFormat::Gif),
|
||
("p.webp", image::ImageFormat::WebP),
|
||
("p.tif", image::ImageFormat::Tiff),
|
||
("p.bmp", image::ImageFormat::Bmp),
|
||
] {
|
||
let r = open(&mut s, name, &image_bytes(5, 4, kind));
|
||
assert_eq!(r["format"], format(name.rsplit('.').next().unwrap()).unwrap().id, "{name}");
|
||
let im = image_of(&s);
|
||
assert_eq!((im.width, im.height), (5, 4), "{name}");
|
||
let doc = &s.doc().unwrap().doc;
|
||
assert_eq!((doc.artboards[0].rect.width(), doc.artboards[0].rect.height()), (5.0, 4.0));
|
||
// Browser-safe formats keep their bytes; TIFF and BMP are stored as PNG.
|
||
let mime = &doc.images[&im.key].mime;
|
||
assert_eq!(mime, if name.ends_with("tif") || name.ends_with("bmp") { "image/png" } else { format_for_name(name).unwrap().mime });
|
||
}
|
||
assert!(s.execute("document.open", &json!({"name": "x.txt", "dataBase64": "aGVsbG8="})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn webp_opens_at_its_pixel_size() {
|
||
let mut s = Session::new();
|
||
open(&mut s, "tiny.webp", &image_bytes(3, 2, image::ImageFormat::WebP));
|
||
let im = image_of(&s);
|
||
assert_eq!((im.width, im.height), (3, 2));
|
||
let ab = s.doc().unwrap().doc.artboards[0].rect;
|
||
assert_eq!((ab.width(), ab.height()), (3.0, 2.0));
|
||
let r = raster_image(&image_bytes(3, 2, image::ImageFormat::WebP)).unwrap();
|
||
assert_eq!((r.width, r.height, r.blob.mime.as_str()), (3, 2, "image/webp"));
|
||
}
|
||
|
||
#[test]
|
||
fn cmyk_tiffs_keep_their_inks_and_other_tiffs_become_png() {
|
||
let inks = vectorcraft_doc::cmyk::Inks::new(2, 1, vec![0, 102, 255, 0, 10, 20, 30, 40]).unwrap();
|
||
let tiff = vectorcraft_doc::ImageBlob::cmyk_tiff(&inks).unwrap();
|
||
let r = raster_image(&tiff.bytes).unwrap();
|
||
assert_eq!((r.width, r.height, r.blob.mime.as_str()), (2, 1, "image/tiff"));
|
||
assert_eq!(r.blob.cmyk(), Some(inks));
|
||
let rgb = raster_image(&image_bytes(2, 1, image::ImageFormat::Tiff)).unwrap();
|
||
assert_eq!(rgb.blob.mime, "image/png");
|
||
}
|
||
|
||
#[test]
|
||
fn content_beats_a_wrong_extension() {
|
||
let png = image_bytes(2, 2, image::ImageFormat::Png);
|
||
assert_eq!(detect("photo.jpg", &png).unwrap().id, "png");
|
||
assert_eq!(detect("noext", b"<?xml version=\"1.0\"?><svg xmlns=\"http://www.w3.org/2000/svg\"/>").unwrap().id, "svg");
|
||
assert_eq!(detect("x.ait", b"%PDF-1.7").unwrap().id, "ait");
|
||
assert_eq!(detect("x.bin", b"%PDF-1.7").unwrap().id, "pdf");
|
||
assert!(detect("x.bin", b"nothing").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn templates_open_untitled() {
|
||
let mut s = session(100.0, 100.0, 1);
|
||
let dir = tmp_dir("tpl");
|
||
let path = dir.join("t.vectorcraft").to_string_lossy().to_string();
|
||
s.execute("file.saveAsTemplate", &json!({"path": path})).unwrap();
|
||
let r = s.execute("document.open", &json!({"path": path})).unwrap();
|
||
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
|
||
assert_eq!(s.doc().unwrap().path, None);
|
||
assert!(!s.doc().unwrap().doc.template);
|
||
|
||
let pdf = b64(&s.execute("document.serialize", &json!({"format": "pdf"})).unwrap());
|
||
let ait = dir.join("t.ait").to_string_lossy().to_string();
|
||
std::fs::write(&ait, pdf).unwrap();
|
||
let r = s.execute("document.open", &json!({"path": ait})).unwrap();
|
||
assert_eq!(r["format"], "ait");
|
||
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
|
||
assert_eq!(s.doc().unwrap().path, None);
|
||
let _ = std::fs::remove_dir_all(dir);
|
||
}
|
||
|
||
#[test]
|
||
fn open_exts_cover_every_readable_format() {
|
||
for f in FORMATS.iter().filter(|f| f.read) {
|
||
for e in f.extensions {
|
||
assert!(OPEN_EXTS.contains(e), "OPEN_EXTS lacks .{e} ({})", f.label);
|
||
}
|
||
}
|
||
for e in OPEN_EXTS {
|
||
assert!(format(e).is_some_and(|f| f.read), ".{e} in OPEN_EXTS is no readable format");
|
||
}
|
||
let filters: Vec<_> = open_filters().collect();
|
||
assert_eq!(filters[0], ("All readable files", OPEN_EXTS));
|
||
assert_eq!(
|
||
filters.len(),
|
||
8 + FORMATS.iter().filter(|f| f.read).count(),
|
||
"and swatch libraries, libraries, flattener, PDF and print presets, scripts, plug-ins"
|
||
);
|
||
assert!(filters.contains(&("Scripts", crate::cmd::scripts::EXTS)), "File › Open checks scripts");
|
||
assert_eq!(filters.last(), Some(&("Plug-ins", crate::cmd::plugin::EXTS)), "File › Open installs plug-ins");
|
||
}
|
||
|
||
/// The extensions usage texts list for export: one per writable format, each picking that format
|
||
/// (`.png` picks PNG; PNG-8 is asked for by `format`).
|
||
#[test]
|
||
fn export_extensions_name_every_writable_format() {
|
||
let exts = export_extensions();
|
||
assert_eq!(
|
||
exts,
|
||
[
|
||
"vectorcraft",
|
||
"svg",
|
||
"svgz",
|
||
"pdf",
|
||
"ai",
|
||
"png",
|
||
"jpg",
|
||
"gif",
|
||
"webp",
|
||
"tif",
|
||
"bmp",
|
||
"vctemplate",
|
||
"txt",
|
||
"dxf",
|
||
"eps",
|
||
"emf",
|
||
"wmf",
|
||
"tga",
|
||
"psd"
|
||
]
|
||
);
|
||
for e in exts {
|
||
assert!(format_for_name(&format!("x.{e}")).is_some_and(|f| f.write), "{e}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn formats_query_lists_readers_writers_and_options() {
|
||
let mut s = Session::new();
|
||
let r = s.execute("document.formats", &json!({})).unwrap();
|
||
let ids = |k: &str| r[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect::<Vec<_>>();
|
||
assert_eq!(
|
||
ids("writable"),
|
||
[
|
||
"vectorcraft",
|
||
"svg",
|
||
"svgz",
|
||
"pdf",
|
||
"ai",
|
||
"png",
|
||
"jpg",
|
||
"gif",
|
||
"webp",
|
||
"tiff",
|
||
"bmp",
|
||
"template",
|
||
"png8",
|
||
"txt",
|
||
"dxf",
|
||
"eps",
|
||
"emf",
|
||
"wmf",
|
||
"tga",
|
||
"psd"
|
||
]
|
||
);
|
||
assert!(ids("readable").contains(&"tiff".to_string()) && ids("readable").contains(&"ait".to_string()));
|
||
let png = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap();
|
||
assert_eq!(png["options"]["scale"]["default"], 1);
|
||
assert_eq!(r["openExtensions"].as_array().unwrap().len(), OPEN_EXTS.len());
|
||
}
|
||
|
||
#[test]
|
||
fn ranges_parse_one_based() {
|
||
assert_eq!(parse_range("1-3, 5", 5).unwrap(), [0, 1, 2, 4]);
|
||
assert_eq!(parse_range("3-, 1", 4).unwrap(), [2, 3, 0]);
|
||
assert_eq!(parse_range("-2", 4).unwrap(), [0, 1]);
|
||
assert_eq!(parse_range("2,2,1\u{2013}2", 4).unwrap(), [1, 0]);
|
||
for bad in ["", "0", "6", "3-1", "a", "-", "1-9"] {
|
||
assert!(parse_range(bad, 5).is_err(), "{bad:?}");
|
||
}
|
||
}
|
||
|
||
/// Pages in a PDF, counted by importing it (one artboard per page).
|
||
fn pdf_pages(bytes: &[u8]) -> usize {
|
||
vectorcraft_pdf::import(bytes).unwrap().artboards.len()
|
||
}
|
||
|
||
#[test]
|
||
fn pdf_export_honours_artboard_and_range() {
|
||
let mut s = session(100.0, 80.0, 3);
|
||
let pages = |s: &mut Session, p: Value| pdf_pages(&b64(&s.execute("document.export", &p).unwrap()));
|
||
assert_eq!(pages(&mut s, json!({"format": "pdf"})), 3);
|
||
assert_eq!(pages(&mut s, json!({"format": "pdf", "artboard": 1})), 1);
|
||
assert_eq!(pages(&mut s, json!({"format": "pdf", "artboards": [0, 2]})), 2);
|
||
assert_eq!(pages(&mut s, json!({"format": "pdf", "range": "2-3"})), 2);
|
||
assert_eq!(pdf_pages(&b64(&s.execute("document.serialize", &json!({"format": "pdf", "artboard": 2})).unwrap())), 1);
|
||
assert!(s.execute("document.export", &json!({"format": "pdf", "artboard": 3})).is_err());
|
||
assert!(s.execute("document.export", &json!({"format": "pdf", "range": "1-4"})).is_err());
|
||
// Single-image formats take one artboard.
|
||
assert!(s.execute("document.export", &json!({"format": "png", "range": "1-2"})).is_err());
|
||
let png = b64(&s.execute("document.export", &json!({"format": "png", "range": "2", "scale": 0.5})).unwrap());
|
||
assert_eq!(image::load_from_memory(&png).unwrap().width(), 50);
|
||
assert!(s.execute("document.export", &json!({"format": "png", "scale": "big"})).is_err(), "typed options reject a bad type");
|
||
}
|
||
|
||
#[test]
|
||
fn export_without_path_returns_bytes_and_never_retargets() {
|
||
let mut s = session(100.0, 100.0, 1);
|
||
let dir = tmp_dir("retarget");
|
||
let doc_path = dir.join("doc.vectorcraft").to_string_lossy().to_string();
|
||
s.execute("document.save", &json!({"path": doc_path})).unwrap();
|
||
let copy = dir.join("copy.vectorcraft").to_string_lossy().to_string();
|
||
let r = s.execute("document.export", &json!({"path": copy})).unwrap();
|
||
assert_eq!(r["format"], "vectorcraft");
|
||
assert!(vectorcraft_format::sniff(&std::fs::read(©).unwrap()));
|
||
assert_eq!(s.doc().unwrap().path.as_deref(), Some(doc_path.as_str()), "exporting the native format keeps the document's path");
|
||
let r = s.execute("document.export", &json!({"format": "jpg"})).unwrap();
|
||
assert_eq!(&b64(&r)[..2], [0xFF, 0xD8]);
|
||
assert!(r.get("path").is_none());
|
||
assert!(s.execute("document.export", &json!({"path": dir.join("x.dwg").to_string_lossy()})).is_err(), "DWG can't be written");
|
||
// A never-saved document hands its native bytes back.
|
||
let mut s = session(10.0, 10.0, 1);
|
||
let r = s.execute("document.save", &json!({})).unwrap();
|
||
assert!(vectorcraft_format::sniff(&b64(&r)));
|
||
assert_eq!(s.doc().unwrap().path, None);
|
||
let _ = std::fs::remove_dir_all(dir);
|
||
}
|
||
|
||
/// A red square on a template layer and a blue one on a normal layer above it.
|
||
fn template_doc() -> (Session, u64, u64) {
|
||
let mut s = session(100.0, 100.0, 1);
|
||
let template = s.doc().unwrap().doc.layers[0].id.0;
|
||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||
let red = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||
s.execute("layer.new", &json!({})).unwrap();
|
||
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
|
||
let blue = s.execute("shape.rectangle", &json!({"x": 50, "y": 0, "width": 50, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||
s.execute("layer.setProps", &json!({"id": template, "template": true})).unwrap();
|
||
(s, red, blue)
|
||
}
|
||
|
||
fn has_red(png: &[u8]) -> bool {
|
||
image::load_from_memory(png).unwrap().to_rgba8().pixels().any(|p| p[0] > 200 && p[2] < 80 && p[3] > 0)
|
||
}
|
||
|
||
#[test]
|
||
fn raster_exports_leave_template_layers_out() {
|
||
let (mut s, red, blue) = template_doc();
|
||
let png = b64(&s.execute("document.export", &json!({"format": "png"})).unwrap());
|
||
assert!(!has_red(&png), "no red pixels from the template layer");
|
||
assert!(image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(75, 50)[2] > 200, "the normal layer is exported");
|
||
s.execute("select.set", &json!({"ids": [red, blue]})).unwrap();
|
||
let r = s.execute("document.exportSelection", &json!({"format": "png"})).unwrap();
|
||
assert!(!has_red(&b64(&r)));
|
||
assert!(r["bounds"][0].as_f64().unwrap() > 40.0, "only the blue square (and its stroke) is exported: {}", r["bounds"]);
|
||
}
|
||
|
||
#[test]
|
||
fn export_for_screens_one_page_per_pdf_unique_names() {
|
||
let mut s = session(100.0, 80.0, 3);
|
||
s.execute("artboard.setProps", &json!({"index": 0, "name": "Icon"})).unwrap();
|
||
s.execute("artboard.setProps", &json!({"index": 1, "name": "Icon"})).unwrap();
|
||
let dir = tmp_dir("screens");
|
||
let folder = dir.to_string_lossy().to_string();
|
||
let r = s
|
||
.execute(
|
||
"document.exportForScreens",
|
||
&json!({"folder": folder, "formats": [{"format": "pdf"}, {"format": "svg", "scale": 2}, {"format": "svg", "scale": 3}, {"format": "png", "scale": 2}]}),
|
||
)
|
||
.unwrap();
|
||
let files: Vec<String> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect();
|
||
let names: Vec<&str> = files.iter().map(|f| f.rsplit('/').next().unwrap()).collect();
|
||
assert_eq!(
|
||
names,
|
||
["Icon.pdf", "Icon.svg", "Icon@2x.png", "Icon-2.pdf", "Icon-2.svg", "Icon-2@2x.png", "Artboard-3.pdf", "Artboard-3.svg", "Artboard-3@2x.png"]
|
||
);
|
||
for f in files.iter().filter(|f| f.ends_with(".pdf")) {
|
||
assert_eq!(pdf_pages(&std::fs::read(f).unwrap()), 1, "{f}");
|
||
}
|
||
// No folder: the files come back as bytes.
|
||
let r = s.execute("document.exportForScreens", &json!({"range": "3", "formats": [{"format": "jpg", "scale": 0.5}]})).unwrap();
|
||
assert_eq!(r["files"][0]["name"], "Artboard-3@0.5x.jpg");
|
||
assert_eq!(image::load_from_memory(&b64(&r["files"][0])).unwrap().width(), 50);
|
||
assert!(s.execute("document.exportForScreens", &json!({"formats": [{"format": "vectorcraft"}]})).is_err());
|
||
let _ = std::fs::remove_dir_all(dir);
|
||
}
|
||
|
||
#[test]
|
||
fn template_sublayers_are_left_out_too() {
|
||
let mut s = session(100.0, 100.0, 1);
|
||
let sub = s.execute("layer.newSublayer", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||
s.execute("layer.setCurrent", &json!({"id": sub})).unwrap();
|
||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||
let red = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||
assert_eq!(s.doc().unwrap().doc.ancestry(vectorcraft_doc::NodeId(red)).unwrap().len(), 3, "the square is on a sublayer");
|
||
assert!(has_red(&b64(&s.execute("document.export", &json!({"format": "png"})).unwrap())));
|
||
s.execute("layer.setProps", &json!({"id": sub, "template": true})).unwrap();
|
||
assert!(!has_red(&b64(&s.execute("document.export", &json!({"format": "png"})).unwrap())), "a template sublayer is left out");
|
||
s.execute("select.set", &json!({"ids": [red]})).unwrap();
|
||
assert!(s.execute("document.exportSelection", &json!({"format": "png"})).is_err(), "nothing but template art is selected");
|
||
}
|
||
|
||
#[test]
|
||
fn export_for_screens_vector_rows_drop_scale_suffixes_and_names_ignore_case() {
|
||
let mut s = session(100.0, 80.0, 3);
|
||
s.execute("artboard.setProps", &json!({"index": 0, "name": "Icon"})).unwrap();
|
||
s.execute("artboard.setProps", &json!({"index": 1, "name": "icon"})).unwrap();
|
||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).artboards[2].name.clear();
|
||
// The dialog's second row starts as PNG @2x; switched to PDF it keeps that suffix text.
|
||
let formats =
|
||
json!([{"format": "png", "scale": 1, "suffix": ""}, {"format": "pdf", "scale": 2, "suffix": "@2x"}, {"format": "svg", "suffix": "-web"}]);
|
||
let r = s.execute("document.exportForScreens", &json!({"formats": formats})).unwrap();
|
||
let names: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
|
||
assert_eq!(
|
||
names,
|
||
[
|
||
"Icon.png",
|
||
"Icon.pdf",
|
||
"Icon-web.svg",
|
||
"icon-2.png",
|
||
"icon-2.pdf",
|
||
"icon-2-web.svg",
|
||
"Artboard-3.png",
|
||
"Artboard-3.pdf",
|
||
"Artboard-3-web.svg"
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn oversized_raster_exports_are_refused() {
|
||
let mut s = session(2000.0, 2000.0, 1);
|
||
let e = s.execute("document.export", &json!({"format": "webp", "scale": 10})).unwrap_err().to_string();
|
||
assert!(e.contains("too large"), "{e}");
|
||
assert!(s.execute("document.export", &json!({"format": "png", "scale": 40})).is_err(), "80000 px a side");
|
||
}
|
||
|
||
#[test]
|
||
fn new_files_never_replace_a_file() {
|
||
let d = tmp_dir("new-file");
|
||
let path = d.join("Brand.ase").to_string_lossy().to_string();
|
||
let _ = std::fs::remove_file(&path);
|
||
write_new_file(&path, b"first").unwrap();
|
||
let e = write_new_file(&path, b"second").unwrap_err().to_string();
|
||
assert!(e.starts_with(&format!("{path}: ")), "{e}");
|
||
assert_eq!(std::fs::read(&path).unwrap(), b"first");
|
||
let _ = std::fs::remove_dir_all(d);
|
||
}
|
||
|
||
#[test]
|
||
fn a_native_save_streams_the_bytes_the_encoder_makes() {
|
||
let d = tmp_dir("stream");
|
||
let mut s = session(200.0, 100.0, 1);
|
||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
|
||
for (name, compress) in [("a.vectorcraft", false), ("b.vectorcraft", true), ("c.vctemplate", true)] {
|
||
let path = d.join(name).to_string_lossy().to_string();
|
||
let p = json!({"path": path, "compress": compress});
|
||
let r = s.execute("file.saveCopy", &p).unwrap();
|
||
let written = std::fs::read(&path).unwrap();
|
||
assert_eq!(r["bytes"], written.len(), "{r}");
|
||
let plan = save_plan(&s, SaveMode::Copy, &p).unwrap();
|
||
let job = save_job(&mut s, plan).unwrap();
|
||
assert!(job.streams());
|
||
assert_eq!(written, job.encode().unwrap().files[0].1, "{name}");
|
||
assert_eq!(vectorcraft_format::load(&written).unwrap().template, name.ends_with("vctemplate"));
|
||
}
|
||
// A writer that fails: the save fails with its error.
|
||
let plan = save_plan(&s, SaveMode::Copy, &json!({"path": "/x/y.vectorcraft"})).unwrap();
|
||
let job = save_job(&mut s, plan).unwrap();
|
||
let e = job
|
||
.write_streamed(&mut |_, fill| {
|
||
fill(&mut std::io::sink()).unwrap();
|
||
Err(EngineError::Other("disk full".into()))
|
||
})
|
||
.unwrap_err();
|
||
assert_eq!(e.to_string(), "disk full");
|
||
let _ = std::fs::remove_dir_all(&d);
|
||
}
|
||
|
||
/// A native save whose file write fails partway (a full disk) leaves the file as it was, and no
|
||
/// temporary file behind.
|
||
#[test]
|
||
fn a_save_that_fails_while_streaming_leaves_the_file_as_it_was() {
|
||
/// Takes `left` bytes, then fails as a full disk does.
|
||
struct FailAfter<'a> {
|
||
inner: &'a mut dyn std::io::Write,
|
||
left: usize,
|
||
}
|
||
impl std::io::Write for FailAfter<'_> {
|
||
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
|
||
if self.left == 0 {
|
||
return Err(std::io::Error::other("disk full"));
|
||
}
|
||
let n = b.len().min(self.left);
|
||
self.left -= n;
|
||
self.inner.write(&b[..n])
|
||
}
|
||
fn flush(&mut self) -> std::io::Result<()> {
|
||
self.inner.flush()
|
||
}
|
||
}
|
||
let d = tmp_dir("stream-fail");
|
||
let mut s = session(2000.0, 2000.0, 1);
|
||
// Enough objects that the file is written in several pieces, the failure in the middle.
|
||
for i in 0..1500 {
|
||
s.execute("shape.rectangle", &json!({"x": i % 100 * 20, "y": i / 100 * 20, "width": 10, "height": 10})).unwrap();
|
||
}
|
||
for compress in [false, true] {
|
||
let path = d.join("a.vectorcraft").to_string_lossy().to_string();
|
||
s.execute("file.saveCopy", &json!({"path": path, "compress": compress})).unwrap();
|
||
let before = std::fs::read(&path).unwrap();
|
||
s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
|
||
let plan = save_plan(&s, SaveMode::Copy, &json!({"path": path, "compress": compress})).unwrap();
|
||
let job = save_job(&mut s, plan).unwrap();
|
||
let e = job.write_streamed(&mut |p, fill| super::write_file_with(p, &mut |w| fill(&mut FailAfter { inner: w, left: 10_000 }))).unwrap_err();
|
||
assert!(e.to_string().contains("disk full") || e.to_string().contains("couldn't be encoded"), "{e}");
|
||
assert_eq!(std::fs::read(&path).unwrap(), before, "compress: {compress}");
|
||
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "no temporary file is left");
|
||
}
|
||
let _ = std::fs::remove_dir_all(&d);
|
||
}
|