Files
vectorcraft/crates/engine/src/cmd/fileio/tests.rs
T
dotCooCoo 975d4975ee Swatches: swatch.library.copyToUser copies a loaded library into User Defined
swatch.library.copyToUser {library} copies a library loaded from
another folder into the user library folder of the desktop app. A
.vcswatches, .gpl or .ase file is copied as it is; the swatches of a
document and a library loaded from the data or dataBase64 parameter are
written as a .vcswatches file. The copy gets a name that no file in the
folder has, and when a file with the same extension and bytes is
already there, that file is used. The library is then listed as User
Defined only. Until the app quits, commands given its loaded id use the
copy, unless another library is loaded under that id. Without a user
library folder (the web and headless sessions) the command is disabled.

A file with the same extension and bytes as a User Defined library
opens as that User Defined library. The unchanged original of a copied
.ase file is one such file. Graphic style libraries open the same way.
2026-10-09 13:32:59 -07:00

411 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(), 6 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries, flattener, PDF and print presets, plug-ins");
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(&copy).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);
}