mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 02:37:01 -06:00
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.
230 lines
12 KiB
Rust
230 lines
12 KiB
Rust
//! 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}");
|
||
}
|