Files
vectorcraft/crates/engine/src/cmd/fileio/tests.rs
T
dotCooCoo dd466d79e4 TIFF: CCITT Group 3 compressed files open and place
1-bit TIFFs compressed with CCITT Group 3 did not open, place or
relink: the image decoder reads Group 4 only and refuses Compression 2
(modified Huffman) and Compression 3 (ITU-T T.4).

They are now decoded with hayro-ccitt, the CCITT decoder that EPS
import uses. Compression 2 rows start at a byte and have no EOL codes.
Compression 3 rows follow an EOL code and are one-dimensional, or, with
T4Options bit 0, one- or two-dimensional as each row's tag bit says.
Fill bits, FillOrder 2, either PhotometricInterpretation, strips and
tiles are read.

A file whose pixels would take more than the decoding allowance as
RGBA is refused as too large to draw. A strip or tile whose data ends
before its last row is refused as damaged, and so is a tile larger
than the allowance. The strips and tiles together are read from at
most as many bytes as the file holds.

Part of #1466.

Docs: docs/file-format-parity.md, docs/target-app-parity.md.
2026-10-18 01:21:30 +07:00

764 lines
38 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");
}
/// A 3 × 2 TIFF at 150 ppi of `channels` (`extra`: their ExtraSamples values), 8 or 16 `bits` a
/// sample, chunky or `planar`.
fn hand_tiff(photometric: u16, bits: u16, extra: &[u16], planar: bool, channels: &[Vec<u16>]) -> Vec<u8> {
let t = vectorcraft_testkit::tiff::Tiff { width: 3, height: 2, photometric, bits, extra, planar, ppi: Some(150), channels };
vectorcraft_testkit::tiff::tiff(&t)
}
/// What Place keeps of TIFF `bytes`: the ink amounts, when it keeps them, and the pixels any image
/// reader decodes from the stored image, as RGBA.
fn kept(what: &str, bytes: &[u8]) -> (Option<Vec<u8>>, Vec<u8>) {
let r = raster_image(bytes).unwrap_or_else(|e| panic!("{what}: {e}"));
assert_eq!((r.width, r.height, r.ppi), (3, 2, Some((150.0, 150.0))), "{what}");
assert_eq!(super::ppi::resolution(&r.blob.bytes), Some((150.0, 150.0)), "{what}: the stored image keeps the resolution");
let rgba = image::load_from_memory(&r.blob.bytes).unwrap_or_else(|e| panic!("{what}: {e}")).to_rgba8().into_raw();
(r.blob.cmyk().map(|i| i.data), rgba)
}
/// TIFFs that store one plane per channel (PlanarConfiguration 2) read as their chunky versions do:
/// grayscale and RGB with extra channels, and CMYK with and without them (#1466).
#[test]
fn planar_tiffs_read_as_their_chunky_versions() {
let ch = |v: [u16; 6]| v.to_vec();
let (gray, alpha, spot) = (ch([0, 51, 102, 153, 204, 255]), ch([255, 128, 0, 255, 64, 255]), ch([9, 8, 7, 6, 5, 4]));
let (r, g, b) = (ch([255, 0, 0, 10, 20, 30]), ch([0, 255, 0, 40, 50, 60]), ch([0, 0, 255, 70, 80, 90]));
let (c, m, y, k) = (ch([255, 0, 0, 0, 12, 200]), ch([0, 255, 0, 0, 34, 100]), ch([0, 0, 255, 0, 56, 50]), ch([0, 0, 0, 255, 78, 0]));
// Pixel by pixel, the samples of `px`.
let interleave = |px: &[&Vec<u16>]| -> Vec<u8> { (0..6).flat_map(|i| px.iter().map(move |v| v[i] as u8)).collect() };
let opaque = ch([255; 6]);
let inks = interleave(&[&c, &m, &y, &k]);
// (photometric, ExtraSamples, channels, the RGBA pixels when the color is gray or RGB, the inks when it is CMYK)
type Case = (u16, Vec<u16>, Vec<Vec<u16>>, Option<Vec<u8>>, Option<Vec<u8>>);
let cases: Vec<Case> = vec![
(1, vec![2], vec![gray.clone(), alpha.clone()], Some(interleave(&[&gray, &gray, &gray, &alpha])), None),
(1, vec![2, 0], vec![gray.clone(), alpha.clone(), spot.clone()], Some(interleave(&[&gray, &gray, &gray, &alpha])), None),
(2, vec![0], vec![r.clone(), g.clone(), b.clone(), spot.clone()], Some(interleave(&[&r, &g, &b, &opaque])), None),
(2, vec![2, 0], vec![r.clone(), g.clone(), b.clone(), alpha.clone(), spot.clone()], Some(interleave(&[&r, &g, &b, &alpha])), None),
(5, vec![], vec![c.clone(), m.clone(), y.clone(), k.clone()], None, Some(inks.clone())),
(5, vec![0], vec![c.clone(), m.clone(), y.clone(), k.clone(), spot.clone()], None, Some(inks.clone())),
(5, vec![2], vec![c.clone(), m.clone(), y.clone(), k.clone(), alpha.clone()], None, None),
];
for (photometric, extra, channels, want_rgba, want_inks) in cases {
for bits in [8, 16] {
let wide: Vec<Vec<u16>> = channels.iter().map(|ch| ch.iter().map(|v| if bits == 16 { v * 257 } else { *v }).collect()).collect();
let what = format!("photometric {photometric}, extra samples {extra:?}, {bits} bits");
let chunky = kept(&format!("{what}, chunky"), &hand_tiff(photometric, bits, &extra, false, &wide));
let planar = kept(&format!("{what}, planar"), &hand_tiff(photometric, bits, &extra, true, &wide));
assert_eq!(planar.0, chunky.0, "{what}: planar keeps the inks chunky keeps");
assert_eq!(planar.0, want_inks, "{what}: the inks");
// A 16-bit chunky CMYK file is stored as it is, and a planar one is written again with
// its 8-bit inks: their RGB pixels may be a level apart.
let close = planar.1.len() == chunky.1.len() && planar.1.iter().zip(&chunky.1).all(|(a, b)| a.abs_diff(*b) <= 1);
assert!(close, "{what}: planar reads as chunky: {:?} against {:?}", planar.1, chunky.1);
if let Some(want) = &want_rgba {
assert_eq!(&planar.1, want, "{what}: the pixels");
}
}
}
}
/// A planar TIFF whose header claims more strips than the decoder can count (here 2^31 + 1 rows
/// a strip each, times 5 planes) is refused as too large to draw, without a panic.
#[test]
fn planar_tiffs_with_more_strips_than_can_be_counted_are_too_large() {
let plane = |v: u16| vec![v; 6];
let mut file = hand_tiff(5, 8, &[0], true, &[plane(200), plane(40), plane(10), plane(0), plane(90)]);
// ImageLength (the directory's second entry, its value at offset 30) and RowsPerStrip (the
// eighth, at offset 102).
file[30..34].copy_from_slice(&(1u32 << 31 | 1).to_le_bytes());
file[102..106].copy_from_slice(&1u32.to_le_bytes());
let e = raster_image(&file).err().expect("refused");
assert!(e.to_string().contains("an image of 3 × 2147483649 pixels is too large to draw"), "{e}");
assert!(too_large_note(&file).is_some());
assert_eq!(vectorcraft_doc::cmyk::oversized_tiff(&file), Some((3, 1 << 31 | 1)));
}
/// A planar CMYK TIFF opens and places with its ink amounts, stored as a TIFF the image decoder
/// reads (#1466).
#[test]
fn planar_cmyk_tiffs_open_and_place_with_their_inks() {
let inks = [vec![255u16, 0, 12, 0, 40, 9], vec![0, 255, 34, 0, 50, 8], vec![0, 0, 56, 0, 60, 7], vec![0, 0, 78, 255, 70, 6]];
let file = hand_tiff(5, 8, &[], true, &inks);
let want: Vec<u8> = (0..6).flat_map(|i| inks.iter().map(move |v| v[i] as u8)).collect();
assert!(image::load_from_memory(&file).is_err(), "the image decoder reads planar CMYK now");
let mut s = Session::new();
open(&mut s, "planar.tif", &file);
let blob = &s.doc().unwrap().doc.images[&image_of(&s).key];
assert_eq!(blob.cmyk().map(|i| i.data), Some(want.clone()), "Open keeps the inks");
let mut s = session(100.0, 100.0, 1);
s.execute("file.place", &json!({"name": "planar.tif", "dataBase64": vectorcraft_format::base64_encode(&file)})).unwrap();
let blob = &s.doc().unwrap().doc.images[&image_of(&s).key];
assert_eq!(blob.cmyk().map(|i| i.data), Some(want), "Place keeps the inks");
assert!(image::load_from_memory(&blob.bytes).is_ok(), "the image decoder reads the stored image");
}
/// A 70 × 7 picture, `true` for black: a white row and a black row (runs past 63 take a make-up
/// code), then runs of several lengths.
fn fax_picture() -> Vec<Vec<bool>> {
(0..7).map(|y| (0..70).map(|x| if y < 2 { y == 1 } else { (x * (y + 2) / 5 + y) % 3 == 0 }).collect()).collect()
}
/// The first `rows` rows of [`fax_picture`] as a TIFF compressed with CCITT Group 3 in one strip:
/// `compression` 2 or 3 with `t4_options`, white is zero, the highest bit of each byte first.
fn group3_tiff(compression: u16, t4_options: u32, rows: usize) -> Vec<u8> {
let rows = &fax_picture()[..rows];
let g =
vectorcraft_testkit::tiff::Group3 { rows, compression, t4_options, photometric: 0, fill_order: 1, rows_per_strip: 7, tile: None, rtc: false };
vectorcraft_testkit::tiff::group3(&g)
}
/// 1-bit TIFFs compressed with CCITT Group 3 open and place with their pixels (#1466): Compression
/// 2 (modified Huffman) and Compression 3 (ITU-T T.4) with one-dimensional rows or two-dimensional
/// ones (T4Options bit 0), with and without fill bits (bit 2) and six EOLs at the end, in one strip,
/// three strips or three 32 × 16 tiles, each byte's highest or lowest bit first (FillOrder 1 and 2),
/// white or black as zero.
#[test]
fn ccitt_group3_tiffs_open_and_place_with_their_pixels() {
let rows = fax_picture();
let mut s = Session::new();
for (compression, t4_options, rtc) in [(2, 0, false), (3, 0, false), (3, 4, true), (3, 1, false), (3, 5, true)] {
for (rows_per_strip, tile) in [(7, None), (3, None), (7, Some((32, 16)))] {
for fill_order in [1, 2] {
for photometric in [0, 1] {
let what = format!(
"Compression {compression}, T4Options {t4_options}, {rows_per_strip} rows a strip, tiles {tile:?}, FillOrder {fill_order}, photometric {photometric}"
);
let g = vectorcraft_testkit::tiff::Group3 {
rows: &rows,
compression,
t4_options,
photometric,
fill_order,
rows_per_strip,
tile,
rtc,
};
let file = vectorcraft_testkit::tiff::group3(&g);
assert!(image::load_from_memory(&file).is_err(), "{what}: the image decoder reads Group 3 now");
// A black run's pixels are 1 bits, which show as black when white is zero.
let want: Vec<u8> = rows.concat().iter().map(|&black| if black == (photometric == 0) { 0 } else { 255 }).collect();
let pixels = |s: &Session| {
let blob = &s.doc().unwrap().doc.images[&image_of(s).key];
image::load_from_memory(&blob.bytes).unwrap_or_else(|e| panic!("{what}: {e}")).to_luma8().into_raw()
};
open(&mut s, "fax.tif", &file);
assert_eq!(pixels(&s), want, "{what}: Open");
let mut placed = session(100.0, 100.0, 1);
placed.execute("file.place", &json!({"name": "fax.tif", "dataBase64": vectorcraft_format::base64_encode(&file)})).unwrap();
assert_eq!(pixels(&placed), want, "{what}: Place");
}
}
}
}
}
/// A Group 3 TIFF whose pixels would take more than the decoding allowance as RGBA is refused as
/// too large to draw, without a panic at 2^31 × 2^31 pixels too. One whose data ends inside a row,
/// or after a whole row before the strip's last, is refused as damaged, and so is one whose tiles
/// are wider than the allowance.
#[test]
fn ccitt_group3_tiffs_too_large_or_cut_short_are_refused() {
// ImageWidth (the directory's first entry, its value at offset 18), ImageLength (the second, at
// 30) and RowsPerStrip (the eighth, at 102).
for (width, height) in [(70u32, 1u32 << 30), (1 << 31, 1 << 31)] {
let mut big = group3_tiff(3, 1, 7);
big[18..22].copy_from_slice(&width.to_le_bytes());
big[30..34].copy_from_slice(&height.to_le_bytes());
big[102..106].copy_from_slice(&height.to_le_bytes());
let e = raster_image(&big).err().expect("refused");
assert!(e.to_string().contains(&format!("an image of {width} × {height} pixels is too large to draw")), "{e}");
}
let mut short = group3_tiff(3, 0, 7);
short.truncate(short.len() - 6);
let e = raster_image(&short).err().expect("refused");
assert!(e.to_string().contains("could not decode image: unexpected end of input"), "{e}");
// Compression 2 rows start at a byte, so the strip of the first six rows is where the strip of
// seven starts. StripByteCounts (the ninth entry, its value at offset 114) gives the six rows'.
let (mut seven, six) = (group3_tiff(2, 0, 7), group3_tiff(2, 0, 6));
seven[114..118].copy_from_slice(&six[114..118]);
assert_eq!(seven[30..34], 7u32.to_le_bytes(), "ImageLength");
assert_eq!(seven[78..82], six[78..82], "StripOffsets (the sixth entry, at offset 78)");
let e = raster_image(&seven).err().expect("refused");
assert!(e.to_string().contains("could not decode image: unexpected end of input"), "{e}");
// A tile wider than the allowance: TileWidth (the eighth entry, its value at offset 102).
let rows = fax_picture();
let g = vectorcraft_testkit::tiff::Group3 {
rows: &rows,
compression: 3,
t4_options: 1,
photometric: 0,
fill_order: 1,
rows_per_strip: 7,
tile: Some((80, 16)),
rtc: false,
};
let mut wide = vectorcraft_testkit::tiff::group3(&g);
assert_eq!(wide[102..106], 80u32.to_le_bytes(), "TileWidth");
wide[102..106].copy_from_slice(&u32::MAX.to_le_bytes());
let e = raster_image(&wide).err().expect("refused");
assert!(e.to_string().contains("could not decode image: arithmetic overflow"), "{e}");
}
#[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();
// A template under a native file name still opens untitled.
std::fs::write(&path, b64(&s.execute("file.saveAsTemplate", &json!({})).unwrap())).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(),
9 + FORMATS.iter().filter(|f| f.read).count(),
"and swatch libraries, libraries, flattener, PDF and print presets, shortcut sets, 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());
}
/// The format ids `command`'s params text lists for `format` (`a|b (default)|c (…)`, up to the
/// first comma), sorted.
fn listed_formats(command: &str) -> Vec<String> {
let params = crate::cmd::find_command(command).unwrap().params;
let (_, rest) = params.split_once("format?: ").unwrap_or_else(|| panic!("{command} lists no formats"));
let list = rest.split(',').next().unwrap_or_default();
let mut ids: Vec<String> = list.split('|').filter_map(|f| f.split_whitespace().next()).map(str::to_string).collect();
ids.sort();
ids
}
/// The params texts of the commands that take any writable format list exactly the formats each
/// command writes (.ai among them).
#[test]
fn export_params_list_every_format_the_command_writes() {
let mut s = session(100.0, 80.0, 1);
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
s.execute("select.all", &json!({})).unwrap();
for command in ["document.serialize", "document.export", "document.exportSelection"] {
let mut refused = vec![];
let mut written: Vec<String> = FORMATS
.iter()
.filter(|f| s.execute(command, &json!({"format": f.id})).map_err(|e| refused.push(format!("{}: {e}", f.id))).is_ok())
.map(|f| f.id.to_string())
.collect();
written.sort();
assert_eq!(listed_formats(command), written, "{command}; refused: {refused:?}");
}
}
#[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);
// A native row: each artboard as a document of its own to edit.
let r = s.execute("document.exportForScreens", &json!({"range": "1", "formats": [{"format": "vectorcraft"}]})).unwrap();
assert!(r["files"][0]["name"].as_str().unwrap().ends_with(".vectorcraft"), "{r}");
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);
}
/// A save that fails leaves File Info's dates and the saved state alone; one that works stamps them.
#[test]
fn a_failed_save_does_not_stamp_the_documents_dates() {
let mut s = session(200.0, 100.0, 1);
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
let before = s.doc().unwrap().doc.metadata.clone();
let dirty = s.doc().unwrap().is_dirty();
let plan = save_plan(&s, SaveMode::SaveAs, &json!({"path": "/x/y.vectorcraft", "modified": 12345})).unwrap();
let e = save_with(&mut s, plan, |_, _| Err(EngineError::Other("disk full".into()))).unwrap_err();
assert_eq!(e.to_string(), "disk full");
let st = s.doc().unwrap();
assert_eq!(st.doc.metadata, before);
assert_eq!(st.is_dirty(), dirty);
// Options the encoder refuses: the same.
assert!(s.execute("file.saveAs", &json!({"path": "/x/y.vectorcraft", "version": 99, "modified": 12345})).is_err());
let st = s.doc().unwrap();
assert_eq!(st.doc.metadata, before);
assert_eq!(st.is_dirty(), dirty);
let plan = save_plan(&s, SaveMode::SaveAs, &json!({"path": "/x/y.vectorcraft", "modified": 12345})).unwrap();
save_with(&mut s, plan, |_, _| Ok(())).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.doc.metadata.modified, Some(12345));
assert!(!st.is_dirty());
// Edited while the file is written (Background Save): the dates go on once it is, and the
// edit keeps the document modified.
let plan = save_plan(&s, SaveMode::SaveAs, &json!({"path": "/x/z.vectorcraft", "modified": 777})).unwrap();
let job = save_job(&mut s, plan).unwrap();
let uid = s.doc().unwrap().uid;
assert_eq!(s.doc().unwrap().doc.metadata.modified, Some(12345));
s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
job.write(|_, _| Ok(())).unwrap();
job.complete(&mut s, uid);
let st = s.doc().unwrap();
assert_eq!(st.doc.metadata.modified, Some(777));
assert_eq!(st.path.as_deref(), Some("/x/z.vectorcraft"));
assert!(st.is_dirty());
// Undoing the edit is back at what the file holds.
s.execute("edit.undo", &json!({})).unwrap();
assert!(!s.doc().unwrap().is_dirty());
}