mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 04:07:12 -06:00
A version 7 .ai wrote each image's matrix as [a b c d tx ty] and its samples on comment lines of 64 hex digits. The Adobe Illustrator File Format Specification version 7.0, which the writer follows, writes the matrix with a space inside each bracket in the XI syntax (section 8.1) and in all five examples (section 8.4), and every example data line holds 60 hex digits. #2692 reports that the reference app leaves out an image inside a clipping group, and all the art after it, in the files written from the reporter's two SVGs. The writer now writes [ a b c d tx ty ] and data lines of 60 hex digits (the last holds the rest), for every image it writes. VectorCraft's reader and the file's PostScript prolog read both forms. The result was not checked in the reference app.
445 lines
25 KiB
Rust
445 lines
25 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::tests_menucmds::place_undecodable_png;
|
||
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_and_appearances_are_written_as_plain_art_and_type_as_text() {
|
||
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("type") && 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);
|
||
assert_eq!(texts(d), ["Hi"]);
|
||
}
|
||
|
||
/// The plain text of every text object of `d`.
|
||
fn texts(d: &vectorcraft_doc::Document) -> Vec<String> {
|
||
let mut out = vec![];
|
||
d.walk(|n| {
|
||
if let vectorcraft_doc::NodeKind::Text(t) = &n.kind {
|
||
out.push(t.plain_text());
|
||
}
|
||
});
|
||
out
|
||
}
|
||
|
||
#[test]
|
||
fn type_can_be_written_as_outlines() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||
s.execute("text.create", &json!({"x": 20, "y": 50, "text": "Café", "size": 24})).unwrap();
|
||
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "outlineText": true})).unwrap();
|
||
s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap();
|
||
let d = &s.doc().unwrap().doc;
|
||
assert!(texts(d).is_empty());
|
||
let mut paths = 0;
|
||
d.walk(|n| paths += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. } | vectorcraft_doc::NodeKind::Path { .. })));
|
||
assert!(paths > 0);
|
||
assert!(s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "outlineText": "yes"})).is_err());
|
||
let v = s.execute("file.formatOptions", &json!({"format": "ai"})).unwrap();
|
||
assert_eq!(v["options"]["outlineText"]["value"], false, "{v}");
|
||
}
|
||
|
||
#[test]
|
||
fn hidden_objects_go_to_a_hidden_layer_or_are_left_out_or_shown() {
|
||
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": 30, "height": 30})).unwrap();
|
||
s.execute("shape.rectangle", &json!({"x": 60, "y": 10, "width": 30, "height": 30})).unwrap();
|
||
s.execute("object.hide", &json!({})).unwrap();
|
||
let saved = |s: &mut Session, hidden: Value| {
|
||
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "hiddenObjects": hidden})).unwrap();
|
||
s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap();
|
||
let d = s.doc().unwrap().doc.clone();
|
||
s.execute("file.close", &json!({})).unwrap();
|
||
(d, warnings(&r))
|
||
};
|
||
let (d, w) = saved(&mut s, Value::Null);
|
||
assert_eq!(layer_names(&d), ["Layer 1", "Layer 1 (hidden)"], "{w:?}");
|
||
assert!(d.layers[0].visible && !d.layers[1].visible);
|
||
assert!(w.iter().any(|w| w.contains("hidden layer")), "{w:?}");
|
||
let (d, _) = saved(&mut s, json!("omit"));
|
||
assert_eq!(layer_names(&d), ["Layer 1"]);
|
||
assert_eq!(d.layers[0].children().map(Vec::len), Some(1));
|
||
let (d, _) = saved(&mut s, json!("visible"));
|
||
assert_eq!(layer_names(&d), ["Layer 1"]);
|
||
assert_eq!(d.layers[0].children().map(|c| c.iter().filter(|n| n.visible).count()), Some(2));
|
||
assert!(s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "hiddenObjects": "behind"})).is_err());
|
||
let v = s.execute("file.formatOptions", &json!({"format": "ai"})).unwrap();
|
||
assert_eq!(v["options"]["hiddenObjects"]["value"], "layer", "{v}");
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
|
||
/// #2687's file: a circle filled with a radial gradient from white to #1b2a6b, and a square filled
|
||
/// with a linear one.
|
||
const RADIAL_AND_LINEAR: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="200" viewBox="0 0 400 200">
|
||
<defs>
|
||
<radialGradient id="r" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#1b2a6b"/></radialGradient>
|
||
<linearGradient id="l" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#1b2a6b"/></linearGradient>
|
||
</defs>
|
||
<circle cx="100" cy="100" r="80" fill="url(#r)"/>
|
||
<rect x="220" y="20" width="160" height="160" fill="url(#l)"/>
|
||
</svg>"##;
|
||
|
||
/// A radial gradient is written with its highlight, `0 0 0 0 Bh` at the center (§3.5.5, §3.5.6).
|
||
/// #2687's file had none, and the reference app drew the circle in its outer color. The linear one
|
||
/// has none. Both open again with their type, stops and place.
|
||
#[test]
|
||
fn a_radial_gradient_is_written_with_its_highlight() {
|
||
let mut s = Session::new();
|
||
let svg = vectorcraft_format::base64_encode(RADIAL_AND_LINEAR.as_bytes());
|
||
s.execute("document.open", &json!({"name": "t_radial.svg", "dataBase64": svg})).unwrap();
|
||
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap();
|
||
let text = String::from_utf8(vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()).unwrap();
|
||
assert!(text.contains("\n%AI5_BeginGradient: (Gradient)\n(Gradient) 1 2 Bd\n[\n"), "{text}");
|
||
assert!(text.contains("\nBb\n0 0 0 0 Bh\n1 (Gradient) 100 100 0 80 1 0 0 1 0 0 Bg\nf\n0 BB\n"), "{text}");
|
||
assert!(text.contains("\nBb\n1 (Gradient 2) 220 180 0 160 1 0 0 1 0 0 Bg\nf\n0 BB\n"), "{text}");
|
||
assert_eq!(text.matches(" Bh\n").count(), 1, "{text}");
|
||
s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap();
|
||
let d = &s.doc().unwrap().doc;
|
||
let mut found = vec![];
|
||
d.walk(|n| {
|
||
if let vectorcraft_color::Paint::Gradient(g) = n.appearance.fill_paint() {
|
||
found.push(*g);
|
||
}
|
||
});
|
||
let [radial, linear] = found.as_slice() else { panic!("{found:#?}") };
|
||
let navy = vectorcraft_color::Color::rgb(27.0 / 255.0, 42.0 / 255.0, 107.0 / 255.0);
|
||
use vectorcraft_color::GradientKind::{Linear, Radial};
|
||
for (g, kind, start, end) in [(radial, Radial, (100.0, 100.0), (180.0, 100.0)), (linear, Linear, (220.0, 20.0), (380.0, 20.0))] {
|
||
assert_eq!(g.gradient.kind, kind);
|
||
let [a, b] = g.gradient.stops.as_slice() else { panic!("{g:?}") };
|
||
assert_eq!((a.offset, b.offset, a.color), (0.0, 1.0, vectorcraft_color::Color::rgb(1.0, 1.0, 1.0)), "{g:?}");
|
||
assert!(b.color.to_rgb().iter().zip(navy.to_rgb()).all(|(x, y)| (x - y).abs() < 1e-3), "{g:?}");
|
||
let geom = g.geom.unwrap();
|
||
assert!((geom.start.x - start.0).abs() < 1e-3 && (geom.start.y - start.1).abs() < 1e-3, "{geom:?}");
|
||
assert!((geom.end.x - end.0).abs() < 1e-3 && (geom.end.y - end.1).abs() < 1e-3 && geom.focal.is_none(), "{geom:?}");
|
||
}
|
||
// White at the center, #1b2a6b at the edge.
|
||
for ((x, y), want) in [((100.0, 100.0), [255, 255, 255]), ((100.0, 21.0), [27, 42, 107])] {
|
||
let got = colour_at(d, x, y);
|
||
assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 8), "{got:?} at {x}, {y}");
|
||
}
|
||
}
|
||
|
||
/// 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");
|
||
// Run as a PostScript program (the file's prolog draws it), it prints the same: two images
|
||
// and the square, on a page that is the art's box (10 pt in from the artboard's top left).
|
||
s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"], "editingData": false})).unwrap();
|
||
let page = &s.doc().unwrap().doc;
|
||
assert_eq!(images(page).0, 2);
|
||
let printed = [colour_at(page, 30.0, 20.0), colour_at(page, 50.0, 20.0)];
|
||
for (a, b) in before.iter().zip(&printed) {
|
||
assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(*b) <= 3), "{before:?} {printed:?}");
|
||
}
|
||
assert_eq!(colour_at(page, 10.0, 20.0), [255, 0, 0], "the square outside the image");
|
||
}
|
||
|
||
/// How many of reply `r`'s warnings say that an image couldn't be decoded, as raster exports do.
|
||
fn undecoded_notes(r: &Value) -> usize {
|
||
warnings(r).iter().filter(|w| *w == "an image couldn't be decoded and is missing from the picture").count()
|
||
}
|
||
|
||
#[test]
|
||
fn a_flattened_image_that_cant_be_decoded_is_noted_once() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||
s.execute("artboard.new", &json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "B"})).unwrap();
|
||
let cut = place_undecodable_png(&mut s);
|
||
s.execute("transparency.set", &json!({"ids": [cut.0], "opacity": 50})).unwrap();
|
||
// Both artboards flatten it.
|
||
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap();
|
||
assert_eq!(r["files"].as_array().map(Vec::len), Some(2), "{r}");
|
||
assert!(warnings(&r).iter().any(|w| w.contains("flattened with the art under them")), "{r}");
|
||
assert_eq!(undecoded_notes(&r), 1, "{r}");
|
||
}
|
||
|
||
#[test]
|
||
fn an_image_that_cant_be_decoded_with_an_expanded_effect_is_noted() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||
let cut = place_undecodable_png(&mut s);
|
||
s.execute("effect.apply", &json!({"effect": "blur.gaussian", "params": {"radius": 3}, "ids": [cut.0]})).unwrap();
|
||
// Left out rather than flattened: only expanding the blur draws it.
|
||
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "transparentImages": "omit"})).unwrap();
|
||
assert_eq!(undecoded_notes(&r), 1, "{r}");
|
||
}
|
||
|
||
#[test]
|
||
fn images_that_decode_give_no_decode_note() {
|
||
let mut s = shadow_over_square();
|
||
s.execute("effect.apply", &json!({"effect": "blur.gaussian", "params": {"radius": 3}})).unwrap(); // the blue image
|
||
for mode in ["flatten", "omit"] {
|
||
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "transparentImages": mode})).unwrap();
|
||
assert!(warnings(&r).iter().any(|w| w.contains("transparent images")), "{r}");
|
||
assert_eq!(undecoded_notes(&r), 0, "{r}");
|
||
}
|
||
}
|
||
|
||
#[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}");
|
||
}
|
||
|
||
/// #2692's files: an image clipped to a circle inside a circle's stroke, and an image with
|
||
/// see-through pixels (which the save flattens into an image inside a clipping group), each with
|
||
/// an orange square on the next layer.
|
||
const CLIPPED_IMAGE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="400" height="200" viewBox="0 0 400 200">
|
||
<defs><clipPath id="c1"><circle cx="100" cy="100" r="70"/></clipPath></defs>
|
||
<g id="clipped-image">
|
||
<circle cx="100" cy="100" r="78" fill="none" stroke="#777777" stroke-width="4"/>
|
||
<g clip-path="url(#c1)"><image x="20" y="20" width="160" height="160" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAGklEQVR42mPMy8tjIAWwcHBwjGoY1TBMNQAAdtYC8NwTJdoAAAAASUVORK5CYII="/></g>
|
||
</g>
|
||
<g id="above"><rect x="220" y="20" width="160" height="160" fill="#f7941d"/></g>
|
||
</svg>"##;
|
||
const SEE_THROUGH_IMAGE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="400" height="200" viewBox="0 0 400 200">
|
||
<g id="alpha-image">
|
||
<image x="20" y="20" width="160" height="160" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAeElEQVR42mNgoBAwoguUlZX9x6ehq6uLEacBdXV1/4mxtampiRHDgLa2tv+kOL2qqooRbkBfX99/cvxfVFTEyMLAwMDAwcFBdiCOGjAYDIAnpHPnzpGUFoyMjBgxkvK1a9eIMkRLS4sRZ2a6d+8eXkOUlJQYGagJAOoJHMzSF7FXAAAAAElFTkSuQmCC"/>
|
||
</g>
|
||
<g id="above"><rect x="220" y="20" width="160" height="160" fill="#f7941d"/></g>
|
||
</svg>"##;
|
||
|
||
/// #2692: the image inside a clipping group, clipped in the file or flattened by the save, is
|
||
/// written as the version 7 specification shows one (§8.1, §8.4): the matrix with a space inside
|
||
/// each bracket and the samples on comment lines of 60 hex digits, between `W n` and `Q`. The next
|
||
/// layer follows with the square's exact lines, and the file opens again with both layers, the
|
||
/// clipping group, the image and the square.
|
||
#[test]
|
||
fn a_clipped_or_flattened_image_is_written_as_the_specification_shows_it() {
|
||
for (svg, layer) in [(CLIPPED_IMAGE, "clipped-image"), (SEE_THROUGH_IMAGE, "alpha-image")] {
|
||
let mut s = Session::new();
|
||
s.execute("document.open", &json!({"name": "t.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap();
|
||
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap();
|
||
let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
|
||
let text = String::from_utf8(bytes).unwrap();
|
||
let lines: Vec<&str> = text.lines().collect();
|
||
let xi: Vec<usize> = (0..lines.len()).filter(|i| lines[*i].ends_with(" XI")).collect();
|
||
assert_eq!(xi.len(), 1, "{text}");
|
||
let at = xi[0];
|
||
assert_eq!(lines[at - 2..at], ["%AI5_File:", "%AI5_BeginRaster"], "{layer}");
|
||
assert!(lines[at].starts_with("[ ") && lines[at].contains(" ] 0 0 "), "{}", lines[at]);
|
||
let end = at + lines[at..].iter().position(|l| *l == "%AI5_EndRaster").unwrap();
|
||
let (last, full) = lines[at + 1..end].split_last().unwrap();
|
||
assert!(full.iter().all(|l| l.len() == 61) && (2..=61).contains(&last.len()), "{layer}");
|
||
assert!(text.contains("\nW\nn\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nQ\nLB\n%AI5_EndLayer--\n"), "{text}");
|
||
let square = "%AI5_BeginLayer\n1 1 1 1 0 0 1 255 79 79 Lb\n(above) Ln\n0 A\n0.9686 0.5804 0.1137 Xa\n0 O\n0 XR\n\
|
||
220 180 m\n380 180 L\n380 20 L\n220 20 L\n220 180 L\nf\nLB\n%AI5_EndLayer--\n%%PageTrailer\n";
|
||
assert!(text.contains(square), "{text}");
|
||
s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap();
|
||
let d = &s.doc().unwrap().doc;
|
||
assert_eq!(layer_names(d), [layer, "above"]);
|
||
assert_eq!(images(d), (1, 1), "{layer}");
|
||
assert_eq!(colour_at(d, 300.0, 100.0), [247, 148, 29], "{layer}");
|
||
assert_ne!(colour_at(d, 100.0, 100.0), [255, 255, 255], "{layer}");
|
||
}
|
||
}
|
||
|
||
#[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}");
|
||
}
|