Files
vectorcraft/crates/engine/src/cmd/fileio/tests.rs
T
bflatasticandClaude Opus 5.5 515ce2ac94 M4.14: Save As format chooser, Save writes back its own format, Save a Copy
Save As offers VectorCraft, VectorCraft Template, PDF, SVG and SVGZ and asks
for the chosen format's options before writing: SVG Options for SVG/SVGZ, the
Save PDF dialog for PDF (its OK finishes the save); on the web a Save As
dialog first names the file and picks the format. A document opened from or
saved as SVG/PDF remembers its format and options in one place
(DocState.format, save_options: the SVG options, or every Save PDF setting),
so Save writes it again; the result's warnings name what the format loses,
then the encoder's notes. Save a Copy writes "<name> copy.<ext>" and leaves
the path, title and modified state alone. Save is off for a clean saved
document, and Save as PDF... is in the File menu.

Engine: document.save, file.saveAs and file.saveCopy take {path?, format?,
options?, svg?} (SVG options also flat) and write linked images beside an
SVG; file.formatOptions reads a format's options. save_plan/save_with are the
one save path of every frontend, on the shared SVG encoder (one SVGZ path).
The template format is writable. Save panels get per-format filters, a folder
and a suggested name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:07:15 +07:00

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", "webp", "template"]);
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");
}