From bcae7ca4860d4ea569f89b4511ce4ec43fbbf0a2 Mon Sep 17 00:00:00 2001 From: Dan Green Date: Mon, 12 Oct 2026 19:08:16 -0700 Subject: [PATCH] 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. --- crates/engine/src/cmd/effectcmd.rs | 2 +- crates/engine/src/cmd/fileio/legacy_ai.rs | 278 ++++++++++++++++++++++ crates/engine/src/cmd/fileio/mod.rs | 1 + crates/engine/src/cmd/fileio/native.rs | 12 + crates/engine/src/cmd/fileio/save.rs | 21 +- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_legacy_ai.rs | 229 ++++++++++++++++++ 7 files changed, 539 insertions(+), 6 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/legacy_ai.rs create mode 100644 crates/engine/src/tests_legacy_ai.rs diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 801491671..292a85b97 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -407,7 +407,7 @@ fn expand_pieces(d: &mut Document, m: &mut Node, all: bool, stroke_art: effects: /// Expand Appearance of `id` (and, recursively, of a group's or layer's members): every fill and /// stroke becomes an object of its own (strokes outlined, brushes as their art), geometry effects /// are baked, raster effects become an embedded image, and the object keeps its transparency. -fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush], out: &mut Vec) { +pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush], out: &mut Vec) { let Some(mut n) = d.node(id).cloned() else { return }; if !expandable(&n) { return; diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs new file mode 100644 index 000000000..e0961b61c --- /dev/null +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -0,0 +1,278 @@ +//! Save As › `.ai` with `aiVersion: "7"`: legacy `.ai` files of version 7 (the PostScript-form `.ai` +//! that `vectorcraft_eps::export_legacy_ai` writes), which keep the layers, groups, compound +//! paths and clipping groups that a PDF-compatible `.ai` loses in apps reading only its PDF part +//! (#868). +//! +//! The format has one artboard: each chosen artboard (default all) is a file of its own, holding +//! the art that touches it, named `{stem}-{artboard}.ai` beside the path when there are several. +//! Appearances the format can't hold (several fills or strokes, effects, brushes) are expanded into +//! objects. Transparency isn't flattened: flattening merges the art that overlaps transparent +//! objects, across layers, into one flattened result, which empties the layers under them, and +//! editable layers are what this format is saved for. Transparent objects are written opaque (a +//! warning says so). +//! +//! Images are the exception (`transparentImages`): an image with see-through pixels, opacity or a +//! blending mode written opaque is a box hiding the art under it (version 7 images have no alpha +//! channel, §8.1). With `flatten` (the default) each becomes an opaque image of itself and the art +//! under it, rendered from the document as it is, clipped to the area where it changes what shows; +//! the art under it stays as it was. With `omit` they are left out. + +use std::sync::Arc; + +use serde_json::Value; +use vectorcraft_color::BlendMode; +use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; +use vectorcraft_geom::{Anchor, FillRule, PathData, Point, Rect, SubPath}; + +use super::super::*; +use super::{ArtboardPick, Encoded, Format}; + +/// Does `p` ask for a legacy `.ai` file of version 7 (`aiVersion: "7"`)? An unknown version is an error. +pub(super) fn wanted(cmd: &str, p: &Value) -> Result { + match p.get("aiVersion") { + None | Some(Value::Null) => Ok(false), + Some(Value::String(v)) if v.trim().is_empty() || v.eq_ignore_ascii_case("pdf") => Ok(false), + Some(Value::String(v)) if v.trim() == "7" => Ok(true), + Some(Value::Number(n)) if n.as_f64() == Some(7.0) => Ok(true), + Some(v) => Err(bad(cmd, format!("aiVersion {v}: \"pdf\" (PDF-compatible, the default) or \"7\" (legacy .ai, version 7)"))), + } +} + +/// What a legacy `.ai` does with the images it can't hold (`transparentImages`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SeeThrough { + Flatten, + Omit, +} + +fn see_through(cmd: &str, p: &Value) -> Result { + match p.get("transparentImages") { + None | Some(Value::Null) => Ok(SeeThrough::Flatten), + Some(Value::String(v)) if v.trim().is_empty() || v.trim().eq_ignore_ascii_case("flatten") => Ok(SeeThrough::Flatten), + Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("omit") => Ok(SeeThrough::Omit), + Some(v) => Err(bad(cmd, format!("transparentImages {v}: \"flatten\" (the default) or \"omit\""))), + } +} + +/// What the format loses against a VectorCraft document, reported with every save. +const FIDELITY: &str = "a legacy .ai (version 7) keeps layers, groups, compound paths, clipping groups, colours, gradients and images, not the rest: save as VectorCraft to keep everything editable"; +const FLATTENED: &str = + "transparent images (see-through pixels, opacity or a blending mode) are flattened with the art under them into opaque images"; +const OMITTED: &str = "transparent images (see-through pixels, opacity or a blending mode) are left out"; +const SEPARATE: &str = "legacy .ai files have one artboard: each artboard is saved to a file of its own"; + +/// Encode `doc` as legacy `.ai` files of version 7: one per chosen artboard (`artboard`, `artboards` or +/// `range`; default all). +pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result { + let n = doc.artboards.len(); + if n == 0 { + return Err(bad(cmd, "the document has no artboard")); + } + let mode = see_through(cmd, p)?; + let picked: ArtboardPick = super::encode::options(f, p)?; + let boards = picked.resolve(n).map_err(|e| bad(cmd, e))?.unwrap_or_else(|| (0..n).collect()); + let mut enc = Encoded { warnings: vec![FIDELITY.to_string()], ..Encoded::default() }; + if boards.len() > 1 { + enc.warnings.push(SEPARATE.to_string()); + } + let created = vectorcraft_doc::metadata::now_unix(); + for b in boards { + let one = super::save::artboard_doc(doc, b); + let (full, placed) = crate::cmd::place::document::full_documents(&one); + let mut d = full.into_owned(); + expand_appearances(&mut d); + let images = see_through_images(&d); + if !images.is_empty() { + note(&mut enc.warnings, if mode == SeeThrough::Flatten { FLATTENED } else { OMITTED }); + if mode == SeeThrough::Flatten { + flatten_images(&mut d, &images)?; + } else { + for (_, id) in images { + let _ = d.remove(id); // Inside an image left out before: gone already. + } + } + } + let o = vectorcraft_eps::LegacyAiOptions { title: doc.title.clone(), created }; + let out = vectorcraft_eps::export_legacy_ai(&d, &o).map_err(|e| bad(cmd, e))?; + for w in placed.iter().chain(&out.warnings) { + note(&mut enc.warnings, w); + } + enc.files.push((Some(b), out.bytes)); + } + Ok(enc) +} + +/// Expand Appearance of every layer's art whose appearance the format can't hold. +fn expand_appearances(d: &mut Document) { + let brushes = vectorcraft_brush::library(d); + let layers: Vec = d.layers.iter().map(|l| l.id).collect(); + let mut out = vec![]; + for id in layers { + super::super::effectcmd::expand_node(d, id, &brushes, &mut out); + } +} + +/// The images of `d` a legacy `.ai` can't hold, in paint order with their places in it (as +/// [`Document::walk`] counts): visible ones with see-through pixels, opacity or a blending mode. +fn see_through_images(d: &Document) -> Vec<(usize, NodeId)> { + let keys = super::super::flatten::see_through_images(d); + let mut out = vec![]; + let mut at = 0; + d.walk(|n| { + if let NodeKind::Image(im) = &n.kind + && (keys.contains(&im.key) || n.opacity < 1.0 || n.blend != BlendMode::Normal) + { + out.push((at, n.id)); + } + at += 1; + }); + out.retain(|(_, id)| d.is_visible(*id)); + out +} + +/// Replace each of `images` (from [`see_through_images`]) with an opaque image of what shows +/// where it is, clipped to where it changes that; one that changes nothing is left out. Each is +/// rendered from `d` as it was, so images over one another flatten as they look. +fn flatten_images(d: &mut Document, images: &[(usize, NodeId)]) -> Result<()> { + let src = d.clone(); + for &(at, id) in images { + let Some((parent, idx, _)) = d.position(id) else { continue }; + let flat = flattened(&src, at, id)?; + d.remove(id)?; + if let Some(flat) = flat { + let png = flat.png; + let key = super::super::menucmds::unique_key(d, "flattened"); + d.images.insert(key.clone(), ImageBlob::new("image/png", png)); + let image = ImageObject { key, width: flat.width, height: flat.height, xf: flat.xf, link: None, placement: Default::default() }; + let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))); + let mut group = super::super::rasterfx::clip_group(d, flat.clip, FillRule::NonZero, image); + group.name = src.node(id).and_then(|n| n.name.clone()); + d.insert(parent, idx, group)?; + } + } + Ok(()) +} + +/// An image made by [`flattened`]: PNG pixels, their place, and the outline they are clipped to. +struct Flat { + png: Vec, + width: u32, + height: u32, + xf: Affine, + clip: PathData, +} + +/// Highest and lowest resolution of a flattened image (pixels per inch); it takes the image's own +/// in between. +const MAX_PPI: f64 = 600.0; +const MIN_PPI: f64 = 72.0; +/// A rendered pixel channel changes when it changes by more than this. +const CHANGED: u8 = 2; +/// The clip outline follows cells of about this many points. +const CELL: f64 = 2.0; + +/// The image `id` (the `at`th node in paint order of `src`) flattened: `src` drawn up to and +/// including it, over white, cropped and clipped to the cells where drawing it changes the +/// result. `None` when it changes nothing. +fn flattened(src: &Document, at: usize, id: NodeId) -> Result> { + let (Some(n), Some(bounds)) = (src.node(id), src.bounds_of(&[id], true)) else { return Ok(None) }; + let NodeKind::Image(im) = &n.kind else { return Ok(None) }; + // The image's own resolution (points per pixel from its placement). + let pt = im.xf.determinant().abs().sqrt(); + let ppi = if pt.is_finite() && pt > 0.0 { (72.0 / pt).clamp(MIN_PPI, MAX_PPI) } else { MIN_PPI }; + let area = (bounds.width() * bounds.height()).max(1.0); + let scale = (ppi / 72.0).min((super::super::menucmds::MAX_PIXELS / area).sqrt()); + if !(scale.is_finite() && scale > 0.0) { + return Ok(None); + } + let region = Rect::new( + bounds.x0, + bounds.y0, + bounds.x0 + (bounds.width() * scale).ceil().max(1.0) / scale, + bounds.y0 + (bounds.height() * scale).ceil().max(1.0) / scale, + ); + let mut with = src.clone(); + let mut i = 0; + with.layers = src.layers.iter().filter_map(|l| up_to(l, at, &mut i)).map(Arc::new).collect(); + let mut without = with.clone(); + without.remove(id)?; + let mut r = vectorcraft_render::Renderer::new(); + let shown = r.render_region(&with, region, scale, true); + let under = r.render_region(&without, region, scale, true); + let (w, h) = (shown.width as usize, shown.height as usize); + if w == 0 || h == 0 || shown.pixels.len() != w * h * 4 || under.pixels.len() != shown.pixels.len() { + return Ok(None); + } + // The cells where it changes anything. + let c = (CELL * scale).round().max(1.0) as usize; + let (cw, ch) = (w.div_ceil(c), h.div_ceil(c)); + let mut cells = vec![false; cw * ch]; + for (k, (a, b)) in shown.pixels.as_chunks::<4>().0.iter().zip(under.pixels.as_chunks::<4>().0).enumerate() { + if a.iter().zip(b).any(|(a, b)| a.abs_diff(*b) > CHANGED) { + let (x, y) = (k % w, k / w); + if let Some(cell) = cells.get_mut(y / c * cw + x / c) { + *cell = true; + } + } + } + let marked = || cells.iter().enumerate().filter(|(_, on)| **on).map(|(k, _)| (k % cw, k / cw)); + let (Some(x0), Some(x1), Some(y0), Some(y1)) = + (marked().map(|p| p.0).min(), marked().map(|p| p.0).max(), marked().map(|p| p.1).min(), marked().map(|p| p.1).max()) + else { + return Ok(None); + }; + // Crop to them. + let (px0, py0) = (x0 * c, y0 * c); + let (px1, py1) = (((x1 + 1) * c).min(w), ((y1 + 1) * c).min(h)); + let mut pixels = Vec::with_capacity((px1 - px0) * (py1 - py0) * 4); + for y in py0..py1 { + pixels.extend_from_slice(shown.pixels.get((y * w + px0) * 4..(y * w + px1) * 4).unwrap_or_default()); + } + let crop = vectorcraft_render::Rendered { width: (px1 - px0) as u32, height: (py1 - py0) as u32, pixels }; + let png = crop.to_png().map_err(EngineError::Other)?; + let at = |x: usize, y: usize| Point::new(region.x0 + (x.min(w)) as f64 / scale, region.y0 + (y.min(h)) as f64 / scale); + let mut clip = PathData::new(vec![]); + for comp in vectorcraft_trace::trace_mask(&cells, cw, ch) { + for lp in std::iter::once(&comp.outer).chain(&comp.holes) { + let pts: Vec<(i32, i32)> = corners(&lp.pts); + let anchors = pts.iter().map(|&(x, y)| Anchor::corner(at(x.max(0) as usize * c, y.max(0) as usize * c))).collect(); + clip.subpaths.push(SubPath::new(anchors, true)); + } + } + let xf = Affine::translate(at(px0, py0).to_vec2()) * Affine::scale(1.0 / scale); + Ok(Some(Flat { png, width: crop.width, height: crop.height, xf, clip })) +} + +/// `n` without the nodes after the `last`th in paint order (`i` counts them, as +/// [`Document::walk`] does); `None` when `n` itself comes after it. +fn up_to(n: &Node, last: usize, i: &mut usize) -> Option { + if *i > last { + return None; + } + *i += 1; + let mut n = n.clone(); + if let Some(ch) = n.children_mut() { + let kept: Vec> = ch.iter().filter_map(|c| up_to(c, last, i)).map(Arc::new).collect(); + *ch = kept; + } + Some(n) +} + +/// A loop's corners: its points without those in the middle of a straight run. +fn corners(pts: &[(i32, i32)]) -> Vec<(i32, i32)> { + let n = pts.len(); + let get = |k: usize| pts.get(k % n.max(1)).copied(); + (0..n) + .filter_map(|k| { + let (a, b, c) = (get(k + n - 1)?, get(k)?, get(k + 1)?); + let straight = (a.0 == b.0 && b.0 == c.0) || (a.1 == b.1 && b.1 == c.1); + (!straight).then_some(b) + }) + .collect() +} + +fn note(warnings: &mut Vec, w: &str) { + if !warnings.iter().any(|x| x == w) { + warnings.push(w.to_string()); + } +} diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 9c078146b..b84bc0dae 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -27,6 +27,7 @@ mod encode; pub mod eps; mod export; pub(crate) mod imagemap; +mod legacy_ai; mod load; pub(crate) use load::{MAX_RASTER_ALLOC, too_large_note}; mod metafile; diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs index 83d9ab452..2874b95a7 100644 --- a/crates/engine/src/cmd/fileio/native.rs +++ b/crates/engine/src/cmd/fileio/native.rs @@ -99,6 +99,18 @@ pub const AI_OPTIONS: &[FormatOption] = &[ default: "true", description: "compress the PDF's content and the native document it carries (the PDF option compression.compressText)", }, + FormatOption { + name: "aiVersion", + ty: "string", + default: "\"pdf\"", + description: "pdf (a PDF-compatible .ai carrying the VectorCraft document) or 7 (the legacy, PostScript-form .ai of version 7, which keeps layers, groups, compound paths and clipping groups in apps that read it; one file per artboard, transparent objects opaque, type as outlines; the other .ai options don't apply)", + }, + FormatOption { + name: "transparentImages", + ty: "string", + default: "\"flatten\"", + description: "with aiVersion 7, the images version 7 can't hold (see-through pixels, opacity or a blending mode): flatten (the default: each becomes an opaque image of itself and the art under it, clipped to where it changes what shows; that art stays as it is) or omit (left out)", + }, ]; #[derive(Default, Deserialize)] diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index fdc8584c7..36925bf41 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -335,7 +335,8 @@ impl std::io::Write for Counted { /// The artboards a native or `.ai` save also writes to files of their own: with /// `separateArtboards`, those `range` names (default all). fn separate_boards(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result> { - if !is_native(f) || !bool_or(p, "separateArtboards", false) { + // A legacy .ai file is a file per artboard already. + if !is_native(f) || !bool_or(p, "separateArtboards", false) || super::legacy_ai::wanted(cmd, p)? { return Ok(vec![]); } let range = str_param(p, "range").filter(|r| !r.trim().is_empty()).map(str::to_string); @@ -346,7 +347,7 @@ fn separate_boards(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result Document { +pub(super) fn artboard_doc(doc: &Document, i: usize) -> Document { let mut d = doc.clone(); let Some(a) = doc.artboards.get(i) else { return d }; let r = a.rect; @@ -387,6 +388,9 @@ const NOT_PDF_COMPATIBLE: &str = "saved without PDF content: VectorCraft opens i /// The shared Save and Export encoder for PDF-compatible `.ai` files. pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result { + if super::legacy_ai::wanted(cmd, p)? { + return super::legacy_ai::encode(cmd, f, doc, p); + } let mut params = if p.is_object() { p.clone() } else { json!({}) }; if let Some(o) = params.as_object_mut() { o.insert("preserveEditing".into(), json!(true)); @@ -463,9 +467,14 @@ fn job_for_stamped(s: &Session, st: &DocState, plan: SavePlan, stamped: Option Result { let (cmd, f) = (self.plan.mode.command(), self.plan.format); let enc = if f.id == "ai" { encode_ai(cmd, f, doc, &self.params)? } else { encode_all(doc, f.id, &self.params)? }; - if enc.files.len() != 1 { + // A legacy .ai file per artboard. + let per_artboard = f.id == "ai" && super::legacy_ai::wanted(cmd, &self.params)?; + if enc.files.len() != 1 && !per_artboard { return Err(bad(cmd, "Save writes one artboard: name one, or export several with document.export")); } Ok(enc) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2d906d5a0..29fc89604 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1854,6 +1854,8 @@ mod tests_layerclip; #[cfg(test)] mod tests_layers; #[cfg(test)] +mod tests_legacy_ai; +#[cfg(test)] mod tests_library; #[cfg(test)] mod tests_linked_stops; diff --git a/crates/engine/src/tests_legacy_ai.rs b/crates/engine/src/tests_legacy_ai.rs new file mode 100644 index 000000000..79f41c822 --- /dev/null +++ b/crates/engine/src/tests_legacy_ai.rs @@ -0,0 +1,229 @@ +//! 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 { + d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect() +} + +fn warnings(r: &Value) -> Vec { + 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 { + 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}"); +}