vectorcraft/crates/engine/src/cmd/fileio/tests.rs

329 lines
16 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 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(), 4 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries, flattener presets and PDF presets");
}
#[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", "png", "jpg", "gif", "webp", "template", "png8", "txt", "dxf"]);
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.bmp").to_string_lossy()})).is_err(), "BMP is read-only");
// 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");
}