From 0990bfb244f85e531293fd576826081d86bf1bf7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 22:10:59 -0800 Subject: [PATCH] .ai save: the editing copy keeps transparency, clipping and opacity masks The editing copy of the art that a PDF-compatible .ai carries now holds: - Transparency: `mode opacity isolate knockout shape Xy` (the blending mode's number, Knockout Group 0 off, 1 on, 2 neutral), written when it changes: in a path's paint after its colors, before a guide, and after a group's end followed by `0 0 Xd` and `6 () XW`. A group without transparency of its own ends `9 () XW`, and a group's name comes before its state block. A layer's transparency follows its art: `Xy`, `0 0 Xd`, `7 () XW`, `LB`. - Clipping groups as `q`, the art, the clipping path or compound path last with `h` (or `H`) and `W` before its painting operator, `Q`, as section 5.6.2 of the version 7 specification shows them (http://mercator.elte.hu/~saman/hu/okt/AI7FileFormat.pdf). A clipping layer whose clipping object is a path or compound path sets the last `Lb` flag and writes its clipping path last. - Opacity masks as an `AI9_Mask` entry in the object's dictionary: the mask's art between `X=` and `X+`, and Clipping, Inverted, Disabled and Linked. Before, clipping groups were written as their art cut to the clipping path, and transparency and opacity masks were left out. Still left out, with a warning: the opacity and blending mode of single fills and strokes, opacity masks on layers (disabled ones too), and opacity masks nested more than 8 deep. A clipping group or layer whose clipping object isn't a path or compound path is written as a group of the art cut to that object, in a layer that doesn't clip, and so are the clipping groups inside that art. A clipping path's paint that a plain fill and line can't draw is written as paths inside the clipping, fills under the art and strokes over it, each side in a group of its own when it has several, with the clipping path's transparency, lock and hidden state; the clipping path's opacity mask stays on the clipping path, with a warning. A clipping group without members is an empty group, and an empty clipping layer is an empty layer. Each of these is written in the form VectorCraft's .ai reader reads, and the tests read the written data back through that reader. Reading .ai editing data: an `Xy` after a layer's last group or compound path, followed by the layer's `7 () XW`, is the layer's (a following `6 () XW` keeps it the group's); groups, compound paths and layers keep Opacity & Mask Define Knockout Shape. The `.ai` option `editingData` and docs/file-format-parity.md describe what the copy now holds. --- crates/engine/src/cmd/fileio/native.rs | 2 +- crates/engine/src/cmd/fileio/tests_pdfedit.rs | 162 +++- crates/engine/tests/import_fuzz.rs | 13 +- crates/eps/src/ai_editing.rs | 25 +- crates/eps/src/ai_editing/tests.rs | 721 +++++++++++++++++- crates/eps/src/ai_editing/writer.rs | 576 +++++++++++--- crates/eps/src/import/ai/read.rs | 6 +- crates/eps/src/import/ai/read/containers.rs | 10 +- crates/eps/src/import/ai/read/ops.rs | 55 +- crates/eps/src/import/ai/tests.rs | 24 + docs/file-format-parity.md | 11 +- 11 files changed, 1411 insertions(+), 194 deletions(-) diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs index 1331d6f89..2f9312259 100644 --- a/crates/engine/src/cmd/fileio/native.rs +++ b/crates/engine/src/cmd/fileio/native.rs @@ -109,7 +109,7 @@ pub const AI_OPTIONS: &[FormatOption] = &[ name: "editingData", ty: "boolean", default: "true", - description: "also carry an editing copy of the artwork in the form other apps that open .ai files read: layers and sublayers, groups, paths, fills and strokes, colors, swatches and artboards, with type, gradients, patterns, images, symbols, clipping and live objects as plain filled and stroked paths, without transparency (written only with every artboard, as Preserve Editing is)", + description: "also carry an editing copy of the artwork in the form other apps that open .ai files read: layers and sublayers, groups, compound paths, paths, fills and strokes, colors, swatches, artboards, the transparency of objects and layers, objects' opacity masks, and clipping groups and layers clipped by a path, with type, gradients, patterns, images, symbols, other clipping objects and live objects as plain filled and stroked paths (written only with every artboard, as Preserve Editing is)", }, FormatOption { name: "aiVersion", diff --git a/crates/engine/src/cmd/fileio/tests_pdfedit.rs b/crates/engine/src/cmd/fileio/tests_pdfedit.rs index 7b9f9774f..ae7c66aa8 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfedit.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfedit.rs @@ -453,27 +453,173 @@ fn ai_files_carry_an_editing_copy_for_other_apps() { } /// A `.ai` whose editing copy for other apps differs from the VectorCraft document it carries (the -/// copy has no transparency) opens in VectorCraft from the VectorCraft document. +/// copy leaves the live effect out) opens in VectorCraft from the VectorCraft document. #[test] fn an_ai_file_opens_from_its_vectorcraft_document_when_the_editing_copy_differs() { 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": 30})).unwrap(); - s.execute("transparency.set", &json!({"opacity": 50})).unwrap(); - let bytes = b64(&s.execute("document.export", &json!({"format": "ai"})).unwrap()); - let opacities = |doc: &vectorcraft_doc::Document| { + let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap(); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id]})).unwrap(); + let r = s.execute("document.export", &json!({"format": "ai"})).unwrap(); + assert!(r["warnings"].to_string().contains("live effects"), "{r}"); + let bytes = b64(&r); + let effects = |doc: &vectorcraft_doc::Document| { let mut out = vec![]; doc.walk(|n| { if matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. }) { - out.push(n.opacity); + out.push(n.appearance.effects.len()); } }); out }; let copy = load::load_with("art.ai", &bytes, &LoadOptions { native: false, trust_editing_data: true, ..Default::default() }).unwrap(); assert!(copy.editing_data && !copy.restored); - assert_eq!(opacities(©.doc), [1.0]); + assert_eq!(effects(©.doc), [0]); let opened = load::load_with("art.ai", &bytes, &LoadOptions::default()).unwrap(); assert!(opened.restored); - assert_eq!(opacities(&opened.doc), [0.5]); + assert_eq!(effects(&opened.doc), [1]); +} + +/// The layers, groups, compound paths and paths of `doc` with their names, visibility, geometric +/// bounds (rounded), transparency, clipping and opacity masks, one line each; a mask's art after +/// its object. +fn see_through_outline(doc: &vectorcraft_doc::Document) -> Vec { + use vectorcraft_doc::NodeKind; + fn walk(nodes: &[std::sync::Arc], depth: usize, out: &mut Vec) { + for n in nodes { + let kind = match &n.kind { + NodeKind::Layer { clip, .. } => format!("layer clip={clip}"), + NodeKind::Group { clip, .. } => format!("group clip={clip}"), + NodeKind::Compound { .. } => "compound".into(), + NodeKind::Path { clipping, .. } => format!("path clipping={clipping}"), + _ => "other".into(), + }; + let b = n.geometric_bounds().map(|b| [b.x0, b.y0, b.x1, b.y1].map(|v| v.round() as i64)); + let mask = n.extras().mask.clone(); + out.push(format!( + "{depth} {kind} {:?} {} {b:?} {} {:?} isolate={} {:?} shape={} mask={:?}", + n.name, + n.visible, + (n.opacity * 100.0).round(), + n.blend, + n.isolate, + n.knockout, + n.knockout_shape, + mask.as_ref().map(|m| (m.clip, m.invert, m.disabled, m.linked)) + )); + if let Some(m) = mask { + walk(std::slice::from_ref(&m.art), depth + 1, out); + } + walk(n.children().map(Vec::as_slice).unwrap_or_default(), depth + 1, out); + } + } + let mut out = vec![]; + walk(&doc.layers, 0, &mut out); + out +} + +/// A document of two artboards (the art on the first) with a compound path at 70% in Screen, a +/// clipping group at 50% in Multiply, a named group (isolated, knockout on, knockout shape) and a +/// square with an opacity mask that doesn't clip on layer `Layer 1`, and layer `Cut` (40%, +/// clipping) holding two squares. +fn see_through_session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200, "artboards": 2})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let id = |v: serde_json::Value| v["id"].as_u64().unwrap(); + let rect = |s: &mut Session, x: f64, y: f64, w: f64, h: f64| { + id(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()) + }; + // A compound path at 70% in Screen. + let (a, b) = (rect(&mut s, 10.0, 10.0, 40.0, 40.0), rect(&mut s, 20.0, 20.0, 20.0, 20.0)); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("object.compoundPath.make", &json!({})).unwrap(); + s.execute("transparency.set", &json!({"opacity": 70, "blend": "Screen"})).unwrap(); + // A clipping group at 50% in Multiply. + let (art, clip) = (rect(&mut s, 60.0, 10.0, 60.0, 40.0), rect(&mut s, 70.0, 20.0, 30.0, 20.0)); + s.execute("select.set", &json!({"ids": [art, clip]})).unwrap(); + s.execute("object.clippingMask.make", &json!({})).unwrap(); + s.execute("transparency.set", &json!({"opacity": 50, "blend": "Multiply"})).unwrap(); + // A named group, isolated, knockout on, whose opacity defines the knockout shape. + let (a, b) = (rect(&mut s, 60.0, 60.0, 30.0, 30.0), rect(&mut s, 75.0, 75.0, 30.0, 30.0)); + let g = id(s.execute("object.group", &json!({"ids": [a, b]})).unwrap()); + s.execute("object.setProps", &json!({"ids": [g], "name": "Knock", "opacity": 80, "isolate": true, "knockout": "on", "knockoutShape": true})) + .unwrap(); + // A square with an opacity mask that doesn't clip. + let masked = rect(&mut s, 130.0, 10.0, 40.0, 40.0); + s.execute("paint.setFill", &json!({"color": "#808080"})).unwrap(); + let mask = rect(&mut s, 140.0, 20.0, 20.0, 20.0); + s.execute("select.set", &json!({"ids": [masked, mask]})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({"clip": false})).unwrap(); + // A clipping layer at 40%. + let cut = id(s.execute("layer.new", &json!({"name": "Cut"})).unwrap()); + s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap(); + rect(&mut s, 10.0, 100.0, 80.0, 60.0); + rect(&mut s, 30.0, 110.0, 40.0, 40.0); + s.execute("layer.clippingMask.toggle", &json!({})).unwrap(); + s.execute("object.setProps", &json!({"ids": [cut], "opacity": 40})).unwrap(); + s +} + +/// The editing copy of `bytes` read alone, checked against its page. +fn editing_copy(bytes: &[u8]) -> vectorcraft_doc::Document { + let l = load::load_with("art.ai", bytes, &LoadOptions { native: false, ..Default::default() }).unwrap(); + assert!(l.editing_data && !l.restored, "{:?}", l.warnings); + assert!(!l.warnings.iter().any(|w| w.contains("editing data")), "{:?}", l.warnings); + l.doc +} + +/// `nodes` as the editing copy reads them back: an opacity mask's art that isn't a group comes +/// back as a plain group holding it. +fn mask_art_grouped(nodes: &mut [std::sync::Arc]) { + use vectorcraft_doc::{Node, NodeKind}; + for n in nodes { + let n = std::sync::Arc::make_mut(n); + if let Some(m) = n.extras_mut().mask.as_mut() + && !matches!(m.art.kind, NodeKind::Group { .. }) + { + let art = m.art.clone(); + m.art = std::sync::Arc::new(Node::new(art.id, NodeKind::Group { children: vec![art], clip: false })); + } + if let Some(c) = n.children_mut() { + mask_art_grouped(c); + } + } +} + +/// Transparency, a compound path, a clipping group, an opacity mask and a clipping layer come back +/// from the editing copy for other apps alone, its page matching it, with every save option that +/// writes the copy; the version 7 `.ai` keeps the compound path and the clipping group. +#[test] +fn transparency_clipping_and_opacity_masks_come_back_from_the_editing_copy() { + let mut s = see_through_session(); + let mut before = (*s.doc().unwrap().doc).clone(); + // The mask's art is one path; it reads back as a group holding the path. + let mut arts = vec![]; + before.walk(|n| arts.extend(n.extras().mask.as_ref().map(|m| matches!(m.art.kind, vectorcraft_doc::NodeKind::Path { .. })))); + assert_eq!(arts, [true]); + mask_art_grouped(&mut before.layers); + let want = see_through_outline(&before); + for opts in [json!({"format": "ai"}), json!({"format": "ai", "compress": false}), json!({"format": "ai", "pdfCompatible": true})] { + let bytes = b64(&s.execute("document.export", &opts).unwrap()); + assert_eq!(see_through_outline(&editing_copy(&bytes)), want, "{opts}"); + } + // Each artboard in a file of its own: the first holds all the art. + let path = tmp("see-through.ai"); + let r = s.execute("document.save", &json!({"path": path, "separateArtboards": true})).unwrap(); + let files: Vec = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect(); + assert_eq!(files.len(), 3, "{r}"); + for f in &files { + let copy = editing_copy(&std::fs::read(f).unwrap()); + if f.ends_with("-2.ai") { + assert!(copy.layers.iter().all(|l| l.children().is_none_or(Vec::is_empty)), "{f} {:#?}", see_through_outline(©)); + } else { + assert_eq!(see_through_outline(©), want, "{f}"); + } + } + // Version 7 has no transparency; it keeps the compound path and the clipping group. + let r = s.execute("document.export", &json!({"format": "ai", "aiVersion": 7})).unwrap(); + let legacy = load::load_with("art.ai", &b64(&r), &LoadOptions::default()).unwrap(); + let kinds = see_through_outline(&legacy.doc); + assert!(kinds.iter().any(|l| l.contains(" compound ")) && kinds.iter().any(|l| l.contains("group clip=true")), "{kinds:#?}"); } diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 4d1a328fd..cbe713e44 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2733,6 +2733,16 @@ fn editing_text() -> String { square(40), "50 10 m 60 10 L 60 20 L 50 20 L h W f".into(), "Q".into(), + // Transparency: a path's, a group's state block, and an object's opacity mask. + "0 0 1 0 k\n2 0.5 0 0 1 Xy".into(), + square(86), + "0 Ae\nu".into(), + square(90), + "U\n0 0.4 1 1 1 Xy\n0 0 Xd\n6 () XW".into(), + square(94), + "%_/ArtDictionary :\n%_/Mask :\n%_X=\n%_0 g\n%_94 10 m\n%_98 10 L\n%_98 14 L\n%_f\n%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n\ + %_0 /Bool (Inverted) ,\n%_0 /Bool (Disabled) ,\n%_1 /Bool (Linked) ,\n%_; (AI9_Mask) ,\n%_;" + .into(), // Smooth and corner anchors by the case of each segment's operator, back to the start. "10 50 m 20 60 30 50 40 50 c 45 50 50 55 v 55 60 60 60 Y 60 50 l 50 40 30 40 10 50 C s".into(), "70 50 m 80 50 l 80 60 L 70 60 y h S".into(), @@ -2761,7 +2771,8 @@ fn editing_text() -> String { format!( "%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n{PATTERN}{PALETTE}{}{}%%Trailer\n", layer("One", 1, &format!("{body}\n{}", layer("Sub", 1, &square(70)))), - layer("Two", 0, &square(80)) + // A layer's own state block after its last compound path. + layer("Two", 0, &format!("{}\n*u\n{}\n{}\n*U\n1 0.6 1 2 1 Xy\n0 0 Xd\n7 () XW", square(80), square(84), square(88))) ) } diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index bdcefb56b..43b82f127 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -24,9 +24,9 @@ //! dictionary's `/Recorded` part and the canvas size as its `AIDocumentCanvasSize`, on `%_` //! lines. //! - Layers: `%AI5_BeginLayer`, `visible preview unlocked printing dimmed 0 1 colorIndex r g b 0 -//! dimPercent 0 Lb` (14 operands; a color that isn't one of the preset layer colors has the -//! index -1), `(name) Ln`, the layer's name as an XML id in an `/ArtDictionary`, its art and -//! sublayers, `LB`, `%AI5_EndLayer--`. +//! dimPercent clipping Lb` (14 operands; a color that isn't one of the preset layer colors has the +//! index -1; `clipping` is 1 for a clipping layer), `(name) Ln`, the layer's name as an XML id in an +//! `/ArtDictionary`, its art and sublayers, `LB`, `%AI5_EndLayer--`. //! - Colors: `g` gray, `k` CMYK, `c m y k r g b Xa` RGB (Lab is written as RGB); a spot color //! `c m y k (name) tint x` (CMYK) or `c m y k r g b|L a b (name) tint 1|2 Xx` (RGB or Lab); a //! global process color `… (name) tint model Xk`; Registration `1 1 1 1 ([Registration]) tint @@ -34,18 +34,31 @@ //! - State before each object: locked `A`, hidden `Xw` (`1` or `0`). An object's name follows it as //! an `/ArtDictionary` whose `AI10_ArtUID` is the name as an XML id. Guides are `(N) *`, the //! path, `(N) *` (`(n) *` when closed). +//! - Transparency: `mode opacity isolate knockout shape Xy` (the blending mode's number, Knockout +//! Group 0 off, 1 on, 2 neutral), written when it changes: in a path's paint after its colors, +//! before a guide, and after a group's end (`U` or `Q`) followed by `0 0 Xd` and `6 () XW`; a +//! group without transparency of its own ends `9 () XW`. A layer's comes after its art: `Xy`, +//! `0 0 Xd`, `7 () XW`, before `LB`. +//! - Compound paths: `*u`, the subpaths, `*U` (§5.3.5). Clipping groups: `q`, the art, the clipping +//! path last with `h` (or `H` when open) and `W` before its painting operator, `Q` (§5.6.2); a +//! clipping layer's clipping path is written the same way, last in the layer. +//! - Opacity masks: an `AI9_Mask` entry in the object's `/ArtDictionary`, a `/Mask` dictionary +//! holding the mask's art between `X=` and `X+` on `%_` lines, `(Art) ,`, and `Clipping`, +//! `Inverted`, `Disabled` and `Linked` as `/Bool` entries. //! //! Lines end in CR LF. Strings are written in UTF-8. Art space is ruler space: y up, its origin at //! the first artboard's top-left corner. On the canvas ([`CANVAS_SIZE`] points square, y down) the //! artboards together are centered; each artboard's `RulerOrigin` is its top-left corner there, and //! `%AI3_TemplateBox` holds the canvas's center in art space. What the form above can't hold is written as plain filled and stroked paths, each such //! loss a warning: type as its outlines and its inline graphics' art, gradients as their average color, pattern fills as their -//! tiles cut to the shape, clipping groups and clipping layers as their art cut to the clipping -//! path (the clipping path's fill cut too, its stroke not), images as their outlines filled with +//! tiles cut to the shape, clipping groups and clipping layers whose clipping object isn't a path, +//! and the clipping groups inside them, as their art cut to that object (its fill cut too, its +//! stroke not), images as their outlines filled with //! their average color, symbols and live objects as their art (symbol art past //! [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects in all left out), strokes a plain line can't draw //! as their filled outlines, several fills or strokes as a group of a path for each, gradient and -//! pattern swatches, transparency and live effects other than geometry effects left out. +//! pattern swatches, the opacity and blending modes of single fills and strokes, opacity masks on +//! layers and opacity masks nested too deep, and live effects other than geometry effects left out. //! //! The data streams hold the program compressed with Zstandard after `%AI24_ZStandard_Data`, with //! the version numbers of the newest files users sent ([`private_data`]). diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 5a355fe3d..dcf928df5 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -3,10 +3,10 @@ use std::sync::Arc; use vectorcraft_color::swatch::{Swatch, SwatchGroup}; -use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; +use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; use vectorcraft_doc::{ - Appearance, AppearanceItem, Artboard, Dash, Document, FillLayer, ImageBlob, ImageObject, LayerColor, LineCap, LineJoin, Node, NodeKind, - StrokeLayer, Symbol, + Appearance, AppearanceItem, Artboard, Dash, Document, FillLayer, ImageBlob, ImageObject, Knockout, LayerColor, LineCap, LineJoin, Node, NodeKind, + OpacityMask, StrokeLayer, Symbol, }; use vectorcraft_geom::{Affine, FillRule, PathData, Rect, shapes}; @@ -223,6 +223,24 @@ fn view(nodes: &[Arc], depth: usize, out: &mut Vec) { } out.push(s); } + // The compound path's own paint and its members' points (the paint is the compound path's). + NodeKind::Compound { rule, .. } => { + let mut s = format!("{head} compound {rule:?}"); + for c in n.children().map(Vec::as_slice).unwrap_or_default() { + for sp in c.path_data().map(|p| p.subpaths.as_slice()).unwrap_or_default() { + let r2 = |v: f64| format!("{:.2}", v + 0.0); + s += &format!(" [closed={}", sp.closed); + for a in &sp.anchors { + s += &format!(" {},{}", r2(a.p.x), r2(a.p.y)); + } + s += "]"; + } + } + for it in n.appearance.items.iter().filter(|i| !i.paint().is_none()) { + s += &format!(" {}", paint(it.paint())); + } + out.push(s); + } other => out.push(format!("{head} {other:?}")), } } @@ -584,8 +602,8 @@ fn hidden_layers_open_hidden() { assert!(text.contains("%AI5_NumLayers: 3\n%AI5_OpenViewLayers: 776\n"), "{text}"); } -/// A 200 × 100 document of what slice 1 writes as plain paths: a gradient fill, a clipping group, -/// a compound path, an image, type, a symbol instance and a half-transparent square. +/// A 200 × 100 document of what the data holds as plain paths: a gradient fill, a compound path, +/// an image, type and a symbol instance. fn unsupported() -> Document { let mut doc = Document::new(200.0, 100.0); let layer = doc.layers[0].id; @@ -597,24 +615,6 @@ fn unsupported() -> Document { let shaded = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), fill(Paint::Gradient(Box::new(GradientPaint::new(g))))); doc.insert(Some(layer), usize::MAX, shaded).unwrap(); - let clip = Node::new( - doc.alloc_id(), - NodeKind::Path { - path: shapes::rectangle(Rect::new(40.0, 10.0, 50.0, 30.0)), - rule: FillRule::NonZero, - live: None, - clipping: true, - guide: false, - }, - ); - let clipped = Node::path( - doc.alloc_id(), - shapes::rectangle(Rect::new(35.0, 15.0, 60.0, 25.0)), - Appearance::basic(solid(Color::gray(0.5)), solid(Color::BLACK), 1.0), - ); - let mut window = Node::new(doc.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(clipped)], clip: true }); - window.name = Some("Window".into()); - doc.insert(Some(layer), usize::MAX, window).unwrap(); let outer = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(70.0, 10.0, 90.0, 30.0)), Appearance::default()); let inner = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(75.0, 15.0, 85.0, 25.0)), Appearance::default()); let mut ring = Node::new(doc.alloc_id(), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(inner)], rule: FillRule::EvenOdd }); @@ -648,9 +648,6 @@ fn unsupported() -> Document { doc.symbols.push(Symbol { dynamic: false, name: "Dot".into(), art: Arc::new(art) }); let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((150.0, 10.0)) }); doc.insert(Some(layer), usize::MAX, instance).unwrap(); - let mut faded = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(170.0, 10.0, 190.0, 30.0)), fill(solid(Color::BLACK))); - faded.opacity = 0.5; - doc.insert(Some(layer), usize::MAX, faded).unwrap(); doc } @@ -668,7 +665,7 @@ fn paths(nodes: &[Arc], out: &mut Vec) { fn art_the_slice_does_not_cover_is_written_as_plain_paths() { let doc = unsupported(); let out = export_ai_editing(&doc, &opts()).unwrap(); - for w in [writer::GRADIENTS, writer::CLIPPING, writer::IMAGES, writer::TYPE, writer::LIVE, writer::TRANSPARENCY] { + for w in [writer::GRADIENTS, writer::IMAGES, writer::TYPE, writer::LIVE] { assert!(out.warnings.iter().any(|x| x == w), "{w}: {:?}", out.warnings); } let back = read_back(&doc); @@ -682,22 +679,15 @@ fn art_the_slice_does_not_cover_is_written_as_plain_paths() { _ => "other", }) .collect(); - assert_eq!(kinds, ["path", "group", "compound", "path", "group", "group", "path"]); + assert_eq!(kinds, ["path", "compound", "path", "group", "group"]); let mut all = vec![]; paths(layer, &mut all); assert!(all.iter().all(|n| matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }))); // The gradient as its average color. assert_eq!(paint(back.layers[0].children().unwrap()[0].appearance.items[0].paint()), paint(&solid(Color::rgb(0.5, 0.5, 0.5)))); - // The clipping group: the gray fill and the black stroke's outline, cut to the 10 × 20 clip. - let window = &layer[1]; - assert_eq!(window.name.as_deref(), Some("Window")); - let b = window.visual_bounds().unwrap(); - assert!((b.x0 - 40.0).abs() < 0.01 && (b.x1 - 50.0).abs() < 0.01, "{b:?}"); // The image's outline filled with its pixels' average. - assert_eq!(paint(layer[3].appearance.items[0].paint()), paint(&solid(Color::rgb(0.5, 0.0, 0.5)))); - assert_eq!(layer[3].visual_bounds().map(|b| (r3(b.x0), r3(b.y0), r3(b.x1), r3(b.y1))), Some((r3(100.0), r3(10.0), r3(120.0), r3(20.0)))); - // The half-transparent square, opaque. - assert_eq!(layer[6].opacity, 1.0); + assert_eq!(paint(layer[2].appearance.items[0].paint()), paint(&solid(Color::rgb(0.5, 0.0, 0.5)))); + assert_eq!(layer[2].visual_bounds().map(|b| (r3(b.x0), r3(b.y0), r3(b.x1), r3(b.y1))), Some((r3(100.0), r3(10.0), r3(120.0), r3(20.0)))); } #[test] @@ -878,7 +868,10 @@ fn a_layer_with_crop_marks_keeps_its_art_and_gets_the_marks() { assert_eq!(back.layers[0].name, name); let members = back.layers[0].children().unwrap(); assert_eq!(members.len(), 2, "clipping {clipping}: the square and the marks"); - assert!(matches!(members[0].kind, NodeKind::Path { .. })); + // A clipping layer's art is a clipping group, outside which the marks lie. + let expected = + if clipping { matches!(members[0].kind, NodeKind::Group { clip: true, .. }) } else { matches!(members[0].kind, NodeKind::Path { .. }) }; + assert!(expected && matches!(back.layers[0].kind, NodeKind::Layer { clip: false, .. }), "clipping {clipping}"); assert!(matches!(members[1].kind, NodeKind::Group { .. })); // The marks lie around the square, outside a clipping path. let marks = members[1].visual_bounds().unwrap(); @@ -1029,3 +1022,655 @@ fn a_document_without_an_artboard_is_an_error() { doc.artboards.push(Artboard { rect: Rect::new(0.0, 0.0, 0.0, 10.0), ..Document::new(1.0, 1.0).artboards[0].clone() }); assert!(export_ai_editing(&doc, &opts()).is_err()); } + +// ---------- transparency, compound paths, clipping, opacity masks ---------- + +/// A rectangle filled with `c`. +fn square(doc: &mut Document, r: Rect, c: Color) -> Node { + Node::path(doc.alloc_id(), shapes::rectangle(r), Appearance::basic(solid(c), Paint::None, 0.0)) +} + +/// A clipping path of `path`, unpainted. +fn clipping(doc: &mut Document, path: PathData) -> Node { + Node::new(doc.alloc_id(), NodeKind::Path { path, rule: FillRule::NonZero, live: None, clipping: true, guide: false }) +} + +fn named(mut n: Node, name: &str) -> Node { + n.name = Some(name.into()); + n +} + +fn see_through(mut n: Node, opacity: f32, blend: BlendMode) -> Node { + (n.opacity, n.blend) = (opacity, blend); + n +} + +fn group(doc: &mut Document, children: Vec, clip: bool) -> Node { + Node::new(doc.alloc_id(), NodeKind::Group { children: children.into_iter().map(Arc::new).collect(), clip }) +} + +/// A 200 × 100 document of what the transparency, compound path and clipping forms hold. Layer +/// `Clear` (60%, Multiply, isolated) holds, bottom first: a path at 50% in Screen whose opacity +/// defines the knockout shape; a group at 40%, isolated, knockout on, whose opacity defines the +/// knockout shape, of a square and a square at 75% in Overlay; a group with knockout off; a group of +/// a square in each blending mode; a clipping group at 50%; a clipping group whose clipping path +/// is an even-odd compound path; a clipping group holding a clipping group; a named path with an +/// inverted opacity mask that doesn't clip; a group with a disabled opacity mask; and a compound +/// path at 70% in Screen whose opacity defines the knockout shape last. The layer's opacity +/// defines the knockout shape too. Layer `Cut` clips by its first path. Layer `Plain` holds an +/// opaque path. +fn transparent() -> Document { + let mut doc = Document::new(200.0, 100.0); + doc.swatches = vec![Swatch { name: "Blue".into(), paint: solid(Color::rgb(0.0, 0.0, 1.0)), global: false, spot: false }].into(); + doc.swatch_groups = vec![].into(); + doc.layers.clear(); + let clear = doc.add_layer(Some("Clear")); + let cut = doc.add_layer(Some("Cut")); + let plain = doc.add_layer(Some("Plain")); + let red = Color::rgb(1.0, 0.0, 0.0); + let blue = Color::rgb(0.0, 0.0, 1.0); + let mut art = vec![]; + + let mut shine = see_through(square(&mut doc, Rect::new(5.0, 5.0, 25.0, 25.0), red), 0.5, BlendMode::Screen); + shine.knockout_shape = true; + art.push(named(shine, "Shine")); + let a = square(&mut doc, Rect::new(30.0, 5.0, 50.0, 25.0), red); + let b = see_through(square(&mut doc, Rect::new(35.0, 10.0, 55.0, 30.0), blue), 0.75, BlendMode::Overlay); + let mut knock = see_through(group(&mut doc, vec![a, b], false), 0.4, BlendMode::Normal); + (knock.isolate, knock.knockout, knock.knockout_shape) = (true, Knockout::On, true); + art.push(named(knock, "Knock")); + let a = square(&mut doc, Rect::new(60.0, 5.0, 70.0, 15.0), red); + let b = square(&mut doc, Rect::new(65.0, 10.0, 75.0, 20.0), blue); + let mut off = group(&mut doc, vec![a, b], false); + off.knockout = Knockout::Off; + art.push(off); + let modes: Vec = BlendMode::ALL + .iter() + .enumerate() + .map(|(i, m)| { + let x = 5.0 + 11.0 * i as f64; + see_through(square(&mut doc, Rect::new(x, 40.0, x + 10.0, 50.0), red), 0.5, *m) + }) + .collect(); + art.push(group(&mut doc, modes, false)); + let clip = clipping(&mut doc, shapes::rectangle(Rect::new(90.0, 5.0, 100.0, 25.0))); + let inside = square(&mut doc, Rect::new(85.0, 10.0, 110.0, 20.0), blue); + art.push(named(see_through(group(&mut doc, vec![named(clip, "Edge"), inside], true), 0.5, BlendMode::Normal), "Window")); + // The subpaths of a compound clipping path are clipping paths, as the reader marks them. + let outer = clipping(&mut doc, shapes::rectangle(Rect::new(115.0, 5.0, 135.0, 25.0))); + let inner = clipping(&mut doc, shapes::rectangle(Rect::new(120.0, 10.0, 130.0, 20.0))); + let compound_clip = Node::new(doc.alloc_id(), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(inner)], rule: FillRule::EvenOdd }); + let green = square(&mut doc, Rect::new(110.0, 0.0, 140.0, 30.0), Color::rgb(0.0, 1.0, 0.0)); + art.push(group(&mut doc, vec![compound_clip, green], true)); + let deep_clip = clipping(&mut doc, shapes::rectangle(Rect::new(145.0, 5.0, 160.0, 25.0))); + let deep = square(&mut doc, Rect::new(140.0, 0.0, 170.0, 30.0), red); + let deep = group(&mut doc, vec![deep_clip, deep], true); + let outer_clip = clipping(&mut doc, shapes::rectangle(Rect::new(150.0, 0.0, 165.0, 20.0))); + art.push(group(&mut doc, vec![outer_clip, deep], true)); + let gray = square(&mut doc, Rect::new(5.0, 60.0, 15.0, 70.0), Color::gray(0.5)); + let white = square(&mut doc, Rect::new(15.0, 60.0, 25.0, 70.0), Color::rgb(1.0, 1.0, 1.0)); + let mask_art = group(&mut doc, vec![gray, white], false); + let mut masked = named(square(&mut doc, Rect::new(0.0, 55.0, 30.0, 75.0), blue), "Masked"); + masked.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(mask_art), clip: false, invert: true, disabled: false, linked: true })); + art.push(masked); + let off_art = square(&mut doc, Rect::new(40.0, 60.0, 50.0, 70.0), Color::gray(0.25)); + let in_faded = square(&mut doc, Rect::new(35.0, 55.0, 60.0, 75.0), red); + let mut faded = group(&mut doc, vec![in_faded], false); + faded.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(off_art), clip: true, invert: false, disabled: true, linked: false })); + art.push(faded); + let o = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(70.0, 55.0, 90.0, 75.0)), Appearance::default()); + let i = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(75.0, 60.0, 85.0, 70.0)), Appearance::default()); + let mut ring = Node::new(doc.alloc_id(), NodeKind::Compound { children: vec![Arc::new(o), Arc::new(i)], rule: FillRule::EvenOdd }); + ring.appearance = Appearance::basic(solid(red), Paint::None, 0.0).into(); + ring.knockout_shape = true; + art.push(named(see_through(ring, 0.7, BlendMode::Screen), "Ring")); + for n in art { + doc.insert(Some(clear), usize::MAX, n).unwrap(); + } + + let edge = clipping(&mut doc, shapes::rectangle(Rect::new(100.0, 40.0, 150.0, 90.0))); + doc.insert(Some(cut), usize::MAX, named(edge, "Edge")).unwrap(); + for (r, c) in [(Rect::new(90.0, 35.0, 130.0, 70.0), red), (Rect::new(120.0, 60.0, 160.0, 95.0), blue)] { + let n = square(&mut doc, r, c); + doc.insert(Some(cut), usize::MAX, n).unwrap(); + } + let opaque = square(&mut doc, Rect::new(170.0, 60.0, 190.0, 80.0), blue); + doc.insert(Some(plain), usize::MAX, opaque).unwrap(); + for (i, l) in doc.layers.iter_mut().enumerate() { + let l = Arc::make_mut(l); + match i { + 0 => { + (l.opacity, l.blend, l.isolate, l.knockout_shape) = (0.6, BlendMode::Multiply, true, true); + } + 1 => { + if let NodeKind::Layer { clip, .. } = &mut l.kind { + *clip = true; + } + } + _ => {} + } + } + doc +} + +/// The transparency, clipping and opacity masks of every object, one line each, the masks' art +/// after their objects. +fn looks(nodes: &[Arc], depth: usize, out: &mut Vec) { + for n in nodes { + let kind = match &n.kind { + NodeKind::Layer { clip, .. } => format!("layer clip={clip}"), + NodeKind::Group { clip, .. } => format!("group clip={clip}"), + NodeKind::Path { clipping, .. } => format!("path clipping={clipping}"), + NodeKind::Compound { .. } => "compound".into(), + _ => "other".into(), + }; + let mut s = format!( + "{depth} {:?} {kind} {} {:?} isolate={} {:?} shape={}", + n.name, + r3(f64::from(n.opacity)), + n.blend, + n.isolate, + n.knockout, + n.knockout_shape + ); + let mask = n.extras().mask.clone(); + if let Some(m) = &mask { + s += &format!(" mask(clip={} invert={} disabled={} linked={})", m.clip, m.invert, m.disabled, m.linked); + } + out.push(s); + if let Some(m) = mask { + view(std::slice::from_ref(&m.art), depth + 1, out); + } + looks(n.children().map(Vec::as_slice).unwrap_or_default(), depth + 1, out); + } +} + +fn all_looks(doc: &Document) -> Vec { + let mut out = vec![]; + looks(&doc.layers, 0, &mut out); + out +} + +/// Fails with the lines where `got` and `want` differ. +fn same_lines(got: &[String], want: &[String]) { + let differ: Vec = (0..got.len().max(want.len())) + .filter(|&i| got.get(i) != want.get(i)) + .map(|i| format!("line {i}:\n got {:?}\n want {:?}", got.get(i), want.get(i))) + .collect(); + assert!(differ.is_empty(), "{}", differ.join("\n")); +} + +/// `nodes` as the reader reads them back: an opacity mask's art that isn't a group comes back as +/// a group holding it. +fn mask_art_grouped(nodes: &mut [Arc]) { + for n in nodes { + let n = Arc::make_mut(n); + if let Some(m) = n.extras_mut().mask.as_mut() + && !matches!(m.art.kind, NodeKind::Group { .. }) + { + let art = m.art.clone(); + m.art = Arc::new(Node::new(art.id, NodeKind::Group { children: vec![art], clip: false })); + } + if let Some(c) = n.children_mut() { + mask_art_grouped(c); + } + } +} + +#[test] +fn transparency_clipping_and_masks_are_read_back() { + let mut doc = transparent(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let back = read_back(&doc); + same_lines(&lines(&back), &lines(&doc)); + // The disabled mask's art is one path: it reads back as a group holding the path. + let mask_arts = |d: &Document| { + let mut out = vec![]; + d.walk(|n| { + if let Some(m) = &n.extras().mask { + out.push(match &m.art.kind { + NodeKind::Group { children, .. } => format!("group of {}", children.len()), + NodeKind::Path { .. } => "path".into(), + _ => "other".into(), + }); + } + }); + out + }; + assert_eq!( + (mask_arts(&doc), mask_arts(&back)), + (vec!["group of 2".to_string(), "path".into()], vec!["group of 2".to_string(), "group of 1".into()]) + ); + mask_art_grouped(&mut doc.layers); + same_lines(&all_looks(&back), &all_looks(&doc)); +} + +#[test] +fn transparency_clipping_and_masks_write_the_same_bytes_again() { + let doc = transparent(); + let first = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(first.program, export_ai_editing(&doc, &opts()).unwrap().program); + assert_eq!(private_data(&doc, &opts()).unwrap().0, private_data(&doc, &opts()).unwrap().0); + // What the reader reads back is written as the same program. + let again = String::from_utf8(export_ai_editing(&read_back(&doc), &opts()).unwrap().program).unwrap(); + let first = String::from_utf8(first.program).unwrap(); + same_lines(&again.lines().map(String::from).collect::>(), &first.lines().map(String::from).collect::>()); +} + +#[test] +fn transparency_is_written_in_its_forms() { + let p = program(&transparent()); + // A path's in its paint, after its colors; Screen is 2. + assert!(p.contains(" Xa\n0 O\n2 0.5 0 0 1 Xy\n0 XR\n"), "{p}"); + // A compound path's in its paint before `*u`, its knockout shape kept. + let (ring, _) = p.rsplit_once("*u\n").unwrap(); + assert!(ring.ends_with(" O\n2 0.7 0 0 1 Xy\n1 XR\n"), "{ring}"); + // A group's after its end and its name, then `Xd` and the object's own state marker. + let knock = "U\n%_/ArtDictionary :\n%_/XMLUID : (Knock) ; (AI10_ArtUID) ,\n%_;\n%_\n0 0.4 1 1 1 Xy\n0 0 Xd\n6 () XW\n"; + assert!(p.contains(knock), "{p}"); + // A group without transparency of its own. + assert!(p.contains("U\n9 () XW\n"), "{p}"); + // A clipping group: the clipping path last, then `W`. + let window = "h\nW\nn\n%_/ArtDictionary :\n%_/XMLUID : (Edge) ; (AI10_ArtUID) ,\n%_;\n%_\nQ\n\ + %_/ArtDictionary :\n%_/XMLUID : (Window) ; (AI10_ArtUID) ,\n%_;\n%_\n0 0.5 0 2 0 Xy\n0 0 Xd\n6 () XW\n"; + assert!(p.contains(window), "{p}"); + // A layer's after its art, with the layer's state marker; its knockout neutral (2). + assert!(p.contains("1 0.6 1 2 1 Xy\n0 0 Xd\n7 () XW\nLB\n"), "{p}"); + // A clipping layer: its last `Lb` flag, its clipping path last. + assert!(p.contains(" 0 50 1 Lb\n(Cut) Ln\n") && p.contains(" 0 50 0 Lb\n(Plain) Ln\n"), "{p}"); + let (_, cut) = p.split_once("(Cut) Ln").unwrap(); + // The name `Edge` is taken by the clipping group's path: this one is numbered. + let edge = "h\nW\nn\n%_/ArtDictionary :\n%_/XMLUID : (Edge_00000000000000000001_) ; (AI10_ArtUID) ,\n%_;\n%_\n"; + assert!(cut.split_once("LB\n").unwrap().0.ends_with(edge), "{cut}"); + // The next layer's first path sets the state back. + let (_, cut) = p.split_once("(Cut) Ln").unwrap(); + assert!(cut.split_once("\nf\n").unwrap().0.contains(" Xa\n0 O\n0 1 0 0 0 Xy\n0 XR\n"), "{cut}"); + // The mask in the object's dictionary, after its name. + assert!(p.contains("%_/XMLUID : (Masked) ; (AI10_ArtUID) ,\n%_/Mask :\n%_X=\n"), "{p}"); + assert!( + p.contains( + "%_X+\n%_(Art) ,\n%_0 /Bool (Clipping) ,\n%_1 /Bool (Inverted) ,\n%_0 /Bool (Disabled) ,\n%_1 /Bool (Linked) ,\n%_; (AI9_Mask) ,\n" + ), + "{p}" + ); +} + +/// A clipping path filled and stroked keeps its stroke; the reader reads its fill as a path of +/// its own under the clipped art, as the file's PDF page draws it. +#[test] +fn a_painted_clipping_path_is_written_with_its_paint() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut clip = clipping(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0))); + clip.appearance = Appearance::basic(solid(Color::rgb(1.0, 1.0, 0.0)), solid(Color::BLACK), 2.0).into(); + let inside = square(&mut doc, Rect::new(0.0, 0.0, 40.0, 20.0), Color::rgb(0.0, 0.0, 1.0)); + let g = group(&mut doc, vec![clip, inside], true); + doc.insert(Some(layer), usize::MAX, g).unwrap(); + let p = program(&doc); + assert!(p.contains("h\nW\nb\nQ\n"), "{p}"); + let back = read_back(&doc); + let NodeKind::Group { children, clip: true } = &back.layers[0].children().unwrap()[0].kind else { panic!("a clipping group") }; + let painted: Vec<(bool, Option, Option)> = children + .iter() + .map(|c| { + let clipping = matches!(c.kind, NodeKind::Path { clipping: true, .. }); + (clipping, c.appearance.fill().map(|f| paint(&f.paint)), c.appearance.stroke().map(|s| paint(&s.paint))) + }) + .collect(); + assert_eq!( + painted, + [ + (true, None, Some(paint(&solid(Color::BLACK)))), + (false, Some(paint(&solid(Color::rgb(1.0, 1.0, 0.0)))), None), + (false, Some(paint(&solid(Color::rgb(0.0, 0.0, 1.0)))), None) + ] + ); +} + +/// A clipping path without points hides what it clips. +#[test] +fn an_empty_clipping_path_hides_the_art() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let clip = clipping(&mut doc, PathData::new(vec![])); + let inside = square(&mut doc, Rect::new(0.0, 0.0, 40.0, 20.0), Color::rgb(0.0, 0.0, 1.0)); + let g = group(&mut doc, vec![clip, inside], true); + doc.insert(Some(layer), usize::MAX, g).unwrap(); + // The art is hidden, as it is drawn: the group comes back empty. + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::CLIPPING), "{:?}", out.warnings); + let back = read_back(&doc); + let art = back.layers[0].children().unwrap(); + assert_eq!(art.len(), 1, "{art:?}"); + assert!(matches!(&art[0].kind, NodeKind::Group { children, .. } if children.is_empty()), "{art:?}"); +} + +/// A clipping group without members is written as an empty group, and reads back as one. +#[test] +fn an_empty_clipping_group_is_an_empty_group() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let g = see_through(group(&mut doc, vec![], true), 0.5, BlendMode::Normal); + doc.insert(Some(layer), usize::MAX, g).unwrap(); + let p = program(&doc); + assert!(p.contains("\nu\nU\n0 0.5 0 2 0 Xy\n0 0 Xd\n6 () XW\n") && !p.contains("\nq\n"), "{p}"); + let back = read_back(&doc); + let art = back.layers[0].children().unwrap(); + assert_eq!(art.len(), 1, "{art:?}"); + assert!(matches!(&art[0].kind, NodeKind::Group { children, clip: false } if children.is_empty()), "{art:?}"); + assert_eq!(art[0].opacity, 0.5); +} + +/// A clipping path's paint that the path can't carry itself keeps the path's opacity. +#[test] +fn a_clipping_paths_other_paint_keeps_its_opacity() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut clip = clipping(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0))); + let g = Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, Color::rgb(1.0, 1.0, 1.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 0.0))], + ); + clip.appearance = Appearance::basic(Paint::Gradient(Box::new(GradientPaint::new(g))), Paint::None, 0.0).into(); + clip.opacity = 0.5; + let inside = square(&mut doc, Rect::new(0.0, 0.0, 40.0, 20.0), Color::rgb(0.0, 0.0, 1.0)); + let g = group(&mut doc, vec![clip, inside], true); + doc.insert(Some(layer), usize::MAX, g).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::CLIP_PAINT), "{:?}", out.warnings); + assert!(!out.warnings.iter().any(|w| w == writer::CLIP_PAINT_MASKS), "{:?}", out.warnings); + // With an opacity mask, which stays on the clipping path: a warning says so. + let mut masked = doc.clone(); + let art = square(&mut masked, Rect::new(10.0, 10.0, 20.0, 20.0), Color::gray(0.5)); + if let Some(c) = Arc::make_mut(&mut masked.layers[0]).children_mut() + && let Some(NodeKind::Group { children, .. }) = c.first_mut().map(|g| &mut Arc::make_mut(g).kind) + { + Arc::make_mut(&mut children[0]).extras_mut().mask = Some(Box::new(OpacityMask::new(art, true))); + } + let out = export_ai_editing(&masked, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::CLIP_PAINT_MASKS), "{:?}", out.warnings); + // Two fills at the clipping path's 50% are a group at 50% of the two at 100%, as the canvas + // draws them together. + let mut two = doc.clone(); + if let Some(c) = Arc::make_mut(&mut two.layers[0]).children_mut() + && let Some(NodeKind::Group { children, .. }) = c.first_mut().map(|g| &mut Arc::make_mut(g).kind) + { + let clip = Arc::make_mut(&mut children[0]); + let mut items = clip.appearance.items.clone(); + items.push(AppearanceItem::Fill(FillLayer::new(solid(Color::rgb(1.0, 0.0, 0.0))))); + clip.appearance = Appearance { items, ..Appearance::default() }.into(); + } + let back = read_back(&two); + let NodeKind::Group { children, clip: true } = &back.layers[0].children().unwrap()[0].kind else { panic!("a clipping group") }; + let fills = &children[1]; + assert!(matches!(fills.kind, NodeKind::Group { clip: false, .. }) && fills.opacity == 0.5, "{fills:?}"); + assert_eq!(fills.children().unwrap().iter().map(|c| c.opacity).collect::>(), [1.0, 1.0]); + // A locked clipping path's fill is locked too, the art it clips not. + let mut locked = doc.clone(); + if let Some(c) = Arc::make_mut(&mut locked.layers[0]).children_mut() + && let Some(NodeKind::Group { children, .. }) = c.first_mut().map(|g| &mut Arc::make_mut(g).kind) + { + Arc::make_mut(&mut children[0]).locked = true; + } + let back = read_back(&locked); + let NodeKind::Group { children, clip: true } = &back.layers[0].children().unwrap()[0].kind else { panic!("a clipping group") }; + assert_eq!(children.iter().map(|c| c.locked).collect::>(), [true, true, false]); + let back = read_back(&doc); + let NodeKind::Group { children, clip: true } = &back.layers[0].children().unwrap()[0].kind else { panic!("a clipping group") }; + // The clipping path, its fill as a path under the art, the art. + let fill = &children[1]; + assert!(matches!(fill.kind, NodeKind::Path { clipping: false, .. })); + assert_eq!((fill.opacity, paint(fill.appearance.items[0].paint())), (0.5, paint(&solid(Color::rgb(0.5, 0.5, 0.5))))); +} + +/// A clipping path's stroke that a plain line can't draw goes over the clipped art, its fill under it. +#[test] +fn a_clipping_paths_other_stroke_goes_over_the_art() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut clip = clipping(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0))); + let g = Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, Color::rgb(1.0, 1.0, 1.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 0.0))], + ); + let mut stroke = StrokeLayer::new(solid(Color::rgb(1.0, 0.0, 0.0)), 4.0); + stroke.dash = Some(Dash { pattern: vec![2.0, 2.0], offset: 0.0, align_corners: true }); + clip.appearance = Appearance { + items: vec![AppearanceItem::Fill(FillLayer::new(Paint::Gradient(Box::new(GradientPaint::new(g))))), AppearanceItem::Stroke(stroke)], + ..Appearance::default() + } + .into(); + let inside = square(&mut doc, Rect::new(0.0, 0.0, 40.0, 20.0), Color::rgb(0.0, 0.0, 1.0)); + let g = group(&mut doc, vec![clip, inside], true); + doc.insert(Some(layer), usize::MAX, g).unwrap(); + let back = read_back(&doc); + let NodeKind::Group { children, clip: true } = &back.layers[0].children().unwrap()[0].kind else { panic!("a clipping group") }; + let colors: Vec = children.iter().skip(1).map(|c| paint(c.appearance.items.first().map_or(&Paint::None, |i| i.paint()))).collect(); + // The fill (its average) under the blue art, the red stroke's dots over it. + assert_eq!(colors.first(), Some(&paint(&solid(Color::rgb(0.5, 0.5, 0.5)))), "{colors:?}"); + assert_eq!(colors.get(1), Some(&paint(&solid(Color::rgb(0.0, 0.0, 1.0)))), "{colors:?}"); + assert!(colors.len() > 2 && colors.iter().skip(2).all(|c| *c == paint(&solid(Color::rgb(1.0, 0.0, 0.0)))), "{colors:?}"); +} + +/// An unpainted path with transparency of its own after a compound path: the compound path keeps its own. +#[test] +fn an_unpainted_paths_transparency_after_a_compound_path_is_its_own() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let o = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), Appearance::default()); + let i = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(15.0, 15.0, 25.0, 25.0)), Appearance::default()); + let mut ring = Node::new(doc.alloc_id(), NodeKind::Compound { children: vec![Arc::new(o), Arc::new(i)], rule: FillRule::EvenOdd }); + ring.appearance = Appearance::basic(solid(Color::BLACK), Paint::None, 0.0).into(); + doc.insert(Some(layer), usize::MAX, ring).unwrap(); + let bare = see_through( + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(50.0, 10.0, 70.0, 30.0)), Appearance::default()), + 0.5, + BlendMode::Multiply, + ); + doc.insert(Some(layer), usize::MAX, bare).unwrap(); + let back = read_back(&doc); + let art = back.layers[0].children().unwrap(); + assert_eq!((art[0].opacity, art[0].blend, art[1].opacity, art[1].blend), (1.0, BlendMode::Normal, 0.5, BlendMode::Multiply)); +} + +/// Opacity masks in mask art nested past the limit are left out, not written empty. +#[test] +fn opacity_masks_nested_too_deep_are_left_out() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut n = square(&mut doc, Rect::new(10.0, 10.0, 20.0, 20.0), Color::gray(0.5)); + for _ in 0..12 { + let mut owner = square(&mut doc, Rect::new(10.0, 10.0, 20.0, 20.0), Color::gray(0.5)); + owner.extras_mut().mask = Some(Box::new(OpacityMask::new(n, true))); + n = owner; + } + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::DEEP_MASKS), "{:?}", out.warnings); + let p = String::from_utf8(out.program).unwrap().replace("\r\n", "\n"); + assert!(!p.contains("%_X=\n%_X+\n"), "no empty mask"); + // The masks within the limit are read back, one in the art of the other. + let back = read_back(&doc); + let mut n = back.layers[0].children().unwrap()[0].clone(); + let mut depth = 0; + while let Some(m) = n.extras().mask.clone() { + depth += 1; + n = match &m.art.kind { + NodeKind::Group { children, .. } if children.len() == 1 => children[0].clone(), + _ => m.art.clone(), + }; + } + assert_eq!(depth, 8); +} + +#[test] +fn a_clipping_object_other_than_a_path_cuts_the_art() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let t = vectorcraft_doc::TextObject::point( + vectorcraft_geom::Point::new(10.0, 70.0), + "Type", + vectorcraft_doc::CharStyle { size: 18.0, fill: solid(Color::BLACK), ..vectorcraft_doc::CharStyle::default() }, + ); + let t = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + let inside = square(&mut doc, Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb(0.0, 0.0, 1.0)); + let g = group(&mut doc, vec![t, inside], true); + doc.insert(Some(layer), usize::MAX, g).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::CLIPPING), "{:?}", out.warnings); + assert!(!String::from_utf8(out.program).unwrap().contains("\nq\n")); + // The type's black fill under the blue square, both cut to the type: within its line, + // narrower than the square. + assert_eq!(cut_to_type(&read_back(&doc).layers[0]), ["black", "blue"]); +} + +/// The colors of the paths `layer` holds, in paint order, each kind once, after checking that they +/// all lie within the line of type `Type` set at (10, 70) in 18 points. +fn cut_to_type(layer: &Node) -> Vec<&'static str> { + let mut all = vec![]; + paths(layer.children().unwrap(), &mut all); + let b = all.iter().filter_map(|n| n.geometric_bounds()).reduce(|a, b| a.union(b)).unwrap(); + assert!(b.x0 >= 9.0 && b.x1 < 100.0 && b.y0 > 40.0 && b.y1 < 80.0, "{b:?}"); + let mut colors = vec![]; + for n in &all { + let c = match n.appearance.fill().map(|f| paint(&f.paint)) { + Some(p) if p == paint(&solid(Color::BLACK)) => "black", + Some(p) if p == paint(&solid(Color::rgb(0.0, 0.0, 1.0))) => "blue", + _ => "other", + }; + if colors.last() != Some(&c) { + colors.push(c); + } + } + colors +} + +/// A clipping layer whose clipping object isn't a path is written as a layer that doesn't clip, +/// holding its art cut to that object. +#[test] +fn a_clipping_layer_clipped_by_type_is_not_written_as_a_clipping_layer() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let t = vectorcraft_doc::TextObject::point( + vectorcraft_geom::Point::new(10.0, 70.0), + "Type", + vectorcraft_doc::CharStyle { size: 18.0, fill: solid(Color::BLACK), ..vectorcraft_doc::CharStyle::default() }, + ); + let t = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + doc.insert(Some(layer), usize::MAX, t).unwrap(); + let inside = square(&mut doc, Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb(0.0, 0.0, 1.0)); + doc.insert(Some(layer), usize::MAX, inside).unwrap(); + if let NodeKind::Layer { clip, .. } = &mut Arc::make_mut(&mut doc.layers[0]).kind { + *clip = true; + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::CLIPPING), "{:?}", out.warnings); + let p = String::from_utf8(out.program).unwrap().replace("\r\n", "\n"); + assert!(p.contains(" 0 50 0 Lb\n") && !p.contains(" 0 50 1 Lb\n") && !p.contains("\nW\n"), "{p}"); + let back = read_back(&doc); + assert!(matches!(back.layers[0].kind, NodeKind::Layer { clip: false, .. })); + assert_eq!(cut_to_type(&back.layers[0]), ["black", "blue"]); +} + +/// An empty clipping layer is written as an empty layer. +#[test] +fn an_empty_clipping_layer_is_an_empty_layer() { + let mut doc = Document::new(200.0, 100.0); + if let NodeKind::Layer { clip, .. } = &mut Arc::make_mut(&mut doc.layers[0]).kind { + *clip = true; + } + let p = program(&doc); + assert!(p.contains(" 0 50 0 Lb\n(Layer 1) Ln\n") && !p.contains("\nu\n"), "{p}"); + let back = read_back(&doc); + assert!(back.layers[0].children().is_none_or(Vec::is_empty), "{:?}", back.layers[0]); +} + +/// A guide takes its own transparency, not the one of the object before it. +#[test] +fn guides_keep_their_own_transparency() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let bright = see_through(square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), Color::rgb(1.0, 0.0, 0.0)), 0.5, BlendMode::Screen); + doc.insert(Some(layer), usize::MAX, bright).unwrap(); + for (r, opacity, blend) in + [(Rect::new(20.0, 20.0, 40.0, 40.0), 1.0, BlendMode::Normal), (Rect::new(50.0, 20.0, 70.0, 40.0), 0.4, BlendMode::Multiply)] + { + let mut g = clipping(&mut doc, shapes::rectangle(r)); + g.kind = NodeKind::Path { path: shapes::rectangle(r), rule: FillRule::NonZero, live: None, clipping: false, guide: true }; + doc.insert(Some(layer), usize::MAX, see_through(g, opacity, blend)).unwrap(); + } + let back = read_back(&doc); + let got: Vec<(bool, f32, BlendMode)> = + back.layers[0].children().unwrap().iter().map(|n| (matches!(n.kind, NodeKind::Path { guide: true, .. }), n.opacity, n.blend)).collect(); + assert_eq!(got, [(false, 0.5, BlendMode::Screen), (true, 1.0, BlendMode::Normal), (true, 0.4, BlendMode::Multiply)]); +} + +/// The transparency a group's last member or a group's state block sets carries over to the object +/// after the group, which then writes no `Xy` of its own when it has the same. +#[test] +fn objects_after_a_group_keep_their_transparency() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let red = Color::rgb(1.0, 0.0, 0.0); + let screen = |doc: &mut Document, x: f64| see_through(square(doc, Rect::new(x, 0.0, x + 10.0, 10.0), red), 0.5, BlendMode::Screen); + // A plain group whose last member is at 50% in Screen, then a path the same. + let a = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), red); + let b = screen(&mut doc, 10.0); + let plain = group(&mut doc, vec![a, b], false); + let after_plain = screen(&mut doc, 30.0); + // A group at 40% with knockout off, then a path at 40%. + let c = square(&mut doc, Rect::new(50.0, 0.0, 60.0, 10.0), red); + let mut faint = see_through(group(&mut doc, vec![c], false), 0.4, BlendMode::Normal); + faint.knockout = Knockout::Off; + let after_faint = see_through(square(&mut doc, Rect::new(70.0, 0.0, 80.0, 10.0), red), 0.4, BlendMode::Normal); + for n in [plain, after_plain, faint, after_faint] { + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + let p = program(&doc); + // No `Xy` between the groups' state blocks and the paths after them: the second square's, the + // third's (opaque, after the second) and the second group's. + let after = |block: &str| p.split_once(block).unwrap().1.split_once("\nf\n").unwrap().0.to_string(); + assert!(!after("U\n9 () XW\n").contains("Xy"), "{p}"); + assert!(!after("U\n0 0.4 0 0 0 Xy\n0 0 Xd\n6 () XW\n").contains("Xy"), "{p}"); + assert_eq!(p.matches(" Xy\n").count(), 3, "{p}"); + same_lines(&all_looks(&read_back(&doc)), &all_looks(&doc)); +} + +#[test] +fn transparency_of_single_fills_and_layer_masks_is_reported_left_out() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut fill = FillLayer::new(solid(Color::BLACK)); + fill.opacity = 0.5; + let n = Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), + Appearance { items: vec![AppearanceItem::Fill(fill)], ..Appearance::default() }, + ); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let art = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), Color::gray(0.5)); + Arc::make_mut(&mut doc.layers[0]).extras_mut().mask = Some(Box::new(OpacityMask::new(art, true))); + let out = export_ai_editing(&doc, &opts()).unwrap(); + for w in [writer::ITEM_TRANSPARENCY, writer::LAYER_MASKS] { + assert!(out.warnings.iter().any(|x| x == w), "{w}: {:?}", out.warnings); + } + // A disabled one too. + if let Some(m) = Arc::make_mut(&mut doc.layers[0]).extras_mut().mask.as_mut() { + m.disabled = true; + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|x| x == writer::LAYER_MASKS), "{:?}", out.warnings); + // A clipping path's fill of its own opacity. + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut clip = clipping(&mut doc, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))); + let mut fill = FillLayer::new(solid(Color::BLACK)); + fill.opacity = 0.5; + clip.appearance = Appearance { items: vec![AppearanceItem::Fill(fill)], ..Appearance::default() }.into(); + let inside = square(&mut doc, Rect::new(0.0, 0.0, 20.0, 20.0), Color::gray(0.5)); + let g = group(&mut doc, vec![clip, inside], true); + doc.insert(Some(layer), usize::MAX, g).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|x| x == writer::ITEM_TRANSPARENCY), "{:?}", out.warnings); +} diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index e0afd0366..99f8cb607 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use vectorcraft_color::cms::Cms; use vectorcraft_color::swatch::{REGISTRATION, Swatch}; use vectorcraft_color::{BlendMode, Color, Paint}; -use vectorcraft_doc::{AppearanceItem, Document, FillLayer, LAYER_COLORS, LayerColor, Node, NodeKind, StrokeLayer}; +use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Knockout, LAYER_COLORS, LayerColor, Node, NodeKind, OpacityMask, StrokeLayer}; use vectorcraft_effects::stroke; use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Shape, SubPath}; use vectorcraft_pathops::BoolOp; @@ -28,18 +28,78 @@ pub(super) const TYPE: &str = "type is written as its outlines in the editing da pub(super) const GRADIENTS: &str = "gradients are written as their average color in the editing data for other apps"; const PATTERNS: &str = "pattern fills are written as their tiles cut to the shape in the editing data for other apps"; pub(super) const PATTERN_TILES: &str = "pattern fills with more tiles than a fill places are written filled gray in the editing data for other apps"; -pub(super) const CLIPPING: &str = - "clipping groups and clipping layers are written as their art cut to the clipping path in the editing data for other apps"; +pub(super) const CLIPPING: &str = "clipping groups and clipping layers whose clipping object isn't a path, and the clipping groups inside them, are written as their art cut to that object in the editing data for other apps"; +pub(super) const CLIP_PAINT: &str = "the fills and strokes of clipping paths that a plain fill and line can't draw are written as paths of their own inside the clipping (fills under the clipped art, strokes over it) in the editing data for other apps"; +pub(super) const CLIP_PAINT_MASKS: &str = "the opacity masks of clipping paths whose fills and strokes are written as paths of their own stay on the clipping path, not on those paths, in the editing data for other apps"; pub(super) const IMAGES: &str = "images are written as their outlines filled with their average color in the editing data for other apps"; pub(super) const LIVE: &str = "symbols, blends, envelopes, meshes, repeats, placed documents and intertwines are written as their art in the editing data for other apps"; const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, dots, dashes fitted to corners, gradients, patterns, arrowheads) are written as filled outlines, and brushed strokes as their brush's art, in the editing data for other apps"; -pub(super) const TRANSPARENCY: &str = "transparency (opacity, blending modes, opacity masks) is left out of the editing data for other apps"; +pub(super) const ITEM_TRANSPARENCY: &str = + "the opacity and blending modes of single fills and strokes are left out of the editing data for other apps"; +pub(super) const LAYER_MASKS: &str = "opacity masks on layers are left out of the editing data for other apps"; +pub(super) const DEEP_MASKS: &str = + "opacity masks more than 8 deep in opacity masks, symbols, live objects and pattern tiles are left out of the editing data for other apps"; +const GROUPED_LAYER: &str = + "the transparency and opacity masks of layers that an effect made into a group are left out of the editing data for other apps"; pub(super) const EFFECTS: &str = "live effects other than geometry effects are left out of the editing data for other apps"; const SWATCHES: &str = "gradient and pattern swatches are left out of the editing data for other apps"; pub(super) const SYMBOL_LIMIT: &str = "symbol instances past 16,000,000 objects of symbol art in all are left out of the editing data for other apps"; const TEMPLATES: &str = "template layers are written as dimmed layers that don't print in the editing data for other apps"; +/// The transparency `mode opacity isolate knockout shape Xy` sets: the blending mode ([`blend_code`]), +/// the opacity, Isolate Blending, Knockout Group (0 off, 1 on, 2 neutral) and Opacity & Mask Define +/// Knockout Shape. It stays set for the objects that follow, as the lock and hidden state do; a +/// group, a clipping group or a layer takes it after its end. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Xy { + blend: BlendMode, + opacity: f32, + isolate: bool, + knockout: Knockout, + shape: bool, +} + +impl Xy { + /// The state before any `Xy`, and an opaque path's. + const OPAQUE: Xy = Xy { blend: BlendMode::Normal, opacity: 1.0, isolate: false, knockout: Knockout::Off, shape: false }; + /// A group's or a layer's without transparency of its own (no `Xy` after its end). + const PLAIN: Xy = Xy { knockout: Knockout::Neutral, ..Self::OPAQUE }; + + /// `n`'s transparency. + fn of(n: &Node) -> Xy { + let opacity = if n.opacity.is_finite() { n.opacity.clamp(0.0, 1.0) } else { 1.0 }; + Xy { blend: n.blend, opacity, isolate: n.isolate, knockout: n.knockout, shape: n.knockout_shape } + } + + /// As a path's own: isolation and knockout are a group's. + fn object(self) -> Xy { + Xy { isolate: false, knockout: Knockout::Off, ..self } + } +} + +/// The number `Xy` gives blending mode `b`. +fn blend_code(b: BlendMode) -> u8 { + match b { + BlendMode::Normal => 0, + BlendMode::Multiply => 1, + BlendMode::Screen => 2, + BlendMode::Overlay => 3, + BlendMode::SoftLight => 4, + BlendMode::HardLight => 5, + BlendMode::ColorDodge => 6, + BlendMode::ColorBurn => 7, + BlendMode::Darken => 8, + BlendMode::Lighten => 9, + BlendMode::Difference => 10, + BlendMode::Exclusion => 11, + BlendMode::Hue => 12, + BlendMode::Saturation => 13, + BlendMode::Color => 14, + BlendMode::Luminosity => 15, + } +} + pub(super) struct Writer<'a> { doc: &'a Document, /// The document → art space. @@ -49,6 +109,12 @@ pub(super) struct Writer<'a> { pub(super) warnings: Vec, /// The lock and hidden state (`A`, `Xw`) the last operators set; `None` at a layer's start. state: Option<(bool, bool)>, + /// The transparency the last `Xy` set; `None` when it isn't known (after an opacity mask, whose + /// art's last `Xy` stays set). + gs: Option, + /// Where the state block of the group ended last starts and ends in [`Self::body`]. + block_start: usize, + block_end: usize, /// The region the art being written is cut to (a clipping path's, in document space). clip: Option, /// Symbols, live objects and pattern tiles being written inside one another. @@ -72,6 +138,9 @@ impl<'a> Writer<'a> { body: String::new(), warnings: vec![], state: None, + gs: Some(Xy::OPAQUE), + block_start: 0, + block_end: usize::MAX, clip: None, nest: 0, ids: HashSet::new(), @@ -115,9 +184,12 @@ impl<'a> Writer<'a> { return self.write_layer(n, &[]); } let members = n.children().map(Vec::as_slice).unwrap_or_default(); - self.transparency(n); + self.effects(n); match members.iter().find(|c| c.is_layer()) { Some(l) => { + if Xy::of(n) != Xy::PLAIN || n.extras().mask.is_some() { + self.warn(GROUPED_LAYER); + } // The group took the layer's name. The added art is outside the layer's clipping. let layer = Node { name: l.name.clone().or_else(|| n.name.clone()), ..(**l).clone() }; let added: Vec> = members.iter().filter(|c| !c.is_layer()).cloned().collect(); @@ -131,8 +203,9 @@ impl<'a> Writer<'a> { } } - /// A layer or sublayer: `%AI5_BeginLayer`, its options `Lb`, its name `Ln` and as an XML id, its art, then - /// `after` (outside its clipping path when it clips), `LB`, `%AI5_EndLayer--`. + /// A layer or sublayer: `%AI5_BeginLayer`, its options `Lb`, its name `Ln` and as an XML id, its art (its + /// clipping path last, with `W`, when it clips), `after` (outside the clipping path), its transparency + /// (`Xy`, `0 0 Xd`, `7 () XW`) when it has any, `LB`, `%AI5_EndLayer--`. fn write_layer(&mut self, n: &Node, after: &[Arc]) { let NodeKind::Layer { color, template, printable, children, clip, preview, dim_images } = &n.kind else { return }; let [r, g, b] = match color { @@ -146,25 +219,36 @@ impl<'a> Writer<'a> { if *template { self.warn(TEMPLATES); } - self.transparency(n); + self.effects(n); + if n.extras().mask.is_some() { + self.warn(LAYER_MASKS); + } let flag = |b: bool| u8::from(b); let dim = dim_images.unwrap_or(50); let name = n.name.as_deref().unwrap_or("Layer"); + // The last flag makes the layer a clipping layer: its top object clips the rest. Only a + // clipping path or compound path, outside any art cut to a clipping object, is written as + // one ([`Self::clip_group`] writes the others). + let clips = *clip && after.is_empty() && self.clip.is_none() && children.first().is_some_and(|c| clip_path(c).is_some()); let _ = writeln!( self.body, - "%AI5_BeginLayer\n{} {} {} {} {} 0 1 {index} {r} {g} {b} 0 {dim} 0 Lb\n{} Ln", + "%AI5_BeginLayer\n{} {} {} {} {} 0 1 {index} {r} {g} {b} 0 {dim} {} Lb\n{} Ln", flag(n.visible), flag(*preview), flag(!n.locked), flag(*printable && !*template), flag(dim_images.is_some() || *template), + flag(clips), ps::bytes(name.as_bytes()), ); self.xml_id(if name.is_empty() { "Layer" } else { name }); self.state = None; - if *clip { - self.warn(CLIPPING); - self.clip_contents(children); + if clips { + self.clipped(children); + } else if *clip && !children.is_empty() { + // The added art lies outside the clipping path, or the clipping object isn't a path: + // the clipped art is a clipping group, or a group of the art cut to the object. + self.clip_group(children, Xy::PLAIN); } else { for c in children { self.node(c); @@ -173,6 +257,12 @@ impl<'a> Writer<'a> { for c in after { self.node(c); } + let xy = Xy::of(n); + if xy != Xy::PLAIN { + // Always written: the layer takes the `Xy` its own state block has. + self.write_xy(xy); + self.line("0 0 Xd\n7 () XW"); + } self.line("LB\n%AI5_EndLayer--"); // The reader's state carries over into the art that follows. self.state = None; @@ -193,59 +283,86 @@ impl<'a> Writer<'a> { self.state = Some((locked, hidden)); } - /// Warn when `n` has transparency or effects the data leaves out. - fn transparency(&mut self, n: &Node) { - if n.opacity < 1.0 || n.blend != BlendMode::Normal || n.extras().mask.as_ref().is_some_and(|m| !m.disabled) { - self.warn(TRANSPARENCY); + /// Set the transparency of the object that follows to `xy` (`Xy`, when it isn't set already). + fn set_xy(&mut self, xy: Xy) { + if self.gs != Some(xy) { + self.write_xy(xy); } + } + + /// `mode opacity isolate knockout shape Xy`. + fn write_xy(&mut self, xy: Xy) { + let knockout = match xy.knockout { + Knockout::Off => 0, + Knockout::On => 1, + Knockout::Neutral => 2, + }; + let _ = write!(self.body, "{} ", blend_code(xy.blend)); + ps::push_num(&mut self.body, f64::from(xy.opacity)); + let _ = writeln!(self.body, " {} {knockout} {} Xy", u8::from(xy.isolate), u8::from(xy.shape)); + self.gs = Some(xy); + } + + /// The end `close` (`U` or `Q`) of a group of transparency `xy`, then its state block: when it + /// has transparency of its own, `Xy`, `0 0 Xd` and `6 () XW`; else `9 () XW`. The group's + /// dictionary goes between the two ([`Self::node`]). + fn end_group(&mut self, close: &str, xy: Xy) { + self.line(close); + self.block_start = self.body.len(); + if xy == Xy::PLAIN { + self.line("9 () XW"); + } else { + self.write_xy(xy); + self.line("0 0 Xd\n6 () XW"); + } + self.block_end = self.body.len(); + } + + /// Warn when `n` has effects the data leaves out. + fn effects(&mut self, n: &Node) { let items = n.appearance.items.iter().filter(|i| i.visible()).flat_map(|i| i.effects()); if n.appearance.effects.iter().chain(items).any(|e| e.visible) { self.warn(EFFECTS); } } - /// One object, its state before it and its name after it. + /// One object: its state before it, its transparency, and its name and opacity mask after it. fn node(&mut self, n: &Node) { if let NodeKind::Layer { .. } = n.kind { return self.write_layer(n, &[]); } - self.transparency(n); + self.effects(n); self.state(n.locked, !n.visible); + let xy = Xy::of(n); let start = self.body.len(); match &n.kind { NodeKind::Layer { .. } => {} - NodeKind::Group { children, clip } => { + NodeKind::Group { children, clip: true } => self.clip_group(children, xy), + NodeKind::Group { children, clip: false } => { self.line("u"); - if *clip { - self.warn(CLIPPING); - self.clip_contents(children); - } else { - for c in children { - self.node(c); - } + for c in children { + self.node(c); } - self.line("U"); + self.end_group("U", xy); } NodeKind::Path { path, rule, guide, .. } => { if *guide { + // Its own transparency, not the one the object before it set. + if path.subpaths.iter().any(|sp| !sp.anchors.is_empty()) { + self.set_xy(xy.object()); + } ai_ops::guide(&mut self.body, self.to_art, path, true); } else { - self.shape(n, path, *rule); + self.shape(n, path, *rule, xy); } } - NodeKind::Compound { children, rule } => { - let mut subpaths = vec![]; - for p in children.iter().filter_map(|c| c.path_data()) { - subpaths.extend(p.subpaths.iter().cloned()); - } - self.shape(n, &PathData::new(subpaths), *rule); - } - NodeKind::Text(_) => self.text(n), + NodeKind::Compound { rule, .. } => self.shape(n, &compound_path(n), *rule, xy), + NodeKind::Text(_) => self.text(n, xy), NodeKind::Image(im) => { let bounds = PathData::from_bezpath(&(im.xf * Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)).to_path(0.1))); let color = image_average(self.doc.image_blob(im).map(|b| b.bytes.as_slice())); self.warn(IMAGES); - self.fill_object(&bounds, FillRule::NonZero, &FillLayer::new(Paint::solid(color))); + self.fill_object(&bounds, FillRule::NonZero, &FillLayer::new(Paint::solid(color)), xy); } NodeKind::SymbolInstance { symbol, xf } => { self.warn(LIVE); @@ -284,7 +401,7 @@ impl<'a> Writer<'a> { self.warn(SYMBOL_LIMIT); } } - self.line("U"); + self.end_group("U", xy); } NodeKind::Blend { .. } | NodeKind::Envelope { .. } @@ -302,23 +419,53 @@ impl<'a> Writer<'a> { } self.nest -= 1; } - self.line("U"); + self.end_group("U", xy); } } if self.body.len() > start { - self.name(n); + // A group's dictionary goes between its end and its state block. + let block = (self.block_end == self.body.len() && self.block_start >= start).then(|| self.body.split_off(self.block_start)); + self.block_end = usize::MAX; + self.dictionary(n); + if let Some(b) = block { + self.body.push_str(&b); + } } } - /// `n`'s name, after it (see [`Self::xml_id`]). - fn name(&mut self, n: &Node) { - let Some(name) = n.name.as_deref().filter(|s| !s.is_empty()) else { return }; - self.xml_id(name); + /// `n`'s `/ArtDictionary`, after it, when it has a name (`AI10_ArtUID`, see [`Self::take_id`]) + /// or an opacity mask (`AI9_Mask`, see [`Self::mask`]). + fn dictionary(&mut self, n: &Node) { + let name = n.name.as_deref().filter(|s| !s.is_empty()); + let mut mask = n.extras().mask.as_deref(); + // Masks nested too deep are left out (an empty mask would hide the object). + if mask.is_some() && self.nest >= MAX_NEST { + self.warn(DEEP_MASKS); + mask = None; + } + if name.is_none() && mask.is_none() { + return; + } + self.body.push_str("%_/ArtDictionary :\n"); + if let Some(name) = name { + let id = self.take_id(name); + let _ = writeln!(self.body, "%_/XMLUID : {} ; (AI10_ArtUID) ,", ps::bytes(id.as_bytes())); + } + if let Some(m) = mask { + self.mask(m); + } + self.body.push_str("%_;\n%_\n"); } - /// An `/ArtDictionary` whose `AI10_ArtUID` is `name` as an XML id, numbered (`_` and 20 - /// digits, `_`) when a layer or another object has that id. + /// An `/ArtDictionary` whose `AI10_ArtUID` is `name` as an XML id. fn xml_id(&mut self, name: &str) { + let id = self.take_id(name); + let _ = writeln!(self.body, "%_/ArtDictionary :\n%_/XMLUID : {} ; (AI10_ArtUID) ,\n%_;\n%_", ps::bytes(id.as_bytes())); + } + + /// `name` as an XML id no layer or object has yet, numbered (`_` and 20 digits, `_`) when one + /// has it. + fn take_id(&mut self, name: &str) -> String { let base = vectorcraft_doc::xml_id::from_object_name(name); let mut id = base.clone(); let next = self.next.entry(base.clone()).or_insert(0); @@ -327,7 +474,150 @@ impl<'a> Writer<'a> { id = format!("{base}_{next:020}_"); } self.ids.insert(id.clone()); - let _ = writeln!(self.body, "%_/ArtDictionary :\n%_/XMLUID : {} ; (AI10_ArtUID) ,\n%_;\n%_", ps::bytes(id.as_bytes())); + id + } + + /// An opacity mask, as an entry of its object's dictionary: `/Mask :`, `X=`, its art on `%_` + /// lines (from the state a dictionary's art starts in: unlocked, shown, opaque), `X+`, `(Art) ,`, + /// its options as the `/Bool` entries `Clipping`, `Inverted`, `Disabled` and `Linked`, and + /// `; (AI9_Mask) ,`. The art is in document coordinates, as the object's is. + fn mask(&mut self, m: &OpacityMask) { + let outer = std::mem::take(&mut self.body); + let (state, clip) = (self.state, self.clip.take()); + (self.state, self.gs) = (Some((false, false)), Some(Xy::OPAQUE)); + // A plain group holding the art is the group the art is read into. + let art = &m.art; + let plain = matches!(art.kind, NodeKind::Group { clip: false, .. }) + && Xy::of(art) == Xy::PLAIN + && art.visible + && !art.locked + && art.name.as_deref().is_none_or(str::is_empty) + && art.extras().mask.is_none(); + let members = match art.children() { + Some(c) if plain => c.as_slice(), + _ => std::slice::from_ref(art), + }; + self.nest += 1; + for c in members { + self.node(c); + } + self.nest -= 1; + let lines = std::mem::replace(&mut self.body, outer); + self.body.push_str("%_/Mask :\n%_X=\n"); + for l in lines.lines() { + if !l.starts_with("%_") { + self.body.push_str("%_"); + } + self.body.push_str(l); + self.body.push('\n'); + } + self.body.push_str("%_X+\n%_(Art) ,\n"); + for (key, on) in [("Clipping", m.clip), ("Inverted", m.invert), ("Disabled", m.disabled), ("Linked", m.linked)] { + let _ = writeln!(self.body, "%_{} /Bool ({key}) ,", u8::from(on)); + } + self.body.push_str("%_; (AI9_Mask) ,\n"); + // The dictionary ends with the state before it, except the transparency its art set last. + (self.state, self.gs, self.clip, self.block_end) = (state, None, clip, usize::MAX); + } + + /// Warn that art is cut to clipping object `first` when it isn't a path or compound path (a path + /// without points cuts everything away, as it clips everything away). + fn warn_cut(&mut self, first: Option<&Arc>) { + if first.is_some_and(|c| !matches!(c.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. })) { + self.warn(CLIPPING); + } + } + + /// A clipping group: `q`, its members, its clipping path or compound path last (the top object: + /// a mask shows the objects beneath it, §5.6.2) with `W`, `Q`, then the group's transparency. + /// `Q` sets back the state at `q`. A clipping object of another kind cuts the members instead, + /// in a group ([`Self::clip_contents`]). A clipping group without members is an empty group. + fn clip_group(&mut self, children: &[Arc], xy: Xy) { + if self.clip.is_some() || children.first().is_none_or(|c| clip_path(c).is_none()) { + self.warn_cut(children.first()); + // Inside art cut to a clipping object, a clipping path cuts its art too. + if self.clip.is_some() && !children.is_empty() { + self.warn(CLIPPING); + } + self.line("u"); + self.clip_contents(children); + return self.end_group("U", xy); + } + let at_q = (self.state, self.gs); + self.line("q"); + self.clipped(children); + (self.state, self.gs) = at_q; + self.end_group("Q", xy); + } + + /// The members of a clipping group or layer, the first one clipping the others: the others, + /// then the first one as the clipping path (see [`Self::clip_group`]), its name after it. Fills + /// and strokes the clipping path can't paint itself are paths of their own, the fills under the + /// others and the strokes over them. + fn clipped(&mut self, children: &[Arc]) { + let Some((first, rest)) = children.split_first() else { return }; + let Some((path, rule)) = clip_path(first) else { + self.warn_cut(Some(first)); + return self.clip_contents(children); + }; + let painted = plain_paint(first); + if visible_items(first).iter().any(|i| i.opacity() < 1.0 || i.blend() != BlendMode::Normal) { + self.warn(ITEM_TRANSPARENCY); + } + // Paint the clipping path can't carry: its fills under the others, its strokes over them. + let (fills, strokes): (Vec<&AppearanceItem>, Vec<&AppearanceItem>) = + if painted.is_none() { visible_items(first).into_iter().partition(|i| matches!(i, AppearanceItem::Fill(_))) } else { (vec![], vec![]) }; + if painted.is_none() { + self.warn(CLIP_PAINT); + if first.extras().mask.is_some() && !visible_items(first).is_empty() { + self.warn(CLIP_PAINT_MASKS); + } + } + self.clip_paint_part(first, &path, rule, &fills); + for c in rest { + self.node(c); + } + self.clip_paint_part(first, &path, rule, &strokes); + let (fill, stroke) = painted.unwrap_or_default(); + self.state(first.locked, !first.visible); + let start = self.body.len(); + self.paint_state(fill, stroke, Xy::of(first).object(), rule); + let op = paint_op(fill.is_some(), stroke.is_some()); + let subpaths: Vec<&SubPath> = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).collect(); + let several = subpaths.len() > 1; + if several { + self.line("*u"); + } + for sp in subpaths { + ai_ops::subpath(&mut self.body, self.to_art, sp); + let op = if sp.closed { op } else { op.to_ascii_uppercase() }; + let _ = writeln!(self.body, "{}\nW\n{op}", if sp.closed { 'h' } else { 'H' }); + } + if several { + self.line("*U"); + } + if self.body.len() > start { + self.dictionary(first); + } + } + + /// The fills or the strokes `items` of clipping path `first` (outline `path`) that it can't paint + /// itself, locked and hidden as it is, with its transparency once for all of them: one object, + /// or a group of an object for each (as the canvas draws them, [`Node::clip_paint`]). + fn clip_paint_part(&mut self, first: &Node, path: &PathData, rule: FillRule, items: &[&AppearanceItem]) { + if items.is_empty() { + return; + } + self.state(first.locked, !first.visible); + let xy = Xy::of(first); + if let [one] = items { + return self.item(path, rule, one, xy); + } + self.line("u"); + for i in items { + self.item(path, rule, i, Xy::PLAIN); + } + self.end_group("U", xy); } /// A container's members, the first one's outline cutting the others: its fills painted @@ -362,63 +652,49 @@ impl<'a> Writer<'a> { // ---------- paths ---------- - /// A path's fills and strokes: one object with one fill and a plain stroke over it where it - /// can be, else a group of an object for each (in paint order). - fn shape(&mut self, n: &Node, path: &PathData, rule: FillRule) { + /// A path's fills and strokes, with the path's transparency `xy`: one object with one fill and + /// a plain stroke over it where it can be, else a group of an object for each (in paint order). + fn shape(&mut self, n: &Node, path: &PathData, rule: FillRule, xy: Xy) { if path.is_empty() { return; } - let items: Vec<&AppearanceItem> = n - .appearance - .items - .iter() - .filter(|i| match i { - AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(), - AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0 && s.width.is_finite(), - }) - .collect(); + let items = visible_items(n); if items.iter().any(|i| i.opacity() < 1.0 || i.blend() != BlendMode::Normal) { - self.warn(TRANSPARENCY); + self.warn(ITEM_TRANSPARENCY); } - let solid = |p: &Paint| matches!(p, Paint::Solid { .. }); - match items.as_slice() { - [] => self.path_object(path, rule, None, None), - [AppearanceItem::Fill(f)] if solid(&f.paint) => self.path_object(path, rule, Some(f), None), - [AppearanceItem::Stroke(s)] if direct_stroke(s) => self.path_object(path, rule, None, Some(s)), - [AppearanceItem::Fill(f), AppearanceItem::Stroke(s)] if solid(&f.paint) && direct_stroke(s) => { - self.path_object(path, rule, Some(f), Some(s)) - } - [one] => self.item(path, rule, one), - items => { + match (plain_paint(n), items.as_slice()) { + (Some((fill, stroke)), _) => self.path_object(path, rule, fill, stroke, xy), + (None, [one]) => self.item(path, rule, one, xy), + (None, items) => { self.line("u"); for i in items { - self.item(path, rule, i); + self.item(path, rule, i, Xy::PLAIN); } - self.line("U"); + self.end_group("U", xy); } } } - /// One fill or stroke of a path as an object of its own. - fn item(&mut self, path: &PathData, rule: FillRule, item: &AppearanceItem) { + /// One fill or stroke of a path as an object of its own, of transparency `xy`. + fn item(&mut self, path: &PathData, rule: FillRule, item: &AppearanceItem, xy: Xy) { match item { - AppearanceItem::Fill(f) => self.fill_object(path, rule, f), - AppearanceItem::Stroke(s) if direct_stroke(s) => self.path_object(path, rule, None, Some(s)), + AppearanceItem::Fill(f) => self.fill_object(path, rule, f, xy), + AppearanceItem::Stroke(s) if direct_stroke(s) => self.path_object(path, rule, None, Some(s), xy), AppearanceItem::Stroke(s) => { self.warn(STROKES); - if self.brush_art(path, s) { + if self.brush_art(path, s, xy) { return; } let outline = stroke::outline_region(path, rule, s); let fill = FillLayer { overprint: s.overprint, ..FillLayer::new(s.paint.clone()) }; - self.fill_object(&outline, FillRule::NonZero, &fill); + self.fill_object(&outline, FillRule::NonZero, &fill, xy); } } } - /// A brushed stroke `st` of `path` as a group of its brush's art. False when it has no brush - /// the document knows (or brushes nest too deep). - fn brush_art(&mut self, path: &PathData, st: &StrokeLayer) -> bool { + /// A brushed stroke `st` of `path` as a group of its brush's art, of transparency `xy`. False + /// when it has no brush the document knows (or brushes nest too deep). + fn brush_art(&mut self, path: &PathData, st: &StrokeLayer, xy: Xy) -> bool { if self.nest >= MAX_NEST { return false; } @@ -435,27 +711,27 @@ impl<'a> Writer<'a> { if let Some((locked, hidden)) = state { self.state(locked, hidden); } - self.line("U"); + self.end_group("U", xy); self.nest -= 1; true } - /// A fill as an object of its own: a gradient as its average color, a pattern as its tiles - /// cut to the path. - fn fill_object(&mut self, path: &PathData, rule: FillRule, f: &FillLayer) { + /// A fill as an object of its own, of transparency `xy`: a gradient as its average color, a + /// pattern as its tiles cut to the path. + fn fill_object(&mut self, path: &PathData, rule: FillRule, f: &FillLayer, xy: Xy) { match &f.paint { - Paint::Solid { .. } => self.path_object(path, rule, Some(f), None), + Paint::Solid { .. } => self.path_object(path, rule, Some(f), None, xy), Paint::Gradient(g) => { self.warn(GRADIENTS); let flat = FillLayer { paint: Paint::solid(average(&g.gradient)), ..f.clone() }; - self.path_object(path, rule, Some(&flat), None); + self.path_object(path, rule, Some(&flat), None, xy); } Paint::Pattern { pattern, xf } => { self.warn(PATTERNS); let doc = self.doc; let Some(def) = doc.pattern(pattern).filter(|_| self.nest < MAX_NEST) else { let flat = FillLayer { paint: Paint::solid(Color::gray(0.5)), ..f.clone() }; - return self.path_object(path, rule, Some(&flat), None); + return self.path_object(path, rule, Some(&flat), None, xy); }; let tiles = def.instances_in(*xf, path.to_bezpath().bounding_box()); // More tiles than a fill places come back as none: the shape is filled gray, as a @@ -463,7 +739,7 @@ impl<'a> Writer<'a> { if tiles.is_empty() && !def.art.is_empty() { self.warn(PATTERN_TILES); let flat = FillLayer { paint: Paint::solid(Color::gray(0.5)), ..f.clone() }; - return self.path_object(path, rule, Some(&flat), None); + return self.path_object(path, rule, Some(&flat), None, xy); } let outer = self.clip.clone(); let region = self.cut(path, rule); @@ -477,7 +753,7 @@ impl<'a> Writer<'a> { if let Some((locked, hidden)) = state { self.state(locked, hidden); } - self.line("U"); + self.end_group("U", xy); self.nest -= 1; self.clip = outer; } @@ -485,39 +761,39 @@ impl<'a> Writer<'a> { } } - /// A path object painted with `fill` (a color) and `stroke` (a plain line of a color) over - /// it; several subpaths make a compound path. Where the art is cut to a clipping path, the - /// fill is the path's region cut to it and the stroke its outline cut to it, filled. - fn path_object(&mut self, path: &PathData, rule: FillRule, fill: Option<&FillLayer>, stroke: Option<&StrokeLayer>) { + /// A path object of transparency `xy` painted with `fill` (a color) and `stroke` (a plain line + /// of a color) over it; several subpaths make a compound path. Where the art is cut to a + /// clipping path, the fill is the path's region cut to it and the stroke its outline cut to it, + /// filled. + fn path_object(&mut self, path: &PathData, rule: FillRule, fill: Option<&FillLayer>, stroke: Option<&StrokeLayer>, xy: Xy) { if self.clip.is_none() { - return self.plain_object(path, rule, fill, stroke); + return self.plain_object(path, rule, fill, stroke, xy.object()); } let both = fill.is_some() && stroke.is_some(); + let own = if both { Xy::OPAQUE } else { xy.object() }; if both { self.line("u"); } if fill.is_some() || stroke.is_none() { let region = self.cut(path, rule); - self.plain_object(®ion, FillRule::NonZero, fill, None); + self.plain_object(®ion, FillRule::NonZero, fill, None, own); } if let Some(s) = stroke { let outline = stroke::outline_region(path, rule, s); let region = self.cut(&outline, FillRule::NonZero); let fill = FillLayer { overprint: s.overprint, ..FillLayer::new(s.paint.clone()) }; - self.plain_object(®ion, FillRule::NonZero, Some(&fill), None); + self.plain_object(®ion, FillRule::NonZero, Some(&fill), None, own); } if both { - self.line("U"); + self.end_group("U", xy); } } - /// [`Self::path_object`] without cutting: the paint (`O`/`R` overprint, line style), `XR`, - /// each subpath's points and painting operator (`*u` … `*U` around several). - fn plain_object(&mut self, path: &PathData, rule: FillRule, fill: Option<&FillLayer>, stroke: Option<&StrokeLayer>) { - let subpaths: Vec<&SubPath> = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).collect(); - if subpaths.is_empty() { - return; - } + /// The paint of a path of transparency `xy` filled by `rule`: its fill color and `O`, its stroke + /// color and `R`, `Xy`, the line style, `XR`. Without paint, `XR` comes first: an `Xy` right + /// after a group or compound path would be theirs. + fn paint_state(&mut self, fill: Option<&FillLayer>, stroke: Option<&StrokeLayer>, xy: Xy, rule: FillRule) { + let rule = if rule == FillRule::EvenOdd { "1 XR" } else { "0 XR" }; if let Some(f) = fill { self.color(&f.paint, false); self.line(if f.overprint { "1 O" } else { "0 O" }); @@ -525,15 +801,27 @@ impl<'a> Writer<'a> { if let Some(s) = stroke { self.color(&s.paint, true); self.line(if s.overprint { "1 R" } else { "0 R" }); + } + if fill.is_none() && stroke.is_none() { + self.line(rule); + return self.set_xy(xy); + } + self.set_xy(xy); + if let Some(s) = stroke { ai_ops::line_style(&mut self.body, s); } - self.line(if rule == FillRule::EvenOdd { "1 XR" } else { "0 XR" }); - let op = match (fill.is_some(), stroke.is_some()) { - (true, true) => 'b', - (true, false) => 'f', - (false, true) => 's', - (false, false) => 'n', - }; + self.line(rule); + } + + /// [`Self::path_object`] without cutting: the paint ([`Self::paint_state`]), each subpath's + /// points and painting operator (`*u` … `*U` around several). + fn plain_object(&mut self, path: &PathData, rule: FillRule, fill: Option<&FillLayer>, stroke: Option<&StrokeLayer>, xy: Xy) { + let subpaths: Vec<&SubPath> = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).collect(); + if subpaths.is_empty() { + return; + } + self.paint_state(fill, stroke, xy, rule); + let op = paint_op(fill.is_some(), stroke.is_some()); let several = subpaths.len() > 1; if several { self.line("*u"); @@ -554,8 +842,9 @@ impl<'a> Writer<'a> { /// Type `n` as a group of what Type › Create Outlines makes of it /// ([`vectorcraft_effects::outline_text`]): each run's glyphs with their underline and /// strikethrough, its color glyphs, and the object's own fills and strokes; then its inline - /// graphics as their symbols' art where the layout places them, as the PDF page draws them. - fn text(&mut self, n: &Node) { + /// graphics as their symbols' art where the layout places them, as the PDF page draws them; + /// the group has the type's transparency `xy`. + fn text(&mut self, n: &Node, xy: Xy) { self.warn(TYPE); self.line("u"); let doc = self.doc; @@ -605,7 +894,7 @@ impl<'a> Writer<'a> { if let Some((locked, hidden)) = state { self.state(locked, hidden); } - self.line("U"); + self.end_group("U", xy); } // ---------- color ---------- @@ -778,6 +1067,63 @@ fn direct_stroke(s: &StrokeLayer) -> bool { matches!(s.paint, Paint::Solid { .. }) && stroke::is_plain(s) } +/// `n`'s fills and strokes that paint. +fn visible_items(n: &Node) -> Vec<&AppearanceItem> { + n.appearance + .items + .iter() + .filter(|i| match i { + AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(), + AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0 && s.width.is_finite(), + }) + .collect() +} + +/// `n`'s paint as one path object paints: no paint, a fill of a color, a plain line of a color, +/// or both, the line over the fill; `None` when it has other paint. +fn plain_paint(n: &Node) -> Option<(Option<&FillLayer>, Option<&StrokeLayer>)> { + let solid = |p: &Paint| matches!(p, Paint::Solid { .. }); + match visible_items(n).as_slice() { + [] => Some((None, None)), + [AppearanceItem::Fill(f)] if solid(&f.paint) => Some((Some(f), None)), + [AppearanceItem::Stroke(s)] if direct_stroke(s) => Some((None, Some(s))), + [AppearanceItem::Fill(f), AppearanceItem::Stroke(s)] if solid(&f.paint) && direct_stroke(s) => Some((Some(f), Some(s))), + _ => None, + } +} + +/// The painting operator of a closed subpath (§5.3.3): `b` fill and stroke, `f` fill, `s` +/// stroke, `n` neither; an open one's is upper case. +fn paint_op(fill: bool, stroke: bool) -> char { + match (fill, stroke) { + (true, true) => 'b', + (true, false) => 'f', + (false, true) => 's', + (false, false) => 'n', + } +} + +/// The subpaths of compound path `n`'s members, as one path. +fn compound_path(n: &Node) -> PathData { + let mut subpaths = vec![]; + for p in n.children().map(Vec::as_slice).unwrap_or_default().iter().filter_map(|c| c.path_data()) { + subpaths.extend(p.subpaths.iter().cloned()); + } + PathData::new(subpaths) +} + +/// The outline and fill rule a clipping path or compound path `n` clips by; `None` for other +/// clipping objects and for a path without points (it clips everything away, see +/// [`Writer::clip_contents`]). +fn clip_path(n: &Node) -> Option<(PathData, FillRule)> { + let (path, rule) = match &n.kind { + NodeKind::Path { path, rule, guide: false, .. } => (path.clone(), *rule), + NodeKind::Compound { rule, .. } => (compound_path(n), *rule), + _ => return None, + }; + path.subpaths.iter().any(|sp| !sp.anchors.is_empty()).then_some((path, rule)) +} + /// The image `bytes` decoded, when its header gives at most [`MAX_AVERAGED_PIXELS`] pixels; the /// decoder may allocate at most 8 bytes a pixel. fn decode_bounded(bytes: &[u8]) -> Option { diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 6435c2dc6..a3eda140e 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -310,10 +310,13 @@ struct Reader<'a> { /// dictionary (its name, its opacity mask and the mask's art). mesh_ended: bool, /// A layer's own state (`Xy` after its art, before `LB`), until something says the art moves on. - layer_state: Option<(f32, BlendMode, bool, Knockout)>, + layer_state: Option<(f32, BlendMode, bool, Knockout, bool)>, /// The state a mesh or blend had before an `Xy` right after its end, which is the layer's /// instead when the layer's `LB` follows rather than their own `6 () XW`. mesh_undo: Option<(f32, BlendMode, bool, Knockout)>, + /// The state a layer's last group or compound path had before an `Xy` right after its end, + /// which is the layer's instead when the layer's own `7 () XW` follows. + container_undo: Option<(f32, BlendMode, bool, Knockout, bool)>, /// Painted copies of clipping paths that have a fill (drawn under what they clip). clip_fills: HashSet, /// Groups closed as an object's drawn look (`9 () XW`). @@ -476,6 +479,7 @@ impl<'a> Reader<'a> { mesh_ended: false, layer_state: None, mesh_undo: None, + container_undo: None, styles: HashMap::new(), style_list: vec![], style_links: vec![], diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index 09d056a4e..2273f087b 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -183,10 +183,12 @@ impl<'a> Reader<'a> { // The compound path's paint is its last path's (a gradient's instance wraps only // that one); the last painted one, should the last have none. let shown = kids.iter().rev().find(|k| !k.appearance.items.is_empty()).or(kids.last())?; - let (appearance, opacity, blend, rule) = (shown.appearance.clone(), shown.opacity, shown.blend, path_rule(shown)); + let (appearance, opacity, blend, shape, rule) = + (shown.appearance.clone(), shown.opacity, shown.blend, shown.knockout_shape, path_rule(shown)); for k in &mut kids { k.opacity = 1.0; k.blend = BlendMode::Normal; + k.knockout_shape = false; k.visible = true; k.locked = false; } @@ -194,6 +196,7 @@ impl<'a> Reader<'a> { n.appearance = appearance; n.opacity = opacity; n.blend = blend; + n.knockout_shape = shape; Some(n) } @@ -234,6 +237,7 @@ impl<'a> Reader<'a> { self.pending_unread_stroke = false; let mut f = self.close("LB", |k| matches!(k, Kind::Layer(_)))?; let state = self.layer_state.take(); + self.container_undo = None; // An `Xy` right after the layer's last mesh or blend, then `LB`: the layer's, not theirs. if let (Some(_), Some(before)) = (state, self.mesh_undo.take()) && let Some(mesh) = f.children.last_mut().filter(|n| matches!(n.kind, NodeKind::Mesh(_) | NodeKind::Blend { .. })) @@ -251,8 +255,8 @@ impl<'a> Reader<'a> { layer.visible = a.visible; layer.locked = a.locked; // The layer's own opacity, blend and knockout, written after its art (#2003). - if let Some((opacity, blend, isolate, knockout)) = state { - (layer.opacity, layer.blend, layer.isolate, layer.knockout) = (opacity, blend, isolate, knockout); + if let Some((opacity, blend, isolate, knockout, shape)) = state { + (layer.opacity, layer.blend, layer.isolate, layer.knockout, layer.knockout_shape) = (opacity, blend, isolate, knockout, shape); } match self.frames.last_mut() { Some(parent) => { diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 9c5878fe0..5d47ce16f 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -256,18 +256,13 @@ impl<'a> Reader<'a> { "Xd" if group_ended => { let gs = self.gs.clone(); if let Some(n) = self.last_object().filter(|n| matches!(n.kind, NodeKind::Group { .. })) { - (n.opacity, n.blend, n.isolate, n.knockout) = (gs.opacity, gs.blend, gs.isolate, gs.knockout); + (n.opacity, n.blend, n.isolate, n.knockout, n.knockout_shape) = + (gs.opacity, gs.blend, gs.isolate, gs.knockout, gs.knockout_shape); } } "Xw" => self.gs.hidden = Self::nums(&vals).last() == Some(&1.0), "A" => self.gs.locked = Self::nums(&vals).last() == Some(&1.0), - "XW" => { - // An object's own state block ends `6 () XW`: an `Xy` after a mesh's end was the mesh's. - if Self::nums(&vals).last() == Some(&6.0) && self.mesh_undo.take().is_some() { - self.layer_state = None; - } - self.style_marker(&vals, hidden) - } + "XW" => self.state_marker(&vals, hidden), // Gradients. "Bd" => self.gradient_def = GradientDef::begin(&vals), "Bs" | "BD" => {} @@ -509,9 +504,37 @@ impl<'a> Reader<'a> { if !matches!(w, "J" | "j" | "w" | "M" | "d" | "XR") { self.layer_state = None; self.mesh_undo = None; + self.container_undo = None; } } + /// `n (style) XW`: the end of a state block, or a style marker ([`Self::style_marker`]). + fn state_marker(&mut self, vals: &[V], hidden: bool) { + let code = Self::nums(vals).last().copied(); + // An object's own state block ends `6 () XW`: an `Xy` after a mesh's end was the mesh's. + if code == Some(6.0) && self.mesh_undo.take().is_some() { + self.layer_state = None; + } + match code { + Some(6.0) => self.container_undo = None, + Some(7.0) => self.layer_state_block(), + _ => {} + } + self.style_marker(vals, hidden) + } + + /// `7 () XW` ends a layer's own state block (after its art, before `LB`): an `Xy` that went to + /// the layer's last group or compound path, right after its end, was the layer's. + fn layer_state_block(&mut self) { + let Some(before) = self.container_undo.take() else { return }; + let gs = &self.gs; + let state = (gs.opacity, gs.blend, gs.isolate, gs.knockout, gs.knockout_shape); + let Some(Frame { kind: Kind::Layer(_), children, .. }) = self.frames.last_mut() else { return }; + let Some(n) = children.last_mut().filter(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Compound { .. })) else { return }; + (n.opacity, n.blend, n.isolate, n.knockout, n.knockout_shape) = before; + self.layer_state = Some(state); + } + /// `mode opacity isolate knockout shape Xy`; after a group's end, the group's too, and right /// after a gradient mesh's `/End X!` or a blend's record (before their `Xd` and `6 () XW`), /// theirs, unless the layer's `LB` follows: then it is the layer's (see `end_layer`). @@ -537,20 +560,18 @@ impl<'a> Reader<'a> { let before = (n.opacity, n.blend, n.isolate, n.knockout); (n.opacity, n.blend, n.isolate, n.knockout) = (gs.opacity, gs.blend, gs.isolate, gs.knockout); self.mesh_undo = Some(before); - self.layer_state = in_layer.then_some((gs.opacity, gs.blend, gs.isolate, gs.knockout)); - } else if self.last_object().is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Compound { .. })) { - if let Some(n) = self.last_object() { - n.opacity = gs.opacity; - n.blend = gs.blend; - n.isolate = gs.isolate; - n.knockout = gs.knockout; - } + self.layer_state = in_layer.then_some((gs.opacity, gs.blend, gs.isolate, gs.knockout, gs.knockout_shape)); + } else if let Some(n) = self.last_object().filter(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Compound { .. })) { + let before = (n.opacity, n.blend, n.isolate, n.knockout, n.knockout_shape); + (n.opacity, n.blend, n.isolate, n.knockout, n.knockout_shape) = (gs.opacity, gs.blend, gs.isolate, gs.knockout, gs.knockout_shape); + // The layer's instead when the layer's `7 () XW` follows (`layer_state_block`). + self.container_undo = in_layer.then_some(before); self.layer_state = None; } else { // After a layer's art and before its `LB`: the layer's own state (#2003). Kept until // an operator that isn't part of the state block (or the `LB` that takes it) says the // art moves on, so a path's state before its paint isn't read as its layer's. - self.layer_state = in_layer.then_some((gs.opacity, gs.blend, gs.isolate, gs.knockout)); + self.layer_state = in_layer.then_some((gs.opacity, gs.blend, gs.isolate, gs.knockout, gs.knockout_shape)); } self.note_state(); } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 6e97b279d..fabf97a5d 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -965,6 +965,30 @@ fn a_layer_keeps_its_own_transparency() { } } +#[test] +fn a_layers_state_after_its_last_group_or_compound_path_is_the_layers() { + // GUARDS: the layer's `Xy` right after its last group or compound path, before the layer's + // `0 0 Xd` and `7 () XW`, went to that group or compound path, and the layer stayed opaque. + let a = rect_path(0.0, 0.0, 10.0, 10.0); + let b = rect_path(2.0, 2.0, 8.0, 8.0); + let layer_block = "1 0.6 1 2 1 Xy\n0 0 Xd\n7 () XW\n"; + for last in [format!("0 Ae\nu\n{RED}{a}f\nU\n"), format!("0 Ae\n*u\n{RED}{a}f\n{b}f\n*U\n")] { + let doc = read_ok(&layer(&format!("{last}{layer_block}"))).doc; + let l = &doc.layers[0]; + assert_eq!( + (l.opacity, l.blend, l.isolate, l.knockout, l.knockout_shape), + (0.6, BlendMode::Multiply, true, Knockout::Neutral, true), + "{last}" + ); + let n = &art(&doc)[0]; + assert_eq!((n.opacity, n.blend, n.isolate, n.knockout_shape), (1.0, BlendMode::Normal, false, false), "{last}"); + // The group's own state block (`6 () XW`) keeps it the group's. + let doc = read_ok(&layer(&format!("{last}1 0.6 1 2 1 Xy\n0 0 Xd\n6 () XW\n"))).doc; + assert_eq!(doc.layers[0].opacity, 1.0, "{last}"); + assert_eq!((art(&doc)[0].opacity, art(&doc)[0].knockout_shape), (0.6, true), "{last}"); + } +} + #[test] fn a_layers_state_keeps_through_the_stroke_style_written_in_it() { // GUARDS: a layer's state block can have the stroke style between its `Xy` and `Xd`; read as diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c9039ea06..206f36801 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -18,7 +18,7 @@ from the public specifications, and users' own `.ai` files are used locally only | | Illustrator | VectorCraft | Notes | |---|---:|---:|---| | Formats opened or placed | ~30 | 29 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, PICT, PS | -| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, artboards) or as legacy version 7 (layers and groups, one artboard per file) | +| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, artboards, transparency, opacity masks, clipping) or as legacy version 7 (layers and groups, one artboard per file) | | Formats exported | ~16 | 17 | missing: DWG, 3D (OBJ/USDA/glTF); extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD | | **Overall (weighted by use)** | | **~75%** | `.ai` and PDF dominate the weighting | @@ -62,9 +62,12 @@ private editing data. VectorCraft: Editing Data for Other Apps) every page's `PieceInfo` also carries an editing copy of the art in `AIPrivateData` streams, in the form VectorCraft's `.ai` reader reads: layers and sublayers, groups, paths with their fills and strokes (a compound path as one path of all its subpaths), process, spot and global colors, swatches and color groups, guides, - hidden and locked objects, object names and every artboard with its name. Type, gradients, patterns, images, - symbols, clipping groups and live objects are written as plain filled and stroked paths, each with a warning; - transparency and effects other than geometry effects are left out. + hidden and locked objects, object names, every artboard with its name, the transparency of objects and layers + (opacity, blending mode, isolation, knockout and knockout shape), objects' opacity masks, and clipping groups and + clipping layers clipped by a path or compound path. Type, gradients, patterns, images, symbols, other clipping + objects and live objects are written as plain filled and stroked paths, each with a warning; the opacity and + blending mode of single fills and strokes, opacity masks on layers and effects other than geometry effects are + left out. - **Writes** a legacy `.ai` of version 7 (Save As › `ai` › Version: Legacy (version 7), `aiVersion: "7"`): the PostScript form, from the published *Adobe Illustrator File Format Specification* version 7.0 ([PRONOM fmt/423](https://www.nationalarchives.gov.uk/PRONOM/fmt/423)), which apps reading `.ai` open with its