Files
vectorcraft/crates/engine/src/tests_legacy_ai.rs
T
Dan Green bcae7ca486 Part of #868: Save As .ai offers Legacy (version 7), with Transparent Images and an artboard range
aiVersion 7 saves through the legacy writer, one file per artboard; range picks the artboards
and document.save writes them again. Appearances are expanded first. Transparent images
(see-through pixels, opacity or a blending mode) are flattened with the art under them into
opaque images clipped to where they change it (transparentImages "flatten", the default), or
left out ("omit"); both warn.
2026-10-16 01:36:24 +07:00

230 lines
12 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.
//! Save As › `.ai` with `aiVersion: "7"`: legacy `.ai` files of version 7, one per artboard, that open with
//! their layers and groups (#868).
use serde_json::{Value, json};
use super::*;
use crate::tests_links::Folder;
/// Three artboards (named A, B and C) with a rectangle on each, on two layers: the first
/// rectangle's group (with a second one) on `Layer 1`, the others on `Top`.
fn three_boards() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
s.execute("artboard.setProps", &json!({"index": 0, "name": "A"})).unwrap();
for (i, name) in ["B", "C"].iter().enumerate() {
let x = 200.0 * (i + 1) as f64;
s.execute("artboard.new", &json!({"x": x, "y": 0, "width": 100, "height": 100, "name": name})).unwrap();
}
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
s.execute("shape.rectangle", &json!({"x": 50, "y": 10, "width": 30, "height": 30})).unwrap();
s.execute("select.all", &json!({})).unwrap();
s.execute("object.group", &json!({})).unwrap();
s.execute("layer.new", &json!({"name": "Top"})).unwrap();
for x in [210, 410] {
s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 30, "height": 30})).unwrap();
}
s
}
fn layer_names(d: &vectorcraft_doc::Document) -> Vec<String> {
d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect()
}
fn warnings(r: &Value) -> Vec<String> {
r["warnings"].as_array().map(|w| w.iter().filter_map(|w| w.as_str().map(str::to_string)).collect()).unwrap_or_default()
}
#[test]
fn each_artboard_is_a_file_that_opens_with_its_layers() {
let dir = Folder::new("ai8");
let mut s = three_boards();
let path = dir.file("Poster.ai");
let r = s.execute("file.saveAs", &json!({"path": path, "format": "ai", "aiVersion": "7"})).unwrap();
let files: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect();
assert_eq!(files, [dir.file("Poster-A.ai"), dir.file("Poster-B.ai"), dir.file("Poster-C.ai")], "{r}");
assert!(warnings(&r).iter().any(|w| w.contains("file of its own")), "{r}");
for (f, objects) in [(files[0], 1), (files[1], 1), (files[2], 1)] {
let bytes = std::fs::read(f).unwrap();
assert!(bytes.starts_with(b"%!PS-Adobe-3.0\n"), "{f}");
s.execute("document.open", &json!({"path": f})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.artboards.len(), 1, "{f}");
assert_eq!(layer_names(d), ["Layer 1", "Top"], "{f}");
let count: usize = d.layers.iter().map(|l| l.children().map_or(0, Vec::len)).sum();
assert_eq!(count, objects, "{f}");
}
// Artboard A's file: the group of two rectangles in Layer 1, nothing on Top.
s.execute("document.open", &json!({"path": files[0]})).unwrap();
let d = &s.doc().unwrap().doc;
let group = &d.layers[0].children().unwrap()[0];
assert!(matches!(&group.kind, vectorcraft_doc::NodeKind::Group { children, clip: false } if children.len() == 2), "{:?}", group.kind);
assert!(d.layers[1].children().unwrap().is_empty());
}
#[test]
fn one_artboard_is_the_file_itself_and_save_writes_it_again() {
let dir = Folder::new("ai8-one");
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
let path = dir.file("One.ai");
let r = s.execute("file.saveAs", &json!({"path": path, "format": "ai", "aiVersion": "7"})).unwrap();
assert!(r["files"].is_null(), "{r}");
assert!(std::fs::read(&path).unwrap().starts_with(b"%!PS"));
// Save remembers the version.
std::fs::remove_file(&path).unwrap();
s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 30, "height": 30})).unwrap();
s.execute("document.save", &json!({})).unwrap();
assert!(std::fs::read(&path).unwrap().starts_with(b"%!PS"));
}
#[test]
fn the_default_stays_pdf_compatible_and_other_versions_are_refused() {
let mut s = three_boards();
for p in [json!({"format": "ai"}), json!({"format": "ai", "aiVersion": "pdf"})] {
let r = s.execute("file.saveCopy", &p).unwrap();
let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
assert!(bytes.starts_with(b"%PDF"), "{p}");
}
assert!(s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "3"})).is_err());
let v = s.execute("file.formatOptions", &json!({"format": "ai"})).unwrap();
assert_eq!(v["options"]["aiVersion"]["value"], "pdf", "{v}");
}
#[test]
fn transparency_type_and_appearances_are_written_as_plain_art() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
s.execute("shape.rectangle", &json!({"x": 30, "y": 30, "width": 50, "height": 50})).unwrap();
s.execute("transparency.set", &json!({"opacity": 50})).unwrap();
s.execute("shape.rectangle", &json!({"x": 120, "y": 10, "width": 30, "height": 30})).unwrap();
s.execute("appearance.addFill", &json!({})).unwrap();
s.execute("text.create", &json!({"x": 100, "y": 80, "text": "Hi", "size": 24})).unwrap();
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap();
let w = warnings(&r);
assert!(w.iter().any(|w| w.contains("written opaque")), "{w:?}");
assert!(w.iter().any(|w| w.contains("outlines")), "{w:?}");
let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
let text = String::from_utf8(bytes.clone()).unwrap();
// No transparency operators: version 7 has none.
assert!(!text.contains(" Xy\n"), "{text}");
s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap();
let d = &s.doc().unwrap().doc;
assert!(d.layers.iter().all(|l| l.children().is_some_and(|c| !c.is_empty())));
let mut opaque = true;
d.walk(|n| opaque &= n.opacity == 1.0);
assert!(opaque);
}
#[test]
fn art_under_transparent_art_stays_in_its_own_layer() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap();
s.execute("layer.new", &json!({"name": "Shade"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap();
s.execute("transparency.set", &json!({"opacity": 50, "blend": "multiply"})).unwrap();
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap();
s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(layer_names(d), ["Layer 1", "Shade"]);
assert!(d.layers.iter().all(|l| l.children().is_some_and(|c| c.len() == 1)), "{:?}", d.layers);
}
/// A `w`×`h` PNG of one colour (`rgba`), 72 ppi.
fn png(w: u32, h: u32, rgba: [u8; 4]) -> Vec<u8> {
let mut out = vec![];
image::RgbaImage::from_pixel(w, h, image::Rgba(rgba)).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
/// A red square (10–50 × 10–50) half under a half-transparent black image (30–70 × 10–50), and
/// an opaque blue image (140–160 × 40–60).
fn shadow_over_square() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap();
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
for (bytes, at) in [(png(40, 40, [0, 0, 0, 128]), [50, 30]), (png(20, 20, [0, 0, 255, 255]), [150, 50])] {
let p = json!({"name": "i.png", "dataBase64": vectorcraft_format::base64_encode(&bytes), "at": at});
s.execute("file.place", &p).unwrap();
}
s
}
/// The colour `d` shows at (`x`, `y`), over white.
fn colour_at(d: &vectorcraft_doc::Document, x: f64, y: f64) -> [u8; 3] {
let px = vectorcraft_render::Renderer::new().render_region(d, vectorcraft_geom::Rect::new(x, y, x + 1.0, y + 1.0), 1.0, true).pixels;
[px[0], px[1], px[2]]
}
fn images(d: &vectorcraft_doc::Document) -> (usize, usize) {
let (mut all, mut clipped) = (0, 0);
d.walk(|n| {
if let vectorcraft_doc::NodeKind::Group { children, clip: true } = &n.kind {
clipped += children.iter().filter(|c| matches!(c.kind, vectorcraft_doc::NodeKind::Image(_))).count();
}
all += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Image(_)));
});
(all, clipped)
}
#[test]
fn a_transparent_image_is_flattened_with_the_art_under_it_which_stays() {
let mut s = shadow_over_square();
let before = [colour_at(&s.doc().unwrap().doc, 40.0, 30.0), colour_at(&s.doc().unwrap().doc, 60.0, 30.0)];
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap();
assert!(warnings(&r).iter().any(|w| w.contains("flattened with the art under them")), "{r}");
assert!(!warnings(&r).iter().any(|w| w.contains("transparent image pixels are written over white")), "{r}");
s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap();
let d = &s.doc().unwrap().doc;
// The square is still there, under an opaque image clipped to the transparent one's place;
// the opaque image stays as it was.
let mut squares = 0;
d.walk(|n| squares += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { clipping: false, .. })));
assert_eq!(squares, 1);
assert_eq!(images(d), (2, 1));
let after = [colour_at(d, 40.0, 30.0), colour_at(d, 60.0, 30.0)];
for (a, b) in before.iter().zip(&after) {
assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(*b) <= 3), "{before:?} {after:?}");
}
assert_eq!(colour_at(d, 20.0, 30.0), [255, 0, 0], "the square outside the image");
assert_eq!(colour_at(d, 80.0, 30.0), [255, 255, 255], "nothing beyond the image");
}
#[test]
fn transparent_images_can_be_left_out_and_opacity_makes_one_transparent() {
let mut s = shadow_over_square();
s.execute("transparency.set", &json!({"opacity": 50})).unwrap(); // the blue image
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "transparentImages": "omit"})).unwrap();
assert!(warnings(&r).iter().any(|w| w.contains("left out")), "{r}");
s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(images(d), (0, 0));
assert_eq!(colour_at(d, 40.0, 30.0), [255, 0, 0]);
assert!(s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "transparentImages": "blur"})).is_err());
let v = s.execute("file.formatOptions", &json!({"format": "ai"})).unwrap();
assert_eq!(v["options"]["transparentImages"]["value"], "flatten", "{v}");
}
#[test]
fn a_range_saves_those_artboards_and_save_writes_them_again() {
let dir = Folder::new("ai7-range");
let mut s = three_boards();
let path = dir.file("Poster.ai");
let r = s.execute("file.saveAs", &json!({"path": path, "format": "ai", "aiVersion": "7", "range": "1, 3"})).unwrap();
let files: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect();
assert_eq!(files, [dir.file("Poster-A.ai"), dir.file("Poster-C.ai")], "{r}");
// One artboard: the file itself. An empty range: every artboard.
let r = s.execute("file.saveAs", &json!({"path": path, "format": "ai", "aiVersion": "7", "range": "2"})).unwrap();
assert!(r["files"].is_null(), "{r}");
let r = s.execute("file.saveAs", &json!({"path": path, "format": "ai", "aiVersion": "7", "range": ""})).unwrap();
assert_eq!(r["files"].as_array().map(Vec::len), Some(3), "{r}");
// Save keeps the range it was saved with.
s.execute("file.saveAs", &json!({"path": path, "format": "ai", "aiVersion": "7", "range": "1-2"})).unwrap();
let r = s.execute("document.save", &json!({})).unwrap();
assert_eq!(r["files"].as_array().map(Vec::len), Some(2), "{r}");
}