mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 18:27:02 -06:00
alongtheblue-sketch took 0.12.0's version 7 output of the SVGs in #2692 and #2830, edited it by hand and opened it in the reference app (https://github.com/storytold/vectorcraft/pull/2801#issuecomment-6244853548). Writing the image lines as #2801 does, with the bracketed matrix and the 60-digit data lines, did not change the result there. With one line, F, added after %AI5_EndRaster, the reference app opened the image in its clipping group and the art after it in both files. The writer now writes F after every %AI5_EndRaster: clipped, grouped and loose images, flattened images and images in pattern tiles. No path is open at that point, so the line paints nothing. VectorCraft's reader opens the files with the same objects as before, and the file's PostScript prolog draws the same page.
451 lines
26 KiB
Rust
451 lines
26 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 file, an image clipped to a circle inside a circle's stroke, and #2830's, 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 and #2830: 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`.
|
||
/// A line `F`, which the specification's examples do not show, follows `%AI5_EndRaster`. The next
|
||
/// layer follows with the square's exact lines, and the file opens again with both layers, the
|
||
/// clipping group, the image, the square and, besides the clipping path, only the paths the SVG
|
||
/// holds.
|
||
#[test]
|
||
fn a_clipped_or_flattened_image_is_written_as_the_specification_shows_it() {
|
||
for (svg, layer, paths) in [(CLIPPED_IMAGE, "clipped-image", 2), (SEE_THROUGH_IMAGE, "alpha-image", 1)] {
|
||
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_eq!(lines[end + 1], "F", "{layer}");
|
||
assert!(text.contains("\nW\nn\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nF\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}");
|
||
let mut drawn = 0;
|
||
d.walk(|n| drawn += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { clipping: false, .. })));
|
||
assert_eq!(drawn, paths, "{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}");
|
||
}
|