From f8e0639d14d2b40663ef6fdcb154a6482fc6d8be Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 5 Nov 2026 08:25:31 -0800 Subject: [PATCH 01/13] .ai save: the editing copy keeps symbols and their instances The editing copy of a PDF-compatible .ai wrote every symbol instance as its art. Symbols are now written as symbols, in the form the .ai reader reads them: - Every symbol is defined in the setup, in a non-printing block after the patterns: %AI14_BeginSymbol, its name, its art in the symbol's own space, its registration point as AI13PatternRegistrationType and %AI10_EndSymbol, each after the symbols its art uses. The nine points are numbered 1 to 9 row by row from the top left, in the order the scripting reference lists them. A definition is written the same wherever its first instance lies. The symbol list after the palette keeps the Symbols panel's order. A hidden root of a symbol's art is written shown, as the canvas draws it. - Each instance is a /SymbolInstance with its symbol's name and the matrix that places the art (to ten decimals), followed by its name, opacity mask, opacity and blending mode as a group's. In a symbol's art, the matrices of instances, gradients and images are written in the canvas's space about the canvas point of the ruler origin. - Pattern fills in a symbol's art are written as their tiles cut to the shape. A symbol whose definition holds such tiles, from its own objects, a brushed stroke's brush art, a live object, an opacity mask, a nested symbol or an inline graphic, has its instances written as their art. - Instances with a fill, appearance changes, isolation, knockout or knockout shape of their own, instances of symbols with 9-slice guides or pattern paint, instances in pattern tiles or in art cut to a clipping object, instances scaled almost flat, instances that turn, mirror or scale symbols holding type, live objects, instances written as their art, shapes with rounded live corners or strokes written as outlines or brush art (the canvas works these out again where the instance puts them), instances that scale their symbol more than 100 times, nested instances included (numbers are written to four decimals), and instances on the layers of a large canvas are written as their art (the symbols are still defined). Symbols past the 4,096 the reader keeps are left out and their instances written as art; an instance of a symbol the document doesn't have is an empty group. 9-slice guides, export types and the dynamic type are left out, and linked images and type in a symbol's art are embedded and written as outlines. Each comes with a warning. The writer takes the reader's limit of 4,096 definitions (MAX_SYMBOLS) and its symbol_art_member check, which now are pub(crate). The editingData option's description lists what the copy now holds, type written as text included. Symbols and their instances: https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html The nine registration points, in the order the scripting reference lists them: https://web.archive.org/web/20180219005134/https://wwwimages.adobe.com/content/dam/acom/en/devnet/illustrator/pdf/Illustrator-Scripting-Reference-JavaScript-cs6.pdf#page=252 Tests: the round trip of symbols, nested symbols and instances in every state, symbols whose art is a group with a state of its own, images and gradients in a symbol's art, every registration point, symbol names with special characters, duplicate and missing symbols, a symbol first used deep in opacity masks, symbols past the reader's count (one found only while its holder's art is written included), a large canvas, symbols with pattern fills (directly, nested, in an opacity mask, through an inline graphic and through a brushed stroke), scaled instances of symbols with an outside stroke, rounded live corners, type, a blend, a tiny or thin object, an ellipse or an instance of a symbol with 9-slice guides, a symbol whose root is hidden, instance matrices from a fixed seed, a second write of what is read back giving the same bytes, the forms written, the instances written as art and the options reported. Docs: docs/file-format-parity.md, README.md, ROADMAP.md, docs/gaps.md, docs/roadmap.md and docs/target-app-parity.md describe the editing copy a saved .ai holds. --- README.md | 2 +- ROADMAP.md | 2 +- crates/engine/src/cmd/fileio/native.rs | 2 +- crates/eps/src/ai_editing.rs | 47 +- crates/eps/src/ai_editing/tests.rs | 1035 ++++++++++++++++++- crates/eps/src/ai_editing/writer.rs | 106 +- crates/eps/src/ai_editing/writer/images.rs | 15 +- crates/eps/src/ai_editing/writer/paints.rs | 5 +- crates/eps/src/ai_editing/writer/symbols.rs | 339 ++++++ crates/eps/src/import.rs | 2 +- crates/eps/src/import/ai.rs | 2 +- crates/eps/src/import/ai/read.rs | 4 + crates/eps/src/import/ai/read/types.rs | 2 +- docs/file-format-parity.md | 19 +- docs/gaps.md | 2 +- docs/roadmap.md | 2 +- docs/target-app-parity.md | 7 +- 17 files changed, 1539 insertions(+), 54 deletions(-) create mode 100644 crates/eps/src/ai_editing/writer/symbols.rs diff --git a/README.md b/README.md index 79d88841e..8f1113c4f 100644 --- a/README.md +++ b/README.md @@ -253,7 +253,7 @@ The scores are self-assessed from our code against public documentation; [target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them. **What's missing:** -- reliable exchange of .ai files (layer structure in real files; the editing copy a save writes leaves out live effects and writes symbols and live objects as their art); +- reliable exchange of .ai files (layer structure in real files; the editing copy a save writes leaves out live effects and writes live objects as their art); - an interaction-fidelity pass covering every tool's modifiers and small behaviours; - 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials); - filter thumbnails and a preview area inside the Effect Gallery (the gallery itself and all 57 Photoshop-style raster filters, approximated, are in); diff --git a/ROADMAP.md b/ROADMAP.md index 68b0b2d2a..ae8417530 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,7 +24,7 @@ and the work list in [`docs/gaps.md`](docs/gaps.md). **Why alpha:** the core workflows exist end to end (drawing and path editing, Pathfinder, paint and appearance, type, SVG/PDF/EPS, print, export) and feature depth is ~75%, but a professional can't yet switch: Illustrator's own `.ai` files open with a wrong layer structure in real cases and are saved back with an editing copy that -leaves out live effects and writes symbols and live objects as their art, the interaction details have never been checked side by side, and some machines fail to start the +leaves out live effects and writes live objects as their art, the interaction details have never been checked side by side, and some machines fail to start the app. Beta needs those closed (the beta list in [gaps.md](docs/gaps.md)). It passes the core-workflow gate: all six core workflows (path drawing and editing, paint and style, type, layers/artboards/export, print production, file exchange) work end to end on macOS with save and reopen, and only the last is partial, without diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs index 770c11644..74cfced2b 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, compound paths, paths, fills and strokes, colors, linear and radial gradients, patterns on a grid of tiles, swatches, artboards and which one is active, the transparency of objects and layers, objects' opacity masks, clipping groups and layers clipped by a path, embedded images with their pixels, and linked images as placed files with a preview, with type, other patterns, symbols, other clipping objects and live objects as plain filled and stroked paths, freeform gradients, gradients along or across a stroke and gradients of more than 256 stops as their average color, gradients that dither or mix perceptually as classic ones, and gradient stops linked to global process colors as their colors (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, linear and radial gradients, patterns on a grid of tiles, swatches, artboards and which one is active, the transparency of objects and layers, objects' opacity masks, clipping groups and layers clipped by a path, embedded images with their pixels, linked images as placed files with a preview, point type, area type and type on a path as text, and symbols and their instances, with other type, other patterns, other clipping objects, other symbol instances and live objects as plain filled and stroked paths, freeform gradients, gradients along or across a stroke and gradients of more than 256 stops as their average color, gradients that dither or mix perceptually as classic ones, and gradient stops linked to global process colors as their colors (written only with every artboard, as Preserve Editing is)", }, FormatOption { name: "aiVersion", diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 2ad1c719c..88b9f0fe2 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -77,7 +77,8 @@ //! a preview of at most 256 pixels a side between `%AI26_BeginPlacedObjectPreview` and its end //! (`w h rowBytes 32 17 `, then rows of premultiplied ARGB), each in versioned content, `~`, //! `%AI5_EndPlace`. The box is the file's pixels at art space's origin on the canvas; the matrix -//! takes it into art space. In opacity masks and pattern tiles, linked images are embedded. +//! takes it into art space. In opacity masks, pattern tiles and symbols, linked images are +//! embedded. //! - Type: a text object `/AI11Text :` naming its story and frame (`/StoryIndex`, `/FrameIndex`), //! for area type and type on a path with its frame's path (`/Art :` … `; /ConfiningPath ,`), and //! the text document after the document data (`%AI11_BeginTextDocument`, see @@ -86,26 +87,49 @@ //! the alignment, indents, spacing, hyphenation and direction of each paragraph, and the frame: //! point type's point and matrix, area type's frame, rows, columns, gutters and first baseline, //! type on a path's path and span, vertical type, and the frames a threaded story flows through. +//! - Symbols, as the reader reads them ([`crate::import`]'s `.ai` reader, `read.rs`): every symbol +//! of the document defined in the setup after the patterns, in a non-printing block +//! (`%AI14_BeginSymbol`, `(name)`, `0 A`, `0 Xw`, `0 Ae`, the art in the symbol's space with y +//! down, the registration point as `AI13PatternRegistrationType` 1 to 9 from the top left, row by +//! row (VectorCraft's numbering, in the order the scripting reference lists the points), in the +//! art's dictionary, `%AI10_EndSymbol`), each after those its art uses; the symbol +//! list after the palette (`%AI24_BeginSymbolList`, the names in the Symbols panel's order, +//! `%AI24_EndSymbolList`); each instance as `/SymbolInstance :`, `(name) /SymbolRef ,`, the +//! matrix taking the art to art space as `a b c d tx ty /RTransformMatrix`, `;`, then its name +//! and opacity mask and a state block as a group's with its opacity and blending mode (an +//! instance with isolation, knockout or knockout shape of its own is written as its art). The +//! definitions are at most the reader's 4,096. In a symbol's art, an instance's matrix and +//! a gradient's take the art to the canvas's space turned y up about art space's origin there. //! //! 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 the text document can't hold (in opacity masks, pattern tiles and art cut to -//! a clipping object, mirrored, turned or unevenly scaled area type and type on a path, inline graphics, ruby, +//! loss a warning: type the text document can't hold (in opacity masks, pattern tiles, symbols and +//! art cut to a clipping object, mirrored, turned or unevenly scaled area type and type on a path, inline graphics, ruby, //! gradient or pattern paint, an appearance of its own) as its outlines and its inline graphics' //! art, the character and paragraph attributes the text document doesn't hold, freeform gradients, gradients //! along or across a stroke and gradients without stops, with more than 256 stops or without extent //! as their average color //! (gradients that dither or mix perceptually are written as classic ones, and gradient stops linked //! to global process colors as their colors), pattern fills whose tiles aren't a grid, whose art reaches past -//! the tile or is painted with patterns as their tiles cut to the shape, clipping groups and clipping layers whose clipping object isn't a path, +//! the tile or is painted with patterns, and pattern fills in symbols, as their 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; an image in a clipping group of the region it is cut to), images that can't be //! decoded or have more than 33,554,432 pixels as their outlines filled mid gray, images of 16 bits -//! a channel at 8 bits, linked images in opacity masks and pattern tiles as embedded images, -//! symbols and live objects (placed documents included) as their art (symbol art past -//! [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects in all left out), strokes a plain line can't draw +//! a channel at 8 bits, linked images in opacity masks, pattern tiles and symbols as embedded images, +//! symbol instances with a fill, appearance changes, isolation, knockout or knockout shape of their +//! own, instances of symbols with 9-slice guides or pattern paint, instances in pattern tiles or in art cut to a +//! clipping object, instances scaled almost flat, instances that turn, mirror or scale symbols holding +//! type, live objects, instances written as their art, shapes with rounded live corners or strokes +//! written as outlines or brush art, +//! instances that scale their symbol more than 100 times, instances on the layers of a large canvas +//! and instances of +//! symbols past 4,096 as their art (symbol art past [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects +//! in all left out), instances of symbols the document doesn't have as empty groups, symbols' 9-slice +//! guides, export types and dynamic type left out, live objects (placed documents included) as their art, +//! strokes a plain line can't draw //! as their filled outlines, several fills or strokes as a group of a path for each, freeform //! gradient swatches, gradient swatches of more than 256 stops, swatches of patterns written as //! tiles and the placement of pattern swatches that move, turn or scale their pattern, the opacity and blending modes of single fills and strokes, opacity masks on @@ -206,6 +230,8 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result Result Result], depth: usize, out: &mut Vec) { } out.push(s); } + // The matrix is written to ten decimals. + NodeKind::SymbolInstance { symbol, xf } => { + let xf: Vec = xf.as_coeffs().iter().map(|v| format!("{:.6}", v + 0.0)).collect(); + out.push(format!("{head} instance {symbol:?} [{}] {} {:?}", xf.join(" "), r3(f64::from(n.opacity)), n.blend)); + } other => out.push(format!("{head} {other:?}")), } } @@ -602,8 +607,8 @@ fn hidden_layers_open_hidden() { assert!(text.contains("%AI5_NumLayers: 3\n%AI5_OpenViewLayers: 776\n"), "{text}"); } -/// A 200 × 100 document of a gradient fill, a compound path and an image, and what the data holds -/// as plain paths: type and a symbol instance. +/// A 200 × 100 document of a gradient fill, a compound path, an image and a symbol instance, and +/// what the data holds as plain paths: type. fn unsupported() -> Document { let mut doc = Document::new(200.0, 100.0); let layer = doc.layers[0].id; @@ -671,9 +676,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::TYPE, writer::LIVE] { - assert!(out.warnings.iter().any(|x| x == w), "{w}: {:?}", out.warnings); - } + assert_eq!(out.warnings, [writer::TYPE]); let back = read_back(&doc); let layer = back.layers[0].children().unwrap(); let kinds: Vec<&str> = layer @@ -683,12 +686,13 @@ fn art_the_slice_does_not_cover_is_written_as_plain_paths() { NodeKind::Group { .. } => "group", NodeKind::Compound { .. } => "compound", NodeKind::Image(_) => "image", + NodeKind::SymbolInstance { .. } => "instance", _ => "other", }) .collect(); - assert_eq!(kinds, ["path", "compound", "image", "group", "group"]); + assert_eq!(kinds, ["path", "compound", "image", "group", "instance"]); let mut all = vec![]; - paths(&layer[3..], &mut all); + paths(&layer[3..4], &mut all); assert!(all.iter().all(|n| matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }))); // The gradient as a gradient, drawing as it did. let bounds = Rect::new(10.0, 10.0, 30.0, 30.0); @@ -802,11 +806,11 @@ fn a_scaled_nine_slice_symbol_instance_keeps_its_corner_size() { assert!(sizes.contains(&(200, 150)), "the frame is scaled: {sizes:?}"); } -/// Nested symbols whose instances multiply stop at [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects of -/// symbol art in all, with a warning. +/// Nested symbols whose instances multiply, written as their art (on a large canvas), stop at +/// [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects of symbol art in all, with a warning. #[test] fn nested_symbol_instances_stop_at_the_symbol_art_limit() { - let mut doc = Document::new(200.0, 100.0); + let mut doc = Document::new(20000.0, 100.0); let layer = doc.layers[0].id; // Empty paths: counted, but written as nothing, so the test stays small; and one filled square. let empty = Arc::new(Node::path(doc.alloc_id(), PathData::new(vec![]), Appearance::default())); @@ -824,7 +828,9 @@ fn nested_symbol_instances_stop_at_the_symbol_art_limit() { assert!(out.warnings.iter().any(|w| w == writer::SYMBOL_LIMIT), "{:?}", out.warnings); // 5,001 objects for the tree and each leaf: 3,198 leaves fit in 16,000,000. let text = String::from_utf8(out.program).unwrap(); - assert_eq!(text.matches("\r\nf\r\n").count(), 3198, "the squares of the leaves written"); + // On the layers; the symbols' definitions come before them, in the setup. + let layers = text.split_once("%%EndSetup").unwrap().1; + assert_eq!(layers.matches("\r\nf\r\n").count(), 3198, "the squares of the leaves written"); } #[test] @@ -900,7 +906,7 @@ fn geometry_effects_in_a_symbol_are_written_as_their_result() { let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((50.0, 20.0)) }); doc.insert(Some(layer), usize::MAX, instance).unwrap(); let back = read_back(&doc); - let b = back.layers[0].children().unwrap()[0].visual_bounds().unwrap(); + let b = back.bounds_of_with(&[back.layers[0].children().unwrap()[0].id], true, &back.symbol_boxes()).unwrap(); // The 10 pt square offset by 5 pt, at (50, 20). assert!((b.x0 - 45.0).abs() < 0.1 && (b.x1 - 65.0).abs() < 0.1 && (b.y0 - 15.0).abs() < 0.1 && (b.y1 - 35.0).abs() < 0.1, "{b:?}"); } @@ -925,7 +931,8 @@ fn an_adjusted_image_in_a_symbol_is_written_in_its_adjusted_color() { doc.insert(Some(layer), usize::MAX, instance).unwrap(); let back = read_back(&doc); let mut found = vec![]; - pictures(&back, &back.layers, "", &mut found); + let art: Vec> = back.symbols.iter().map(|s| s.art.clone()).collect(); + pictures(&back, &art, "", &mut found); assert_eq!(found.len(), 1, "the adjusted image is found"); assert!(!found[0].ends_with("rgba [0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255]"), "the image is adjusted: {found:?}"); } @@ -1206,6 +1213,7 @@ fn looks(nodes: &[Arc], depth: usize, out: &mut Vec) { NodeKind::Group { clip, .. } => format!("group clip={clip}"), NodeKind::Path { clipping, .. } => format!("path clipping={clipping}"), NodeKind::Compound { .. } => "compound".into(), + NodeKind::SymbolInstance { symbol, .. } => format!("instance {symbol:?}"), _ => "other".into(), }; let mut s = format!( @@ -3322,6 +3330,126 @@ fn type_lines(doc: &Document) -> Vec { out } +// ---------- symbols ---------- + +const LEAF: Color = Color::Cmyk { c: 0.6, m: 0.0, y: 0.9, k: 0.1 }; + +fn instance(doc: &mut Document, symbol: &str, xf: Affine) -> Node { + Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: symbol.into(), xf }) +} + +/// A `w` × `h` document whose swatches are one CMYK color (the default swatches' CMYK equivalents +/// change at the fourth decimal when read back, so a second write would differ there). +fn one_swatch_document(w: f64, h: f64) -> Document { + let mut doc = Document::new(w, h); + doc.swatches = vec![Swatch { name: "Leaf".into(), paint: solid(LEAF), global: false, spot: false }].into(); + doc.swatch_groups = vec![].into(); + doc +} + +/// A 300 × 200 document of symbols. Its symbols, in the Symbols panel's order: `Spare`, a path no +/// instance uses; `Pair`, a group of a red square and an instance of `Leaf` at half its size; `Leaf`, +/// a group of a square in `LEAF` and an ellipse, its registration point the top left; `Shade`, a +/// group of a rectangle filled with a gradient, a clipping group and an image, its registration +/// point the bottom right; `Solo`, a group of one square, which no instance uses. Layer `Art` +/// holds, bottom first: `Leaf` moved; `Leaf` turned 30° and scaled 1.5 by 0.5, named `Turned`; a +/// hidden one; a locked one; one at 50% in Multiply; `Pair`; a group holding `Leaf`; a clipping +/// group holding `Leaf`; `Leaf` with an opacity mask whose art holds an instance of `Leaf`; `Shade`; +/// and sublayer `Inner` (60%, Screen), which holds `Pair` mirrored. +fn symbols_document() -> Document { + use vectorcraft_color::gradient::GradientGeom; + let mut doc = Document::new(300.0, 200.0); + doc.swatches = vec![Swatch { name: "Leaf".into(), paint: solid(LEAF), global: false, spot: false }].into(); + doc.swatch_groups = vec![].into(); + doc.layers.clear(); + let art_layer = doc.add_layer(Some("Art")); + // The symbols. + let spare = square(&mut doc, Rect::new(-5.0, -5.0, 5.0, 5.0), Color::gray(0.4)); + let leaf_square = square(&mut doc, Rect::new(0.0, 0.0, 20.0, 20.0), LEAF); + let ellipse = + Node::path(doc.alloc_id(), shapes::ellipse(Rect::new(5.0, 10.0, 25.0, 30.0)), Appearance::basic(Paint::None, solid(Color::BLACK), 1.5)); + let leaf = group(&mut doc, vec![leaf_square, ellipse], false); + let red = square(&mut doc, Rect::new(-10.0, -10.0, 10.0, 10.0), Color::rgb(1.0, 0.0, 0.0)); + let small = instance(&mut doc, "Leaf", Affine::translate((15.0, -5.0)) * Affine::scale(0.5)); + let pair = group(&mut doc, vec![red, small], false); + let geom = GradientGeom { start: (0.0, 0.0).into(), end: (40.0, 10.0).into(), aspect: 1.0, focal: None }; + let shaded = painted( + &mut doc, + shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0)), + vec![AppearanceItem::Fill(FillLayer::new(gradient_paint(sunset(), Some(geom), None)))], + ); + let edge = clipping(&mut doc, shapes::ellipse(Rect::new(0.0, 20.0, 20.0, 40.0))); + let inside = square(&mut doc, Rect::new(0.0, 25.0, 40.0, 35.0), Color::rgb(0.0, 0.0, 1.0)); + let clipped = group(&mut doc, vec![edge, inside], true); + let pixels = png_of(image::DynamicImage::ImageRgba8(image::RgbaImage::from_raw(2, 1, vec![255, 0, 0, 255, 0, 128, 255, 255]).unwrap())); + let picture = image_node(&mut doc, ImageBlob::new("image/png", pixels), (2, 1), Affine::new([10.0, 0.0, 0.0, 10.0, 25.0, 25.0]), None); + let shade = group(&mut doc, vec![shaded, clipped, picture], false); + let lone = square(&mut doc, Rect::new(0.0, 0.0, 8.0, 8.0), Color::gray(0.2)); + let solo = group(&mut doc, vec![lone], false); + for (name, art) in [("Spare", spare), ("Pair", pair), ("Leaf", leaf), ("Shade", shade), ("Solo", solo)] { + doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); + } + doc.set_symbol_registration("Leaf", vectorcraft_doc::SymbolRegistration(0)); + doc.set_symbol_registration("Shade", vectorcraft_doc::SymbolRegistration(8)); + // The instances. + let mut art = vec![instance(&mut doc, "Leaf", Affine::translate((10.0, 10.0)))]; + let turned = Affine::translate((80.0, 20.0)) * Affine::rotate(30f64.to_radians()) * Affine::scale_non_uniform(1.5, 0.5); + art.push(named(instance(&mut doc, "Leaf", turned), "Turned")); + let mut hidden = instance(&mut doc, "Leaf", Affine::translate((130.0, 10.0))); + hidden.visible = false; + art.push(hidden); + let mut locked = instance(&mut doc, "Leaf", Affine::translate((160.0, 10.0))); + locked.locked = true; + art.push(locked); + art.push(see_through(instance(&mut doc, "Leaf", Affine::translate((190.0, 10.0))), 0.5, BlendMode::Multiply)); + art.push(instance(&mut doc, "Pair", Affine::translate((240.0, 30.0)))); + let grouped = instance(&mut doc, "Leaf", Affine::translate((10.0, 60.0))); + art.push(group(&mut doc, vec![grouped], false)); + let window = clipping(&mut doc, shapes::rectangle(Rect::new(50.0, 60.0, 65.0, 75.0))); + let clipped = instance(&mut doc, "Leaf", Affine::translate((50.0, 60.0))); + art.push(group(&mut doc, vec![window, clipped], true)); + let mask_leaf = instance(&mut doc, "Leaf", Affine::translate((90.0, 60.0))); + let mask_art = group(&mut doc, vec![mask_leaf], false); + let mut masked = instance(&mut doc, "Leaf", Affine::translate((90.0, 60.0))); + masked.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(mask_art), clip: true, invert: false, disabled: false, linked: true })); + art.push(masked); + art.push(instance(&mut doc, "Shade", Affine::translate((130.0, 60.0)))); + for n in art { + doc.insert(Some(art_layer), usize::MAX, n).unwrap(); + } + let inner = doc.add_layer(Some("Inner")); + let inner_node = doc.layers.iter().position(|l| l.id == inner).map(|i| doc.layers.remove(i)).unwrap(); + doc.insert(Some(art_layer), usize::MAX, (*inner_node).clone()).unwrap(); + let mirrored = instance(&mut doc, "Pair", Affine::new([-1.0, 0.0, 0.0, 1.0, 260.0, 120.0])); + doc.insert(Some(inner), usize::MAX, mirrored).unwrap(); + // The sublayer's own transparency, after its last object, an instance. + let sub = doc.node_mut(inner).unwrap(); + (sub.opacity, sub.blend) = (0.6, BlendMode::Screen); + doc +} + +/// Each symbol of `doc` in its order: its name and registration point, then what its art draws, its +/// objects as [`view`] shows them (points, handles, paint, strokes and instances), its images, and +/// its objects' names, transparency and clipping. +fn symbol_looks(doc: &Document) -> Vec { + let mut out = vec![]; + for s in doc.symbols.iter() { + out.push(format!("symbol {} {:?}", s.name, doc.symbol_registration(&s.name))); + let art = std::slice::from_ref(&s.art); + paints_of(doc, art, 1, &mut out); + let mut objects = vec![]; + view(art, 1, &mut objects); + // Images are compared by `pictures`, gradients by `paints_of`. + out.extend(objects.into_iter().filter(|l| !l.contains(" Image(")).map(|l| match l.find(" fill(Gradient(") { + Some(at) => format!("{} fill(gradient)", &l[..at]), + None => l, + })); + pictures(doc, art, &s.name, &mut out); + looks(art, 1, &mut out); + } + out +} + #[test] fn type_comes_back_as_type() { let doc = typed(); @@ -4132,3 +4260,884 @@ fn type_with_an_opacity_mask_is_written_as_its_outlines() { assert!(!matches!(top.kind, NodeKind::Text(_))); assert!(top.extras().mask.is_some()); } + +#[test] +fn symbols_and_their_instances_are_read_back() { + let doc = symbols_document(); + 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)); + same_lines(&all_looks(&back), &all_looks(&doc)); + same_lines(&symbol_looks(&back), &symbol_looks(&doc)); + // Each instance draws where it did: the symbols' art and the instances' places agree. + let boxes = |d: &Document| -> Vec { + let b = d.symbol_boxes(); + let mut ids = vec![]; + d.walk(|n| { + if matches!(n.kind, NodeKind::SymbolInstance { .. }) { + ids.push(n.id); + } + }); + ids.iter().map(|id| format!("{:?}", d.bounds_of_with(&[*id], true, &b).map(|r| [r.x0, r.y0, r.x1, r.y1].map(r3)))).collect() + }; + same_lines(&boxes(&back), &boxes(&doc)); +} + +#[test] +fn symbols_write_the_same_bytes_again() { + let doc = symbols_document(); + 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); + let again = export_ai_editing(&read_back(&doc), &opts()).unwrap().program; + same_lines(&shown(&again).lines().map(String::from).collect::>(), &shown(&first.program).lines().map(String::from).collect::>()); + assert_eq!(again, first.program); +} + +#[test] +fn symbols_are_written_in_their_forms() { + let p = program_shown(&symbols_document()); + // The definitions in a non-printing block after the patterns, in the order the art first uses + // them (each after those its art uses), the others after them; and the list after the palette + // in the panel's order. + let defs = ["(Leaf)", "(Pair)", "(Shade)", "(Spare)", "(Solo)"].map(|n| p.find(&format!("%AI14_BeginSymbol\n{n}\n0 A\n0 Xw\n0 Ae\n")).unwrap()); + assert!(defs.windows(2).all(|w| w[0] < w[1]), "{p}"); + assert!(p.contains("%AI5_Begin_NonPrinting\nNp\n%AI14_BeginSymbol\n(Leaf)\n"), "{p}"); + let list = + "%AI5_Begin_NonPrinting\nNp\n%AI24_BeginSymbolList\n(Spare)\n(Pair)\n(Leaf)\n(Shade)\n(Solo)\n%AI24_EndSymbolList\n%AI5_End_NonPrinting--\n"; + let at = p.find(list).unwrap(); + assert!(p.find("%AI5_EndPalette").unwrap() < at && at < p.find("%AI9_BeginDocumentData").unwrap(), "{p}"); + // A group's registration point in its dictionary, before its state block; a path is written in + // a group of its own. + assert!(p.contains("U\n%_/ArtDictionary :\n%_1 /Int (AI13PatternRegistrationType) ,\n%_;\n%_\n9 () XW\n%AI10_EndSymbol\n"), "{p}"); + assert!(p.contains("%_/ArtDictionary :\n%_9 /Int (AI13PatternRegistrationType) ,\n"), "{p}"); + assert!(p.contains("%AI14_BeginSymbol\n(Spare)\n0 A\n0 Xw\n0 Ae\nu\n"), "{p}"); + // A plain group of one object in a group of its own. + assert!(p.contains("%AI14_BeginSymbol\n(Solo)\n0 A\n0 Xw\n0 Ae\nu\nu\n"), "{p}"); + assert!(p.contains("f\nU\n%_/ArtDictionary :\n%_5 /Int (AI13PatternRegistrationType) ,\n%_;\n%_\n9 () XW\n%AI10_EndSymbol\n"), "{p}"); + // An instance on the page: its matrix takes the art to art space (y up from the artboard's top + // left), then its state block. + assert!(p.contains("/SymbolInstance :\n(Leaf) /SymbolRef ,\n1 0 0 -1 10 -10 /RTransformMatrix\n;\n9 () XW\n"), "{p}"); + // Its name between the two, and its transparency as a group's. + assert!(p.contains("/RTransformMatrix\n;\n%_/ArtDictionary :\n%_/XMLUID : (Turned) ; (AI10_ArtUID) ,\n%_;\n%_\n9 () XW\n"), "{p}"); + assert!(p.contains("1 0 0 -1 190 -10 /RTransformMatrix\n;\n1 0.5 0 2 0 Xy\n0 0 Xd\n6 () XW\n"), "{p}"); + // In a symbol's art, about the canvas point of art space's origin (the template box's center + // is the canvas's). + let t: Vec = p.lines().find_map(|l| l.strip_prefix("%AI3_TemplateBox: ")).unwrap().split(' ').map(|v| v.parse().unwrap()).collect(); + let (ox, oy) = (f64::from(CANVAS_SIZE) / 2.0 - t[0], f64::from(CANVAS_SIZE) / 2.0 + t[1]); + let nested = format!("(Leaf) /SymbolRef ,\n0.5 0 0 -0.5 {} {} /RTransformMatrix\n", ps::num(15.0 - ox), ps::num(oy + 5.0)); + assert!(p.contains(&nested), "{nested}\n{p}"); +} + +/// Instances that the data can't hold as instances are written as their art, with a warning: a +/// stained one, one of a symbol with 9-slice guides, one scaled almost flat, every one on the +/// layers of a large canvas (whose symbols are still defined), one in a pattern's tile, one with a +/// dynamic symbol's appearance changes and one in art cut to a clipping object that isn't a path. +/// A symbol whose art holds an instance of itself is defined once, that instance as art. +#[test] +fn instances_the_data_cant_hold_are_written_as_their_art() { + let leaf = |doc: &mut Document| { + let art = square(doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Leaf".into(), art: Arc::new(art) }); + }; + let kinds = |d: &Document| -> Vec<&'static str> { + d.layers[0].children().unwrap().iter().map(|n| if matches!(n.kind, NodeKind::SymbolInstance { .. }) { "instance" } else { "art" }).collect() + }; + // Stained. + let mut doc = Document::new(200.0, 100.0); + leaf(&mut doc); + let mut stained = instance(&mut doc, "Leaf", Affine::translate((50.0, 10.0))); + stained.appearance = Appearance::basic(solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into(); + let plain = instance(&mut doc, "Leaf", Affine::translate((80.0, 10.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, stained).unwrap(); + doc.insert(Some(layer), usize::MAX, plain).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART]); + assert_eq!(kinds(&read_back(&doc)), ["art", "instance"]); + // 9-slice guides. + doc.set_symbol_nine_slice("Leaf", Some(vectorcraft_doc::SymbolNineSlice::default())); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(out.warnings, [writer::SYMBOL_ART, writer::SYMBOL_OPTIONS]); + let back = read_back(&doc); + assert_eq!((kinds(&back), back.symbols.len()), (vec!["art", "art"], 1)); + // Scaled so flat that its matrix to ten decimals has no area. + let mut doc = Document::new(200.0, 100.0); + leaf(&mut doc); + let flat = instance(&mut doc, "Leaf", Affine::new([1e-11, 0.0, 0.0, 1.0, 50.0, 10.0])); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, flat).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART]); + assert_eq!(kinds(&read_back(&doc)), ["art"]); + // A large canvas. + let mut doc = Document::new(20000.0, 100.0); + leaf(&mut doc); + let one = instance(&mut doc, "Leaf", Affine::translate((50.0, 10.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, one).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART]); + // The symbol is still defined, at its own size: the canvas's scale doesn't touch its art. + let back = read_back(&doc); + assert_eq!(kinds(&back), ["art"]); + assert_eq!(symbol_looks(&back), symbol_looks(&doc)); + // A pattern a symbol's art and the layers both paint with: the layers' fill keeps it, the + // symbol's art has its tiles. + let dot = Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 4.0, 4.0), Color::BLACK)); + let mut dots = vectorcraft_doc::PatternDef::new("Dots", vec![dot]); + dots.tile = Rect::new(0.0, 0.0, 8.0, 8.0); + doc.patterns.push(dots); + let fill = || Appearance::basic(Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }, Paint::None, 0.0); + let patch = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 16.0, 16.0)), fill()); + doc.symbols.push(Symbol { dynamic: false, name: "Patch".into(), art: Arc::new(patch) }); + let filled = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(100.0, 10.0, 132.0, 42.0)), fill()); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, filled).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(out.warnings, [writer::SYMBOL_ART, writer::PATTERNS]); + let back = read_back(&doc); + let on_layer = back.layers[0].children().unwrap()[1].appearance.items[0].paint().clone(); + assert!(matches!(on_layer, Paint::Pattern { .. }), "{on_layer:?}"); + let patch = &back.symbols.iter().find(|s| s.name == "Patch").unwrap().art; + assert!(matches!(patch.kind, NodeKind::Group { .. }), "the symbol's art is its tiles: {:?}", patch.kind); + // A pattern's tile: the tile holds the symbol's art. + let mut doc = Document::new(200.0, 100.0); + leaf(&mut doc); + let tiled = instance(&mut doc, "Leaf", Affine::IDENTITY); + doc.patterns.push(vectorcraft_doc::PatternDef::new("Tiles", vec![Arc::new(tiled)])); + let fill = Appearance::basic(Paint::Pattern { pattern: "Tiles".into(), xf: Affine::IDENTITY }, Paint::None, 0.0); + let filled = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), fill); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, filled).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART]); + let back = read_back(&doc); + let tile = &back.patterns.iter().find(|p| p.name == "Tiles").unwrap().art; + assert_eq!(tile.len(), 1); + assert_eq!(instances(&tile[0]), 0, "{:?}", tile[0].kind); + let NodeKind::Group { children, .. } = &tile[0].kind else { panic!("{:?}", tile[0].kind) }; + assert_eq!(paints_of_nodes(&back, children), paints_of_nodes(&back, &[Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF))])); + // A symbol holding an instance of itself: defined once, that instance as its art. + let mut doc = Document::new(200.0, 100.0); + let looped_art = { + let me = instance(&mut doc, "Loop", Affine::translate((20.0, 0.0))); + let dot = square(&mut doc, Rect::new(0.0, 0.0, 5.0, 5.0), LEAF); + group(&mut doc, vec![dot, me], false) + }; + doc.symbols.push(Symbol { dynamic: false, name: "Loop".into(), art: Arc::new(looped_art) }); + let looped = instance(&mut doc, "Loop", Affine::translate((100.0, 10.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, looped).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART]); + let p = program(&doc); + assert_eq!(p.matches("%AI14_BeginSymbol\n(Loop)\n").count(), 1, "{p}"); + assert_eq!(kinds(&read_back(&doc)), ["instance"]); + // Appearance changes of a dynamic symbol's instance. + let mut doc = Document::new(200.0, 100.0); + leaf(&mut doc); + doc.symbols[0].dynamic = true; + let mut changed = instance(&mut doc, "Leaf", Affine::translate((50.0, 10.0))); + changed.extras_mut().symbol_overrides = + vec![vectorcraft_doc::SymbolOverride { fill: Some(solid(Color::rgb(0.0, 1.0, 0.0))), ..Default::default() }]; + let plain = instance(&mut doc, "Leaf", Affine::translate((80.0, 10.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, changed).unwrap(); + doc.insert(Some(layer), usize::MAX, plain).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART, writer::SYMBOL_OPTIONS]); + let back = read_back(&doc); + assert_eq!(kinds(&back), ["art", "instance"]); + let green = back.layers[0].children().unwrap()[0].clone(); + let drawn = paints_of_nodes(&back, &[green]); + assert!(drawn.iter().any(|l| l.contains("rgb 0.000 1.000 0.000")), "the change is drawn: {drawn:?}"); + // Art cut to a clipping object that isn't a path. + let mut doc = Document::new(200.0, 100.0); + leaf(&mut doc); + let pixels = png_of(image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(2, 2, image::Rgba([0, 0, 255, 255])))); + let frame = image_node(&mut doc, ImageBlob::new("image/png", pixels), (2, 2), Affine::new([20.0, 0.0, 0.0, 20.0, 40.0, 0.0]), None); + let cut = instance(&mut doc, "Leaf", Affine::translate((45.0, 5.0))); + let clipped = group(&mut doc, vec![frame, cut], true); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, clipped).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::SYMBOL_ART), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(back.layers.iter().map(|l| instances(l)).sum::(), 0); + assert_eq!(back.symbols.len(), 1); +} + +/// Symbols whose art paints with a pattern, directly, through a nested symbol, in an opacity mask +/// and through an inline graphic: their definitions hold the pattern fills as tiles cut to the shape (the reader keeps no +/// symbol whose art paints with a pattern), and their instances are written as their art, whose +/// pattern fills keep the document's placement as the canvas draws them, each with a warning. An +/// instance of a symbol without pattern paint stays an instance. +#[test] +fn symbols_with_pattern_fills_are_defined_with_tiles_and_placed_as_art() { + let mut doc = Document::new(200.0, 100.0); + // Tiles that cover the shape, so its tiles cut to it draw its box. + let dot = Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 8.0, 8.0), Color::BLACK)); + let mut dots = vectorcraft_doc::PatternDef::new("Dots", vec![dot]); + dots.tile = Rect::new(0.0, 0.0, 8.0, 8.0); + doc.patterns.push(dots); + let fill = || Appearance::basic(Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }, Paint::None, 0.0); + let patch = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 16.0, 16.0)), fill()); + doc.symbols.push(Symbol { dynamic: false, name: "Patch".into(), art: Arc::new(patch) }); + let inner = instance(&mut doc, "Patch", Affine::translate((20.0, 0.0))); + let edge = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + let outer = group(&mut doc, vec![edge, inner], false); + doc.symbols.push(Symbol { dynamic: false, name: "Outer".into(), art: Arc::new(outer) }); + let mask_art = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 12.0, 12.0)), fill()); + let mut masked = square(&mut doc, Rect::new(0.0, 0.0, 12.0, 12.0), LEAF); + masked.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(mask_art), clip: true, invert: false, disabled: false, linked: true })); + doc.symbols.push(Symbol { dynamic: false, name: "Masked".into(), art: Arc::new(masked) }); + let plain = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Plain".into(), art: Arc::new(plain) }); + // Type holding `Patch` as an inline graphic. + let style = vectorcraft_doc::CharStyle { size: 18.0, fill: solid(Color::BLACK), ..vectorcraft_doc::CharStyle::default() }; + let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(0.0, 20.0), "A", style.clone()); + t.runs.push(vectorcraft_doc::TextRun::inline(vectorcraft_doc::InlineArt::new("Patch"), style)); + let typed = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + doc.symbols.push(Symbol { dynamic: false, name: "Typed".into(), art: Arc::new(typed) }); + let layer = doc.layers[0].id; + // Two instances of `Patch` a part of a tile apart. + for (name, x) in [("Patch", 10.0), ("Patch", 33.0), ("Outer", 60.0), ("Masked", 120.0), ("Plain", 140.0), ("Typed", 10.0)] { + let one = instance(&mut doc, name, Affine::translate((x, 10.0))); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + let filled = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(150.0, 10.0, 182.0, 42.0)), fill()); + doc.insert(Some(layer), usize::MAX, filled).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::SYMBOL_ART) && out.warnings.iter().any(|w| w == writer::PATTERNS), "{:?}", out.warnings); + let back = read_back(&doc); + let names: Vec<&str> = back.symbols.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, ["Patch", "Outer", "Masked", "Plain", "Typed"]); + let patch = &back.symbols[0].art; + assert!(matches!(patch.kind, NodeKind::Group { .. }), "the symbol's art is its tiles: {:?}", patch.kind); + assert_eq!(instances(&back.symbols[1].art), 0, "the nested instance is its art"); + let kids = back.layers[0].children().unwrap(); + let kinds: Vec = kids.iter().map(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })).collect(); + assert_eq!(kinds, [false, false, false, false, true, false, false]); + // The pattern fills of both `Patch` instances and of the layers' path keep one placement. + let placements = |n: &Node| { + let mut out = vec![]; + vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| { + for it in m.appearance.items.iter() { + if let Paint::Pattern { xf, .. } = it.paint() { + out.push(xf.as_coeffs().map(r3)); + } + } + }); + out + }; + let layer_fill = placements(&kids[6]); + assert_eq!(layer_fill.len(), 1); + assert_eq!(placements(&kids[0]), layer_fill); + assert_eq!(placements(&kids[1]), layer_fill); + // Each instance draws where it did. + let boxes = |d: &Document| -> Vec { + let b = d.symbol_boxes(); + let ids: Vec<_> = d.layers[0].children().unwrap().iter().take(5).map(|n| n.id).collect(); + ids.iter().map(|id| format!("{:?}", d.bounds_of_with(&[*id], true, &b).map(|r| [r.x0, r.y0, r.x1, r.y1].map(r3)))).collect() + }; + same_lines(&boxes(&back), &boxes(&doc)); +} + +/// A symbol whose art paints with a pattern only through a brushed stroke, whose brush's art is +/// filled with a pattern: the definition holds the tiles, and its instance is written as its art. +#[test] +fn symbols_painting_patterns_through_brush_art_are_placed_as_art() { + let mut doc = Document::new(200.0, 100.0); + let dot = Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 8.0, 8.0), Color::BLACK)); + let mut dots = vectorcraft_doc::PatternDef::new("Dots", vec![dot]); + dots.tile = Rect::new(0.0, 0.0, 8.0, 8.0); + doc.patterns.push(dots); + let fill = Appearance::basic(Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }, Paint::None, 0.0); + let art = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 8.0)), fill); + let brush = vectorcraft_brush::Brush { + name: "Dotted".into(), + kind: vectorcraft_brush::BrushKind::Art(vectorcraft_brush::ArtBrush { + art, + colorization: vectorcraft_brush::Colorization::None, + ..Default::default() + }), + }; + vectorcraft_brush::store(&mut doc, &[brush]); + let mut line = + Node::path(doc.alloc_id(), shapes::line((0.0, 0.0).into(), (60.0, 0.0).into()), Appearance::basic(Paint::None, solid(Color::BLACK), 4.0)); + line.appearance.stroke_mut().unwrap().brush = Some("Dotted".into()); + doc.symbols.push(Symbol { dynamic: false, name: "Brushed".into(), art: Arc::new(line) }); + let layer = doc.layers[0].id; + let one = instance(&mut doc, "Brushed", Affine::translate((10.0, 40.0))); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::SYMBOL_ART), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(back.symbols.iter().map(|s| s.name.as_str()).collect::>(), ["Brushed"]); + assert_eq!(instances(&back.layers[0]), 0, "the instance is its art"); +} + +/// A symbol whose stroke is written as its outline (an outside stroke): a scaled instance, a scaled +/// instance of a symbol holding it and a turned and mirrored instance are written as their art, +/// which keeps the stroke the canvas draws; a moved instance stays an instance. +#[test] +fn scaled_instances_of_symbols_with_outlined_strokes_are_written_as_their_art() { + let mut doc = Document::new(200.0, 100.0); + let mut look = Appearance::basic(Paint::None, solid(Color::BLACK), 4.0); + look.stroke_mut().unwrap().align = vectorcraft_doc::StrokeAlign::Outside; + let framed = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), look); + doc.symbols.push(Symbol { dynamic: false, name: "Framed".into(), art: Arc::new(framed) }); + let inner = instance(&mut doc, "Framed", Affine::IDENTITY); + let edge = square(&mut doc, Rect::new(20.0, 0.0, 30.0, 10.0), LEAF); + let outer = group(&mut doc, vec![inner, edge], false); + doc.symbols.push(Symbol { dynamic: false, name: "Outer".into(), art: Arc::new(outer) }); + let layer = doc.layers[0].id; + let turned = Affine::translate((150.0, 20.0)) * Affine::rotate(0.5) * Affine::new([-1.0, 0.0, 0.0, 1.0, 0.0, 0.0]); + for (name, xf) in [ + ("Framed", Affine::translate((40.0, 40.0)) * Affine::scale(2.0)), + ("Outer", Affine::translate((80.0, 10.0)) * Affine::scale(2.0)), + ("Framed", turned), + ("Framed", Affine::translate((170.0, 60.0))), + ] { + let one = instance(&mut doc, name, xf); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::SYMBOL_ART), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(back.symbols.iter().map(|s| s.name.as_str()).collect::>(), ["Framed", "Outer"]); + let kids = back.layers[0].children().unwrap(); + let kinds: Vec = kids.iter().map(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })).collect(); + assert_eq!(kinds, [false, false, false, true]); + // Each draws where it did, with the stroke the canvas draws: 4 pt wide whatever the scale. + let boxes = |d: &Document| -> Vec { + let b = d.symbol_boxes(); + let ids: Vec<_> = d.layers[0].children().unwrap().iter().map(|n| n.id).collect(); + ids.iter().map(|id| format!("{:?}", d.bounds_of_with(&[*id], true, &b).map(|r| [r.x0, r.y0, r.x1, r.y1].map(r3)))).collect() + }; + let (got, want) = (boxes(&back), boxes(&doc)); + same_lines(&[got[0].clone(), got[2].clone(), got[3].clone()], &[want[0].clone(), want[2].clone(), want[3].clone()]); + // `Outer` at twice its size from (80, 10): the frame's square is 20 pt from 80 with its 4 pt + // stroke outside, and the plain square is 40 to 60 pt in from it. + assert_eq!(got[1], format!("{:?}", Some([76.0, 6.0, 140.0, 34.0].map(r3)))); +} + +/// Scaled instances of a symbol holding a rounded rectangle with live corners, of one clipped by +/// it, of one holding type, of one holding a blend and of one holding an instance written as its +/// art (of a symbol with 9-slice guides), and instances that scale their symbol more than 100 +/// times (tiny, thin and stroked tiny objects, and a 10 pt square), are written as their art; +/// instances that scale a 10 pt square a thousandth, scale an ellipse unevenly or only move stay +/// instances. +#[test] +fn scaled_instances_of_symbols_drawn_only_at_their_own_size_are_written_as_their_art() { + let mut doc = Document::new(200.0, 100.0); + let live = vectorcraft_doc::LiveShape::Rectangle { + w: 100.0, + h: 100.0, + radii: [10.0; 4], + kinds: Default::default(), + radii_relative: false, + xf: Affine::IDENTITY, + }; + let mut rounded = Node::new( + doc.alloc_id(), + NodeKind::Path { path: live.to_path(), rule: FillRule::NonZero, live: Some(Box::new(live)), clipping: false, guide: false }, + ); + rounded.appearance = Appearance::basic(solid(LEAF), Paint::None, 0.0).into(); + // The same rounded rectangle clipping a square. + let mut frame = rounded.clone(); + frame.id = doc.alloc_id(); + let inside = square(&mut doc, Rect::new(0.0, 0.0, 100.0, 100.0), LEAF); + let clipped = group(&mut doc, vec![frame, inside], true); + doc.symbols.push(Symbol { dynamic: false, name: "Rounded".into(), art: Arc::new(rounded) }); + doc.symbols.push(Symbol { dynamic: false, name: "Clipped".into(), art: Arc::new(clipped) }); + // A tiny square with a 1 pt stroke. + let mut dot = square(&mut doc, Rect::new(0.0, 0.0, 0.00001, 0.00001), LEAF); + dot.appearance = Appearance::basic(solid(LEAF), solid(Color::BLACK), 1.0).into(); + doc.symbols.push(Symbol { dynamic: false, name: "Dot".into(), art: Arc::new(dot) }); + // A blend of two squares 20 pt apart with a step every 10 pt. + let keys = + vec![Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 2.0, 2.0), LEAF)), Arc::new(square(&mut doc, Rect::new(20.0, 0.0, 22.0, 2.0), LEAF))]; + let spec = vectorcraft_doc::BlendSpec { spacing: vectorcraft_doc::BlendSpacing::Distance(10.0), ..Default::default() }; + let blend = Node::new(doc.alloc_id(), NodeKind::Blend { children: keys, spec: Box::new(spec) }); + doc.symbols.push(Symbol { dynamic: false, name: "Steps".into(), art: Arc::new(blend) }); + // A symbol holding an instance of one with 9-slice guides. + let frame = Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 100.0, 100.0), LEAF)); + let corner = Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK)); + let sliced = Node::group(doc.alloc_id(), vec![frame, corner]); + doc.symbols.push(Symbol { dynamic: false, name: "Sliced".into(), art: Arc::new(sliced) }); + doc.set_symbol_nine_slice("Sliced", Some(vectorcraft_doc::SymbolNineSlice::default())); + let inner = instance(&mut doc, "Sliced", Affine::IDENTITY); + let holder = group(&mut doc, vec![inner], false); + doc.symbols.push(Symbol { dynamic: false, name: "Holder".into(), art: Arc::new(holder) }); + let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(0.0, 10.0), "Hi", style(10.0, Color::BLACK)); + let typed = text_node(&mut doc, t); + doc.symbols.push(Symbol { dynamic: false, name: "Typed".into(), art: Arc::new(typed) }); + let tiny = square(&mut doc, Rect::new(0.0, 0.0, 0.00001, 0.00001), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Tiny".into(), art: Arc::new(tiny) }); + let thin = square(&mut doc, Rect::new(0.0, 0.0, 0.00001, 1.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Thin".into(), art: Arc::new(thin) }); + let plain = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Plain".into(), art: Arc::new(plain) }); + let oval = vectorcraft_doc::LiveShape::Ellipse { w: 10.0, h: 6.0, pie: (0.0, 360.0), xf: Affine::IDENTITY }; + let mut ellipse = Node::new( + doc.alloc_id(), + NodeKind::Path { path: oval.to_path(), rule: FillRule::NonZero, live: Some(Box::new(oval)), clipping: false, guide: false }, + ); + ellipse.appearance = Appearance::basic(solid(LEAF), Paint::None, 0.0).into(); + doc.symbols.push(Symbol { dynamic: false, name: "Oval".into(), art: Arc::new(ellipse) }); + let layer = doc.layers[0].id; + for (name, xf) in [ + ("Rounded", Affine::new([0.3, 0.0, 0.0, 0.1, 10.0, 10.0])), + ("Typed", Affine::translate((50.0, 50.0)) * Affine::scale(3.0)), + ("Tiny", Affine::translate((100.0, 50.0)) * Affine::scale(1e6)), + ("Plain", Affine::translate((150.0, 50.0)) * Affine::scale(1e-3)), + ("Plain", Affine::translate((150.0, 60.0)) * Affine::scale(1e3)), + ("Oval", Affine::translate((170.0, 20.0)) * Affine::new([3.0, 0.0, 0.0, 1.0, 0.0, 0.0])), + ("Thin", Affine::translate((100.0, 70.0)) * Affine::new([1e6, 0.0, 0.0, 10.0, 0.0, 0.0])), + ("Clipped", Affine::new([0.3, 0.0, 0.0, 0.1, 10.0, 30.0])), + ("Dot", Affine::translate((120.0, 70.0)) * Affine::scale(1e6)), + ("Steps", Affine::translate((10.0, 80.0)) * Affine::scale(5.0)), + ("Holder", Affine::scale(2.0)), + ("Holder", Affine::translate((5.0, 5.0))), + ] { + let one = instance(&mut doc, name, xf); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::SYMBOL_ART), "{:?}", out.warnings); + let back = read_back(&doc); + let kids = back.layers[0].children().unwrap(); + let kinds: Vec = kids.iter().map(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })).collect(); + assert_eq!(kinds, [false, false, false, true, false, true, false, false, false, false, false, true]); + let b = kids[6].visual_bounds().map(|r| [r.x0, r.y0, r.x1, r.y1].map(r3)); + assert_eq!(b, Some([100.0, 70.0, 110.0, 80.0].map(r3))); + // The tiny square comes back 10 pt across, where the instance put it. + let b = kids[2].visual_bounds().map(|r| [r.x0, r.y0, r.x1, r.y1].map(r3)); + assert_eq!(b, Some([100.0, 50.0, 110.0, 60.0].map(r3))); +} + +/// A symbol whose art is a hidden path with two fills: the canvas draws a symbol's art even when +/// its root is hidden, so the definition's root is written shown and the instance still draws. +#[test] +fn a_symbol_whose_root_is_hidden_still_draws() { + let mut doc = Document::new(200.0, 100.0); + let mut look = Appearance::basic(solid(LEAF), Paint::None, 0.0); + look.items.push(AppearanceItem::Fill(FillLayer::new(solid(Color::BLACK)))); + let mut art = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), look); + art.visible = false; + doc.symbols.push(Symbol { dynamic: false, name: "Hidden".into(), art: Arc::new(art) }); + let layer = doc.layers[0].id; + let one = instance(&mut doc, "Hidden", Affine::translate((20.0, 20.0))); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + let back = read_back(&doc); + let kids = back.layers[0].children().unwrap(); + assert!(matches!(kids[0].kind, NodeKind::SymbolInstance { .. })); + let mut shown = 0; + vectorcraft_doc::swatches::walk_with_masks(&back.symbols[0].art, &mut |m| { + shown += usize::from(m.visible && matches!(m.kind, NodeKind::Path { .. })) + }); + assert!(back.symbols[0].art.visible && shown > 0, "{:?}", back.symbols[0].art); +} + +/// The number of symbol instances in `n` and its subtree. +fn instances(n: &Node) -> usize { + let mut count = 0; + vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| count += usize::from(matches!(m.kind, NodeKind::SymbolInstance { .. }))); + count +} + +/// What `nodes` draw ([`paints_of`]). +fn paints_of_nodes(doc: &Document, nodes: &[Arc]) -> Vec { + let mut out = vec![]; + paints_of(doc, nodes, 1, &mut out); + out +} + +/// An instance with isolation, knockout or knockout shape of its own is written as its art, which +/// keeps them as a group's; a plain one beside it stays an instance. +#[test] +fn instances_with_a_groups_transparency_are_written_as_their_art() { + let mut doc = Document::new(200.0, 100.0); + let art = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Leaf".into(), art: Arc::new(art) }); + let mut shape = instance(&mut doc, "Leaf", Affine::translate((10.0, 10.0))); + shape.knockout_shape = true; + let mut isolated = instance(&mut doc, "Leaf", Affine::translate((30.0, 10.0))); + isolated.isolate = true; + let mut knocks = instance(&mut doc, "Leaf", Affine::translate((50.0, 10.0))); + knocks.knockout = Knockout::On; + let plain = instance(&mut doc, "Leaf", Affine::translate((70.0, 10.0))); + let mut outer = group(&mut doc, vec![shape, isolated, knocks, plain], false); + outer.knockout = Knockout::On; + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, outer).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART]); + let back = read_back(&doc); + let kids = back.layers[0].children().unwrap()[0].children().unwrap().clone(); + let states: Vec<_> = kids.iter().map(|n| (matches!(n.kind, NodeKind::SymbolInstance { .. }), n.knockout_shape, n.isolate, n.knockout)).collect(); + assert_eq!( + states, + [ + (false, true, false, Knockout::Neutral), + (false, false, true, Knockout::Neutral), + (false, false, false, Knockout::On), + (true, false, false, Knockout::Neutral) + ] + ); + // Each group holds the symbol's art where the instance placed it. + for (i, k) in kids[..3].iter().enumerate() { + let b = k.geometric_bounds().unwrap(); + assert_eq!([b.x0, b.y0, b.x1, b.y1].map(r3), [10.0 + 20.0 * i as f64, 10.0, 20.0 + 20.0 * i as f64, 20.0].map(r3)); + } +} + +/// A symbol's definition is the same wherever its first instance lies: one first met deep in +/// opacity masks keeps its own masks and instances. +#[test] +fn a_symbol_first_used_deep_in_masks_is_defined_whole() { + let mut doc = Document::new(200.0, 100.0); + let leaf = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Leaf".into(), art: Arc::new(leaf) }); + // `Deep`: a square with an opacity mask, and an instance of `Leaf`. + let mask_art = square(&mut doc, Rect::new(0.0, 0.0, 5.0, 5.0), Color::WHITE); + let mut veiled = square(&mut doc, Rect::new(0.0, 0.0, 20.0, 20.0), Color::BLACK); + veiled.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(mask_art), clip: true, invert: false, disabled: false, linked: true })); + let inner = instance(&mut doc, "Leaf", Affine::translate((5.0, 5.0))); + let deep = group(&mut doc, vec![veiled, inner], false); + doc.symbols.push(Symbol { dynamic: false, name: "Deep".into(), art: Arc::new(deep) }); + // Its only instance, in the art of an opacity mask seven masks deep. + let mut art = instance(&mut doc, "Deep", Affine::translate((50.0, 20.0))); + for _ in 0..7 { + let mut holder = square(&mut doc, Rect::new(0.0, 0.0, 100.0, 100.0), Color::gray(0.5)); + holder.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(art), clip: false, invert: false, disabled: false, linked: true })); + art = holder; + } + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, art).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::DEEP_MASKS || w == writer::SYMBOL_ART), "{:?}", out.warnings); + let back = read_back(&doc); + same_lines(&symbol_looks(&back), &symbol_looks(&doc)); + let deep = &back.symbols.iter().find(|s| s.name == "Deep").unwrap().art; + assert_eq!(instances(deep), 1); + let mut masks = 0; + deep.walk(&mut |n| masks += usize::from(n.extras().mask.is_some())); + assert_eq!(masks, 1); +} + +/// Symbols past the 4,096 the reader keeps are left out, their instances written as their art. +#[test] +fn symbols_past_the_readers_count_are_left_out() { + let mut doc = Document::new(200.0, 100.0); + for i in 0..=crate::import::MAX_SYMBOLS { + let art = square(&mut doc, Rect::new(0.0, 0.0, 4.0, 4.0), Color::gray(0.5)); + doc.symbols.push(Symbol { dynamic: false, name: format!("S{i}"), art: Arc::new(art) }); + } + // An instance of each, in the panel's order: the last one's symbol is past the count. + let layer = doc.layers[0].id; + for i in 0..=crate::import::MAX_SYMBOLS { + let one = instance(&mut doc, &format!("S{i}"), Affine::translate((10.0, 10.0))); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(out.warnings, [writer::SYMBOL_COUNT, writer::SYMBOL_ART]); + let back = read_back(&doc); + assert_eq!(back.symbols.len(), crate::import::MAX_SYMBOLS); + assert!(back.symbols.iter().all(|s| s.name != format!("S{}", crate::import::MAX_SYMBOLS))); + let live = back.layers[0].children().unwrap().iter().map(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })).collect::>(); + assert_eq!(live.len(), crate::import::MAX_SYMBOLS + 1); + assert!(live[..crate::import::MAX_SYMBOLS].iter().all(|l| *l) && !live[crate::import::MAX_SYMBOLS]); +} + +/// With one place left under the reader's count, a symbol whose type holds an inline graphic with +/// an instance of another symbol (found only as its art is written) takes the last place, and that +/// instance is written as its art: no more definitions are written than the reader keeps. +#[test] +fn a_symbol_found_while_its_holder_is_written_keeps_within_the_readers_count() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + for i in 0..crate::import::MAX_SYMBOLS - 1 { + let art = square(&mut doc, Rect::new(0.0, 0.0, 4.0, 4.0), Color::gray(0.5)); + doc.symbols.push(Symbol { dynamic: false, name: format!("S{i}"), art: Arc::new(art) }); + let one = instance(&mut doc, &format!("S{i}"), Affine::translate((10.0, 10.0))); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + let style = vectorcraft_doc::CharStyle { size: 18.0, fill: solid(Color::BLACK), ..vectorcraft_doc::CharStyle::default() }; + let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(0.0, 20.0), "A", style.clone()); + t.runs.push(vectorcraft_doc::TextRun::inline(vectorcraft_doc::InlineArt::new("Inner"), style)); + let typed = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + doc.symbols.push(Symbol { dynamic: false, name: "Typed".into(), art: Arc::new(typed) }); + // The inline graphic's art holds an instance of a symbol of its own. + let deeper = square(&mut doc, Rect::new(0.0, 0.0, 4.0, 4.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Deeper".into(), art: Arc::new(deeper) }); + let nested = instance(&mut doc, "Deeper", Affine::translate((1.0, 1.0))); + let edge = square(&mut doc, Rect::new(0.0, 0.0, 2.0, 2.0), LEAF); + let inner = group(&mut doc, vec![edge, nested], false); + doc.symbols.push(Symbol { dynamic: false, name: "Inner".into(), art: Arc::new(inner) }); + let one = instance(&mut doc, "Typed", Affine::translate((50.0, 50.0))); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + let program = String::from_utf8_lossy(&out.program); + assert_eq!(program.matches("%AI14_BeginSymbol").count(), crate::import::MAX_SYMBOLS); + let back = read_back(&doc); + assert_eq!(back.symbols.len(), crate::import::MAX_SYMBOLS); + assert!(back.symbols.iter().any(|s| s.name == "Typed")); + assert!(matches!(back.layers[0].children().unwrap().last().unwrap().kind, NodeKind::SymbolInstance { .. })); +} + +/// A linked image in a symbol's art is embedded; a dynamic symbol and a symbol's export type are +/// reported left out. +#[test] +fn symbol_options_and_linked_images_in_symbols_are_reported() { + let mut doc = Document::new(200.0, 100.0); + let pixels = png_of(image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(2, 2, image::Rgba([0, 0, 255, 255])))); + let link = vectorcraft_doc::LinkInfo { path: "/tmp/blue.png".into(), ..Default::default() }; + let picture = image_node(&mut doc, ImageBlob::new("image/png", pixels), (2, 2), Affine::scale(5.0), Some(link)); + doc.symbols.push(Symbol { dynamic: true, name: "Picture".into(), art: Arc::new(picture) }); + let one = instance(&mut doc, "Picture", Affine::translate((50.0, 10.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, one).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(out.warnings, [writer::SYMBOL_OPTIONS, writer::LINKS_EMBEDDED]); + let p = program_shown(&doc); + assert!(p.contains("%AI5_BeginRaster") && !p.contains("%AI5_BeginPlace"), "{p}"); + let back = read_back(&doc); + let NodeKind::Image(im) = &back.symbols[0].art.kind else { panic!("the symbol's image") }; + assert!(im.link.is_none()); + doc.symbols[0].dynamic = false; + doc.set_symbol_export_type("Picture", vectorcraft_doc::SymbolExportType::Graphic); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_OPTIONS, writer::LINKS_EMBEDDED]); +} + +/// Each of the nine registration points is written 1 to 9 by its place on the grid, row by row +/// from the top left, and read back. +#[test] +fn every_registration_point_is_written_by_its_place() { + for (point, written, read) in (0..9).map(|r| (r, r + 1, r)) { + let mut doc = Document::new(200.0, 100.0); + let art = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(art) }); + doc.set_symbol_registration("Mark", vectorcraft_doc::SymbolRegistration(point)); + let p = program_shown(&doc); + assert!(p.contains(&format!("%_{written} /Int (AI13PatternRegistrationType) ,\n")), "{point}: {p}"); + assert_eq!(read_back(&doc).symbol_registration("Mark"), vectorcraft_doc::SymbolRegistration(read), "{point}"); + } +} + +/// Symbols whose art is a group with a state of its own (a name, transparency, an opacity mask, a +/// clipping group) come back with it; the registration point shares the group's dictionary. +#[test] +fn symbols_whose_art_is_a_group_with_a_state_come_back() { + let mut doc = one_swatch_document(200.0, 100.0); + let pair = |doc: &mut Document| { + let a = square(doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + let b = square(doc, Rect::new(5.0, 5.0, 15.0, 15.0), Color::rgb(1.0, 0.0, 0.0)); + vec![a, b] + }; + let kids = pair(&mut doc); + let labeled = named(group(&mut doc, kids, false), "Label"); + let kids = pair(&mut doc); + let faded = see_through(group(&mut doc, kids, false), 0.5, BlendMode::Multiply); + let kids = pair(&mut doc); + let mut masked = group(&mut doc, kids, false); + let mask_art = square(&mut doc, Rect::new(0.0, 0.0, 8.0, 8.0), Color::WHITE); + masked.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(mask_art), clip: true, invert: true, disabled: false, linked: true })); + let edge = clipping(&mut doc, shapes::ellipse(Rect::new(0.0, 0.0, 12.0, 12.0))); + let inside = square(&mut doc, Rect::new(0.0, 0.0, 20.0, 20.0), Color::rgb(0.0, 0.0, 1.0)); + let window = group(&mut doc, vec![edge, inside], true); + let lone = square(&mut doc, Rect::new(0.0, 0.0, 6.0, 6.0), Color::gray(0.3)); + let lone_named = named(group(&mut doc, vec![lone], false), "Lone"); + for (name, art) in [("Labeled", labeled), ("Faded", faded), ("Masked", masked), ("Window", window), ("Lone", lone_named)] { + doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); + let one = instance(&mut doc, name, Affine::translate((20.0, 20.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + doc.set_symbol_registration("Labeled", vectorcraft_doc::SymbolRegistration(2)); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let back = read_back(&doc); + same_lines(&symbol_looks(&back), &symbol_looks(&doc)); + same_lines(&all_looks(&back), &all_looks(&doc)); + assert_eq!(export_ai_editing(&back, &opts()).unwrap().program, out.program); + let p = program_shown(&doc); + // The name and the registration point in one dictionary, the group not wrapped again. + assert!(p.contains("%AI14_BeginSymbol\n(Labeled)\n0 A\n0 Xw\n0 Ae\nu\n0.6 0 0.9 0.1 k\n"), "{p}"); + assert!(p.contains("%_/ArtDictionary :\n%_3 /Int (AI13PatternRegistrationType) ,\n%_/XMLUID : (Label) ; (AI10_ArtUID) ,\n%_;\n"), "{p}"); +} + +/// The names of symbols in every form a string takes: parentheses, a backslash, a slash, text +/// outside ASCII, an empty name (which the reader names `Symbol`); two symbols of one name keep the +/// first; an instance of a symbol the document doesn't have is an empty group, with a warning that +/// says so. +#[test] +fn symbol_names_and_missing_symbols() { + let mut doc = Document::new(200.0, 100.0); + let names = ["Paren (a)", "Back\\slash", "Path/To", "Hoja ñ 葉", "", "Twice"]; + for (i, name) in names.iter().chain(["Twice"].iter()).enumerate() { + let art = square(&mut doc, Rect::new(0.0, 0.0, 4.0 + i as f64, 4.0), Color::gray(0.5)); + doc.symbols.push(Symbol { dynamic: false, name: (*name).into(), art: Arc::new(art) }); + } + let layer = doc.layers[0].id; + for name in names { + let one = instance(&mut doc, name, Affine::translate((10.0, 10.0))); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let back = read_back(&doc); + let names = names.map(|n| if n.is_empty() { "Symbol" } else { n }); + assert_eq!(back.symbols.iter().map(|s| s.name.as_str()).collect::>(), names); + let refs: Vec = back.layers[0] + .children() + .unwrap() + .iter() + .map(|n| match &n.kind { + NodeKind::SymbolInstance { symbol, .. } => symbol.clone(), + other => panic!("{other:?}"), + }) + .collect(); + assert_eq!(refs, names); + // The first symbol of a name is the one defined. + let twice = &back.symbols.iter().find(|s| s.name == "Twice").unwrap().art; + assert_eq!(paints_of_nodes(&back, std::slice::from_ref(twice)), paints_of_nodes(&doc, std::slice::from_ref(&doc.symbols[5].art))); + // A missing symbol. + let lost = instance(&mut doc, "Gone", Affine::translate((40.0, 10.0))); + doc.insert(Some(layer), usize::MAX, lost).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::MISSING_SYMBOL]); + let back = read_back(&doc); + let last = back.layers[0].children().unwrap().last().unwrap().clone(); + assert!(matches!(&last.kind, NodeKind::Group { children, clip: false } if children.is_empty()), "{:?}", last.kind); +} + +/// On a large canvas, a symbol's art holding a gradient and a nested instance is defined at its own +/// size and comes back as it was. +#[test] +fn a_symbol_with_a_gradient_and_an_instance_on_a_large_canvas_comes_back() { + use vectorcraft_color::gradient::GradientGeom; + let mut doc = Document::new(20000.0, 100.0); + let leaf = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Leaf".into(), art: Arc::new(leaf) }); + let geom = GradientGeom { start: (0.0, 0.0).into(), end: (40.0, 10.0).into(), aspect: 1.0, focal: None }; + let shaded = painted( + &mut doc, + shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0)), + vec![AppearanceItem::Fill(FillLayer::new(gradient_paint(sunset(), Some(geom), None)))], + ); + let nested = instance(&mut doc, "Leaf", Affine::translate((25.0, 5.0)) * Affine::rotate(0.4)); + let both = group(&mut doc, vec![shaded, nested], false); + doc.symbols.push(Symbol { dynamic: false, name: "Both".into(), art: Arc::new(both) }); + let one = instance(&mut doc, "Both", Affine::translate((100.0, 10.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, one).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART]); + let back = read_back(&doc); + same_lines(&symbol_looks(&back), &symbol_looks(&doc)); +} + +/// Instance matrices of every kind (turned, sheared, mirrored, scaled near the smallest area the +/// data keeps), nested in one another, are read back as written, and written again as the same +/// bytes. The matrices come from a fixed seed. +#[test] +fn generated_instance_matrices_come_back() { + let mut seed = 0x2545_f491_4f6c_dd1du64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed >> 11) as f64 / (1u64 << 53) as f64 + }; + for round in 0..40 { + let mut doc = one_swatch_document(300.0, 300.0); + let base = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "S0".into(), art: Arc::new(base) }); + let matrix = |next: &mut dyn FnMut() -> f64| { + // Four nested matrices together scale by at most 64, within the 100 an instance may. + let scale = 10f64.powf(next() * 3.0 - 3.0); + Affine::new([ + (next() * 4.0 - 2.0) * scale, + (next() * 4.0 - 2.0) * scale, + (next() * 4.0 - 2.0) * scale, + (next() * 4.0 - 2.0) * scale, + next() * 300.0, + next() * 300.0, + ]) + }; + // A chain of symbols, each holding an instance of the one before. + for level in 1..4 { + let xf = matrix(&mut next); + let inner = instance(&mut doc, &format!("S{}", level - 1), xf); + let dot = square(&mut doc, Rect::new(0.0, 0.0, 3.0, 3.0), Color::gray(0.4)); + let art = group(&mut doc, vec![dot, inner], false); + doc.symbols.push(Symbol { dynamic: false, name: format!("S{level}"), art: Arc::new(art) }); + } + let layer = doc.layers[0].id; + for _ in 0..6 { + let pick = format!("S{}", (next() * 4.0) as usize % 4); + let xf = matrix(&mut next); + let one = instance(&mut doc, &pick, xf); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + let back = read_back(&doc); + // Each instance written as one comes back with its matrix to ten decimals. + let written = |d: &Document| { + let mut all = vec![]; + for n in d.layers[0].children().unwrap().iter().chain(d.symbols.iter().map(|s| &s.art)) { + n.walk(&mut |m| { + if let NodeKind::SymbolInstance { symbol, xf } = &m.kind { + all.push((symbol.clone(), *xf)); + } + }); + } + all + }; + assert!(out.warnings.is_empty(), "round {round}: {:?}", out.warnings); + let (got, want) = (written(&back), written(&doc)); + assert_eq!(got.len(), want.len(), "round {round}"); + for (g, w) in got.iter().zip(&want) { + assert_eq!(g.0, w.0, "round {round}"); + for (a, b) in g.1.as_coeffs().iter().zip(w.1.as_coeffs()) { + assert!((a - b).abs() <= 1e-9 * b.abs().max(1.0), "round {round}: {:?} {:?}", g.1, w.1); + } + } + let again = export_ai_editing(&back, &opts()).unwrap().program; + same_lines(&shown(&again).lines().map(String::from).collect::>(), &shown(&out.program).lines().map(String::from).collect::>()); + assert_eq!(again, out.program, "round {round}"); + } + // About the smallest area the data keeps: an instance whose matrix keeps it stays one, one + // whose matrix (to ten decimals) loses it is written as its art. + for (xf, live) in [ + (Affine::new([1e-6, 0.0, 0.0, 1.1e-6, 50.0, 10.0]), true), + (Affine::new([1e-6, 0.0, 0.0, 0.9e-6, 50.0, 10.0]), false), + (Affine::new([1.0, 0.4, 2.5, 1.0000000001, 50.0, 10.0]), true), + (Affine::new([4e-11, 0.0, 0.0, 1e6, 50.0, 10.0]), false), + ] { + let mut doc = Document::new(200.0, 100.0); + let base = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "S0".into(), art: Arc::new(base) }); + let one = instance(&mut doc, "S0", xf); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, one).unwrap(); + let back = read_back(&doc); + assert_eq!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::SymbolInstance { .. }), live, "{xf:?}"); + } +} + +/// Type in a symbol's art is written as its outlines (the reader reads no type there), so the +/// symbol and its instance come back; type on the layers stays type. +#[test] +fn type_in_a_symbols_art_is_written_as_its_outlines() { + let mut doc = Document::new(200.0, 100.0); + let label = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(0.0, 20.0), "Tag", style(18.0, Color::BLACK)); + let label = text_node(&mut doc, label); + doc.symbols.push(Symbol { dynamic: false, name: "Tag".into(), art: Arc::new(label) }); + let one = instance(&mut doc, "Tag", Affine::translate((20.0, 20.0))); + let plain = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 90.0), "Plain", style(18.0, Color::BLACK)); + let plain = text_node(&mut doc, plain); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, one).unwrap(); + doc.insert(Some(layer), usize::MAX, plain).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::TYPE]); + assert_eq!(program(&doc).matches("/AI11Text :").count(), 1); + let back = read_back(&doc); + assert_eq!(back.symbols.len(), 1); + assert!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::SymbolInstance { .. })); +} diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 37b8868ec..14fa89fe6 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -17,6 +17,7 @@ use crate::ps; mod images; mod paints; +mod symbols; mod texts; pub(super) use images::PAYLOAD; @@ -25,6 +26,8 @@ pub(super) use images::{IMAGE_DEPTH, IMAGES, LINKS_EMBEDDED}; #[cfg(test)] pub(super) use paints::{GRADIENT_MIX, GRADIENT_SHAPES, GRADIENTS, STOP_LINKS, SWATCH_PLACEMENT, free_gradient_name}; #[cfg(test)] +pub(super) use symbols::{MISSING_SYMBOL, SYMBOL_ART, SYMBOL_COUNT, SYMBOL_OPTIONS}; +#[cfg(test)] pub(super) use texts::TYPE_ATTRIBUTES; /// How deep symbols, live objects and pattern tiles may nest in one another (deeper art is left @@ -32,14 +35,14 @@ pub(super) use texts::TYPE_ATTRIBUTES; /// [`vectorcraft_doc::MAX_DEPTH`]. const MAX_NEST: u32 = 8; -pub(super) const TYPE: &str = "type in opacity masks, pattern tiles and art cut to a clipping object, mirrored type, area type and type on a path that is turned or unevenly scaled or whose frame or path has several parts, area type in an open frame, type on an open path whose ends meet, area type that flows around a text wrap, type of only white space, threads of type on a path, threads whose frames differ in orientation or hold a frame written as outlines, type of sizes past what the reader reads, stories past what the reader reads (1,048,576 bytes a story, 16,777,216 bytes in all, and the size of text document it reads), and type with inline graphics, ruby, gradient or pattern paint, or fills, strokes, effects or an opacity mask of the object's own, is written as its outlines in the editing data for other apps"; -pub(super) const PATTERNS: &str = "pattern fills whose tiles aren't a grid or whose art reaches past the tile, pattern fills in another pattern's tile and pattern fills nested more than 8 deep are written as their tiles cut to the shape in the editing data for other apps"; +pub(super) const TYPE: &str = "type in opacity masks, pattern tiles, symbols and art cut to a clipping object, mirrored type, area type and type on a path that is turned or unevenly scaled or whose frame or path has several parts, area type in an open frame, type on an open path whose ends meet, area type that flows around a text wrap, type of only white space, threads of type on a path, threads whose frames differ in orientation or hold a frame written as outlines, type of sizes past what the reader reads, stories past what the reader reads (1,048,576 bytes a story, 16,777,216 bytes in all, and the size of text document it reads), and type with inline graphics, ruby, gradient or pattern paint, or fills, strokes, effects or an opacity mask of the object's own, is written as its outlines in the editing data for other apps"; +pub(super) const PATTERNS: &str = "pattern fills whose tiles aren't a grid or whose art reaches past the tile, pattern fills in another pattern's tile or in a symbol, and pattern fills nested more than 8 deep 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 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 LIVE: &str = - "symbols, blends, envelopes, meshes, repeats, placed documents and intertwines are written as their art in the editing data for other apps"; + "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 ITEM_TRANSPARENCY: &str = "the opacity and blending modes of single fills and strokes are left out of the editing data for other apps"; @@ -162,6 +165,49 @@ pub(super) struct Writer<'a> { hidden: u32, /// The text document: the stories of the type written as text. pub(super) texts: texts::TextDoc, + /// Symbol instances on the layers may be written as instances ([`Self::live_instance`]); false + /// on a large canvas, where they are written as their art. + pub(super) symbols_live: bool, + /// The document symbols are defined from: on a large canvas, the document at its own size + /// (symbols' art is in its own space, which the canvas's scale doesn't change). + pub(super) symbol_doc: &'a Document, + /// A symbol's art is being written ([`Self::define_symbol`]). + in_symbol: bool, + /// The document → the space an instance's `RTransformMatrix` takes its symbol's art to: art + /// space, and in a symbol's art the canvas's turned y up about art space's origin there. + instance_space: Affine, + /// The symbol definitions written (`%AI14_BeginSymbol` … `%AI10_EndSymbol`), each after those + /// its art uses, and their names. + symbol_defs: Vec, + defined_symbols: HashSet, + /// The symbols whose definitions are being written. + defining_symbols: Vec, + /// Whether each symbol's own art paints with a pattern, and the symbols its instances use + /// ([`Self::symbol_own_patterns`]). + pattern_symbols: HashMap)>, + /// Whether each symbol's art paints with a pattern, nested symbols included + /// ([`Self::symbol_paints_pattern`]). + patterned_symbols: HashMap, + /// Pattern tiles were written in the art being written ([`Self::define_symbol`] starts it over + /// for each symbol's art). + wrote_pattern: bool, + /// The symbols whose definitions hold pattern tiles: a brushed stroke's brush art or a live + /// object's art can paint with a pattern that the symbol's own objects don't name. + pattern_written: HashSet, + /// Art that draws as it should only where it is was written in the art being written + /// ([`Self::define_symbol`] starts it over for each symbol's art): a stroke as its outline or its + /// brush's art, type as its outlines, a live object or a symbol instance as its art, or a shape + /// with rounded live corners. + wrote_fixed: bool, + /// The symbols whose definitions hold such art, nested symbols' included + /// ([`Self::keeps_scale`]). + fixed_symbols: HashSet, + /// The largest scale a live instance in the art being written applies, nested symbols' scales + /// included, and that of each symbol's definition. + inner_scale: f64, + symbol_scales: HashMap, + /// The entries a symbol's dictionary adds to its art's dictionary, and the art's id. + root_entries: Option<(vectorcraft_doc::NodeId, String)>, } impl<'a> Writer<'a> { @@ -195,6 +241,22 @@ impl<'a> Writer<'a> { next: HashMap::new(), brushes: None, symbol_nodes: 0, + symbols_live: true, + symbol_doc: doc, + in_symbol: false, + instance_space: to_art, + symbol_defs: vec![], + defined_symbols: HashSet::new(), + defining_symbols: vec![], + pattern_symbols: HashMap::new(), + patterned_symbols: HashMap::new(), + wrote_pattern: false, + pattern_written: HashSet::new(), + wrote_fixed: false, + fixed_symbols: HashSet::new(), + inner_scale: 1.0, + symbol_scales: HashMap::new(), + root_entries: None, } } @@ -394,6 +456,9 @@ impl<'a> Writer<'a> { if let NodeKind::Layer { .. } = n.kind { return self.write_layer(n, &[]); } + if self.in_symbol { + self.wrote_fixed |= symbols::redrawn(n); + } self.effects(n); self.state(n.locked, !n.visible); let xy = Xy::of(n); @@ -422,10 +487,18 @@ impl<'a> Writer<'a> { NodeKind::Compound { rule, .. } => self.shape(n, &compound_path(n), *rule, xy), NodeKind::Text(_) => self.text(n, xy), NodeKind::Image(im) => self.image(im, xy), + NodeKind::SymbolInstance { symbol, xf } if self.live_instance(n, symbol, *xf) => self.symbol_instance(symbol, *xf, xy), NodeKind::SymbolInstance { symbol, xf } => { - self.warn(LIVE); - self.line("u"); + // The canvas places an instance inside a symbol's art where the outer instance puts + // it (9-slice guides, stains and patterns included), and this art doesn't move with it. + self.wrote_fixed |= self.in_symbol; let doc = self.doc; + if doc.symbols.iter().any(|s| &s.name == symbol) { + self.warn(symbols::SYMBOL_ART); + } else { + self.warn(symbols::MISSING_SYMBOL); + } + self.line("u"); if let Some(sym) = doc.symbols.iter().find(|s| &s.name == symbol) && self.nest < MAX_NEST { @@ -492,7 +565,8 @@ impl<'a> Writer<'a> { } /// `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`]). + /// or an opacity mask (`AI9_Mask`, see [`Self::mask`]), or is a symbol's art (its symbol's + /// entries first, see [`Self::define_symbol`]). 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(); @@ -501,10 +575,17 @@ impl<'a> Writer<'a> { self.warn(DEEP_MASKS); mask = None; } - if name.is_none() && mask.is_none() { + let entries = match &self.root_entries { + Some((id, _)) if *id == n.id => self.root_entries.take().map(|(_, e)| e), + _ => None, + }; + if name.is_none() && mask.is_none() && entries.is_none() { return; } self.body.push_str("%_/ArtDictionary :\n"); + if let Some(e) = entries { + self.body.push_str(&e); + } if let Some(name) = name { let id = self.take_id(name); let _ = writeln!(self.body, "%_/XMLUID : {} ; (AI10_ArtUID) ,", ps::bytes(id.as_bytes())); @@ -751,6 +832,7 @@ impl<'a> Writer<'a> { AppearanceItem::Stroke(s) if self.direct_stroke(s) => self.path_object(path, rule, None, Some(s), xy), AppearanceItem::Stroke(s) => { self.warn(STROKES); + self.wrote_fixed = true; if self.brush_art(path, s, xy) { return; } @@ -812,6 +894,7 @@ impl<'a> Writer<'a> { let flat = FillLayer { paint: Paint::solid(Color::gray(0.5)), ..f.clone() }; return self.path_object(path, rule, Some(&flat), None, xy); } + self.wrote_pattern = true; let outer = self.clip.clone(); let region = self.cut(path, rule); self.clip = Some(region); @@ -850,6 +933,7 @@ impl<'a> Writer<'a> { self.plain_object(®ion, FillRule::NonZero, fill, None, own); } if let Some(s) = stroke { + self.wrote_fixed = true; 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()) }; @@ -913,13 +997,13 @@ impl<'a> Writer<'a> { // ---------- type ---------- /// Type `n`: a text object stub naming its story in the text document ([`texts`]) when the - /// text document can hold it, outside opacity masks, pattern tiles and art cut to a clipping - /// object; else its outlines ([`Self::outlined_text`]). A frame of threaded area type names the - /// story of its thread, which its first frame holds. + /// text document can hold it, outside opacity masks, pattern tiles, symbols' art and art cut to + /// a clipping object; else its outlines ([`Self::outlined_text`]). A frame of threaded area type + /// names the story of its thread, which its first frame holds. fn text(&mut self, n: &Node, xy: Xy) { let NodeKind::Text(t) = &n.kind else { return }; let doc = self.doc; - let placed = if self.hidden > 0 || self.clip.is_some() { + let placed = if self.hidden > 0 || self.clip.is_some() || self.in_symbol { None } else if let Some(thread) = doc.text_threads.iter().find(|th| th.contains(&n.id)) { // A thread is checked for what it loses, in every frame, when its story is written. diff --git a/crates/eps/src/ai_editing/writer/images.rs b/crates/eps/src/ai_editing/writer/images.rs index d8bb4b16d..31acb1dd8 100644 --- a/crates/eps/src/ai_editing/writer/images.rs +++ b/crates/eps/src/ai_editing/writer/images.rs @@ -13,7 +13,7 @@ use crate::ps; pub(in crate::ai_editing) const IMAGES: &str = "images that can't be decoded or have more than 33,554,432 pixels are written as their outlines filled mid gray in the editing data for other apps"; pub(in crate::ai_editing) const IMAGE_DEPTH: &str = "images of 16 bits a channel are written at 8 bits a channel in the editing data for other apps"; pub(in crate::ai_editing) const LINKS_EMBEDDED: &str = - "linked images in opacity masks and pattern tiles are written as embedded images in the editing data for other apps"; + "linked images in opacity masks, pattern tiles and symbols are written as embedded images in the editing data for other apps"; /// The most pixels an image is written with: as many as the reader reads. pub(super) const MAX_PIXELS: u64 = 1 << 25; @@ -94,7 +94,7 @@ impl Samples { impl Writer<'_> { /// Image object `im` of transparency `xy`: embedded, a raster ([`Self::raster`]); linked, a /// placed file with a preview ([`Self::placed_file`]), except in a part of the data written on - /// `%_` lines. One whose pixels can't be read is its outline filled mid gray. Where the art is + /// `%_` lines and in a symbol's art. One whose pixels can't be read is its outline filled mid gray. Where the art is /// cut to a clipping path, the image is in a clipping group of that path. pub(super) fn image(&mut self, im: &ImageObject, xy: Xy) { let doc = self.doc; @@ -120,7 +120,7 @@ impl Writer<'_> { let fit = Affine::scale_non_uniform(f64::from(im.width.max(1)) / f64::from(samples.width), f64::from(im.height.max(1)) / f64::from(samples.height)); match &im.link { - Some(link) if self.hidden == 0 => self.placed_file(link, &samples, (im.width.max(1), im.height.max(1)), im.xf), + Some(link) if self.hidden == 0 && !self.in_symbol => self.placed_file(link, &samples, (im.width.max(1), im.height.max(1)), im.xf), other => { if other.is_some() { self.warn(LINKS_EMBEDDED); @@ -154,9 +154,12 @@ impl Writer<'_> { /// `[matrix] 0 0 w h w h 8 type alpha reserved 1 0 4 4 0 0`, `%%BeginData:` counting `XI`, its /// line end and the color samples, `XI`, the color samples then the alpha channel in binary, /// `%%EndData`, `XH`, `%AI5_EndRaster`, `N`. The matrix is an art-space matrix applied to the - /// pixels with their rows running downwards, `(u, -v)`, as §8.1's image matrix. + /// pixels with their rows running downwards, `(u, -v)`, as §8.1's image matrix; in a symbol's + /// art (outside a pattern's tile in it), a matrix in the canvas's space turned y up about art + /// space's origin there, as an instance's matrix in it is. fn raster(&mut self, s: &Samples, xf: Affine) { - let m = matrix(self.to_art * xf * Affine::FLIP_Y); + let space = if self.in_symbol && self.defining.is_empty() { self.instance_space } else { self.to_art }; + let m = matrix(space * xf * Affine::FLIP_Y); let (w, h) = (s.width, s.height); let alpha = u8::from(s.alpha.is_some()); let mut payload = Vec::with_capacity(2 + s.color.len() + s.alpha.as_ref().map_or(0, Vec::len)); @@ -233,7 +236,7 @@ fn matrix(m: Affine) -> String { } /// `v` rounded to ten decimals, without trailing zeros (0 when not finite). -fn precise(v: f64) -> String { +pub(super) fn precise(v: f64) -> String { let r = (v * 1e10).round() / 1e10; if !r.is_finite() { return if v.is_finite() { format!("{}", v + 0.0) } else { "0".into() }; diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs index d805d9d0e..a29ab3df9 100644 --- a/crates/eps/src/ai_editing/writer/paints.rs +++ b/crates/eps/src/ai_editing/writer/paints.rs @@ -341,9 +341,10 @@ impl Writer<'_> { /// Can pattern `name` be written as a definition and painted with from here: the document has /// it, its tiles make a grid, its art is within its tile and holds something, and it isn't used - /// in another pattern's tile or nested more than [`MAX_NEST`] deep? + /// in another pattern's tile or a symbol's art (the reader keeps no symbol whose art paints with + /// a pattern) or nested more than [`MAX_NEST`] deep? pub(super) fn pattern_writable(&self, name: &str) -> bool { - if !self.defining.is_empty() || self.nest >= MAX_NEST { + if !self.defining.is_empty() || self.nest >= MAX_NEST || self.in_symbol { return false; } if self.patterns.iter().any(|p| p.name == name) { diff --git a/crates/eps/src/ai_editing/writer/symbols.rs b/crates/eps/src/ai_editing/writer/symbols.rs new file mode 100644 index 000000000..ccec95d42 --- /dev/null +++ b/crates/eps/src/ai_editing/writer/symbols.rs @@ -0,0 +1,339 @@ +//! Symbols: their definitions, the symbol list and their instances (see [`crate::ai_editing`] for +//! the form). + +use std::collections::HashSet; +use std::fmt::Write; + +use vectorcraft_color::Paint; +use vectorcraft_doc::{Knockout, Node, NodeKind, SymbolExportType}; +use vectorcraft_geom::Affine; + +use super::{MAX_NEST, Writer, Xy}; +use crate::import::MAX_SYMBOLS; +use crate::ps; + +pub(in crate::ai_editing) const SYMBOL_ART: &str = "symbol instances with a fill, appearance changes, isolation, knockout or knockout shape of their own, instances of symbols with 9-slice guides or pattern paint, instances in pattern tiles or in art cut to a clipping object, instances scaled almost flat, instances that turn, mirror or scale symbols holding type, live objects, instances written as their art, shapes with rounded live corners or strokes written as outlines or brush art, instances that scale their symbol more than 100 times, instances on the layers of a large canvas and instances of symbols past 4,096 are written as their art in the editing data for other apps"; +pub(in crate::ai_editing) const SYMBOL_OPTIONS: &str = + "the 9-slice guides, export types and dynamic type of symbols are left out of the editing data for other apps"; +pub(in crate::ai_editing) const MISSING_SYMBOL: &str = + "symbol instances of symbols the document doesn't have are written as empty groups in the editing data for other apps"; +/// The most an instance may scale its symbol's art, nested instances' scales included: the art's +/// numbers are written to four decimals, so a scale of 100 places them within 0.005 pt. +const MAX_SCALE: f64 = 100.0; +pub(in crate::ai_editing) const SYMBOL_COUNT: &str = "symbols past 4,096 are left out of the editing data for other apps"; + +/// Whether the canvas works `n` itself (not its children) out again after placing an instance: +/// type, live objects and shapes with rounded live corners, whose strokes, steps and corners depend +/// on where the instance puts them. +pub(super) fn redrawn(n: &Node) -> bool { + let rounded = |l: &vectorcraft_doc::LiveShape| match l { + vectorcraft_doc::LiveShape::Rectangle { radii, .. } => radii.iter().any(|r| *r > 0.0), + vectorcraft_doc::LiveShape::Polygon { radii, .. } | vectorcraft_doc::LiveShape::Path { radii, .. } => radii.iter().any(|r| *r > 0.0), + vectorcraft_doc::LiveShape::Ellipse { .. } | vectorcraft_doc::LiveShape::Line { .. } => false, + }; + match &n.kind { + NodeKind::Path { live: Some(l), .. } => rounded(l), + NodeKind::Text(_) + | NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + | NodeKind::Mesh(_) + | NodeKind::Repeat(_) + | NodeKind::PlacedDocument(_) + | NodeKind::Intertwine { .. } => true, + _ => false, + } +} + +impl Writer<'_> { + /// Whether instance `n` of symbol `symbol`, placed by `xf`, is written as an instance: not on the + /// layers of a large canvas, in a pattern's tile or in art cut to a clipping object, without a + /// fill or appearance changes of its own (which stain or change the art it draws) and without + /// isolation, knockout or knockout shape of its own (which an instance's transparency doesn't + /// hold), of a symbol without 9-slice guides and without pattern paint in its art (the canvas + /// keeps an instance's pattern tiles where the document puts them, and a definition's art moves + /// with each instance) whose definition can be written ([`Self::define_symbol`]), by a matrix + /// that keeps the art's area as it is written and scales it as the definition allows + /// ([`Self::keeps_scale`]). + pub(super) fn live_instance(&mut self, n: &Node, symbol: &str, xf: Affine) -> bool { + let own = vectorcraft_brush::stain(n).is_some() || !n.extras().symbol_overrides.is_empty(); + let group_state = n.isolate || n.knockout != Knockout::Neutral || n.knockout_shape; + // The matrix as written (to ten decimals) must still keep the art's area. + let m = Affine::new((self.instance_space * xf).as_coeffs().map(|v| (v * 1e10).round() / 1e10)); + (self.symbols_live || self.in_symbol) + && self.defining.is_empty() + && self.clip.is_none() + && !own + && !group_state + && self.doc.symbol_nine_slice(symbol).is_none() + && m.as_coeffs().iter().all(|v| v.is_finite()) + && m.determinant().abs() >= 1e-12 + && self.define_symbol(symbol) + && !self.symbol_paints_pattern(symbol) + && self.keeps_scale(symbol, xf) + } + + /// Whether instance matrix `xf` places symbol `symbol`'s definition as the canvas draws the + /// instance. A definition holding art the canvas works out again after placing an instance + /// ([`redrawn`], strokes written as outlines or brush art, and instances written as their art) + /// takes only matrices that move it; + /// any other takes a scale up to [`MAX_SCALE`], nested instances' scales included. A live + /// instance in a symbol's art passes both to that symbol. + fn keeps_scale(&mut self, symbol: &str, xf: Affine) -> bool { + let [a, b, c, d, ..] = xf.as_coeffs(); + let fixed = self.fixed_symbols.contains(symbol); + let scale = a.hypot(b).max(c.hypot(d)) * self.symbol_scales.get(symbol).copied().unwrap_or(1.0); + let keeps = if fixed { (a - 1.0).abs() < 1e-9 && b.abs() < 1e-9 && c.abs() < 1e-9 && (d - 1.0).abs() < 1e-9 } else { scale <= MAX_SCALE }; + if keeps && self.in_symbol { + self.wrote_fixed |= fixed; + self.inner_scale = self.inner_scale.max(scale); + } + keeps + } + + /// Whether the art of symbol `name`, an opacity mask in it or the art of a symbol it holds an + /// instance of, at any depth, fills or strokes something (type included) with a pattern, or its + /// definition holds pattern tiles. Worked out once a symbol, after it is defined. + fn symbol_paints_pattern(&mut self, name: &str) -> bool { + if self.pattern_written.contains(name) { + return true; + } + if let Some(known) = self.patterned_symbols.get(name) { + return *known; + } + let mut seen = HashSet::from([name.to_string()]); + let mut todo = vec![name.to_string()]; + let mut found = false; + while let Some(next) = todo.pop() { + let (own, used) = self.symbol_own_patterns(&next); + if own { + found = true; + break; + } + for u in used { + if seen.insert(u.clone()) { + todo.push(u); + } + } + } + self.patterned_symbols.insert(name.to_string(), found); + found + } + + /// Whether the art of symbol `name` or an opacity mask in it fills or strokes something (type + /// included) with a pattern, and the symbols its instances and inline graphics there use; looked + /// at once a symbol. + fn symbol_own_patterns(&mut self, name: &str) -> (bool, Vec) { + if let Some(known) = self.pattern_symbols.get(name) { + return known.clone(); + } + let doc = self.symbol_doc; + let mut own = false; + let mut used = vec![]; + if let Some(sym) = doc.symbols.iter().find(|s| s.name == name) { + let pattern = |p: &Paint| matches!(p, Paint::Pattern { .. }); + vectorcraft_doc::swatches::walk_with_masks(&sym.art, &mut |m| { + own |= vectorcraft_doc::pattern::uses_pattern(m, None); + match &m.kind { + NodeKind::Text(t) => { + own |= t.runs.iter().any(|r| pattern(&r.style.fill) || pattern(&r.style.stroke)); + // Inline graphics are written as their symbols' art. + used.extend(t.runs.iter().filter_map(|r| r.inline.as_ref()).map(|art| art.symbol.clone())); + } + NodeKind::SymbolInstance { symbol, .. } => used.push(symbol.clone()), + _ => {} + } + }); + } + self.pattern_symbols.insert(name.to_string(), (own, used.clone())); + (own, used) + } + + /// An instance of `symbol` placed by `xf`, of transparency `xy`: `/SymbolInstance :`, `(name) + /// /SymbolRef ,`, the matrix taking the symbol's art to art space (in a symbol's art, to the + /// canvas's space turned y up about art space's origin there) as `a b c d tx ty + /// /RTransformMatrix` (to ten decimals), `;`, then its state block as a group's: `Xy`, `0 0 Xd` + /// and `6 () XW` when it has transparency, else `9 () XW`. Its dictionary goes between the two. + pub(super) fn symbol_instance(&mut self, symbol: &str, xf: Affine, xy: Xy) { + let m = self.instance_space * xf; + let _ = write!(self.body, "/SymbolInstance :\n{} /SymbolRef ,\n", ps::bytes(symbol.as_bytes())); + for v in m.as_coeffs() { + self.body.push_str(&super::images::precise(v)); + self.body.push(' '); + } + self.body.push_str("/RTransformMatrix\n;\n"); + // An instance with isolation, knockout or knockout shape of its own is written as its art + // ([`Self::live_instance`]); these are the values of the others. + let xy = Xy { isolate: false, knockout: Knockout::Neutral, shape: false, ..xy }; + 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(); + } + + /// Write symbol `name`'s definition, once: `%AI14_BeginSymbol`, `(name)`, `0 A`, `0 Xw`, `0 Ae`, + /// the art in the symbol's space (y down: its numbers are the symbol's own) as a group (`u` … + /// `U`; art that isn't a group, or is a plain group of one object, in a group of its own), the + /// symbol's registration point (`AI13PatternRegistrationType`, 1 to 9 from the top left, row by + /// row) in that group's dictionary, `9 () XW`, `%AI10_EndSymbol`. Linked images in the + /// art are embedded. The symbols its art's instances use are defined first, and the art is + /// written from the top of the nesting whatever the instance that asks for it, so a definition + /// is the same wherever its first instance lies. False when it can't be: the document doesn't + /// have the symbol, it is being written (its art holds an instance of itself), it is reached + /// through more than [`MAX_NEST`] definitions being written, or the definitions written and + /// being written leave no place under [`MAX_SYMBOLS`]. + pub(super) fn define_symbol(&mut self, name: &str) -> bool { + if self.defined_symbols.contains(name) { + return true; + } + let doc = self.symbol_doc; + let Some(sym) = doc.symbols.iter().find(|s| s.name == name) else { return false }; + if self.defining_symbols.iter().any(|d| d == name) || self.defining_symbols.len() >= MAX_NEST as usize { + return false; + } + // The reader keeps no more definitions than this, and each definition being written takes + // one when it ends. + if self.symbol_defs.len() + self.defining_symbols.len() >= MAX_SYMBOLS { + self.warn(SYMBOL_COUNT); + return false; + } + if sym.dynamic || doc.symbol_export_type(name) != SymbolExportType::default() || doc.symbol_nine_slice(name).is_some() { + self.warn(SYMBOL_OPTIONS); + } + self.defining_symbols.push(name.to_string()); + let mut used = vec![]; + vectorcraft_doc::swatches::walk_with_masks(&sym.art, &mut |m| { + if let NodeKind::SymbolInstance { symbol, .. } = &m.kind { + used.push(symbol.as_str()); + } + }); + for u in used { + self.define_symbol(u); + } + if self.symbol_defs.len() + self.defining_symbols.len() > MAX_SYMBOLS { + self.defining_symbols.pop(); + self.warn(SYMBOL_COUNT); + return false; + } + // The art is written in the symbol's space, from the state `0 A` and `0 Xw` set, from the + // document at its own size. + let origin = self.canvas_origin; + let about_origin = Affine::new([1.0, 0.0, 0.0, -1.0, -origin.0, origin.1]); + let page_doc = std::mem::replace(&mut self.doc, doc); + let saved = ( + std::mem::take(&mut self.body), + (self.to_art, self.grad_space, self.canvas, self.instance_space), + self.state.replace((false, false)), + self.gs.replace(Xy::OPAQUE), + self.clip.take(), + (self.block_start, self.block_end), + (std::mem::replace(&mut self.in_symbol, true), std::mem::take(&mut self.hidden)), + self.root_entries.take(), + std::mem::take(&mut self.wrote_pattern), + (std::mem::take(&mut self.wrote_fixed), std::mem::replace(&mut self.inner_scale, 1.0)), + ); + // Gradients' matrices and instances' matrices are written about the canvas point of art + // space's origin, y up; `Bg` places a gradient on the art as it lies in the symbol's space. + (self.to_art, self.grad_space, self.canvas, self.instance_space) = (Affine::IDENTITY, about_origin, Affine::IDENTITY, about_origin); + self.block_end = usize::MAX; + let registration = u16::from(doc.symbol_registration(name).0.min(8)) + 1; + let entries = format!("%_{registration} /Int (AI13PatternRegistrationType) ,\n"); + // The canvas draws a symbol's art even when its root is hidden, so the root is written shown. + let shown; + let root: &Node = if sym.art.visible { + &sym.art + } else { + let mut a = (*sym.art).clone(); + a.visible = true; + shown = a; + &shown + }; + // A definition holds a group: art of another kind, and a plain group of one object (which + // the reader takes for that group), are written in one. + let wrap = !matches!(root.kind, NodeKind::Group { clip: false, .. }) || crate::import::symbol_art_member(root).is_some(); + let nest = std::mem::replace(&mut self.nest, 1); + if wrap { + self.line("u"); + self.node(root); + let _ = write!(self.body, "U\n%_/ArtDictionary :\n{entries}%_;\n%_\n9 () XW\n"); + } else { + // A group always writes its dictionary ([`Self::dictionary`]), which takes the entries. + self.root_entries = Some((root.id, entries)); + self.node(root); + } + self.nest = nest; + // Objects the writing doesn't pass through one by one (clipping paths, a clipping group's + // own path) count as well. + vectorcraft_doc::swatches::walk_with_masks(&sym.art, &mut |m| self.wrote_fixed |= redrawn(m)); + if self.wrote_pattern { + self.pattern_written.insert(name.to_string()); + } + if self.wrote_fixed { + self.fixed_symbols.insert(name.to_string()); + } + if self.inner_scale > 1.0 { + self.symbol_scales.insert(name.to_string(), self.inner_scale); + } + let art; + ( + art, + (self.to_art, self.grad_space, self.canvas, self.instance_space), + self.state, + self.gs, + self.clip, + (self.block_start, self.block_end), + (self.in_symbol, self.hidden), + self.root_entries, + self.wrote_pattern, + (self.wrote_fixed, self.inner_scale), + ) = saved; + self.doc = page_doc; + let art = std::mem::replace(&mut self.body, art); + self.defining_symbols.pop(); + let text = format!("%AI14_BeginSymbol\n{}\n0 A\n0 Xw\n0 Ae\n{art}%AI10_EndSymbol\n", ps::bytes(name.as_bytes())); + self.defined_symbols.insert(name.to_string()); + self.symbol_defs.push(text); + true + } + + /// Every symbol of the document defined, in its order (those its art uses before it): the + /// Symbols panel keeps the ones no instance uses too, and on a large canvas all of them. + pub(in crate::ai_editing) fn symbols(&mut self) { + let doc = self.symbol_doc; + for s in &doc.symbols { + self.define_symbol(&s.name); + } + } + + /// The symbol definitions, in the setup after the patterns: `%AI5_Begin_NonPrinting`, `Np`, each + /// definition, `%AI5_End_NonPrinting--`. + pub(in crate::ai_editing) fn symbol_definitions(&self, s: &mut String) { + if self.symbol_defs.is_empty() { + return; + } + s.push_str("%AI5_Begin_NonPrinting\nNp\n"); + for d in &self.symbol_defs { + s.push_str(d); + } + s.push_str("%AI5_End_NonPrinting--\n"); + } + + /// The symbol list, after the palette: the names of the symbols defined in the document's order + /// (the Symbols panel's), between `%AI24_BeginSymbolList` and `%AI24_EndSymbolList` in a + /// non-printing block. + pub(in crate::ai_editing) fn symbol_list(&self, s: &mut String) { + if self.symbol_defs.is_empty() { + return; + } + s.push_str("%AI5_Begin_NonPrinting\nNp\n%AI24_BeginSymbolList\n"); + for sym in &self.symbol_doc.symbols { + if self.defined_symbols.contains(&sym.name) { + let _ = writeln!(s, "{}", ps::bytes(sym.name.as_bytes())); + } + } + s.push_str("%AI24_EndSymbolList\n%AI5_End_NonPrinting--\n"); + } +} diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index e0110f3de..87f1505d6 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -70,7 +70,7 @@ use graphics::{GState, Out}; use interp::{Fault, Interp}; use obj::PsError; -pub(crate) use ai::MAX_GRADIENT_STOPS; +pub(crate) use ai::{MAX_GRADIENT_STOPS, MAX_SYMBOLS, symbol_art_member}; pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES}; pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, is_loss, layered_ai, layered_ai_from}; pub(crate) use native::{MAX_TEXT, scale_document}; diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index 21c6a62aa..c1fd84db3 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -41,7 +41,7 @@ pub use read::{ BindingRef, Brushes, IMAGE_TOO_LARGE, PaletteSwatches, Structure, artboards, bindings_note, brushes, graphic_styles, palette, read, read_beside, variables, }; -pub(crate) use read::{join_and, slot_frame, slot_of}; +pub(crate) use read::{MAX_SYMBOLS, join_and, slot_frame, slot_of, symbol_art_member}; pub(crate) use read::is_look_slot; diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 025ccc480..41479444a 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -145,6 +145,10 @@ pub(super) use types::{blend_mode, blend_name}; #[path = "legacy_text.rs"] mod legacy_text; +// The `.ai` writer defines symbols as this reader keeps them. +pub(crate) use directives::symbol_art_member; +pub(crate) use types::MAX_SYMBOLS; + /// What the editing data holds: the document of its art, and where its art space is. #[derive(Debug)] pub struct Structure { diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 17f843d9a..aac869fc7 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -41,7 +41,7 @@ pub(crate) const FLIP: Affine = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); pub(super) const MAX_PATTERNS: usize = 4096; /// Most symbol definitions read (instances of others come in as the page draws them). -pub(super) const MAX_SYMBOLS: usize = 4096; +pub(crate) const MAX_SYMBOLS: usize = 4096; /// Most names a symbol list orders. pub(super) const MAX_LISTED: usize = 4 * MAX_SYMBOLS; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f60d46f62..aeecaa140 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, gradients, patterns, images, type, artboards, transparency, opacity masks, clipping) 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, gradients, patterns, images, type, symbols, 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 | @@ -75,15 +75,26 @@ private editing data. VectorCraft: RGB or CMYK ink amounts, and their alpha channel) and linked images as placed files with their path, their folder relative to the document's and a preview; images that can't be decoded or have more than 33,554,432 pixels are written as their outlines filled mid gray, images of 16 bits a channel at 8 bits, and linked images in opacity - masks and pattern tiles as embedded images, each with a warning. Point type, area type (with its rows, columns, + masks, pattern tiles and symbols as embedded images, each with a warning. Every symbol is written as a symbol + definition (its art, its registration point and its place in the Symbols panel's order, nested symbols included) + and its instances as symbol instances with their matrix, name, hide, lock, opacity, blending mode and opacity + mask; instances with a fill, appearance changes, isolation, knockout or knockout shape of their own, instances of + symbols with 9-slice guides or pattern paint, instances in pattern tiles or in art cut to a clipping object, + instances scaled almost flat, instances that turn, mirror or scale symbols holding type, live objects, + instances written as their art, shapes with rounded live corners or strokes written as outlines or brush art, + instances that scale their symbol more than 100 times and instances on the layers of a large canvas are written as their art, pattern fills in a + symbol's art are written as their tiles cut to the shape, symbols past 4,096 are written only as their + instances' art, and symbols' 9-slice guides, export types and dynamic type are left out, each with a + warning. + Point type, area type (with its rows, columns, gutters and first baseline, threaded frames included) and type on a path are written as text: a text object for each, naming its story in the text document, with the characters, fonts, sizes, leading, tracking, baseline shift, scales, solid fills and strokes, capitals and paragraph alignment, indents, spacing and direction; other character - and paragraph attributes are left out with a warning. Type in opacity masks, pattern tiles and art cut to a + and paragraph attributes are left out with a warning. Type in opacity masks, pattern tiles, symbols and art cut to a clipping object, mirrored type, turned or unevenly scaled area type and type on a path, area type in an open frame, type on an open path whose ends meet, area type that flows around a text wrap, type of only white space, threads of type on a path, threads whose frames differ in orientation, type and stories past the sizes VectorCraft's reader reads, and - type with inline graphics, ruby, gradient or pattern paint or an appearance or opacity mask of its own, other patterns, symbols, other clipping objects and live + type with inline graphics, ruby, gradient or pattern paint or an appearance or opacity mask of its own, other patterns, other clipping objects and live objects (placed documents included) are written as plain filled and stroked paths, and freeform gradients, gradients along or across a stroke and gradients without stops, with more than 256 stops or without extent as their average color, each with a warning. Gradients that dither or mix their colors perceptually are written as classic diff --git a/docs/gaps.md b/docs/gaps.md index 4dac9431e..fd7927481 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -203,7 +203,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall of 14), open speed on large files (#758), live effects, Scatter, Art, Pattern and Bristle brushes on paths, a path's own Calligraphic brush settings, and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). A PDF-compatible `.ai` carries an editing copy of the art in the form VectorCraft's reader reads (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks, - clipping and type as text), with live effects left out and symbols and live objects written as their art + clipping, type as text and symbols), with live effects left out and live objects written as their art ([file-format-parity.md](file-format-parity.md)). Save As `.ai` › Version: Legacy (version 7) writes the PostScript form from the published version 7 specification, keeping layers (names, order, visibility, lock), groups, compound paths, clipping groups, gradients and diff --git a/docs/roadmap.md b/docs/roadmap.md index c4246c711..002b3a525 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -89,7 +89,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V | Set type: point and area type, Character and Paragraph styles, threading, outlines | yes | type core 80%; styles, threading, Create Outlines and font matching tested ([type-parity.md](type-parity.md)); justification options missing (not blocking) | 0 | | Organise layers and artboards and export assets (Export for Screens, SVG, PNG, PDF per artboard) | yes | Layers panel rework, Artboards panel and tool, Export for Screens and Asset Export done; Art Optimized supersample closes ghost borders (#983 / #1037) | 0 | | Print production: CMYK and spot colour, bleed and marks, PDF/X out, separations preview | yes | PDF/X-1a/3/4, presets, marks and bleed, ICC output intent, Separations Preview, Print with PostScript output | 0 | -| Exchange files with Illustrator users: open their `.ai`/EPS/PDF, hand back a file they can open | partial (doesn't block alpha) | `.ai`, EPS and PDF open as editable art, but real files can get the wrong layer structure (#951); Save As `.ai` is PDF-compatible with an editing copy of the art (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks, clipping and type as text, with live effects left out and symbols and live objects written as their art; #868), or legacy version 7, which keeps layers, groups and type as text, one artboard per file, and waits for users' testing. EPS legacy mesh corners, vector opacity masks and raster print-page comparison now preserve editable art under synthetic regressions. This is the beta main-format gap ([gaps.md](gaps.md) G9) | 10–20 (for beta) | +| Exchange files with Illustrator users: open their `.ai`/EPS/PDF, hand back a file they can open | partial (doesn't block alpha) | `.ai`, EPS and PDF open as editable art, but real files can get the wrong layer structure (#951); Save As `.ai` is PDF-compatible with an editing copy of the art (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks, clipping, type as text and symbols, with live effects left out and live objects written as their art; #868), or legacy version 7, which keeps layers, groups and type as text, one artboard per file, and waits for users' testing. EPS legacy mesh corners, vector opacity masks and raster print-page comparison now preserve editable art under synthetic regressions. This is the beta main-format gap ([gaps.md](gaps.md) G9) | 10–20 (for beta) | Hindi interface follow-up (2026-11-06): the catalog and zero/one plural rule are implemented; native wording review, layout checks and a web Devanagari font remain. [Notes](localization-hi.md). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0af2f0670..766dcfd5e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -201,7 +201,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects and with symbols and live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged) | | Guides, grids, smart guides, snapping, rulers | 3 | 80% | "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented; video rulers (View › Rulers › Show Video Rulers, #2849) done, score unchanged | @@ -282,8 +282,8 @@ The discounts, with the evidence: ones are about structure, not missing art: layers flattened or multiplied (#951: 109 layers instead of 14; #868), guides lost (#779), slower opening (#758), live effects, brushes and symbols as their drawn look (#637). VectorCraft's PDF-compatible `.ai` carries an editing copy of the art (layers, groups, paths, colors, - gradients, patterns, images, transparency, opacity masks, clipping and type as text), with live effects left out - and symbols and live objects written as their art; the legacy `.ai` of version 7 it also writes keeps layers, groups and type as text (one + gradients, patterns, images, transparency, opacity masks, clipping, type as text and symbols), with live effects left out + and live objects written as their art; the legacy `.ai` of version 7 it also writes keeps layers, groups and type as text (one artboard per file, no transparency) but hasn't been tested by users yet (#868). That blocks beta (the main-format rule) for people who hand files back to Illustrator users, but it doesn't stop someone opening and reworking their own archive. - **Interaction fidelity (×0.9).** Pen, Direct Selection, smart guides and snapping are deep @@ -1218,6 +1218,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur At a precision of 9 decimals or fewer, SVG export writes each number from an integer when the integer rounds it as the decimal formatter does; the formatter still writes values halfway between two written numbers and values too large for the integer. Exporting one path of 200,000 anchors takes a median of 43.4 ms instead of 167.0 ms on Windows 11, and 20,000 rectangles 81.4 ms instead of 141.4 ms, with the same SVG (`crates/svg/src/lib.rs`). SVG import reads each `` and `` as the ellipse VectorCraft draws: four smooth anchors on the axes, starting at the right, with handles of the standard length, so an ellipse exported as `` comes back with the anchors it was written from and draws the same (`circles_and_ellipses_import_as_the_ellipse_drawn` in `crates/svg/tests/it/basic_shapes.rs`, `filled_ellipse_on_the_artboard_edge_survives_visually` in `prop_svg.rs`). SVG import keeps attributes in other applications' XML namespaces (`plotter:pen="2"`) on the objects and layers they belong to, as it keeps their `data-*` attributes: copies, duplicates and `.vectorcraft` files keep them, and SVG export writes them back with each namespace declared once on the root (the rest of #522; `crates/svg/tests/it/foreign_attributes.rs`, `tests/attributes.rs`). + A PDF-compatible `.ai` saved with Include Editing Data for Other Apps defines every symbol in its editing copy with its art, registration point and place in the Symbols panel's order, and writes its instances as symbol instances, which VectorCraft's `.ai` reader opens as symbols and instances again (`symbols_and_their_instances_are_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the instances written as their art). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes point type, area type and type on a path as type in its editing copy, and objects next to it keep their own transparency (`crates/eps/src/ai_editing/tests.rs`, `cmd::fileio::tests_pdfedit::type_comes_back_from_the_editing_copy`; [file-format-parity.md](file-format-parity.md) lists what is written as outlines). In a `.ai` or EPS opened from its editing data, type has its text object's opacity, blend mode and knockout shape flag, whether it is made from the file's text document or taken from the page, and type made from the text document takes its text object's lock. Where such type has transparency and comes in with other art (the rest of its look, or page type and strokes that go with it), a group of them has it. Where the page draws its type, or the strokes around it, with transparency that is neither its text object's nor its text object's combined with that of the objects around it, the page's type and strokes keep their own instead (`crates/eps/src/import/tests_native.rs`). Where the page draws type of no text object over text objects made from the text document, with their words in any case, that type is theirs as the page draws it and is left out; the page's other type of no text object goes only to text objects whose type the text document can't make, or else is left out with a warning. `.ai` editing-data import restores the document's listed graphic styles in panel order and links objects that use a listed style; unsupported appearances keep their drawn look (`crates/eps/src/import/ai/tests.rs`). From 6c285d30429588852b341e1843a3bbede8f5c04f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 15:02:56 -0800 Subject: [PATCH 02/13] .ai save: the editing copy keeps live effects The editing copy of a PDF-compatible .ai wrote an object's live effects as their result (geometry effects) or left them out (the others). Objects with effects the .ai reader reads are now written as art styles: - An object is written as its drawn look (a group of its vector art as its geometry effects leave it, without shadows, glows or feathers, with the object's transparency after it), then the object itself on %_ lines (its points painted with nothing, or with its gradient fill placed on the outline it draws; a group with its members), its name and opacity mask, and 1 (style) XW naming its style. - Styles are written in a non-printing block after the symbols, in %AI9_BeginArtStyles: a chain of the stack of fills and strokes (with the contents where the appearance puts them), the effects and the object's transparency. Objects of the same appearance share a style. - The effects written are the ones the reader reads on objects, with their settings in the units it reads them in: Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Convert to Shape, Offset Path, Outline Object, Outline Stroke and Pathfinder > Add. - Each style is read back before it is written, by a new reader function, read_art_style, which runs the reader's style parser and its reading of a graphic style with the object's own gradient. An object whose style reads back differently (several fills or strokes, fills or strokes with transparency or effects of their own, pattern fills, other effects), a filled object that fills by the even-odd rule (a style's fill fills by the non-zero rule), a group whose effects change its members' outlines while a member is written on another outline (strokes as filled outlines, pattern fills as tiles, gradients that fit their object), or one in a container whose own effects change its members, is written as its drawn look with the warning, and a style that is not written leaves no gradient definition behind. Graphic styles and links to them are left out with a warning. No art style is written on a large canvas. Appearance attributes: https://helpx.adobe.com/illustrator/using/appearance-attributes.html Tests: the round trip of objects with each effect, a gradient fill, a group with transparency and a mask, Pathfinder > Add and a hidden effect; the same bytes on a second write; the forms written; the objects the reader would not read; a large canvas; the warning for graphic styles; filled even-odd objects; a gradient placed on the outline Offset Path draws; a style that is not written; effects in a pattern a drawn look uses; the reader's check of a style; fuzz cases for damaged written styles. Docs: docs/file-format-parity.md. --- crates/engine/src/cmd/fileio/tests_pdfedit.rs | 2 +- crates/engine/tests/import_fuzz.rs | 85 ++ crates/eps/src/ai_editing.rs | 32 +- crates/eps/src/ai_editing/tests.rs | 1166 +++++++++++++++++ crates/eps/src/ai_editing/writer.rs | 30 + crates/eps/src/ai_editing/writer/paints.rs | 56 +- crates/eps/src/ai_editing/writer/styles.rs | 685 ++++++++++ crates/eps/src/import.rs | 2 +- crates/eps/src/import/ai.rs | 2 +- crates/eps/src/import/ai/read.rs | 1 + crates/eps/src/import/ai/read/ops.rs | 31 + crates/eps/src/import/ai/style/tests.rs | 29 + docs/file-format-parity.md | 13 +- 13 files changed, 2112 insertions(+), 22 deletions(-) create mode 100644 crates/eps/src/ai_editing/writer/styles.rs diff --git a/crates/engine/src/cmd/fileio/tests_pdfedit.rs b/crates/engine/src/cmd/fileio/tests_pdfedit.rs index 605f82967..a38e35e82 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfedit.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfedit.rs @@ -488,7 +488,7 @@ 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(); 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(); + s.execute("effect.apply", &json!({"effect": "blur.gaussian", "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); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index f1a1f62c0..684cf3bfa 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3691,6 +3691,91 @@ proptest! { survive("mutated .ai symbols", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; survive("mutated .ai symbols, trusted", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?; } + + /// A `.ai` whose art styles, as the writer writes them, are damaged: the styles, the style + /// list, a styled object's drawn look, the object on `%_` lines and its `1 (style) XW`, cut + /// short or edited. + #[test] + fn ai_written_styles_never_panic(cut in 0usize..STYLES_FUZZ_CHARS, edits in prop::collection::vec((0usize..STYLES_FUZZ_CHARS, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', ',', '%', '_', 'u', 'U', 'X', 'W'])), 0..12)) { + let text = mutate_text(styles_text(), cut, &edits); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat(); + survive("mutated .ai art styles", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; + survive("mutated .ai art styles, trusted", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?; + } +} + +/// The range of [`ai_written_styles_never_panic`]'s cuts and edits: past the end of [`styles_text`]. +const STYLES_FUZZ_CHARS: usize = 64_000; + +/// The editing data VectorCraft writes for a document with a drop shadow on a path, a gradient fill +/// and a width profile on others, a stroked even-odd path with an opacity mask and Pathfinder › Add +/// on a group. +fn styles_text() -> &'static str { + static TEXT: std::sync::OnceLock = std::sync::OnceLock::new(); + TEXT.get_or_init(|| { + use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; + use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeKind, OpacityMask, StrokeLayer, WidthProfile}; + use vectorcraft_geom::{FillRule, Rect, shapes}; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let fx = |id: &str| vectorcraft_render::effects::new_effect(id, &json!({})).unwrap(); + let mut ap = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::WHITE), 2.0); + ap.effects = vec![fx("stylize.dropShadow")]; + let shaded = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap.clone()); + doc.insert(Some(layer), usize::MAX, shaded).unwrap(); + let ramp = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]); + let mut graded = Appearance::basic(Paint::Gradient(Box::new(GradientPaint::new(ramp))), Paint::None, 0.0); + graded.effects = vec![fx("stylize.outerGlow")]; + let n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 50.0, 50.0, 90.0)), graded); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let mut tapered = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0); + tapered.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 0.5), (1.0, 0.0, 1.0)] }); + let profiled = Appearance { items: vec![AppearanceItem::Stroke(tapered)], effects: vec![fx("stylize.roundCorners")], contents_index: None }; + let n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(100.0, 10.0, 150.0, 40.0)), profiled); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let mut stroked = Appearance::basic(Paint::None, Paint::solid(Color::WHITE), 2.0); + stroked.effects = vec![fx("stylize.dropShadow")]; + let mut odd = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(100.0, 50.0, 150.0, 90.0)), stroked); + if let NodeKind::Path { rule, .. } = &mut odd.kind { + *rule = FillRule::EvenOdd; + } + let mask_art = Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(100.0, 50.0, 150.0, 70.0)), + Appearance::basic(Paint::solid(Color::gray(0.0)), Paint::None, 0.0), + ); + odd.extras_mut().mask = Some(Box::new(OpacityMask::new(mask_art, false))); + doc.insert(Some(layer), usize::MAX, odd).unwrap(); + let square = |doc: &mut Document, x: f64| { + Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(x, 50.0, x + 30.0, 80.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ) + }; + let (a, b) = (square(&mut doc, 60.0), square(&mut doc, 80.0)); + let mut merged = Node::group(doc.alloc_id(), vec![std::sync::Arc::new(a), std::sync::Arc::new(b)]); + merged.appearance = Appearance { effects: vec![fx("pathfinder.add")], ..Appearance::default() }.into(); + doc.insert(Some(layer), usize::MAX, merged).unwrap(); + let out = vectorcraft_eps::export_ai_editing(&doc, &vectorcraft_eps::AiEditingOptions::default()).unwrap(); + String::from_utf8(out.program).unwrap() + }) +} + +/// The data [`ai_written_styles_never_panic`] damages, as it is, opens with its styles live, and the +/// property's cuts and edits reach all of it. +#[test] +fn the_written_styles_fuzz_data_opens_live() { + let text = styles_text(); + assert!(text.chars().count() < STYLES_FUZZ_CHARS, "{}", text.chars().count()); + for mark in ["%AI9_BeginArtStyles", "%AI5_BeginLayer", "\n6 () XW", "\n1 (Anon", "(BSLeftWidthsArray)", "0 /Bool (UseEvenOdd)", "Bb"] { + assert!(text.contains(mark), "{mark}"); + } + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(text)].concat(); + let (d, warnings, layered) = vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0); + assert!(layered, "{warnings:?}"); + let art = d.layers[0].children().unwrap(); + assert_eq!(art.iter().map(|n| n.appearance.effects.len()).collect::>(), [1, 1, 1, 1, 1]); } /// Editing data with symbols (S2's art holds an instance of S1, F's holds non-native art), the diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 88b9f0fe2..06a3f2935 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -100,6 +100,17 @@ //! instance with isolation, knockout or knockout shape of its own is written as its art). The //! definitions are at most the reader's 4,096. In a symbol's art, an instance's matrix and //! a gradient's take the art to the canvas's space turned y up about art space's origin there. +//! - Art styles, as the reader reads them (`style.rs`, `read/ops.rs`): a path, compound path or +//! group with live effects is written as its drawn look (`u`, its vector art as its geometry +//! effects leave it, without shadows, glows or feathers, `U`, its transparency, `0 0 Xd`, +//! `6 () XW`), then the object itself on `%_` lines (its points painted +//! with nothing, or with its gradient fill; a group with its members) with its name and opacity +//! mask, then `1 (style) XW`. The styles are written in a non-printing block after the symbols +//! (`%AI9_BeginArtStyles`, each `/KnownStyle :` with its name and a filter tree: a chain of the +//! stack of fills and strokes with the contents among them, the effects and the blend filter). A +//! fill filter fills by the non-zero rule (`0 /Bool (UseEvenOdd)`), so a filled object that fills by +//! the even-odd rule is written as its drawn look. A style is written only when the reader reads it +//! back as the object's appearance. //! //! 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 @@ -133,7 +144,9 @@ //! as their filled outlines, several fills or strokes as a group of a path for each, freeform //! gradient swatches, gradient swatches of more than 256 stops, swatches of patterns written as //! tiles and the placement of pattern swatches that move, turn or scale their pattern, 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. +//! layers and opacity masks nested too deep, and, on objects written without an art style, live +//! effects other than geometry effects left out; graphic styles, links to them and every art style +//! on a large canvas are 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`]). @@ -210,11 +223,15 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result 0.0 && board.height() > 0.0) { return Err("the document has no artboard".into()); } + // Artboards that don't fit on the canvas are written on a large canvas, at 1/scale. + let scale = canvas_scale(doc); + // The objects that may keep their effects in an art style, as they are before baking (not on + // a large canvas, whose scale the styles' strokes and effects would need). + let styled = writer::styled_objects(doc); + let graphic_styles = writer::has_graphic_styles(doc); // Live geometry effects as their result, as the PDF part draws them. let baked = baked(doc); let doc = baked.as_ref().unwrap_or(doc); - // Artboards that don't fit on the canvas are written on a large canvas, at 1/scale. - let scale = canvas_scale(doc); let reduced = (scale != 1.0).then(|| { let mut d = doc.clone(); crate::import::scale_document(&mut d, 1.0 / scale, true); @@ -243,6 +260,14 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result Result vectorcraft_doc::Effect { + vectorcraft_effects::new_effect(id, ¶ms).unwrap() +} + +/// A path `name` over `r` filled with `fill` and stroked 2 pt in `stroke` (none when `Paint::None`), +/// with `effects`. +fn styled_rect(doc: &mut Document, name: &str, r: Rect, fill: Paint, stroke: Paint, effects: Vec) -> Node { + let mut ap = Appearance::basic(fill, stroke, 2.0); + ap.effects = effects; + named(Node::path(doc.alloc_id(), shapes::rectangle(r), ap), name) +} + +/// A 400 × 300 document whose objects keep their live effects as art styles: a drop shadow in a +/// color and one by darkness, an inner and an outer glow in that order, Feather, Round Corners, +/// Scribble, Convert to Shape, Offset Path, Outline Stroke on an open path, a drop shadow on a +/// gradient fill and on a group with transparency, a hidden effect, Pathfinder › Add on a group, a +/// hidden locked object, an opacity mask, an object linked to a graphic style; and a graphic style +/// of its own with two fills, a stroke, item transparency and effects. +fn styles_document() -> Document { + let mut doc = Document::new(400.0, 300.0); + let layer = doc.layers[0].id; + let add = |doc: &mut Document, n: Node| { + doc.insert(Some(layer), usize::MAX, n).unwrap(); + }; + let red = solid(Color::rgb(0.9, 0.2, 0.1)); + let blue = solid(Color::rgb(0.1, 0.3, 0.8)); + let shadow = fx("stylize.dropShadow", serde_json::json!({"x": 6.0, "y": 4.0, "blur": 8.0, "opacity": 60.0, "color": "#203040"})); + let n = styled_rect(&mut doc, "Shadowed", Rect::new(10.0, 10.0, 60.0, 50.0), red.clone(), blue.clone(), vec![shadow.clone()]); + add(&mut doc, n); + let dark = fx("stylize.dropShadow", serde_json::json!({"shade": "darkness", "darkness": 40.0, "mode": "darken"})); + let n = styled_rect(&mut doc, "Darkened", Rect::new(70.0, 10.0, 120.0, 50.0), blue.clone(), Paint::None, vec![dark]); + add(&mut doc, n); + let glows = vec![ + fx("stylize.innerGlow", serde_json::json!({"source": "center", "blur": 3.0})), + fx("stylize.outerGlow", serde_json::json!({"blur": 9.0, "opacity": 50.0})), + ]; + let n = styled_rect(&mut doc, "Glowing", Rect::new(130.0, 10.0, 180.0, 50.0), red.clone(), Paint::None, glows); + add(&mut doc, n); + let n = styled_rect( + &mut doc, + "Soft", + Rect::new(190.0, 10.0, 240.0, 50.0), + blue.clone(), + Paint::None, + vec![fx("stylize.feather", serde_json::json!({"radius": 7.0}))], + ); + add(&mut doc, n); + let round = fx("stylize.roundCorners", serde_json::json!({"radius": 9.0})); + let n = styled_rect(&mut doc, "Rounded", Rect::new(250.0, 10.0, 300.0, 50.0), red.clone(), blue.clone(), vec![round]); + add(&mut doc, n); + let n = styled_rect( + &mut doc, + "Scribbled", + Rect::new(310.0, 10.0, 360.0, 50.0), + blue.clone(), + Paint::None, + vec![fx("stylize.scribble", serde_json::json!({}))], + ); + add(&mut doc, n); + let shape = fx("convertToShape.roundedRectangle", serde_json::json!({"relative": true, "extraW": 12.0, "extraH": 8.0, "radius": 5.0})); + let n = styled_rect(&mut doc, "Shaped", Rect::new(10.0, 70.0, 60.0, 110.0), red.clone(), Paint::None, vec![shape]); + add(&mut doc, n); + let offset = fx("path.offsetPath", serde_json::json!({"offset": 4.0, "joins": "miter", "miterLimit": 6.0})); + let n = styled_rect(&mut doc, "Offset", Rect::new(70.0, 70.0, 120.0, 110.0), blue.clone(), red.clone(), vec![offset]); + add(&mut doc, n); + let mut open = PathData::from_bezpath(&kurbo::BezPath::from_vec(vec![ + kurbo::PathEl::MoveTo((130.0, 110.0).into()), + kurbo::PathEl::LineTo((180.0, 70.0).into()), + ])); + open.subpaths.iter_mut().for_each(|sp| sp.closed = false); + let mut ap = Appearance::basic(Paint::None, red.clone(), 6.0); + ap.effects = vec![fx("path.outlineStroke", serde_json::json!({}))]; + let n = named(Node::path(doc.alloc_id(), open, ap), "Outlined"); + add(&mut doc, n); + let ramp = Gradient { + kind: GradientKind::Linear, + stops: vec![GradientStop::new(0.0, Color::rgb(1.0, 1.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.6, 0.2))], + ..Gradient::default() + }; + let graded = Paint::Gradient(Box::new(GradientPaint::new(ramp))); + let n = styled_rect(&mut doc, "Graded", Rect::new(190.0, 70.0, 240.0, 110.0), graded, Paint::None, vec![shadow.clone()]); + add(&mut doc, n); + let mut hidden_fx = fx("stylize.outerGlow", serde_json::json!({})); + hidden_fx.visible = false; + let mut quiet = styled_rect(&mut doc, "Quiet", Rect::new(250.0, 70.0, 300.0, 110.0), blue.clone(), Paint::None, vec![hidden_fx]); + quiet.visible = false; + quiet.locked = true; + add(&mut doc, quiet); + // A group with a shadow, transparency and a mask; a group merged by Pathfinder › Add. + let a = square(&mut doc, Rect::new(10.0, 130.0, 50.0, 170.0), Color::rgb(0.2, 0.6, 0.3)); + let b = square(&mut doc, Rect::new(30.0, 150.0, 70.0, 190.0), Color::rgb(0.6, 0.2, 0.5)); + let mut shaded = see_through(group(&mut doc, vec![a, b], false), 0.5, BlendMode::Multiply); + shaded.appearance = Appearance { effects: vec![shadow.clone()], ..Appearance::default() }.into(); + let mask_art = square(&mut doc, Rect::new(10.0, 130.0, 70.0, 160.0), Color::gray(0.0)); + shaded.extras_mut().mask = Some(OpacityMask { art: Arc::new(mask_art), clip: true, invert: false, disabled: false, linked: true }.into()); + add(&mut doc, named(shaded, "Shaded group")); + let a = square(&mut doc, Rect::new(90.0, 130.0, 130.0, 170.0), Color::rgb(0.9, 0.6, 0.1)); + let b = square(&mut doc, Rect::new(110.0, 150.0, 150.0, 190.0), Color::rgb(0.9, 0.6, 0.1)); + let mut merged = group(&mut doc, vec![a, b], false); + merged.appearance = Appearance { effects: vec![fx("pathfinder.add", serde_json::json!({}))], ..Appearance::default() }.into(); + add(&mut doc, named(merged, "Merged")); + doc +} + +/// Each object's name, kind, transparency, state, mask and appearance, and whether it is linked to a +/// graphic style (by name), compared with [`writer::same_appearance`] apart. +fn style_view(doc: &Document) -> Vec<(String, Appearance)> { + let mut out = vec![]; + for n in doc.layers.iter().flat_map(|l| l.children().cloned().unwrap_or_default()) { + let kind = match &n.kind { + NodeKind::Group { .. } => "group", + NodeKind::Path { .. } => "path", + NodeKind::Compound { .. } => "compound", + _ => "other", + }; + let link = n.extras().graphic_style.and_then(|id| doc.graphic_style_by_id(id)).map(|g| g.name.clone()); + let head = format!( + "{:?} {kind} {} {:?} isolate={} {:?} shown={} locked={} mask={} link={link:?} members={}", + n.name, + r3(f64::from(n.opacity)), + n.blend, + n.isolate, + n.knockout, + n.visible, + n.locked, + n.extras().mask.is_some(), + n.children().map_or(0, Vec::len) + ); + out.push((head, (*n.appearance).clone())); + } + out +} + +/// `ap` with its gradients' stops and kind only: their placement is the slice of gradients' (a +/// gradient comes back placed on the object, named after its definition). +fn unplaced(ap: &Appearance) -> Appearance { + let mut ap = ap.clone(); + for item in ap.items.iter_mut() { + let paint = match item { + AppearanceItem::Fill(f) => &mut f.paint, + AppearanceItem::Stroke(s) => &mut s.paint, + }; + if let Paint::Gradient(g) = paint { + (g.geom, g.swatch, g.angle) = (None, None, 0.0); + } + } + ap +} + +fn same_styles(got: &Document, want: &Document) { + let (g, w) = (style_view(got), style_view(want)); + same_lines(&g.iter().map(|(h, _)| h.clone()).collect::>(), &w.iter().map(|(h, _)| h.clone()).collect::>()); + for ((h, a), (_, b)) in g.iter().zip(&w) { + assert!(writer::same_appearance(&unplaced(a), &unplaced(b)), "{h}:\n got {a:?}\n want {b:?}"); + } + same_lines(&outlines(got), &outlines(want)); +} + +/// The points of `doc`'s objects and of their opacity masks, each path's fill rule and each +/// object's nesting, to the written precision. +fn outlines(doc: &Document) -> Vec { + fn walk(n: &Node, depth: usize, out: &mut Vec) { + let mut points = |path: &PathData, rule: Option| { + let mut s = format!("{depth} {:?} {rule:?}", n.name); + for sp in &path.subpaths { + s += &format!(" [closed={}", sp.closed); + for a in &sp.anchors { + s += &format!(" {:.2},{:.2}", a.p.x + 0.0, a.p.y + 0.0); + } + s += "]"; + } + out.push(s); + }; + match &n.kind { + NodeKind::Path { path, rule, .. } => points(path, Some(*rule)), + NodeKind::Compound { rule, children } => { + for c in children { + points(c.path_data().unwrap(), Some(*rule)); + } + } + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => { + out.push(format!("{depth} {:?} container", n.name)); + for c in children { + walk(c, depth + 1, out); + } + } + other => out.push(format!("{depth} {other:?}")), + } + if let Some(mask) = &n.extras().mask { + out.push(format!("{depth} mask clip={} invert={} disabled={} linked={}", mask.clip, mask.invert, mask.disabled, mask.linked)); + // The reader holds a mask's art in a group. + match &mask.art.kind { + NodeKind::Group { children, .. } if children.len() == 1 => walk(&children[0], depth + 1, out), + _ => walk(&mask.art, depth + 1, out), + } + } + } + let mut out = vec![]; + for l in doc.layers.iter() { + walk(l, 0, &mut out); + } + out +} + +/// Fails with the lines where `got` and `want` differ in more than their numbers' last written +/// decimal (a color read back from four decimals gives its other model's values again from them). +fn near_lines(got: &str, want: &str) { + let near = |a: &str, b: &str| { + let (a, b): (Vec<&str>, Vec<&str>) = (a.split(' ').collect(), b.split(' ').collect()); + a.len() == b.len() + && a.iter().zip(&b).all(|(x, y)| match (x.trim_start_matches("%_").parse::(), y.trim_start_matches("%_").parse::()) { + (Ok(x), Ok(y)) => (x - y).abs() <= 2e-3, + _ => x == y, + }) + }; + let (g, w): (Vec<&str>, Vec<&str>) = (got.lines().collect(), want.lines().collect()); + let differ: Vec = (0..g.len().max(w.len())) + .filter(|&i| !matches!((g.get(i), w.get(i)), (Some(a), Some(b)) if near(a, b))) + .map(|i| format!("line {i}:\n got {:?}\n want {:?}", g.get(i), w.get(i))) + .collect(); + assert!(differ.is_empty(), "{}", differ.join("\n")); +} + +#[test] +fn objects_with_live_effects_are_read_back() { + let doc = styles_document(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let back = read_back(&doc); + same_styles(&back, &doc); + // A group's members come back as they were. + let members = |d: &Document, name: &str| { + let mut out = vec![]; + let g = d.layers[0].children().unwrap().iter().find(|n| n.name.as_deref() == Some(name)).unwrap().clone(); + view(g.children().unwrap(), 0, &mut out); + out + }; + same_lines(&members(&back, "Shaded group"), &members(&doc, "Shaded group")); + same_lines(&members(&back, "Merged"), &members(&doc, "Merged")); +} + +#[test] +fn art_styles_are_written_in_their_forms() { + let p = program_shown(&styles_document()); + // The styles in a non-printing block after the symbols' and before the palette. + let styles = p.find("%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyles\n/KnownStyle :\n(Anon 1) /Name ,\n/ActiveStyle :\n").unwrap(); + assert!(styles < p.find("%AI5_BeginPalette").unwrap(), "{p}"); + assert!(!p.contains("%AI9_BeginArtStyleList"), "{p}"); + // A drop shadow on the object, after its stack and before its transparency. + assert!( + p.contains( + "/BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n1 /Int (blnd) ,\n0.6 /Real (opac) ,\n\ + 4 /Real (vert) ,\n6 /Real (horz) ,\n4 /Real (blur) ,\n/FillStyle : 0 O\n" + ), + "{p}" + ); + // The drawn look, its state block with the object's transparency, the object on `%_` lines + // painted with nothing, its name, and its style. + assert!(p.contains("U\n0 1 0 2 0 Xy\n0 0 Xd\n6 () XW\n%_0 XR\n%_0 1 0 0 0 Xy\n%_10 -10 m\n"), "{p}"); + assert!(p.contains("%_n\n%_/ArtDictionary :\n%_/XMLUID : (Shadowed) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Anon 1) XW\n"), "{p}"); + assert!(p.contains("%_n\n%_/ArtDictionary :\n%_/XMLUID : (Offset) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Anon "), "{p}"); + // A group's members on `%_` lines, then its name and style. + assert!(p.contains("6 () XW\n%_u\n"), "{p}"); + assert!(p.contains("%_U\n%_/ArtDictionary :\n%_/XMLUID : (Merged) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Anon "), "{p}"); + // Objects of the same appearance share their style. + let two = { + let mut doc = Document::new(200.0, 100.0); + let shadow = fx("stylize.dropShadow", serde_json::json!({})); + for x in [10.0, 60.0] { + let n = styled_rect(&mut doc, "Twin", Rect::new(x, 10.0, x + 30.0, 40.0), solid(Color::BLACK), Paint::None, vec![shadow.clone()]); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + program(&doc) + }; + assert_eq!((two.matches("/KnownStyle :").count(), two.matches("1 (Anon 1) XW\n").count()), (1, 2), "{two}"); + assert!(!two.contains("%AI9_BeginArtStyleList"), "{two}"); +} + +/// Objects whose appearance the reader wouldn't read with their style are written as their +/// drawn look, with the warnings they were written with before; and a document without effects has +/// no art styles. +#[test] +fn appearances_the_reader_wouldnt_read_are_their_drawn_look() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let shadow = fx("stylize.dropShadow", serde_json::json!({})); + // Two fills and an effect: the reader keeps such an object's drawn look. + let mut two = styled_rect(&mut doc, "Two fills", Rect::new(10.0, 10.0, 40.0, 40.0), solid(Color::BLACK), Paint::None, vec![shadow.clone()]); + let mut ap = (*two.appearance).clone(); + ap.items.push(AppearanceItem::Fill(FillLayer { opacity: 0.5, ..FillLayer::new(solid(Color::WHITE)) })); + two.appearance = ap.into(); + doc.insert(Some(layer), usize::MAX, two).unwrap(); + // A warp on a path: the reader keeps a path's drawn look for it. + let warped = styled_rect( + &mut doc, + "Warped", + Rect::new(50.0, 10.0, 80.0, 40.0), + solid(Color::BLACK), + Paint::None, + vec![fx("warp.arc", serde_json::json!({}))], + ); + doc.insert(Some(layer), usize::MAX, warped).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let p = String::from_utf8(out.program).unwrap(); + assert!(!p.contains("%AI9_BeginArtStyles") && !p.lines().any(|l| l.starts_with("1 (")), "{p}"); + let back = read_back(&doc); + assert!(back.layers[0].children().unwrap().iter().all(|n| n.appearance.effects.is_empty())); + // Nothing styled: no art styles. + assert!(!program(&sample()).contains("ArtStyle")); +} + +/// A compound shape draws as one area, which an art style's object wouldn't keep: it and a group +/// holding one are written as their drawn look, with a warning. +#[test] +fn compound_shapes_are_not_written_with_an_art_style() { + let shadow = || fx("stylize.dropShadow", serde_json::json!({})); + let shape = |doc: &mut Document| { + let a = square(doc, Rect::new(10.0, 10.0, 50.0, 50.0), Color::BLACK); + let b = square(doc, Rect::new(30.0, 30.0, 70.0, 70.0), Color::BLACK); + let mut g = group(doc, vec![a, b], false); + g.extras_mut().compound_shape = true; + g + }; + let mut own = Document::new(200.0, 100.0); + let mut n = shape(&mut own); + n.appearance = Appearance { effects: vec![shadow()], ..Appearance::default() }.into(); + let layer = own.layers[0].id; + own.insert(Some(layer), usize::MAX, n).unwrap(); + let mut nested = Document::new(200.0, 100.0); + let member = shape(&mut nested); + let mut n = group(&mut nested, vec![member], false); + n.appearance = Appearance { effects: vec![shadow()], ..Appearance::default() }.into(); + let layer = nested.layers[0].id; + nested.insert(Some(layer), usize::MAX, n).unwrap(); + for doc in [own, nested] { + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + assert!(!shown(&out.program).contains("1 (Anon 1) XW")); + } +} + +/// A member of a group whose Transform effect moves it is written where baking put it, not with +/// its own effects where it was. +#[test] +fn a_styled_member_of_a_moved_group_stays_where_it_draws() { + fn left_of_paths(n: &Node) -> f64 { + let own = n.path_data().map_or(f64::INFINITY, |p| kurbo::Shape::bounding_box(&p.to_bezpath()).x0); + n.children().map_or(own, |c| c.iter().map(|m| left_of_paths(m)).fold(own, f64::min)) + } + let mut doc = Document::new(300.0, 100.0); + let layer = doc.layers[0].id; + let shadow = fx("stylize.dropShadow", serde_json::json!({})); + let member = styled_rect(&mut doc, "Member", Rect::new(10.0, 10.0, 40.0, 40.0), solid(Color::BLACK), Paint::None, vec![shadow]); + let mut moved = group(&mut doc, vec![member], false); + moved.appearance = Appearance { effects: vec![fx("distort.transform", serde_json::json!({"moveH": 70.0}))], ..Appearance::default() }.into(); + doc.insert(Some(layer), usize::MAX, moved).unwrap(); + let back = read_back(&doc); + let left = left_of_paths(&back.layers[0]); + assert!((left - 80.0).abs() < 1e-3, "the art starts at x = {left}"); +} + +/// A hidden, locked styled group keeps its state when its members are shown and unlocked. +#[test] +fn a_styled_group_keeps_its_state_after_members_in_another() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let member = square(&mut doc, Rect::new(10.0, 10.0, 40.0, 40.0), Color::BLACK); + let mut shaded = group(&mut doc, vec![member], false); + shaded.appearance = Appearance { effects: vec![fx("stylize.dropShadow", serde_json::json!({}))], ..Appearance::default() }.into(); + shaded.visible = false; + shaded.locked = true; + doc.insert(Some(layer), usize::MAX, shaded).unwrap(); + let back = read_back(&doc); + let n = &back.layers[0].children().unwrap()[0]; + assert!(matches!(n.kind, NodeKind::Group { .. })); + assert_eq!(n.appearance.effects.len(), 1); + assert!(!n.visible && n.locked, "visible {} locked {}", n.visible, n.locked); +} + +/// Effects in a styled object's opacity mask are not its style's: leaving them out warns. +#[test] +fn effects_in_a_styled_objects_mask_are_warned_about() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let shadow = fx("stylize.dropShadow", serde_json::json!({})); + let mut n = styled_rect(&mut doc, "Masked", Rect::new(10.0, 10.0, 40.0, 40.0), solid(Color::BLACK), Paint::None, vec![shadow]); + let blurred = styled_rect( + &mut doc, + "Mask art", + Rect::new(10.0, 10.0, 40.0, 25.0), + solid(Color::gray(0.0)), + Paint::None, + vec![fx("blur.gaussian", serde_json::json!({}))], + ); + n.extras_mut().mask = Some(OpacityMask { art: Arc::new(blurred), clip: true, invert: false, disabled: false, linked: true }.into()); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + assert!(shown(&out.program).contains("\n1 (Anon 1) XW\n")); +} + +/// On a large canvas no art style is written (the reader scales the styles of a large canvas): +/// objects with effects are their drawn look, with a warning. +#[test] +fn a_large_canvas_writes_no_art_styles() { + let mut doc = Document::new(20000.0, 100.0); + let n = styled_rect( + &mut doc, + "Far", + Rect::new(10.0, 10.0, 40.0, 40.0), + solid(Color::BLACK), + Paint::None, + vec![fx("stylize.feather", serde_json::json!({}))], + ); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::LARGE_CANVAS_STYLES), "{:?}", out.warnings); + assert!(!String::from_utf8(out.program).unwrap().contains("ArtStyle")); +} + +#[test] +fn art_styles_write_the_same_bytes_again() { + let doc = styles_document(); + let first = export_ai_editing(&doc, &opts()).unwrap(); + assert!(shown(&first.program).contains("\n1 (Anon 1) XW\n")); + 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 is read back is written as it was. + let again = export_ai_editing(&read_back(&doc), &opts()).unwrap().program; + near_lines(&shown(&again), &shown(&first.program)); +} + +/// `n` on the first layer of `doc`. +fn put(doc: &mut Document, n: Node) { + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, n).unwrap(); +} + +fn drop_shadow() -> vectorcraft_doc::Effect { + fx("stylize.dropShadow", serde_json::json!({})) +} + +fn black_fill() -> Paint { + solid(Color::BLACK) +} + +/// An even-odd compound path of a square inside a square, with `ap`. +fn ring(doc: &mut Document, ap: Appearance) -> Node { + let outer = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(70.0, 10.0, 110.0, 50.0)), Appearance::default()); + let inner = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(80.0, 20.0, 100.0, 40.0)), Appearance::default()); + let mut n = Node::new(doc.alloc_id(), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(inner)], rule: FillRule::EvenOdd }); + n.appearance = ap.into(); + n +} + +/// A path filled black with no stroke and `effects`. +fn plain_rect(doc: &mut Document, name: &str, effects: Vec) -> Node { + styled_rect(doc, name, Rect::new(10.0, 10.0, 50.0, 40.0), black_fill(), Paint::None, effects) +} + +/// A 200 × 100 document's first object as it reads back from a document of only `n`. +fn read_one(make: impl FnOnce(&mut Document) -> Node) -> (Arc, Vec, String) { + let mut doc = Document::new(200.0, 100.0); + let n = make(&mut doc); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + let back = read_back(&doc); + (back.layers[0].children().unwrap()[0].clone(), out.warnings, shown(&out.program)) +} + +/// A filled path or compound path with live effects that fills by the even-odd rule is written as its +/// drawn look with a warning, since a style's fill fills by the non-zero rule. A stroke-only object +/// keeps its style and its own fill rule. +#[test] +fn a_filled_even_odd_object_is_written_as_its_drawn_look() { + let mut doc = Document::new(300.0, 100.0); + let mut odd = plain_rect(&mut doc, "Odd", vec![drop_shadow()]); + if let NodeKind::Path { rule, .. } = &mut odd.kind { + *rule = FillRule::EvenOdd; + } + put(&mut doc, odd); + let ap = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(black_fill(), Paint::None, 0.0) }; + let n = named(ring(&mut doc, ap), "Ring"); + put(&mut doc, n); + let n = plain_rect(&mut doc, "Even", vec![drop_shadow()]); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let p = shown(&out.program); + assert_eq!((p.matches("/KnownStyle :").count(), p.matches("\n1 (Anon 1) XW\n").count()), (1, 1), "{p}"); + assert!(!p.contains("1 /Bool (UseEvenOdd)"), "{p}"); + + let mut doc = Document::new(300.0, 100.0); + let ap = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(Paint::None, solid(Color::BLACK), 2.0) }; + let n = named(ring(&mut doc, ap), "Stroked"); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let back = read_back(&doc); + same_styles(&back, &doc); + assert!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::Compound { rule: FillRule::EvenOdd, .. })); +} + +/// A compound path filled by the non-zero rule keeps its effects and its gradient fill. +#[test] +fn a_compound_path_keeps_its_style() { + let mut doc = Document::new(300.0, 100.0); + let non_zero = |mut n: Node| { + if let NodeKind::Compound { rule, .. } = &mut n.kind { + *rule = FillRule::NonZero; + } + n + }; + let ap = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(black_fill(), solid(Color::WHITE), 2.0) }; + let n = named(non_zero(ring(&mut doc, ap)), "Shadowed"); + put(&mut doc, n); + let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]; + let graded = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let ap = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(graded, Paint::None, 0.0) }; + let n = named(non_zero(ring(&mut doc, ap)), "Graded"); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let back = read_back(&doc); + same_styles(&back, &doc); + assert!(back.layers[0].children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Compound { .. }))); +} + +/// A group holding an image is written as its drawn look: the image stays a placed file, once. +#[test] +fn a_group_holding_an_image_is_written_as_its_drawn_look() { + let mut doc = Document::new(200.0, 100.0); + let link = Some(vectorcraft_doc::LinkInfo::new("/tmp/ramp.png")); + let im = image_node(&mut doc, ImageBlob::png(rgb_png()), (3, 2), Affine::scale(10.0), link); + let mut g = group(&mut doc, vec![im], false); + g.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::default() }.into(); + put(&mut doc, g); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + assert!(!out.warnings.iter().any(|w| w == writer::LINKS_EMBEDDED), "{:?}", out.warnings); + assert!(!shown(&out.program).contains("(Anon 1)")); + let back = read_back(&doc); + let g = &back.layers[0].children().unwrap()[0]; + let member = g.children().map_or(g, |c| &c[0]); + let NodeKind::Image(im) = &member.kind else { panic!("{member:?}") }; + assert!(im.link.is_some()); +} + +/// Graphic styles and links to them are left out with a warning: an object linked to a style +/// keeps its effects in a style of its own. A new document's styles without a link don't warn. +#[test] +fn graphic_styles_and_links_are_left_out_with_a_warning() { + let warns = |doc: &Document| export_ai_editing(doc, &opts()).unwrap().warnings.iter().any(|w| w == writer::GRAPHIC_STYLES); + assert!(!warns(&Document::new(200.0, 100.0))); + // A style of the document's own. + let mut own = Document::new(200.0, 100.0); + own.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Mine", Appearance::basic(black_fill(), Paint::None, 0.0))); + assert!(warns(&own)); + assert!(!shown(&export_ai_editing(&own, &opts()).unwrap().program).contains("ArtStyle")); + assert_eq!(read_back(&own).graphic_style("Mine"), None); + // A link to a new document's style, on an object with an effect. + let mut linked = Document::new(200.0, 100.0); + let id = linked.graphic_style("Sunshine").unwrap().id; + let mut n = plain_rect(&mut linked, "Linked", vec![drop_shadow()]); + n.extras_mut().graphic_style = Some(id); + put(&mut linked, n); + assert!(warns(&linked)); + let back = read_back(&linked); + let n = &back.layers[0].children().unwrap()[0]; + assert_eq!(n.extras().graphic_style, None); + assert_eq!(n.appearance.effects.len(), 1); + // A link in a symbol's art. + let mut in_symbol = Document::new(200.0, 100.0); + let id = in_symbol.graphic_style("Sunshine").unwrap().id; + let mut art = square(&mut in_symbol, Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK); + art.extras_mut().graphic_style = Some(id); + in_symbol.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); + assert!(warns(&in_symbol)); +} + +/// A path or compound path with no points writes nothing, with or without a style; a group with no +/// members keeps its effects. +#[test] +fn objects_with_nothing_to_write_are_left_out() { + let mut doc = Document::new(200.0, 100.0); + let mut empty = plain_rect(&mut doc, "Empty", vec![drop_shadow()]); + if let NodeKind::Path { path, .. } = &mut empty.kind { + path.subpaths.clear(); + } + put(&mut doc, empty); + let mut hollow = Node::new(doc.alloc_id(), NodeKind::Compound { children: vec![], rule: FillRule::NonZero }); + hollow.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(black_fill(), Paint::None, 0.0) }.into(); + put(&mut doc, named(hollow, "Hollow")); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let p = shown(&out.program); + assert!(!p.contains("(Anon 1)") && !p.contains("(Empty)") && !p.contains("6 () XW"), "{p}"); + assert_eq!(read_back(&doc).layers[0].children().unwrap().len(), 0); + + let (n, ..) = read_one(|doc| { + let mut g = group(doc, vec![], false); + g.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::default() }.into(); + named(g, "Bare") + }); + assert_eq!((n.name.as_deref(), n.appearance.effects.len(), n.children().map(Vec::len)), (Some("Bare"), 1, Some(0))); +} + +/// Outline Object, every Convert to Shape form and size, Offset Path's joins, Inner Glow's sources +/// and a stroke's width profile, each on an object, read back as they are. Drop Shadow and the glows +/// keep their colors, in CMYK too. +#[test] +fn every_effect_setting_written_on_an_object_is_read_back() { + use serde_json::json; + let cases = [ + ("path.outlineObject", json!({})), + ("path.outlineStroke", json!({})), + ("convertToShape.rectangle", json!({"relative": true, "extraW": 6.0, "extraH": 4.0})), + ("convertToShape.rectangle", json!({"relative": false, "width": 30.0, "height": 20.0})), + ("convertToShape.roundedRectangle", json!({"relative": false, "width": 30.0, "height": 20.0, "radius": 3.0})), + ("convertToShape.roundedRectangle", json!({"relative": true, "extraW": 6.0, "extraH": 4.0, "radius": 3.0})), + ("convertToShape.ellipse", json!({"relative": false, "width": 30.0, "height": 20.0})), + ("path.offsetPath", json!({"offset": 3.0, "joins": "round", "miterLimit": 4.0})), + ("path.offsetPath", json!({"offset": -3.0, "joins": "bevel", "miterLimit": 4.0})), + ("path.offsetPath", json!({"offset": 3.0, "joins": "miter", "miterLimit": 9.0})), + ("stylize.innerGlow", json!({"source": "edge", "blur": 2.5, "opacity": 40.0, "mode": "screen", "color": "#102030"})), + ("stylize.innerGlow", json!({"source": "center", "blur": 2.5})), + ("stylize.outerGlow", json!({"blur": 7.5, "opacity": 33.333, "mode": "normal", "color": "#a0b0c0"})), + ("stylize.dropShadow", json!({"x": 1.25, "y": -2.5, "blur": 3.5, "opacity": 12.5, "mode": "overlay", "color": "#405060"})), + ("stylize.dropShadow", json!({"shade": "darkness", "darkness": 62.5, "x": 0.0, "y": 0.0, "blur": 0.0})), + ("stylize.feather", json!({"radius": 11.5})), + ("stylize.roundCorners", json!({"radius": 0.5})), + ( + "stylize.scribble", + json!({"angle": 15.0, "overlap": 2.0, "overlapVariation": 1.0, "strokeWidth": 1.5, "curviness": 12.5, "curvinessVariation": 7.5, "spacing": 4.0, "spacingVariation": 1.5}), + ), + ]; + for (id, params) in cases { + let effect = fx(id, params.clone()); + let (n, warnings, p) = read_one(|doc| plain_rect(doc, "One", vec![effect.clone()])); + assert!(!warnings.iter().any(|w| w == writer::EFFECTS), "{id} {params}: {warnings:?}\n{p}"); + assert_eq!(n.appearance.effects.len(), 1, "{id} {params}"); + let (got, want) = (&n.appearance.effects[0], &effect); + assert_eq!(got.id, want.id); + let (g, w) = (vectorcraft_effects::merged_params(&got.id, &got.params), vectorcraft_effects::merged_params(&want.id, &want.params)); + for (key, value) in w.as_object().unwrap() { + let read = g.get(key).unwrap_or(&serde_json::Value::Null); + match (key.as_str(), value.as_f64()) { + // A case the file doesn't keep: `seed` and the like, which the reader fills in as their defaults. + ("mode" | "shade" | "source" | "joins", _) => { + let lower = |v: &serde_json::Value| v.as_str().unwrap_or_default().to_ascii_lowercase(); + assert_eq!(lower(read), lower(value), "{id} {key}"); + } + ("color", _) => { + let color = |p: &serde_json::Value| vectorcraft_effects::color_param(p, Color::BLACK).to_hex(); + assert_eq!(color(&g), color(&w), "{id}"); + } + (_, Some(v)) => assert!((read.as_f64().unwrap() - v).abs() < 6e-3, "{id} {key}: {read} for {v}"), + _ => assert_eq!(read, value, "{id} {key}"), + } + } + } + // A relative Ellipse: the reader keeps the drawn look, which is the effect's result. + let effect = fx("convertToShape.ellipse", json!({"relative": true, "extraW": 6.0, "extraH": 4.0})); + let (n, _, p) = read_one(|doc| plain_rect(doc, "One", vec![effect.clone()])); + assert!(!p.contains("%AI9_BeginArtStyles"), "{p}"); + assert!(n.appearance.effects.is_empty()); + // A stroke's width profile. + let profile = vectorcraft_doc::WidthProfile { points: vec![(0.0, 1.0, 0.5), (0.5, 0.25, 0.75), (1.0, 0.0, 1.0)] }; + let (n, warnings, p) = read_one(|doc| { + let mut stroke = StrokeLayer::new(black_fill(), 4.0); + stroke.profile = Some(profile.clone()); + let ap = Appearance { items: vec![AppearanceItem::Stroke(stroke)], effects: vec![drop_shadow()], contents_index: None }; + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap) + }); + assert!(!warnings.iter().any(|w| w == writer::EFFECTS), "{warnings:?}"); + assert!(p.contains("(BSVariableWidthTValuesArray)") && p.contains("(BSLeftWidthsArray)") && p.contains("(BSRightWidthsArray)"), "{p}"); + assert_eq!(n.appearance.stroke().and_then(|s| s.profile.clone()), Some(profile)); + // A CMYK document: a CMYK and a spot fill, a CMYK stroke, and an effect's CMYK color stay as they are. + let mut doc = Document::new(200.0, 100.0); + doc.color_mode = vectorcraft_doc::ColorMode::Cmyk; + let mut swatches = (*doc.swatches).clone(); + swatches.push(Swatch { name: "Ink".into(), paint: solid(INK), global: true, spot: true }); + swatches.push(Swatch { name: "Brand".into(), paint: solid(BRAND), global: true, spot: false }); + doc.swatches = swatches.into(); + let shadow = fx("stylize.dropShadow", json!({"color": serde_json::to_value(GOLD).unwrap()})); + let n = styled_rect(&mut doc, "Process", Rect::new(10.0, 10.0, 40.0, 40.0), solid(GOLD), solid(BRAND), vec![shadow.clone()]); + put(&mut doc, n); + let n = styled_rect(&mut doc, "Spot", Rect::new(50.0, 10.0, 80.0, 40.0), linked("Ink", INK, 0.5), Paint::None, vec![shadow.clone()]); + put(&mut doc, n); + let n = styled_rect(&mut doc, "Global", Rect::new(90.0, 10.0, 120.0, 40.0), linked("Brand", BRAND, 1.0), Paint::None, vec![shadow]); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let back = read_back(&doc); + same_styles(&back, &doc); + for n in back.layers[0].children().unwrap() { + assert!(matches!(vectorcraft_effects::color_param(&n.appearance.effects[0].params, Color::BLACK), Color::Cmyk { .. }), "{n:?}"); + } + let spot = &back.layers[0].children().unwrap()[1]; + assert!(matches!(&spot.appearance.fill().unwrap().paint, Paint::Solid { swatch: Some(s), .. } if s == "Ink"), "{spot:?}"); +} + +/// Every kind of appearance a style isn't written for is written as its drawn look, with a warning. +#[test] +fn appearances_written_as_their_drawn_look() { + type Make = Box Node>; + let cases: Vec<(&str, Make)> = vec![ + ( + "a pattern fill", + Box::new(|doc| { + let tile = square(doc, Rect::new(0.0, 0.0, 5.0, 5.0), Color::BLACK); + let mut dots = vectorcraft_doc::PatternDef::new("Dots", vec![Arc::new(tile)]); + dots.tile = Rect::new(0.0, 0.0, 10.0, 10.0); + doc.patterns.push(dots); + let fill = Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }; + styled_rect(doc, "P", Rect::new(10.0, 10.0, 50.0, 40.0), fill, Paint::None, vec![drop_shadow()]) + }), + ), + ( + "an overprinting fill", + Box::new(|doc| { + let ap = Appearance { + items: vec![AppearanceItem::Fill(FillLayer { overprint: true, ..FillLayer::new(black_fill()) })], + effects: vec![drop_shadow()], + contents_index: None, + }; + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap) + }), + ), + ( + "an overprinting stroke", + Box::new(|doc| { + let ap = Appearance { + items: vec![AppearanceItem::Stroke(StrokeLayer { overprint: true, ..StrokeLayer::new(black_fill(), 2.0) })], + effects: vec![drop_shadow()], + contents_index: None, + }; + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap) + }), + ), + ( + "a gradient stroke", + Box::new(|doc| { + let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]; + let graded = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + styled_rect(doc, "G", Rect::new(10.0, 10.0, 50.0, 40.0), Paint::None, graded, vec![drop_shadow()]) + }), + ), + ( + "a fill with an opacity of its own", + Box::new(|doc| { + let ap = Appearance { + items: vec![AppearanceItem::Fill(FillLayer { opacity: 0.5, ..FillLayer::new(black_fill()) })], + effects: vec![drop_shadow()], + contents_index: None, + }; + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap) + }), + ), + ( + "a fill with an effect of its own", + Box::new(|doc| { + let fill = FillLayer { effects: vec![fx("stylize.feather", serde_json::json!({}))], ..FillLayer::new(black_fill()) }; + let ap = Appearance { items: vec![AppearanceItem::Fill(fill)], ..Appearance::default() }; + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap) + }), + ), + ("Pathfinder on a path", Box::new(|doc| plain_rect(doc, "P", vec![fx("pathfinder.add", serde_json::json!({}))]))), + ( + "a group with a fill of its own", + Box::new(|doc| { + let member = square(doc, Rect::new(10.0, 10.0, 40.0, 40.0), Color::BLACK); + let mut g = group(doc, vec![member], false); + g.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(solid(Color::WHITE), Paint::None, 0.0) }.into(); + g + }), + ), + ("an effect the style doesn't hold", Box::new(|doc| plain_rect(doc, "B", vec![fx("blur.gaussian", serde_json::json!({}))]))), + ( + "a group whose member's stroke is written as its outline", + Box::new(|doc| { + let mut stroke = StrokeLayer::new(solid(Color::BLACK), 4.0); + stroke.profile = Some(vectorcraft_doc::WidthProfile { points: vec![(0.0, 1.0, 0.5), (0.5, 0.25, 0.75), (1.0, 0.0, 1.0)] }); + let ap = Appearance { items: vec![AppearanceItem::Stroke(stroke)], ..Appearance::default() }; + let member = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), ap); + let mut g = group(doc, vec![member], false); + let effects = vec![fx("stylize.roundCorners", serde_json::json!({})), drop_shadow()]; + g.appearance = Appearance { effects, ..Appearance::default() }.into(); + g + }), + ), + ( + "a group whose effects reshape a member filled with pattern tiles", + Box::new(|doc| { + let big = square(doc, Rect::new(-2.0, -2.0, 6.0, 6.0), Color::rgb(1.0, 0.0, 0.0)); + let mut over = vectorcraft_doc::PatternDef::new("Over", vec![Arc::new(big)]); + over.tile = Rect::new(0.0, 0.0, 10.0, 10.0); + doc.patterns.push(over); + let tiles = Paint::Pattern { pattern: "Over".into(), xf: Affine::IDENTITY }; + let member = + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)), Appearance::basic(tiles, Paint::None, 0.0)); + let mut g = group(doc, vec![member], false); + let offset = fx("path.offsetPath", serde_json::json!({"offset": 3.0})); + g.appearance = Appearance { effects: vec![offset, drop_shadow()], ..Appearance::default() }.into(); + g + }), + ), + ( + "a group whose effects reshape a member with a gradient that fits it", + Box::new(|doc| { + let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]; + let graded = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let member = + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(30.0, 10.0, 100.0, 40.0)), Appearance::basic(graded, Paint::None, 0.0)); + let mut g = group(doc, vec![member], false); + let offset = fx("path.offsetPath", serde_json::json!({"offset": 15.0})); + g.appearance = Appearance { effects: vec![offset, drop_shadow()], ..Appearance::default() }.into(); + g + }), + ), + ( + "Define Knockout Shape", + Box::new(|doc| { + let mut n = plain_rect(doc, "K", vec![drop_shadow()]); + n.knockout_shape = true; + n + }), + ), + ]; + for (what, make) in cases { + let mut doc = Document::new(200.0, 100.0); + let n = make(&mut doc); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{what}: {:?}", out.warnings); + let p = shown(&out.program); + assert!(!p.contains("%AI9_BeginArtStyles") && !p.contains("(Anon 1) XW"), "{what}:\n{p}"); + let back = read_back(&doc); + let n = &back.layers[0].children().unwrap()[0]; + assert!(n.appearance.effects.is_empty(), "{what}: {n:?}"); + } +} + +/// A gradient fill that fits its object is placed on the outline its geometry effects draw: an +/// object with Offset Path reads back with the gradient a plain object of the drawn size has. +#[test] +fn a_styled_objects_gradient_fits_its_drawn_outline() { + let stops = || vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]; + let graded = || Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops())))); + let first = |doc: &Document| doc.layers[0].children().unwrap()[0].clone(); + let mut doc = Document::new(200.0, 100.0); + let offset = fx("path.offsetPath", serde_json::json!({"offset": 20.0, "joins": "miter"})); + let n = styled_rect(&mut doc, "G", Rect::new(50.0, 30.0, 100.0, 60.0), graded(), Paint::None, vec![offset]); + put(&mut doc, n); + let styled = first(&read_back(&doc)); + assert_eq!(styled.appearance.effects.len(), 1); + let mut doc = Document::new(200.0, 100.0); + let n = styled_rect(&mut doc, "P", Rect::new(30.0, 10.0, 120.0, 80.0), graded(), Paint::None, vec![]); + put(&mut doc, n); + let plain = first(&read_back(&doc)); + let geom = |n: &Node| match &n.appearance.fill().unwrap().paint { + Paint::Gradient(g) => { + let g = g.geom.unwrap(); + format!("{:.2} {:.2} {:.2} {:.2} {:.2}", g.start.x, g.start.y, g.end.x, g.end.y, g.aspect) + } + other => panic!("{other:?}"), + }; + assert_eq!(geom(&styled), geom(&plain)); +} + +/// The effects of a pattern's tile art are its own: a styled group's drawn look that first uses the +/// pattern still warns that the tile's Gaussian Blur is left out. +#[test] +fn effects_in_a_pattern_a_styled_look_uses_are_warned_about() { + let mut doc = Document::new(200.0, 100.0); + let dot = styled_rect( + &mut doc, + "Dot", + Rect::new(2.0, 2.0, 8.0, 8.0), + solid(Color::BLACK), + Paint::None, + vec![fx("blur.gaussian", serde_json::json!({}))], + ); + let mut grid = vectorcraft_doc::PatternDef::new("Grid", vec![Arc::new(dot)]); + grid.tile = Rect::new(0.0, 0.0, 10.0, 10.0); + doc.patterns.push(grid); + let tiles = Paint::Pattern { pattern: "Grid".into(), xf: Affine::IDENTITY }; + let member = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)), Appearance::basic(tiles, Paint::None, 0.0)); + let mut g = group(&mut doc, vec![member], false); + g.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::default() }.into(); + put(&mut doc, g); + let out = export_ai_editing(&doc, &opts()).unwrap(); + let p = shown(&out.program); + assert!(p.contains("%AI3_BeginPattern") && p.contains("\n1 (Anon 1) XW\n"), "{p}"); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); +} + +/// A style that isn't written defines no gradient: a hidden gradient fill that only the style +/// would have held leaves no gradient the art uses. +#[test] +fn a_style_that_isnt_written_defines_no_gradient() { + let mut doc = Document::new(200.0, 100.0); + let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]; + let graded = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let ap = Appearance { + items: vec![ + AppearanceItem::Fill(FillLayer { visible: false, ..FillLayer::new(graded) }), + AppearanceItem::Stroke(StrokeLayer::new(solid(Color::BLACK), 2.0)), + ], + effects: vec![fx("blur.gaussian", serde_json::json!({}))], + contents_index: None, + }; + let n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + let p = shown(&out.program); + assert!(!p.contains("(Anon 1) XW"), "{p}"); + assert!(p.contains("%%BeginSetup\n0 Bn\n"), "{p}"); +} + +/// An object in a plain group, a sublayer or a clipping group by a path keeps its style there. One +/// in a symbol's art, in an opacity mask or in art cut to a clipping object other than a path is +/// written as its drawn look, with a warning. +#[test] +fn where_a_styled_object_keeps_its_style() { + let inside = |doc: &mut Document| plain_rect(doc, "Inside", vec![drop_shadow()]); + let found = |doc: &Document| { + fn find(nodes: &[Arc]) -> Option<&Arc> { + nodes.iter().find_map(|n| if n.name.as_deref() == Some("Inside") { Some(n) } else { n.children().and_then(|c| find(c)) }) + } + find(&doc.layers).cloned() + }; + // In a plain group. + let mut doc = Document::new(200.0, 100.0); + let n = inside(&mut doc); + let g = group(&mut doc, vec![n], false); + put(&mut doc, g); + assert_eq!(found(&read_back(&doc)).unwrap().appearance.effects.len(), 1); + same_styles(&read_back(&doc), &doc); + // In a sublayer. + let mut doc = Document::new(200.0, 100.0); + let n = inside(&mut doc); + let mut sub = Node::layer(doc.alloc_id(), "Sub", LayerColor::Preset(1)); + sub.children_mut().unwrap().push(Arc::new(n)); + put(&mut doc, sub); + assert_eq!(found(&read_back(&doc)).unwrap().appearance.effects.len(), 1); + // In a clipping group. + let mut doc = Document::new(200.0, 100.0); + let clip = Node::new( + doc.alloc_id(), + NodeKind::Path { + path: shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 60.0)), + rule: FillRule::NonZero, + live: None, + clipping: true, + guide: false, + }, + ); + let n = inside(&mut doc); + let g = group(&mut doc, vec![clip, n], true); + put(&mut doc, g); + assert_eq!(found(&read_back(&doc)).unwrap().appearance.effects.len(), 1); + // In a symbol's art: the instance keeps the symbol, the effects are left out. + let mut doc = Document::new(200.0, 100.0); + let n = inside(&mut doc); + doc.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(n) }); + let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((50.0, 20.0)) }); + put(&mut doc, instance); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + assert!(!shown(&out.program).contains("(Anon 1) XW")); + // In an opacity mask. + let mut doc = Document::new(200.0, 100.0); + let art = inside(&mut doc); + let mut n = square(&mut doc, Rect::new(10.0, 10.0, 60.0, 60.0), Color::BLACK); + n.extras_mut().mask = Some(OpacityMask { art: Arc::new(art), clip: true, invert: false, disabled: false, linked: true }.into()); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + assert!(!shown(&out.program).contains("(Anon 1) XW")); + // In art cut to a clipping object other than a path: type. + let mut doc = Document::new(200.0, 100.0); + 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 n = inside(&mut doc); + let g = group(&mut doc, vec![t, n], true); + put(&mut doc, g); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + assert!(!shown(&out.program).contains("(Anon 1) XW")); +} + +/// A styled group's members of every kind allowed come back as they were: a compound path, a named +/// path, a hidden and a locked path, a nested group and a clipping group. +#[test] +fn a_styled_groups_members_of_each_kind_are_read_back() { + let mut doc = Document::new(200.0, 100.0); + let solid_ap = Appearance::basic(solid(Color::rgb(0.2, 0.4, 0.6)), Paint::None, 0.0); + let compound = named(ring(&mut doc, solid_ap.clone()), "Compound"); + let plain = named(square(&mut doc, Rect::new(10.0, 10.0, 30.0, 30.0), Color::rgb(0.8, 0.1, 0.1)), "Named"); + let mut quiet = named(square(&mut doc, Rect::new(30.0, 10.0, 50.0, 30.0), Color::rgb(0.1, 0.8, 0.1)), "Hidden"); + quiet.visible = false; + let mut fixed = named(square(&mut doc, Rect::new(50.0, 10.0, 70.0, 30.0), Color::rgb(0.1, 0.1, 0.8)), "Locked"); + fixed.locked = true; + let a = square(&mut doc, Rect::new(10.0, 40.0, 30.0, 60.0), Color::BLACK); + let nested = named(group(&mut doc, vec![a], false), "Nested"); + let clip = Node::new( + doc.alloc_id(), + NodeKind::Path { + path: shapes::rectangle(Rect::new(40.0, 40.0, 60.0, 60.0)), + rule: FillRule::NonZero, + live: None, + clipping: true, + guide: false, + }, + ); + let b = square(&mut doc, Rect::new(40.0, 40.0, 80.0, 80.0), Color::WHITE); + let cut = named(group(&mut doc, vec![clip, b], true), "Cut"); + let mut g = named(group(&mut doc, vec![compound, plain, quiet, fixed, nested, cut], false), "Holder"); + g.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::default() }.into(); + put(&mut doc, g); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let back = read_back(&doc); + same_styles(&back, &doc); + let members = |d: &Document| { + let mut out = vec![]; + view(d.layers[0].children().unwrap()[0].children().unwrap(), 0, &mut out); + out + }; + same_lines(&members(&back), &members(&doc)); +} + +/// What compares two appearances for the writer's checks and for these tests tells them apart. +#[test] +fn same_appearance_tells_appearances_apart() { + use serde_json::json; + let base = || Appearance { + items: vec![ + AppearanceItem::Fill(FillLayer::new(solid(Color::rgb(0.5, 0.25, 0.75)))), + AppearanceItem::Stroke(StrokeLayer::new(black_fill(), 2.0)), + ], + effects: vec![fx("stylize.dropShadow", json!({"x": 10.0, "mode": "Multiply", "color": "#102030"}))], + contents_index: None, + }; + let same = writer::same_appearance; + assert!(same(&base(), &base())); + // Within the written precision, a mode in another case and a color in another form. + let mut other = base(); + other.effects[0].params["x"] = json!(10.005); + other.effects[0].params["mode"] = json!("multiply"); + other.effects[0].params["color"] = serde_json::to_value(Color::from_hex("#102030").unwrap()).unwrap(); + assert!(same(&other, &base())); + // A fill or stroke that doesn't paint. + let mut other = base(); + other.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None))); + assert!(same(&other, &base())); + type Change = Box; + let change: Vec = vec![ + Box::new(|a| a.effects[0].params["x"] = json!(10.01)), + Box::new(|a| a.effects[0].params["color"] = json!("#102031")), + Box::new(|a| a.effects[0].params["mode"] = json!("screen")), + Box::new(|a| a.effects[0].visible = false), + Box::new(|a| a.effects[0].id = "stylize.outerGlow".into()), + Box::new(|a| a.effects.clear()), + Box::new(|a| a.items.truncate(1)), + Box::new(|a| a.items.swap(0, 1)), + Box::new(|a| a.contents_index = Some(2)), + Box::new(|a| { + if let AppearanceItem::Fill(f) = &mut a.items[0] { + f.paint = solid(Color::rgb(0.5, 0.25, 0.76)); + } + }), + Box::new(|a| { + if let AppearanceItem::Fill(f) = &mut a.items[0] { + f.opacity = 0.5; + } + }), + Box::new(|a| { + if let AppearanceItem::Stroke(s) = &mut a.items[1] { + s.width = 2.5; + } + }), + ]; + for (i, f) in change.iter().enumerate() { + let mut other = base(); + f(&mut other); + assert!(!same(&other, &base()), "change {i}"); + } +} + +/// A gradient fill in a style is placed as on an object without a style: a turned linear gradient +/// and a radial one with a focal point, and on a first artboard away from the origin among several. +#[test] +fn a_styled_objects_gradient_is_placed_as_a_plain_objects_is() { + use vectorcraft_color::gradient::GradientGeom; + let stops = || vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::rgb(0.1, 0.2, 0.8))]; + let turned = GradientGeom { start: (120.0, 60.0).into(), end: (170.0, 90.0).into(), aspect: 1.0, focal: None }; + let mut focal = GradientGeom { start: (140.0, 70.0).into(), end: (170.0, 70.0).into(), aspect: 0.5, focal: None }; + focal.set_focal(Some((150.0, 65.0).into())); + let boards = [Rect::new(0.0, 0.0, 300.0, 200.0), Rect::new(100.0, 50.0, 400.0, 250.0)]; + for (kind, geom) in [(GradientKind::Linear, turned), (GradientKind::Radial, focal)] { + for board in boards { + let paint = gradient_paint(Gradient::new(kind, stops()), Some(geom), None); + let make = |effects: Vec| { + let mut doc = Document::new(300.0, 200.0); + doc.artboards[0].rect = board; + let second = doc.artboards[0].clone(); + doc.artboards.push(Artboard { rect: board + vectorcraft_geom::Vec2::new(400.0, 0.0), ..second }); + let n = styled_rect(&mut doc, "G", Rect::new(120.0, 60.0, 180.0, 100.0), paint.clone(), Paint::None, effects); + put(&mut doc, n); + doc + }; + let (with, without) = (read_back(&make(vec![drop_shadow()])), read_back(&make(vec![]))); + let (a, b) = (&with.layers[0].children().unwrap()[0], &without.layers[0].children().unwrap()[0]); + assert_eq!(a.appearance.effects.len(), 1); + let geom = |n: &Node| match &n.appearance.fill().unwrap().paint { + Paint::Gradient(g) => { + let g = g.geom.unwrap(); + format!( + "{:.2} {:.2} {:.2} {:.2} {:.2} {:?}", + g.start.x, + g.start.y, + g.end.x, + g.end.y, + g.aspect, + g.focal.map(|f| (r3(f.x), r3(f.y))) + ) + } + other => panic!("{other:?}"), + }; + assert_eq!(geom(a), geom(b)); + same_lines(&outlines(&with), &outlines(&without)); + } + } +} + +/// On a large canvas an object with an effect warns and comes back as its drawn look at its place. +#[test] +fn a_large_canvas_writes_every_object_as_its_look() { + let warns = |doc: &Document| export_ai_editing(doc, &opts()).unwrap().warnings.iter().any(|w| w == writer::LARGE_CANVAS_STYLES); + let mut plain = Document::new(20000.0, 100.0); + let n = plain_rect(&mut plain, "None", vec![]); + put(&mut plain, n); + assert!(!warns(&plain)); + let mut shaded = Document::new(20000.0, 100.0); + let n = plain_rect(&mut shaded, "Shaded", vec![drop_shadow()]); + put(&mut shaded, n); + assert!(warns(&shaded)); + let back = read_back(&shaded); + let n = &back.layers[0].children().unwrap()[0]; + assert!(n.appearance.effects.is_empty()); + let p = n.path_data().unwrap().subpaths[0].anchors[0].p; + assert!((p.x - 10.0).abs() < 1e-2 && (p.y - 10.0).abs() < 1e-2, "{p:?}"); +} diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 14fa89fe6..89fd5abb7 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -17,9 +17,14 @@ use crate::ps; mod images; mod paints; +mod styles; mod symbols; mod texts; +#[cfg(test)] +pub(super) use styles::same_appearance; +pub(super) use styles::{GRAPHIC_STYLES, LARGE_CANVAS_STYLES, has_graphic_styles, styled_objects}; + pub(super) use images::PAYLOAD; #[cfg(test)] pub(super) use images::{IMAGE_DEPTH, IMAGES, LINKS_EMBEDDED}; @@ -208,6 +213,14 @@ pub(super) struct Writer<'a> { symbol_scales: HashMap, /// The entries a symbol's dictionary adds to its art's dictionary, and the art's id. root_entries: Option<(vectorcraft_doc::NodeId, String)>, + /// The objects that may be written with an art style, as they were before baking, by id + /// ([`styles::styled_objects`]). + pub(super) styled: HashMap>, + /// The art styles written ([`styles`]). + styles: styles::Styles, + /// An object's drawn look is being written: no object in it is written with a style, and its + /// effects are the style's. + in_look: bool, } impl<'a> Writer<'a> { @@ -257,6 +270,9 @@ impl<'a> Writer<'a> { inner_scale: 1.0, symbol_scales: HashMap::new(), root_entries: None, + styled: HashMap::new(), + styles: styles::Styles::default(), + in_look: false, } } @@ -445,6 +461,9 @@ impl<'a> Writer<'a> { /// Warn when `n` has effects the data leaves out. fn effects(&mut self, n: &Node) { + if self.in_look { + return; + } 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); @@ -459,6 +478,17 @@ impl<'a> Writer<'a> { if self.in_symbol { self.wrote_fixed |= symbols::redrawn(n); } + // On the layers, outside art cut to a clipping object: with its art style, when it has one. + if !self.in_look + && self.hidden == 0 + && self.nest == 0 + && self.clip.is_none() + && !self.in_symbol + && let Some(orig) = self.styled.get(&n.id).cloned() + && let Some(name) = self.object_style(n, &orig) + { + return self.styled_object(n, &orig, &name); + } self.effects(n); self.state(n.locked, !n.visible); let xy = Xy::of(n); diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs index a29ab3df9..b4966968e 100644 --- a/crates/eps/src/ai_editing/writer/paints.rs +++ b/crates/eps/src/ai_editing/writer/paints.rs @@ -103,6 +103,22 @@ impl Writer<'_> { name } + /// The gradient definitions' state, for [`Self::gradient_rollback`]: their count, which ones the + /// art uses, and the next name's number. + pub(super) fn gradient_mark(&self) -> (usize, Vec, u64) { + (self.gradients.len(), self.gradients.iter().map(|d| d.used).collect(), self.next_gradient) + } + + /// The gradient definitions as they were at `mark` ([`Self::gradient_mark`]). + pub(super) fn gradient_rollback(&mut self, mark: (usize, Vec, u64)) { + let (len, used, next) = mark; + self.gradients.truncate(len); + for (d, u) in self.gradients.iter_mut().zip(used) { + d.used = u; + } + self.next_gradient = next; + } + /// `p` as the paint of a fill or stroke whose gradient is placed on `bounds`: a gradient is /// placed there (it then no longer depends on the shape it paints, which may be cut); one the /// data can't hold is its average color, with a warning. @@ -222,24 +238,11 @@ impl Writer<'_> { /// last stop; a radial one's `… Bm` (the unit circle onto its extent ellipse). fn instance(&mut self, g: &GradientPaint, stroke: bool, clip: bool, path: &BezPath) { let Some(geom) = g.geom else { return }; - let name = self.define_gradient(&g.gradient, g.swatch.as_deref()); self.line(if stroke { "1 Bb" } else { "Bb" }); + self.instance_head(g, clip, path); let m = self.grad_space; let u = geom.end - geom.start; let radial = g.gradient.kind == GradientKind::Radial; - let place = placement(&geom, radial, &(self.canvas * path.clone()), self.canvas); - if radial { - let c = m * geom.start; - let off = geom.focal.map_or(Vec2::ZERO, |f| m * f - c); - let (angle, fraction) = if off.hypot() > 1e-9 { place.focal } else { (0.0, 0.0) }; - let off = if fraction > 0.0 { off } else { Vec2::ZERO }; - ps::push_nums(&mut self.body, &[off.x, off.y, angle, fraction]); - self.line("Bh"); - } - let _ = write!(self.body, "{} {} ", u8::from(!clip), ps::bytes(name.as_bytes())); - ps::push_nums(&mut self.body, &place.bg); - ps::push_nums(&mut self.body, &place.transform.as_coeffs()); - self.line("1 Bg"); // The unit vector along the gradient, and across it as long. let frame = |from: f64, to: f64| { let p = geom.start + u * from; @@ -267,6 +270,28 @@ impl Writer<'_> { matrix(frame(last, last + CAP / len), "Bc"); } + /// A gradient instance's focal point (`Bh`, radial gradients only) and its `Bg` line, as + /// [`Self::instance`] writes them; also what a fill's art style holds after its `Bb`. + pub(super) fn instance_head(&mut self, g: &GradientPaint, clip: bool, path: &BezPath) { + let Some(geom) = g.geom else { return }; + let name = self.define_gradient(&g.gradient, g.swatch.as_deref()); + let m = self.grad_space; + let radial = g.gradient.kind == GradientKind::Radial; + let place = placement(&geom, radial, &(self.canvas * path.clone()), self.canvas); + if radial { + let c = m * geom.start; + let off = geom.focal.map_or(Vec2::ZERO, |f| m * f - c); + let (angle, fraction) = if off.hypot() > 1e-9 { place.focal } else { (0.0, 0.0) }; + let off = if fraction > 0.0 { off } else { Vec2::ZERO }; + ps::push_nums(&mut self.body, &[off.x, off.y, angle, fraction]); + self.line("Bh"); + } + let _ = write!(self.body, "{} {} ", u8::from(!clip), ps::bytes(name.as_bytes())); + ps::push_nums(&mut self.body, &place.bg); + ps::push_nums(&mut self.body, &place.transform.as_coeffs()); + self.line("1 Bg"); + } + /// A gradient definition: `%AI5_BeginGradient: (name)`, `(name) type stops Bd` (type 0 linear, /// 1 radial), `[`, each stop on a `%_` line (`color style opacity 6 midpoint rampPoint Bs`), a /// linear gradient's from the last to the first and a radial one's from the first to the last, @@ -390,9 +415,12 @@ impl Writer<'_> { self.line("n\n%_/ArtDictionary :\n%_1 /Bool (AIPattern_Editor_Backing_Tile_Rect) ,\n%_;\n%_"); self.nest += 1; self.hidden += 1; + // The tile's effects are its own, not those of a drawn look that first uses it. + let in_look = std::mem::take(&mut self.in_look); for a in &def.art { self.node(a); } + self.in_look = in_look; self.hidden -= 1; self.nest -= 1; let art; diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs new file mode 100644 index 000000000..de74bc99e --- /dev/null +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -0,0 +1,685 @@ +//! Art styles: objects with live effects a plain path can't hold, written as their drawn look, the +//! object itself on `%_` lines and `1 (style) XW`, and the styles they name in +//! `%AI9_BeginArtStyles` (see [`crate::ai_editing`] for the form). Each style is read back before it +//! is written ([`crate::import::read_art_style`]: the `.ai` reader's style parser and its reading of +//! a graphic style, with the object's own gradient): one that reads back differently is not +//! written, and the object is written as its drawn look alone. + +use std::collections::HashMap; +use std::fmt::Write; +use std::sync::Arc; + +use serde_json::Value; +use vectorcraft_color::{BlendMode, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Knockout, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_geom::{BezPath, FillRule, Rect, Shape}; + +use super::{Writer, Xy, blend_code, compound_path, hide}; +use crate::ai_ops; +use crate::ps; + +pub(in crate::ai_editing) const LARGE_CANVAS_STYLES: &str = + "on a large canvas, objects with live effects are written as their drawn look in the editing data for other apps"; +pub(in crate::ai_editing) const GRAPHIC_STYLES: &str = "graphic styles and links to them are left out of the editing data for other apps"; + +/// Does `doc` have graphic styles other than a new document's, or an object in its layers, opacity +/// masks or symbols linked to a graphic style? +pub(in crate::ai_editing) fn has_graphic_styles(doc: &Document) -> bool { + fn linked(n: &Node) -> bool { + n.extras().graphic_style.is_some() + || n.extras().mask.as_ref().is_some_and(|m| linked(&m.art)) + || n.children().is_some_and(|c| c.iter().any(|m| linked(m))) + } + doc.graphic_styles != Document::new(1.0, 1.0).graphic_styles || doc.layers.iter().any(|l| linked(l)) || doc.symbols.iter().any(|s| linked(&s.art)) +} + +/// The objects of `doc`'s layers that may be written with an art style ([`Writer::styled_object`]), +/// by id: paths and compound paths with points and groups, with live effects, without Opacity & +/// Mask Define Knockout Shape (the reader takes an object's transparency from its look, which +/// doesn't carry it). A group's members are paths, compound paths or such groups, none with effects +/// or anything baking changes. +pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> HashMap> { + fn walk(nodes: &[Arc], out: &mut HashMap>) { + for n in nodes { + if candidate(n) { + out.insert(n.id, n.clone()); + continue; + } + // Baking a container with effects of its own changes its members, which keep their ids: + // they are written as baking left them. + if let NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } = &n.kind + && !bakes_members(n) + { + walk(children, out); + } + } + } + let mut out = HashMap::new(); + walk(&doc.layers, &mut out); + out +} + +/// Does container `n` need baking for what it has itself, whatever its members hold? +fn bakes_members(n: &Node) -> bool { + let mut own = n.clone(); + if let Some(children) = own.children_mut() { + children.clear(); + } + vectorcraft_effects::needs_bake(&own) +} + +/// Whether `n` may be written with an art style ([`styled_objects`]). +fn candidate(n: &Node) -> bool { + if n.appearance.effects.is_empty() || n.knockout_shape { + return false; + } + match &n.kind { + NodeKind::Path { path, guide: false, clipping: false, .. } => has_points(path), + NodeKind::Compound { .. } => has_points(&compound_path(n)), + NodeKind::Group { children, clip: false } => !n.is_compound_shape() && children.iter().all(|c| plain_member(c)), + _ => false, + } +} + +/// Does `path` write anything ([`Writer::plain_object`] skips subpaths without points)? +fn has_points(path: &vectorcraft_geom::PathData) -> bool { + path.subpaths.iter().any(|sp| !sp.anchors.is_empty()) +} + +/// A member of a group written with an art style: written as it is, and read back as it is. +fn plain_member(n: &Node) -> bool { + let quiet = n.appearance.effects.is_empty() && n.appearance.items.iter().all(|i| i.effects().is_empty()) && !vectorcraft_effects::needs_bake(n); + quiet + && match &n.kind { + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => true, + NodeKind::Group { children, .. } => children.iter().all(|c| plain_member(c)), + _ => false, + } +} + +/// The art styles objects name, each with its text after its name (`/ActiveStyle :` to `/Def ;`). +#[derive(Default)] +pub(in crate::ai_editing) struct Styles { + /// Every style, in the order written: its name and text. + known: Vec<(String, String)>, + /// The name of each style by its text. + by_text: HashMap, + next_anon: u64, +} + +impl Styles { + /// The name of an object style of `text`: a style with the same text already written, else a + /// new one named `Anon` and a number. + fn object_style(&mut self, text: String) -> String { + if let Some(name) = self.by_text.get(&text) { + return name.clone(); + } + self.next_anon += 1; + let name = format!("Anon {}", self.next_anon); + self.by_text.insert(text.clone(), name.clone()); + self.known.push((name.clone(), text)); + name + } +} + +/// One style as the reader's section holds it: `/KnownStyle :`, `(name) /Name ,`, its text. +fn known_style(name: &str, text: &str) -> String { + format!("/KnownStyle :\n{} /Name ,\n{text}", ps::bytes(name.as_bytes())) +} + +/// The number transparency's `Xy` gives the blending mode a live effect's `mode` names (any case, +/// spaces and dashes ignored). +fn mode_code(mode: &str) -> Option { + let key: String = mode.chars().filter(char::is_ascii_alphanumeric).collect::().to_ascii_lowercase(); + BlendMode::ALL.iter().find(|m| format!("{m:?}").to_ascii_lowercase() == key).map(|m| blend_code(*m)) +} + +/// A filter's `Xy` line: `mode opacity isolate knockout shape Xy`. +fn xy_line(s: &mut String, xy: Xy) { + let knockout = match xy.knockout { + Knockout::Off => 0, + Knockout::On => 1, + Knockout::Neutral => 2, + }; + let _ = write!(s, "{} ", blend_code(xy.blend)); + ps::push_num(s, f64::from(xy.opacity)); + let _ = writeln!(s, " {} {knockout} {} Xy", u8::from(xy.isolate), u8::from(xy.shape)); +} + +/// The line style of a paint style that isn't a stroke's. +const NO_LINE: &str = "0 J 0 j 1 w 10 M []0 d\n"; + +/// A basic filter's start: its name and kind numbers (`1 0` for an effect, `0 0` for the +/// style's own filters), `/Visible` and the dictionary's start. +fn basic(s: &mut String, name: &str, effect: bool, visible: bool) { + let _ = write!(s, "/BasicFilter :\n{} {} 0 /Filter ,\n{} /Visible ,\n", ps::bytes(name.as_bytes()), u8::from(effect), u8::from(visible)); +} + +/// The filter that stands for the object's contents in its stack. +const CONDUIT: &str = "/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n"; + +/// A basic filter's end. +const END_BASIC: &str = "; /Dict ;\n /Part ,\n"; + +/// The blend filter of transparency `xy`. +fn blend_filter(s: &mut String, xy: Xy) { + basic(s, "Blend Style Filter", false, true); + s.push_str("/Dictionary : /NotRecorded ,\n/BlendStyle : "); + xy_line(s, xy); + s.push_str(NO_LINE); + s.push_str("0 XR\n/Def ; \n (BlendStyle) ,\n"); + s.push_str(END_BASIC); +} + +/// A live effect's dictionary entries. +#[derive(Default)] +struct Entries(String); + +impl Entries { + fn real(&mut self, key: &str, v: f64) { + ps::push_num(&mut self.0, v); + let _ = writeln!(self.0, " /Real {} ,", ps::bytes(key.as_bytes())); + } + + fn int(&mut self, key: &str, v: i64) { + let _ = writeln!(self.0, "{v} /Int {} ,", ps::bytes(key.as_bytes())); + } + + fn bool(&mut self, key: &str, v: bool) { + let _ = writeln!(self.0, "{} /Bool {} ,", u8::from(v), ps::bytes(key.as_bytes())); + } +} + +/// `p`'s entry `key` as a number. +fn num(p: &Value, key: &str) -> Option { + p.get(key)?.as_f64().filter(|v| v.is_finite()) +} + +/// `p`'s entry `key` as text, for comparing: lower case, letters and digits only. +fn word(p: &Value, key: &str) -> Option { + Some(p.get(key)?.as_str()?.chars().filter(char::is_ascii_alphanumeric).collect::().to_ascii_lowercase()) +} + +/// `p`'s entry `key` as a switch. +fn flag(p: &Value, key: &str) -> Option { + p.get(key)?.as_bool() +} + +impl Writer<'_> { + /// What `paint` writes with [`Self::color`] (a color, a spot or global color), as a string. + fn color_ops(&mut self, paint: &Paint, stroke: bool) -> String { + let saved = std::mem::take(&mut self.body); + self.color(paint, stroke); + std::mem::replace(&mut self.body, saved) + } + + /// A fill style holding a color, as an effect's entry `key` or a fill's own. + fn fill_style(&mut self, s: &mut String, color: vectorcraft_color::Color, key: &str) { + let ops = self.color_ops(&Paint::solid(color), false); + let _ = write!(s, "/FillStyle : 0 O\n{ops}0 1 0 0 0 Xy\n{NO_LINE}0 XR\n0 1 Xd\n/Def ; \n {} ,\n", ps::bytes(key.as_bytes())); + } + + /// A fill's basic filter: a color, or a gradient placed on the object's outline (`on`: the + /// object's own outline and rule), filling by the non-zero rule. `None` for other paint. + fn fill_filter(&mut self, s: &mut String, f: &FillLayer, visible: bool, on: Option<(&BezPath, FillRule)>) -> Option<()> { + if f.overprint { + return None; + } + // A color comes before the paint state, a gradient's instance after it. + let (color, gradient) = match &f.paint { + Paint::Solid { .. } => (self.color_ops(&f.paint, false), String::new()), + Paint::Gradient(_) => { + let (path, _) = on?; + let Paint::Gradient(placed) = self.placed(&f.paint, path.bounding_box()) else { return None }; + (String::new(), format!("Bb\n{}0 BB\n", self.gradient_bg(&placed, path)?)) + } + Paint::Pattern { .. } | Paint::None => return None, + }; + basic(s, "Fill Style Filter", false, visible); + let _ = write!( + s, + "/Dictionary : /NotRecorded ,\n0 /Bool (UseEvenOdd) ,\n/FillStyle : 0 O\n{color}0 1 0 0 0 Xy\n{NO_LINE}0 XR\n0 1 Xd\n{gradient}" + ); + s.push_str("/Def ; \n (FillStyle) ,\n"); + s.push_str(END_BASIC); + Some(()) + } + + /// A stroke's basic filter: a color, its line style, and its width profile as the arrays + /// `BSVariableWidthTValuesArray`, `BSLeftWidthsArray` and `BSRightWidthsArray`. `None` for + /// other paint. + fn stroke_filter(&mut self, s: &mut String, st: &StrokeLayer, visible: bool) -> Option<()> { + if !matches!(st.paint, Paint::Solid { .. }) || st.overprint { + return None; + } + let ops = self.color_ops(&st.paint, true); + basic(s, "Stroke Style Filter", false, visible); + s.push_str("2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n/StrokeStyle : 0 R\n"); + s.push_str(&ops); + s.push_str("0 1 0 0 0 Xy\n"); + ai_ops::line_style(s, st); + s.push_str("0 XR\n1 0 Xd\n/Def ; \n (StrokeStyle) ,\n"); + if let Some(p) = &st.profile { + let array = |s: &mut String, key: &str, values: &mut dyn Iterator| { + s.push_str("/Array :"); + for v in values { + s.push(' '); + ps::push_num(s, v); + s.push_str(" /Real ,"); + } + let _ = writeln!(s, " ; {} ,", ps::bytes(key.as_bytes())); + }; + array(s, "BSVariableWidthTValuesArray", &mut p.points.iter().map(|q| q.0)); + array(s, "BSLeftWidthsArray", &mut p.points.iter().map(|q| q.1)); + array(s, "BSRightWidthsArray", &mut p.points.iter().map(|q| q.2)); + } + s.push_str(END_BASIC); + Some(()) + } + + /// A gradient instance's focal point (`Bh`, radial gradients only) and its `flag (name) x y + /// angle length a b c d tx ty 1 Bg` line for placed gradient `g` on `path`, as a fill's style + /// holds them ([`Self::instance_head`]). + fn gradient_bg(&mut self, g: &vectorcraft_color::GradientPaint, path: &BezPath) -> Option { + let saved = std::mem::take(&mut self.body); + self.instance_head(g, false, path); + let line = std::mem::replace(&mut self.body, saved); + (!line.is_empty()).then_some(line) + } + + /// An appearance item's basic filter. `None` for an item with transparency or effects of its + /// own, which the reader doesn't read on an object. + fn item_filter(&mut self, s: &mut String, item: &AppearanceItem, on: Option<(&BezPath, FillRule)>) -> Option<()> { + if item.opacity() < 1.0 || item.blend() != BlendMode::Normal || !item.effects().is_empty() { + return None; + } + match item { + AppearanceItem::Fill(f) => self.fill_filter(s, f, item.visible(), on), + AppearanceItem::Stroke(st) => self.stroke_filter(s, st, item.visible()), + } + } + + /// The text of a style of appearance `ap` and transparency `xy` (after its name): the chain + /// of its stack (the fills and strokes that paint, the top one first, the contents among them + /// where the appearance puts them), its effects and its blend filter. A gradient fill is + /// placed on the outline of `on`, and a fill fills by its rule (`None`: the non-zero rule, on no + /// object). `None` when a fill, stroke or effect isn't one written here. + pub(super) fn style_text(&mut self, ap: &Appearance, xy: Xy, on: Option<(&BezPath, FillRule)>) -> Option { + let mut s = String::from( + "/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n", + ); + let ci = ap.contents_index.unwrap_or(0).min(ap.items.len()); + let painting: Vec<(usize, &AppearanceItem)> = ap.items.iter().enumerate().filter(|(_, i)| !i.paint().is_none()).collect(); + let below = painting.iter().filter(|(at, _)| *at < ci).count(); + // Listed top first: the contents go above the `below` fills and strokes under them. + for (written, (_, item)) in painting.iter().rev().enumerate() { + if painting.len() - written == below { + s.push_str(CONDUIT); + } + self.item_filter(&mut s, item, on)?; + } + if below == 0 { + s.push_str(CONDUIT); + } + s.push_str(";\n /Part ,\n"); + for e in &ap.effects { + s.push_str(&self.effect_filter(e)?); + } + blend_filter(&mut s, xy); + s.push_str(";\n\n/Execution ;\n /Def ;\n"); + Some(s) + } + + /// A live effect's basic filter, with the settings the reader reads + /// ([`crate::import`]'s `.ai` reader, `style.rs`), in the units it reads them in. `None` for an + /// effect, or a setting, that isn't written here. + fn effect_filter(&mut self, e: &Effect) -> Option { + let p = vectorcraft_effects::merged_params(&e.id, &e.params); + let mut d = Entries::default(); + let id = e.id.as_str(); + let name = if id.starts_with("stylize.") { self.stylize_entries(id, &p, &mut d)? } else { Self::path_entries(id, &p, &mut d)? }; + let mut s = String::new(); + basic(&mut s, name, true, e.visible); + s.push_str("/Dictionary : /NotRecorded ,\n"); + s.push_str(&d.0); + s.push_str(END_BASIC); + Some(s) + } + + /// A Stylize effect's entries ([`Self::effect_filter`]) and its filter's name. + fn stylize_entries(&mut self, id: &str, p: &Value, d: &mut Entries) -> Option<&'static str> { + Some(match id { + "stylize.dropShadow" => { + d.int("blnd", i64::from(mode_code(p.get("mode")?.as_str()?)?)); + d.real("opac", num(p, "opacity")? / 100.0); + let darkness = word(p, "shade").as_deref() == Some("darkness"); + if darkness { + d.real("dark", num(p, "darkness")?); + } + d.real("vert", num(p, "y")?); + d.real("horz", num(p, "x")?); + d.real("blur", num(p, "blur")? / 2.0); + let color = vectorcraft_effects::color_param(p, vectorcraft_color::Color::BLACK); + self.fill_style(&mut d.0, color, "sclr"); + d.int("csrc", i64::from(darkness)); + "Adobe Drop Shadow" // brand-ok: the filter's name in the files + } + "stylize.innerGlow" => { + d.int("blnd", i64::from(mode_code(p.get("mode")?.as_str()?)?)); + d.real("opac", num(p, "opacity")? / 100.0); + d.real("blur", num(p, "blur")?); + let color = vectorcraft_effects::color_param(p, vectorcraft_color::Color::WHITE); + self.fill_style(&mut d.0, color, "gclr"); + d.int("gtyp", i64::from(word(p, "source").as_deref() == Some("edge"))); + "Adobe Inner Glow" // brand-ok: the filter's name in the files + } + "stylize.outerGlow" => { + d.int("blnd", i64::from(mode_code(p.get("mode")?.as_str()?)?)); + d.real("opac", num(p, "opacity")? / 100.0); + d.real("blur", num(p, "blur")? / 2.0); + let color = vectorcraft_effects::color_param(p, vectorcraft_color::Color::BLACK); + self.fill_style(&mut d.0, color, "sclr"); + "Adobe Outer Glow" // brand-ok: the filter's name in the files + } + "stylize.feather" => { + d.real("Radius", num(p, "radius")?); + "Adobe Fuzzy Mask" // brand-ok: the filter's name in the files + } + "stylize.roundCorners" => { + d.real("radius", num(p, "radius")?); + "Adobe Round Corners" // brand-ok: the filter's name in the files + } + "stylize.scribble" => { + d.real("Angle", num(p, "angle")?); + d.real("EdgeOverlap", num(p, "overlap")?); + d.real("EdgeOverlapVariation", num(p, "overlapVariation")?); + d.real("StrokeWidth", num(p, "strokeWidth")?); + d.real("Scribbliness", num(p, "curviness")? / 100.0); + d.real("ScribbleVariation", num(p, "curvinessVariation")? / 100.0); + d.real("Spacing", num(p, "spacing")?); + d.real("SpacingVariation", num(p, "spacingVariation")?); + "Adobe Scribble Fill" // brand-ok: the filter's name in the files + } + _ => return None, + }) + } + + /// Convert to Shape's, a Path effect's or Pathfinder › Add's entries ([`Self::effect_filter`]) + /// and its filter's name. + fn path_entries(id: &str, p: &Value, d: &mut Entries) -> Option<&'static str> { + Some(match id { + "convertToShape.rectangle" | "convertToShape.roundedRectangle" | "convertToShape.ellipse" => { + let shape = match id { + "convertToShape.rectangle" => 0, + "convertToShape.roundedRectangle" => 1, + _ => 2, + }; + let relative = flag(p, "relative")?; + d.int("Shape", shape); + d.real("Absolute", if relative { 0.0 } else { 1.0 }); + d.real("AbsWidth", num(p, "width")?); + d.real("AbsHeight", num(p, "height")?); + d.real("RelWidth", num(p, "extraW")? / 2.0); + d.real("RelHeight", num(p, "extraH")? / 2.0); + if shape == 1 { + d.real("CornerRadius", num(p, "radius")?); + } + "Adobe Shape Effects" // brand-ok: the filter's name in the files + } + "path.offsetPath" => { + d.real("ofst", num(p, "offset")?); + let joins = match word(p, "joins")?.as_str() { + "round" => 0, + "bevel" => 1, + "miter" => 2, + _ => return None, + }; + d.int("jntp", joins); + d.real("mlim", num(p, "miterLimit")?); + "Adobe Offset Path" // brand-ok: the filter's name in the files + } + "path.outlineObject" => "Adobe Outline Type", // brand-ok: the filter's name in the files + "path.outlineStroke" => "Adobe Outline Stroke", // brand-ok: the filter's name in the files + "pathfinder.add" => { + d.int("Command", 0); + d.bool("ConvertCustom", true); + d.bool("ExtractUnpainted", true); + d.real("Mix", 0.5); + d.real("Precision", 10.0); + d.bool("RemovePoints", false); + d.real("TrapAspect", 1.0); + d.bool("TrapConvertCustom", false); + d.real("TrapMaxTint", 1.0); + d.bool("TrapReverse", false); + d.real("TrapThickness", 0.25); + d.real("TrapTint", 0.4); + d.real("TrapTintTolerance", 0.05); + "Adobe Pathfinder" // brand-ok: the filter's name in the files + } + _ => return None, + }) + } + + /// The style object `orig` (`n` before baking) is written with; `None` when the reader wouldn't + /// read the object as it is with it. A gradient fill is placed on `n`'s outline, as the object + /// draws it. A style that isn't written leaves no gradient definition or warning behind. + pub(super) fn object_style(&mut self, n: &Node, orig: &Node) -> Option { + if let NodeKind::Group { children, .. } = &orig.kind + && bakes_members(orig) + && !children.iter().all(|c| self.member_keeps_outline(c)) + { + return None; + } + let graded = orig.appearance.items.iter().any(|i| matches!(i.paint(), Paint::Gradient(_))); + let saved = (graded.then(|| self.gradient_mark()), self.warnings.len()); + let path = drawn_outline(n, orig); + let rule = object_rule(orig); + let text = self.style_text(&orig.appearance, Xy::of(orig), path.as_ref().map(|p| (p, rule))).filter(|t| object_reads(orig, t)); + let Some(text) = text else { + if let Some(mark) = saved.0 { + self.gradient_rollback(mark); + } + self.warnings.truncate(saved.1); + return None; + }; + Some(self.styles.object_style(text)) + } + + /// Is member `m` of a styled group whose effects change its members' outlines (and each of its + /// own members) written on the outline those effects draw on: every fill and stroke as the + /// object paints it ([`Self::direct`], [`Self::direct_stroke`]), not as filled outlines, brush + /// art or pattern tiles, and no gradient that fits its object, which the effects would fit to + /// the new outline? + fn member_keeps_outline(&self, m: &Node) -> bool { + let items_keep = m.appearance.items.iter().all(|i| match i { + AppearanceItem::Fill(f) => f.paint.is_none() || self.direct(&f.paint), + AppearanceItem::Stroke(s) => s.paint.is_none() || self.direct_stroke(s), + }); + items_keep && m.children().is_none_or(|c| c.iter().all(|c| self.member_keeps_outline(c))) + } + + /// Object `n` (as baking left it; `orig` before baking) with the style `name`: its lock and + /// hidden state; its drawn look (a group of its vector art as baking left it, without shadows, + /// glows or feathers, the object's transparency + /// after the group's end, `0 0 Xd`, `6 () XW`); the object itself on `%_` lines (a path's or + /// compound path's points painted with nothing, or with its gradient fill when its style has + /// one; a group with its members), its name and opacity mask; then `1 (name) XW`. + pub(super) fn styled_object(&mut self, n: &Node, orig: &Node, name: &str) { + self.state(orig.locked, !orig.visible); + let xy = Xy::of(orig); + let was = self.in_look; + self.in_look = true; + self.line("u"); + let mut look = n.clone(); + (look.name, look.opacity, look.blend, look.isolate, look.knockout, look.knockout_shape) = + (None, 1.0, BlendMode::Normal, false, Knockout::Neutral, false); + if look.extras().mask.is_some() { + look.extras_mut().mask = None; + } + match &look.kind { + NodeKind::Group { children, clip: false } => { + for c in children.clone() { + self.node(&c); + } + } + _ => self.node(&look), + } + self.line("U"); + self.write_xy(xy); + self.line("0 0 Xd\n6 () XW"); + self.in_look = was; + // The object, on `%_` lines, in its own state (the look's members may have changed it). + self.state(orig.locked, !orig.visible); + let saved = std::mem::take(&mut self.body); + self.hidden += 1; + let fit = drawn_outline(n, orig).map(|p| p.bounding_box()); + match &orig.kind { + NodeKind::Path { path, rule, .. } => self.styled_outline(orig, path, *rule, fit, xy), + NodeKind::Compound { rule, .. } => self.styled_outline(orig, &compound_path(orig), *rule, fit, xy), + NodeKind::Group { children, .. } => { + self.line("u"); + for c in children { + self.node(c); + } + self.line("U"); + } + _ => {} + } + self.dictionary(orig); + self.hidden -= 1; + let part = std::mem::replace(&mut self.body, saved); + hide(&part, &mut self.body); + self.block_end = usize::MAX; + let _ = writeln!(self.body, "1 {} XW", ps::bytes(name.as_bytes())); + } + + /// A styled path's or compound path's points: painted with nothing, or with its gradient fill + /// (which its style's gradient fill is), placed on `fit` (the outline's bounds when `None`). + fn styled_outline(&mut self, orig: &Node, path: &vectorcraft_geom::PathData, rule: vectorcraft_geom::FillRule, fit: Option, xy: Xy) { + let bounds = fit.unwrap_or_else(|| path.to_bezpath().bounding_box()); + let gradient = orig.appearance.items.iter().find_map(|i| match i { + AppearanceItem::Fill(f) if matches!(f.paint, Paint::Gradient(_)) => Some(f), + _ => None, + }); + match gradient { + Some(f) => { + let fill = FillLayer { paint: self.placed(&f.paint, bounds), ..f.clone() }; + self.plain_object(path, rule, Some(&fill), None, xy.object()); + } + None => self.plain_object(path, rule, None, None, xy.object()), + } + } + + /// The art styles written, in a non-printing block: `%AI9_BeginArtStyles`, each style, + /// `%AI9_EndArtStyles`. + pub(in crate::ai_editing) fn art_styles(&self, s: &mut String) { + if self.styles.known.is_empty() { + return; + } + s.push_str("%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyles\n"); + for (name, text) in &self.styles.known { + s.push_str(&known_style(name, text)); + } + s.push_str("%AI9_EndArtStyles\n%AI5_End_NonPrinting--\n"); + } +} + +/// The outline a path's or compound path's style places a gradient fill on. +fn object_outline(n: &Node) -> Option { + match &n.kind { + NodeKind::Path { path, .. } => Some(path.to_bezpath()), + NodeKind::Compound { .. } => Some(compound_path(n).to_bezpath()), + _ => None, + } +} + +/// The outline a styled path or compound path draws: `n`'s, as baking left it, when it is still a +/// path or compound path, else `orig`'s. +fn drawn_outline(n: &Node, orig: &Node) -> Option { + object_outline(orig)?; + object_outline(n).or_else(|| object_outline(orig)) +} + +/// The fill rule of a path or compound path (a group's is the default). +fn object_rule(n: &Node) -> FillRule { + match &n.kind { + NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule, + _ => FillRule::NonZero, + } +} + +/// Does the reader read object `n` written with style text `text` as it is: a path or compound +/// path with a style it reads on any object, filled by the non-zero rule when the style fills it, +/// or a group with no fills or strokes of its own? +fn object_reads(n: &Node, text: &str) -> bool { + let section = known_style("x", text); + let own = n.appearance.fill().map(|f| &f.paint).filter(|p| matches!(p, Paint::Gradient(_))); + let Some(mut read) = crate::import::read_art_style(section.as_bytes(), "x", own) else { return false }; + let group = matches!(n.kind, NodeKind::Group { .. }); + if !read.simple || (group && !read.appearance.items.is_empty()) || (!group && read.pathfinder) { + return false; + } + if !group { + let filled = n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(_)) && !i.paint().is_none()); + if filled && object_rule(n) == FillRule::EvenOdd { + return false; + } + // A path's contents are nothing: the reader keeps the object's own. + read.appearance.contents_index = n.appearance.contents_index; + } + same_appearance(&read.appearance, &n.appearance) +} + +/// Do appearances `a` and `b` draw the same, as the reader reads one written from the other: the +/// fills and strokes that paint, within the written numbers' precision, and the effects with +/// their settings filled in from their defaults (blending modes in any case, colors as colors). +pub(in crate::ai_editing) fn same_appearance(a: &Appearance, b: &Appearance) -> bool { + let norm = |ap: &Appearance| -> Value { + let mut ap = ap.clone(); + let ci = ap.contents_index.unwrap_or(0).min(ap.items.len()); + let below = ap.items.iter().take(ci).filter(|i| !i.paint().is_none()).count(); + ap.items.retain(|i| !i.paint().is_none()); + ap.contents_index = (below > 0).then_some(below); + for e in ap.effects.iter_mut().chain(ap.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut())) { + e.params = normalized_params(e); + } + serde_json::to_value(&ap).unwrap_or(Value::Null) + }; + near(&norm(a), &norm(b)) +} + +/// Effect `e`'s settings with its defaults filled in, its blending mode in lower case and its color as +/// `#rrggbb`. +fn normalized_params(e: &Effect) -> Value { + let mut p = vectorcraft_effects::merged_params(&e.id, &e.params); + if let Some(m) = word(&p, "mode") { + p["mode"] = Value::String(m); + } + if p.get("color").is_some() { + p["color"] = Value::String(vectorcraft_effects::color_param(&p, vectorcraft_color::Color::BLACK).to_hex()); + } + for key in ["shade", "source", "joins", "points", "orientation"] { + if let Some(w) = word(&p, key) { + p[key] = Value::String(w); + } + } + p +} + +/// How far apart two numbers may read in [`near`]: a number written to 4 decimals, then converted +/// from a fraction to a percentage (`5e-3`) or from radians to degrees (`3e-3`). +const NEAR: f64 = 6e-3; + +/// JSON values equal but for numbers within the written numbers' precision. +fn near(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) { + (Some(x), Some(y)) => (x - y).abs() <= NEAR, + _ => x == y, + }, + (Value::Array(x), Value::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(x, y)| near(x, y)), + (Value::Object(x), Value::Object(y)) => x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| near(v, w))), + _ => a == b, + } +} diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index 87f1505d6..4b5b47c28 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -70,7 +70,7 @@ use graphics::{GState, Out}; use interp::{Fault, Interp}; use obj::PsError; -pub(crate) use ai::{MAX_GRADIENT_STOPS, MAX_SYMBOLS, symbol_art_member}; +pub(crate) use ai::{MAX_GRADIENT_STOPS, MAX_SYMBOLS, read_art_style, symbol_art_member}; pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES}; pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, is_loss, layered_ai, layered_ai_from}; pub(crate) use native::{MAX_TEXT, scale_document}; diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index c1fd84db3..a0720d3c1 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -41,7 +41,7 @@ pub use read::{ BindingRef, Brushes, IMAGE_TOO_LARGE, PaletteSwatches, Structure, artboards, bindings_note, brushes, graphic_styles, palette, read, read_beside, variables, }; -pub(crate) use read::{MAX_SYMBOLS, join_and, slot_frame, slot_of, symbol_art_member}; +pub(crate) use read::{MAX_SYMBOLS, join_and, read_art_style, slot_frame, slot_of, symbol_art_member}; pub(crate) use read::is_look_slot; diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 41479444a..3386f5098 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -136,6 +136,7 @@ pub(crate) use art::join_and; use art::preview_png; pub use document::{BindingRef, Brushes, PaletteSwatches, VariableRead, artboards, bindings_note, brushes, graphic_styles, palette, variables}; pub(crate) use document::{slot_frame, slot_of}; +pub(crate) use ops::read_art_style; pub use types::IMAGE_TOO_LARGE; use types::{BLEND_BUDGET, FLIP, Frame, GState, Graph, Kind, PatternHead, PendingEnvelope, Place, Raster, SymbolHead}; #[cfg(test)] diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 9535aa062..1be4f716e 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -1121,6 +1121,37 @@ pub(super) fn appearance_from_art_style(st: &crate::import::ai::style::ArtStyle) Some(Appearance { items, effects: st.effects.clone(), contents_index: st.contents_index }) } +/// What the reader makes of art style `name` in `section` (the text between `%AI9_BeginArtStyles` +/// and its end), for writers that check a style before they write it. +#[derive(Clone, Debug)] +pub(crate) struct StyleRead { + /// The style's appearance: a gradient fill is the object's own gradient. + pub appearance: Appearance, + /// An object other than type is read with it ([`crate::import::ai::style::ArtStyle::simple`]). + pub simple: bool, + /// It has Effect › Pathfinder effects, which only a group with no fills or strokes is read with. + pub pathfinder: bool, +} + +/// Art style `name` of `section` as the reader reads it ([`StyleRead`]): [`appearance_from_art_style`], +/// with a style's one gradient fill as `own_gradient`, the object's own gradient, as [`styled`] reads +/// it on objects other than type. `None` when the section doesn't define it, the reader doesn't read +/// it, or it paints with a pattern, a brush or a gradient that isn't the object's own. +pub(crate) fn read_art_style(section: &[u8], name: &str, own_gradient: Option<&Paint>) -> Option { + use crate::import::ai::style::StyleItem; + let mut st = crate::import::ai::style::art_styles(section).remove(name)?; + if st.items.iter().filter(|i| matches!(i, StyleItem::Gradient { .. })).count() == 1 { + let own = own_gradient?; + for item in &mut st.items { + if matches!(item, StyleItem::Gradient { .. }) { + *item = StyleItem::Fill(own.clone()); + } + } + } + let appearance = appearance_from_art_style(&st)?; + Some(StyleRead { appearance, simple: st.simple, pathfinder: st.pathfinder }) +} + /// A style's stroke as an appearance item (`None` for a fill). fn stroke_layer(item: &crate::import::ai::style::StyleItem) -> Option { let crate::import::ai::style::StyleItem::Stroke { paint, width, cap, join, miter, dash, profile, brush } = item else { return None }; diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index bdb9a3d6e..da9a83ad7 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -1186,3 +1186,32 @@ fn convert_to_shape_settings_that_arent_read_keep_the_drawn_look() { format!("/KnownStyle :\n(Odd) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[compound("Stack Style Filter", &[item])])); assert!(!art_styles(data.as_bytes())["Odd"].plain); } + +/// A style as a writer checks it: the reader's appearance, with a gradient fill as the object's own, +/// and nothing for styles it doesn't read. +#[test] +fn a_style_is_read_as_a_writer_checks_it() { + let read = |data: &str, own: Option<&Paint>| crate::import::read_art_style(data.as_bytes(), "Odd", own); + let style = |kids: &[String]| format!("/KnownStyle :\n(Odd) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", kids)); + let gradient = fill("Bb\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB"); + let stack = compound("Stack Style Filter", std::slice::from_ref(&gradient)); + let data = style(&[stack, blend(1, 0.5)]); + let own = Paint::solid(vectorcraft_color::Color::WHITE); + let got = read(&data, Some(&own)).unwrap(); + assert_eq!(got.appearance.items.len(), 1); + assert_eq!(got.appearance.items[0].paint(), &own); + assert!(got.simple && !got.pathfinder); + // A gradient that isn't the object's own, a pattern, another name, two gradients: nothing. + assert!(read(&data, None).is_none()); + let pattern = style(&[compound("Stack Style Filter", &[fill("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p")])]); + assert!(read(&pattern, None).is_none()); + assert!(crate::import::read_art_style(data.as_bytes(), "Even", Some(&own)).is_none()); + assert!(read(&style(&[compound("Stack Style Filter", &[gradient.clone(), gradient])]), Some(&own)).is_none()); + // Damaged text: every cut of the style, and stray bytes, without a panic. + for cut in 0..data.len() { + let _ = read(data.get(..cut).unwrap_or_default(), Some(&own)); + } + for junk in ["(", ")", ";", "/KnownStyle :\n(Odd) /Name ,\n;;;;", "/KnownStyle :\n(Odd) /Name ,\n/Array : 1 /Real , ; (x) ,"] { + let _ = read(junk, Some(&own)); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index aeecaa140..369127d3e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -85,7 +85,15 @@ private editing data. VectorCraft: instances that scale their symbol more than 100 times and instances on the layers of a large canvas are written as their art, pattern fills in a symbol's art are written as their tiles cut to the shape, symbols past 4,096 are written only as their instances' art, and symbols' 9-slice guides, export types and dynamic type are left out, each with a - warning. + warning. Paths, compound paths and groups with live effects keep them as art styles: the drawn look, the object + itself and the style it names, for a fill and a stroke at most in solid colors or the object's gradient (a stroke + with its variable-width profile), filled by the non-zero rule, with Drop Shadow, Inner Glow, Outer Glow, Feather, + Round Corners, Scribble, Convert to Shape, Offset Path, Outline Object or Outline Stroke, and groups with Pathfinder › + Add. Groups keep them when their members are paths, compound paths or such groups, without effects of their own; when the + group's effects change its members' outlines, a member's strokes are plain lines, and its fills are not pattern + fills written as tiles or gradients that fit their object. The look holds the object's vector art, without the shadow, glow or feather. Other objects are + written as their drawn look with a warning. Graphic styles and links to them are left out with a warning. No art style is written on a large + canvas. Point type, area type (with its rows, columns, gutters and first baseline, threaded frames included) and type on a path are written as text: a text object for each, naming its story in the text document, with the characters, fonts, sizes, leading, tracking, baseline shift, @@ -100,7 +108,8 @@ private editing data. VectorCraft: color, each with a warning. Gradients that dither or mix their colors perceptually are written as classic gradients, and gradient stops linked to global process colors as their colors, each with a warning. The opacity and blending mode of single fills and strokes, opacity masks on layers, the placement of pattern swatches that - move, turn or scale their pattern, and effects other than geometry effects are left out. + move, turn or scale their pattern, and the effects of objects written without an art style, 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 From 1f6be41d8acd3d67d26d89af08c22f4d7c7c5bde Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 10:59:50 -0800 Subject: [PATCH 03/13] Docs: the .ai editing copy keeps live effects on simple objects only The fallback for objects that can't use an art style keeps their vector art with geometry effects applied and leaves out their other effects, such as Drop Shadow, the glows and Feather. README, ROADMAP, gaps, roadmap and target-app-parity no longer say every live effect is left out, and target-app-parity lists the round-trip tests. --- README.md | 2 +- ROADMAP.md | 2 +- docs/file-format-parity.md | 5 +++-- docs/gaps.md | 3 ++- docs/roadmap.md | 2 +- docs/target-app-parity.md | 8 +++++--- 6 files changed, 13 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 8f1113c4f..be5ed4958 100644 --- a/README.md +++ b/README.md @@ -253,7 +253,7 @@ The scores are self-assessed from our code against public documentation; [target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them. **What's missing:** -- reliable exchange of .ai files (layer structure in real files; the editing copy a save writes leaves out live effects and writes live objects as their art); +- reliable exchange of .ai files (layer structure in real files; the editing copy a save writes keeps live effects only on paths, compound paths and groups with at most a fill and a stroke, leaves out other objects' effects other than geometry effects, and writes live objects as their art); - an interaction-fidelity pass covering every tool's modifiers and small behaviours; - 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials); - filter thumbnails and a preview area inside the Effect Gallery (the gallery itself and all 57 Photoshop-style raster filters, approximated, are in); diff --git a/ROADMAP.md b/ROADMAP.md index ae8417530..108cd9207 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,7 +24,7 @@ and the work list in [`docs/gaps.md`](docs/gaps.md). **Why alpha:** the core workflows exist end to end (drawing and path editing, Pathfinder, paint and appearance, type, SVG/PDF/EPS, print, export) and feature depth is ~75%, but a professional can't yet switch: Illustrator's own `.ai` files open with a wrong layer structure in real cases and are saved back with an editing copy that -leaves out live effects and writes live objects as their art, the interaction details have never been checked side by side, and some machines fail to start the +keeps live effects only on paths, compound paths and groups with at most a fill and a stroke and writes live objects as their art, the interaction details have never been checked side by side, and some machines fail to start the app. Beta needs those closed (the beta list in [gaps.md](docs/gaps.md)). It passes the core-workflow gate: all six core workflows (path drawing and editing, paint and style, type, layers/artboards/export, print production, file exchange) work end to end on macOS with save and reopen, and only the last is partial, without diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 369127d3e..9984686f4 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -91,8 +91,9 @@ private editing data. VectorCraft: Round Corners, Scribble, Convert to Shape, Offset Path, Outline Object or Outline Stroke, and groups with Pathfinder › Add. Groups keep them when their members are paths, compound paths or such groups, without effects of their own; when the group's effects change its members' outlines, a member's strokes are plain lines, and its fills are not pattern - fills written as tiles or gradients that fit their object. The look holds the object's vector art, without the shadow, glow or feather. Other objects are - written as their drawn look with a warning. Graphic styles and links to them are left out with a warning. No art style is written on a large + fills written as tiles or gradients that fit their object. The look holds the object's vector art, without the shadow, glow or feather. Other objects with + live effects are written as their vector art with their geometry effects applied, and their other effects, such as + Drop Shadow, the glows and Feather, are left out with a warning. Graphic styles and links to them are left out with a warning. No art style is written on a large canvas. Point type, area type (with its rows, columns, gutters and first baseline, threaded frames included) and type on a path are written as text: a text object for diff --git a/docs/gaps.md b/docs/gaps.md index fd7927481..e51ed0760 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -203,7 +203,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall of 14), open speed on large files (#758), live effects, Scatter, Art, Pattern and Bristle brushes on paths, a path's own Calligraphic brush settings, and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). A PDF-compatible `.ai` carries an editing copy of the art in the form VectorCraft's reader reads (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks, - clipping, type as text and symbols), with live effects left out and live objects written as their art + clipping, type as text and symbols), with live effects kept only on paths, compound paths and groups with at most a + fill and a stroke, other objects' effects other than geometry effects left out, and live objects written as their art ([file-format-parity.md](file-format-parity.md)). Save As `.ai` › Version: Legacy (version 7) writes the PostScript form from the published version 7 specification, keeping layers (names, order, visibility, lock), groups, compound paths, clipping groups, gradients and diff --git a/docs/roadmap.md b/docs/roadmap.md index 002b3a525..3be081973 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -89,7 +89,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V | Set type: point and area type, Character and Paragraph styles, threading, outlines | yes | type core 80%; styles, threading, Create Outlines and font matching tested ([type-parity.md](type-parity.md)); justification options missing (not blocking) | 0 | | Organise layers and artboards and export assets (Export for Screens, SVG, PNG, PDF per artboard) | yes | Layers panel rework, Artboards panel and tool, Export for Screens and Asset Export done; Art Optimized supersample closes ghost borders (#983 / #1037) | 0 | | Print production: CMYK and spot colour, bleed and marks, PDF/X out, separations preview | yes | PDF/X-1a/3/4, presets, marks and bleed, ICC output intent, Separations Preview, Print with PostScript output | 0 | -| Exchange files with Illustrator users: open their `.ai`/EPS/PDF, hand back a file they can open | partial (doesn't block alpha) | `.ai`, EPS and PDF open as editable art, but real files can get the wrong layer structure (#951); Save As `.ai` is PDF-compatible with an editing copy of the art (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks, clipping, type as text and symbols, with live effects left out and live objects written as their art; #868), or legacy version 7, which keeps layers, groups and type as text, one artboard per file, and waits for users' testing. EPS legacy mesh corners, vector opacity masks and raster print-page comparison now preserve editable art under synthetic regressions. This is the beta main-format gap ([gaps.md](gaps.md) G9) | 10–20 (for beta) | +| Exchange files with Illustrator users: open their `.ai`/EPS/PDF, hand back a file they can open | partial (doesn't block alpha) | `.ai`, EPS and PDF open as editable art, but real files can get the wrong layer structure (#951); Save As `.ai` is PDF-compatible with an editing copy of the art (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks, clipping, type as text and symbols, with live effects kept only on paths, compound paths and groups with at most a fill and a stroke, other objects' effects other than geometry effects left out, and live objects written as their art; #868), or legacy version 7, which keeps layers, groups and type as text, one artboard per file, and waits for users' testing. EPS legacy mesh corners, vector opacity masks and raster print-page comparison now preserve editable art under synthetic regressions. This is the beta main-format gap ([gaps.md](gaps.md) G9) | 10–20 (for beta) | Hindi interface follow-up (2026-11-06): the catalog and zero/one plural rule are implemented; native wording review, layout checks and a web Devanagari font remain. [Notes](localization-hi.md). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 766dcfd5e..22223fb9d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -201,7 +201,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups with at most a fill and a stroke, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged) | | Guides, grids, smart guides, snapping, rulers | 3 | 80% | "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented; video rulers (View › Rulers › Show Video Rulers, #2849) done, score unchanged | @@ -282,8 +282,9 @@ The discounts, with the evidence: ones are about structure, not missing art: layers flattened or multiplied (#951: 109 layers instead of 14; #868), guides lost (#779), slower opening (#758), live effects, brushes and symbols as their drawn look (#637). VectorCraft's PDF-compatible `.ai` carries an editing copy of the art (layers, groups, paths, colors, - gradients, patterns, images, transparency, opacity masks, clipping, type as text and symbols), with live effects left out - and live objects written as their art; the legacy `.ai` of version 7 it also writes keeps layers, groups and type as text (one + gradients, patterns, images, transparency, opacity masks, clipping, type as text and symbols), with live effects kept only + on paths, compound paths and groups with at most a fill and a stroke, other objects' effects other than geometry + effects left out, and live objects written as their art; the legacy `.ai` of version 7 it also writes keeps layers, groups and type as text (one artboard per file, no transparency) but hasn't been tested by users yet (#868). That blocks beta (the main-format rule) for people who hand files back to Illustrator users, but it doesn't stop someone opening and reworking their own archive. - **Interaction fidelity (×0.9).** Pen, Direct Selection, smart guides and snapping are deep @@ -1219,6 +1220,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur SVG import reads each `` and `` as the ellipse VectorCraft draws: four smooth anchors on the axes, starting at the right, with handles of the standard length, so an ellipse exported as `` comes back with the anchors it was written from and draws the same (`circles_and_ellipses_import_as_the_ellipse_drawn` in `crates/svg/tests/it/basic_shapes.rs`, `filled_ellipse_on_the_artboard_edge_survives_visually` in `prop_svg.rs`). SVG import keeps attributes in other applications' XML namespaces (`plotter:pen="2"`) on the objects and layers they belong to, as it keeps their `data-*` attributes: copies, duplicates and `.vectorcraft` files keep them, and SVG export writes them back with each namespace declared once on the root (the rest of #522; `crates/svg/tests/it/foreign_attributes.rs`, `tests/attributes.rs`). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps defines every symbol in its editing copy with its art, registration point and place in the Symbols panel's order, and writes its instances as symbol instances, which VectorCraft's `.ai` reader opens as symbols and instances again (`symbols_and_their_instances_are_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the instances written as their art). + A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes paths, compound paths and groups with live effects with an art style, which VectorCraft's `.ai` reader opens as the same appearance with its effects live (`objects_with_live_effects_are_read_back` and `every_effect_setting_written_on_an_object_is_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the objects written without one and the effects they lose). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes point type, area type and type on a path as type in its editing copy, and objects next to it keep their own transparency (`crates/eps/src/ai_editing/tests.rs`, `cmd::fileio::tests_pdfedit::type_comes_back_from_the_editing_copy`; [file-format-parity.md](file-format-parity.md) lists what is written as outlines). In a `.ai` or EPS opened from its editing data, type has its text object's opacity, blend mode and knockout shape flag, whether it is made from the file's text document or taken from the page, and type made from the text document takes its text object's lock. Where such type has transparency and comes in with other art (the rest of its look, or page type and strokes that go with it), a group of them has it. Where the page draws its type, or the strokes around it, with transparency that is neither its text object's nor its text object's combined with that of the objects around it, the page's type and strokes keep their own instead (`crates/eps/src/import/tests_native.rs`). Where the page draws type of no text object over text objects made from the text document, with their words in any case, that type is theirs as the page draws it and is left out; the page's other type of no text object goes only to text objects whose type the text document can't make, or else is left out with a warning. `.ai` editing-data import restores the document's listed graphic styles in panel order and links objects that use a listed style; unsupported appearances keep their drawn look (`crates/eps/src/import/ai/tests.rs`). From f9788771dbe3a1d6773069a1552686094d29bb08 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 11:28:29 -0800 Subject: [PATCH 04/13] Docs: art styles cover eligible objects only The .ai editing copy writes an art style for paths, compound paths and groups with live effects only when they have at most a fill and a stroke, fill by the non-zero rule, and are not on a large canvas. README, ROADMAP, gaps, roadmap and target-app-parity say so. --- README.md | 2 +- ROADMAP.md | 2 +- docs/gaps.md | 4 ++-- docs/roadmap.md | 2 +- docs/target-app-parity.md | 6 +++--- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index be5ed4958..3fdb4f3fc 100644 --- a/README.md +++ b/README.md @@ -253,7 +253,7 @@ The scores are self-assessed from our code against public documentation; [target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them. **What's missing:** -- reliable exchange of .ai files (layer structure in real files; the editing copy a save writes keeps live effects only on paths, compound paths and groups with at most a fill and a stroke, leaves out other objects' effects other than geometry effects, and writes live objects as their art); +- reliable exchange of .ai files (layer structure in real files; the editing copy a save writes keeps live effects only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas), leaves out other objects' effects other than geometry effects, and writes live objects as their art); - an interaction-fidelity pass covering every tool's modifiers and small behaviours; - 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials); - filter thumbnails and a preview area inside the Effect Gallery (the gallery itself and all 57 Photoshop-style raster filters, approximated, are in); diff --git a/ROADMAP.md b/ROADMAP.md index 108cd9207..d9b11cfd2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,7 +24,7 @@ and the work list in [`docs/gaps.md`](docs/gaps.md). **Why alpha:** the core workflows exist end to end (drawing and path editing, Pathfinder, paint and appearance, type, SVG/PDF/EPS, print, export) and feature depth is ~75%, but a professional can't yet switch: Illustrator's own `.ai` files open with a wrong layer structure in real cases and are saved back with an editing copy that -keeps live effects only on paths, compound paths and groups with at most a fill and a stroke and writes live objects as their art, the interaction details have never been checked side by side, and some machines fail to start the +keeps live effects only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas) and writes live objects as their art, the interaction details have never been checked side by side, and some machines fail to start the app. Beta needs those closed (the beta list in [gaps.md](docs/gaps.md)). It passes the core-workflow gate: all six core workflows (path drawing and editing, paint and style, type, layers/artboards/export, print production, file exchange) work end to end on macOS with save and reopen, and only the last is partial, without diff --git a/docs/gaps.md b/docs/gaps.md index e51ed0760..725319355 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -203,8 +203,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall of 14), open speed on large files (#758), live effects, Scatter, Art, Pattern and Bristle brushes on paths, a path's own Calligraphic brush settings, and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). A PDF-compatible `.ai` carries an editing copy of the art in the form VectorCraft's reader reads (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks, - clipping, type as text and symbols), with live effects kept only on paths, compound paths and groups with at most a - fill and a stroke, other objects' effects other than geometry effects left out, and live objects written as their art + clipping, type as text and symbols), with live effects kept only on paths, compound paths and groups that + qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas), other objects' effects other than geometry effects left out, and live objects written as their art ([file-format-parity.md](file-format-parity.md)). Save As `.ai` › Version: Legacy (version 7) writes the PostScript form from the published version 7 specification, keeping layers (names, order, visibility, lock), groups, compound paths, clipping groups, gradients and diff --git a/docs/roadmap.md b/docs/roadmap.md index 3be081973..83056d59b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -89,7 +89,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V | Set type: point and area type, Character and Paragraph styles, threading, outlines | yes | type core 80%; styles, threading, Create Outlines and font matching tested ([type-parity.md](type-parity.md)); justification options missing (not blocking) | 0 | | Organise layers and artboards and export assets (Export for Screens, SVG, PNG, PDF per artboard) | yes | Layers panel rework, Artboards panel and tool, Export for Screens and Asset Export done; Art Optimized supersample closes ghost borders (#983 / #1037) | 0 | | Print production: CMYK and spot colour, bleed and marks, PDF/X out, separations preview | yes | PDF/X-1a/3/4, presets, marks and bleed, ICC output intent, Separations Preview, Print with PostScript output | 0 | -| Exchange files with Illustrator users: open their `.ai`/EPS/PDF, hand back a file they can open | partial (doesn't block alpha) | `.ai`, EPS and PDF open as editable art, but real files can get the wrong layer structure (#951); Save As `.ai` is PDF-compatible with an editing copy of the art (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks, clipping, type as text and symbols, with live effects kept only on paths, compound paths and groups with at most a fill and a stroke, other objects' effects other than geometry effects left out, and live objects written as their art; #868), or legacy version 7, which keeps layers, groups and type as text, one artboard per file, and waits for users' testing. EPS legacy mesh corners, vector opacity masks and raster print-page comparison now preserve editable art under synthetic regressions. This is the beta main-format gap ([gaps.md](gaps.md) G9) | 10–20 (for beta) | +| Exchange files with Illustrator users: open their `.ai`/EPS/PDF, hand back a file they can open | partial (doesn't block alpha) | `.ai`, EPS and PDF open as editable art, but real files can get the wrong layer structure (#951); Save As `.ai` is PDF-compatible with an editing copy of the art (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks, clipping, type as text and symbols, with live effects kept only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas), other objects' effects other than geometry effects left out, and live objects written as their art; #868), or legacy version 7, which keeps layers, groups and type as text, one artboard per file, and waits for users' testing. EPS legacy mesh corners, vector opacity masks and raster print-page comparison now preserve editable art under synthetic regressions. This is the beta main-format gap ([gaps.md](gaps.md) G9) | 10–20 (for beta) | Hindi interface follow-up (2026-11-06): the catalog and zero/one plural rule are implemented; native wording review, layout checks and a web Devanagari font remain. [Notes](localization-hi.md). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 22223fb9d..d04777775 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -201,7 +201,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups with at most a fill and a stroke, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas), and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged) | | Guides, grids, smart guides, snapping, rulers | 3 | 80% | "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented; video rulers (View › Rulers › Show Video Rulers, #2849) done, score unchanged | @@ -283,7 +283,7 @@ The discounts, with the evidence: #868), guides lost (#779), slower opening (#758), live effects, brushes and symbols as their drawn look (#637). VectorCraft's PDF-compatible `.ai` carries an editing copy of the art (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks, clipping, type as text and symbols), with live effects kept only - on paths, compound paths and groups with at most a fill and a stroke, other objects' effects other than geometry + on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas), other objects' effects other than geometry effects left out, and live objects written as their art; the legacy `.ai` of version 7 it also writes keeps layers, groups and type as text (one artboard per file, no transparency) but hasn't been tested by users yet (#868). That blocks beta (the main-format rule) for people who hand files back to Illustrator users, but it doesn't stop someone opening and reworking their own archive. @@ -1220,7 +1220,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur SVG import reads each `` and `` as the ellipse VectorCraft draws: four smooth anchors on the axes, starting at the right, with handles of the standard length, so an ellipse exported as `` comes back with the anchors it was written from and draws the same (`circles_and_ellipses_import_as_the_ellipse_drawn` in `crates/svg/tests/it/basic_shapes.rs`, `filled_ellipse_on_the_artboard_edge_survives_visually` in `prop_svg.rs`). SVG import keeps attributes in other applications' XML namespaces (`plotter:pen="2"`) on the objects and layers they belong to, as it keeps their `data-*` attributes: copies, duplicates and `.vectorcraft` files keep them, and SVG export writes them back with each namespace declared once on the root (the rest of #522; `crates/svg/tests/it/foreign_attributes.rs`, `tests/attributes.rs`). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps defines every symbol in its editing copy with its art, registration point and place in the Symbols panel's order, and writes its instances as symbol instances, which VectorCraft's `.ai` reader opens as symbols and instances again (`symbols_and_their_instances_are_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the instances written as their art). - A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes paths, compound paths and groups with live effects with an art style, which VectorCraft's `.ai` reader opens as the same appearance with its effects live (`objects_with_live_effects_are_read_back` and `every_effect_setting_written_on_an_object_is_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the objects written without one and the effects they lose). + A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes eligible paths, compound paths and groups with live effects with an art style (not a filled path that uses the even-odd rule, and none on a large canvas), which VectorCraft's `.ai` reader opens as the same appearance with its effects live (`objects_with_live_effects_are_read_back` and `every_effect_setting_written_on_an_object_is_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the objects written without one and the effects they lose). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes point type, area type and type on a path as type in its editing copy, and objects next to it keep their own transparency (`crates/eps/src/ai_editing/tests.rs`, `cmd::fileio::tests_pdfedit::type_comes_back_from_the_editing_copy`; [file-format-parity.md](file-format-parity.md) lists what is written as outlines). In a `.ai` or EPS opened from its editing data, type has its text object's opacity, blend mode and knockout shape flag, whether it is made from the file's text document or taken from the page, and type made from the text document takes its text object's lock. Where such type has transparency and comes in with other art (the rest of its look, or page type and strokes that go with it), a group of them has it. Where the page draws its type, or the strokes around it, with transparency that is neither its text object's nor its text object's combined with that of the objects around it, the page's type and strokes keep their own instead (`crates/eps/src/import/tests_native.rs`). Where the page draws type of no text object over text objects made from the text document, with their words in any case, that type is theirs as the page draws it and is left out; the page's other type of no text object goes only to text objects whose type the text document can't make, or else is left out with a warning. `.ai` editing-data import restores the document's listed graphic styles in panel order and links objects that use a listed style; unsupported appearances keep their drawn look (`crates/eps/src/import/ai/tests.rs`). From 8374e694d8816fa228754ea79fbe3d8dc0edd46f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 11:57:54 -0800 Subject: [PATCH 05/13] Docs: effects are kept only where the writer supports the appearance The editing copy keeps live effects on qualifying objects only when the art-style writer supports their appearance and effects. README, ROADMAP and the progress docs now say so and link to docs/file-format-parity.md for the limits. --- README.md | 2 +- ROADMAP.md | 2 +- docs/gaps.md | 2 +- docs/roadmap.md | 2 +- docs/target-app-parity.md | 6 +++--- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 3fdb4f3fc..3a34bb7f9 100644 --- a/README.md +++ b/README.md @@ -253,7 +253,7 @@ The scores are self-assessed from our code against public documentation; [target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them. **What's missing:** -- reliable exchange of .ai files (layer structure in real files; the editing copy a save writes keeps live effects only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas), leaves out other objects' effects other than geometry effects, and writes live objects as their art); +- reliable exchange of .ai files (layer structure in real files; the editing copy a save writes keeps live effects only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas) and whose appearance and effects the art-style writer supports (see [docs/file-format-parity.md](docs/file-format-parity.md)), leaves out other objects' effects other than geometry effects, and writes live objects as their art); - an interaction-fidelity pass covering every tool's modifiers and small behaviours; - 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials); - filter thumbnails and a preview area inside the Effect Gallery (the gallery itself and all 57 Photoshop-style raster filters, approximated, are in); diff --git a/ROADMAP.md b/ROADMAP.md index d9b11cfd2..3f2882bc4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,7 +24,7 @@ and the work list in [`docs/gaps.md`](docs/gaps.md). **Why alpha:** the core workflows exist end to end (drawing and path editing, Pathfinder, paint and appearance, type, SVG/PDF/EPS, print, export) and feature depth is ~75%, but a professional can't yet switch: Illustrator's own `.ai` files open with a wrong layer structure in real cases and are saved back with an editing copy that -keeps live effects only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas) and writes live objects as their art, the interaction details have never been checked side by side, and some machines fail to start the +keeps live effects only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas) and whose appearance and effects the art-style writer supports (see [docs/file-format-parity.md](docs/file-format-parity.md)), and writes live objects as their art, the interaction details have never been checked side by side, and some machines fail to start the app. Beta needs those closed (the beta list in [gaps.md](docs/gaps.md)). It passes the core-workflow gate: all six core workflows (path drawing and editing, paint and style, type, layers/artboards/export, print production, file exchange) work end to end on macOS with save and reopen, and only the last is partial, without diff --git a/docs/gaps.md b/docs/gaps.md index 725319355..e09887bc8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -204,7 +204,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall path's own Calligraphic brush settings, and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). A PDF-compatible `.ai` carries an editing copy of the art in the form VectorCraft's reader reads (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks, clipping, type as text and symbols), with live effects kept only on paths, compound paths and groups that - qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas), other objects' effects other than geometry effects left out, and live objects written as their art + qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas) and whose appearance and effects the art-style writer supports, other objects' effects other than geometry effects left out, and live objects written as their art ([file-format-parity.md](file-format-parity.md)). Save As `.ai` › Version: Legacy (version 7) writes the PostScript form from the published version 7 specification, keeping layers (names, order, visibility, lock), groups, compound paths, clipping groups, gradients and diff --git a/docs/roadmap.md b/docs/roadmap.md index 83056d59b..1c177d294 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -89,7 +89,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V | Set type: point and area type, Character and Paragraph styles, threading, outlines | yes | type core 80%; styles, threading, Create Outlines and font matching tested ([type-parity.md](type-parity.md)); justification options missing (not blocking) | 0 | | Organise layers and artboards and export assets (Export for Screens, SVG, PNG, PDF per artboard) | yes | Layers panel rework, Artboards panel and tool, Export for Screens and Asset Export done; Art Optimized supersample closes ghost borders (#983 / #1037) | 0 | | Print production: CMYK and spot colour, bleed and marks, PDF/X out, separations preview | yes | PDF/X-1a/3/4, presets, marks and bleed, ICC output intent, Separations Preview, Print with PostScript output | 0 | -| Exchange files with Illustrator users: open their `.ai`/EPS/PDF, hand back a file they can open | partial (doesn't block alpha) | `.ai`, EPS and PDF open as editable art, but real files can get the wrong layer structure (#951); Save As `.ai` is PDF-compatible with an editing copy of the art (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks, clipping, type as text and symbols, with live effects kept only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas), other objects' effects other than geometry effects left out, and live objects written as their art; #868), or legacy version 7, which keeps layers, groups and type as text, one artboard per file, and waits for users' testing. EPS legacy mesh corners, vector opacity masks and raster print-page comparison now preserve editable art under synthetic regressions. This is the beta main-format gap ([gaps.md](gaps.md) G9) | 10–20 (for beta) | +| Exchange files with Illustrator users: open their `.ai`/EPS/PDF, hand back a file they can open | partial (doesn't block alpha) | `.ai`, EPS and PDF open as editable art, but real files can get the wrong layer structure (#951); Save As `.ai` is PDF-compatible with an editing copy of the art (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks, clipping, type as text and symbols, with live effects kept only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas) and whose appearance and effects the art-style writer supports ([file-format-parity.md](file-format-parity.md)), other objects' effects other than geometry effects left out, and live objects written as their art; #868), or legacy version 7, which keeps layers, groups and type as text, one artboard per file, and waits for users' testing. EPS legacy mesh corners, vector opacity masks and raster print-page comparison now preserve editable art under synthetic regressions. This is the beta main-format gap ([gaps.md](gaps.md) G9) | 10–20 (for beta) | Hindi interface follow-up (2026-11-06): the catalog and zero/one plural rule are implemented; native wording review, layout checks and a web Devanagari font remain. [Notes](localization-hi.md). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d04777775..04feefc13 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -201,7 +201,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas), and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged) | | Guides, grids, smart guides, snapping, rulers | 3 | 80% | "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented; video rulers (View › Rulers › Show Video Rulers, #2849) done, score unchanged | @@ -283,7 +283,7 @@ The discounts, with the evidence: #868), guides lost (#779), slower opening (#758), live effects, brushes and symbols as their drawn look (#637). VectorCraft's PDF-compatible `.ai` carries an editing copy of the art (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks, clipping, type as text and symbols), with live effects kept only - on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas), other objects' effects other than geometry + on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas) and whose appearance and effects the art-style writer supports, other objects' effects other than geometry effects left out, and live objects written as their art; the legacy `.ai` of version 7 it also writes keeps layers, groups and type as text (one artboard per file, no transparency) but hasn't been tested by users yet (#868). That blocks beta (the main-format rule) for people who hand files back to Illustrator users, but it doesn't stop someone opening and reworking their own archive. @@ -1220,7 +1220,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur SVG import reads each `` and `` as the ellipse VectorCraft draws: four smooth anchors on the axes, starting at the right, with handles of the standard length, so an ellipse exported as `` comes back with the anchors it was written from and draws the same (`circles_and_ellipses_import_as_the_ellipse_drawn` in `crates/svg/tests/it/basic_shapes.rs`, `filled_ellipse_on_the_artboard_edge_survives_visually` in `prop_svg.rs`). SVG import keeps attributes in other applications' XML namespaces (`plotter:pen="2"`) on the objects and layers they belong to, as it keeps their `data-*` attributes: copies, duplicates and `.vectorcraft` files keep them, and SVG export writes them back with each namespace declared once on the root (the rest of #522; `crates/svg/tests/it/foreign_attributes.rs`, `tests/attributes.rs`). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps defines every symbol in its editing copy with its art, registration point and place in the Symbols panel's order, and writes its instances as symbol instances, which VectorCraft's `.ai` reader opens as symbols and instances again (`symbols_and_their_instances_are_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the instances written as their art). - A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes eligible paths, compound paths and groups with live effects with an art style (not a filled path that uses the even-odd rule, and none on a large canvas), which VectorCraft's `.ai` reader opens as the same appearance with its effects live (`objects_with_live_effects_are_read_back` and `every_effect_setting_written_on_an_object_is_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the objects written without one and the effects they lose). + A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes eligible paths, compound paths and groups with live effects with an art style when the writer supports their appearance and effects (not a filled path that uses the even-odd rule, and none on a large canvas), which VectorCraft's `.ai` reader opens as the same appearance with its effects live (`objects_with_live_effects_are_read_back` and `every_effect_setting_written_on_an_object_is_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the objects written without one and the effects they lose). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes point type, area type and type on a path as type in its editing copy, and objects next to it keep their own transparency (`crates/eps/src/ai_editing/tests.rs`, `cmd::fileio::tests_pdfedit::type_comes_back_from_the_editing_copy`; [file-format-parity.md](file-format-parity.md) lists what is written as outlines). In a `.ai` or EPS opened from its editing data, type has its text object's opacity, blend mode and knockout shape flag, whether it is made from the file's text document or taken from the page, and type made from the text document takes its text object's lock. Where such type has transparency and comes in with other art (the rest of its look, or page type and strokes that go with it), a group of them has it. Where the page draws its type, or the strokes around it, with transparency that is neither its text object's nor its text object's combined with that of the objects around it, the page's type and strokes keep their own instead (`crates/eps/src/import/tests_native.rs`). Where the page draws type of no text object over text objects made from the text document, with their words in any case, that type is theirs as the page draws it and is left out; the page's other type of no text object goes only to text objects whose type the text document can't make, or else is left out with a warning. `.ai` editing-data import restores the document's listed graphic styles in panel order and links objects that use a listed style; unsupported appearances keep their drawn look (`crates/eps/src/import/ai/tests.rs`). From 9c0e7ccb808aceccc40c9172fb30bb1a10e315c8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 15:17:55 -0800 Subject: [PATCH 06/13] .ai save: the editing copy keeps graphic styles and links to them The editing copy of a PDF-compatible .ai left out the Graphic Styles panel's styles and objects' links to them, with a warning. They are now written as art styles: - The panel's styles come first in %AI9_BeginArtStyles and are listed in its order in %AI9_BeginArtStyleList after the palette, with their fills, strokes, transparency and effects. Fills and strokes may have transparency and effects of their own, and a style may also hold Warp, Transform, Free Distort, Pucker & Bloat, Twist, Zig Zag or Gaussian Blur. A style of an object's own holds the same effects as before. - A path, compound path or group linked to a graphic style names it when it has the style's appearance, the style is written, and the reader reads that style on that object. Objects linked to a new document's styles keep their link: the panel is written for them too. - The writer reads each style back with the reader before it writes it. A style with gradient or pattern fills, with anything the reader does not read back, with a name another style has, with a name over 1,024 bytes or past 4,096 styles is left out with a warning. Other links are left out with a warning, and a panel with no style written warns that it opens with a new document's styles. No graphic style is written on a large canvas, with a warning. Graphic styles: https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/apply-graphic-styles.html Tests: the round trip of the panel's styles and of links; each effect a style can hold; the forms written; the list's limits; duplicate names; an empty panel; links left out; a style's opacity at the written precision; a large canvas; Twist and Transform on an object with no link; a link to a style whose Transform moves the outline, with and without an opacity mask; the reader's reading of warp numbers; fuzz cases for damaged written styles. Docs: docs/file-format-parity.md. --- crates/engine/tests/import_fuzz.rs | 25 +- crates/eps/src/ai_editing.rs | 33 +- crates/eps/src/ai_editing/tests.rs | 497 +++++++++++++++++++-- crates/eps/src/ai_editing/writer.rs | 4 +- crates/eps/src/ai_editing/writer/styles.rs | 309 +++++++++++-- crates/eps/src/import.rs | 4 +- crates/eps/src/import/ai.rs | 3 +- crates/eps/src/import/ai/read.rs | 2 +- crates/eps/src/import/ai/read/ops.rs | 16 +- crates/eps/src/import/ai/style.rs | 10 +- crates/eps/src/import/ai/style/tests.rs | 10 + docs/file-format-parity.md | 9 +- 12 files changed, 810 insertions(+), 112 deletions(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 684cf3bfa..b7bc7c65e 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3708,13 +3708,13 @@ proptest! { const STYLES_FUZZ_CHARS: usize = 64_000; /// The editing data VectorCraft writes for a document with a drop shadow on a path, a gradient fill -/// and a width profile on others, a stroked even-odd path with an opacity mask and Pathfinder › Add -/// on a group. +/// and a width profile on others, a stroked even-odd path with an opacity mask, Pathfinder › Add on +/// a group and a graphic style of its own with effects on one of its fills. fn styles_text() -> &'static str { static TEXT: std::sync::OnceLock = std::sync::OnceLock::new(); TEXT.get_or_init(|| { use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; - use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeKind, OpacityMask, StrokeLayer, WidthProfile}; + use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, GraphicStyle, Node, NodeKind, OpacityMask, StrokeLayer, WidthProfile}; use vectorcraft_geom::{FillRule, Rect, shapes}; let mut doc = Document::new(200.0, 100.0); let layer = doc.layers[0].id; @@ -3757,6 +3757,10 @@ fn styles_text() -> &'static str { let mut merged = Node::group(doc.alloc_id(), vec![std::sync::Arc::new(a), std::sync::Arc::new(b)]); merged.appearance = Appearance { effects: vec![fx("pathfinder.add")], ..Appearance::default() }.into(); doc.insert(Some(layer), usize::MAX, merged).unwrap(); + doc.graphic_styles.push(GraphicStyle::new("Shaded", ap)); + let chained = FillLayer { opacity: 0.5, effects: vec![fx("distort.twist")], ..FillLayer::new(Paint::solid(Color::WHITE)) }; + let chained = Appearance { items: vec![AppearanceItem::Fill(chained)], ..Appearance::default() }; + doc.graphic_styles.push(GraphicStyle::new("Chained", chained)); let out = vectorcraft_eps::export_ai_editing(&doc, &vectorcraft_eps::AiEditingOptions::default()).unwrap(); String::from_utf8(out.program).unwrap() }) @@ -3768,14 +3772,25 @@ fn styles_text() -> &'static str { fn the_written_styles_fuzz_data_opens_live() { let text = styles_text(); assert!(text.chars().count() < STYLES_FUZZ_CHARS, "{}", text.chars().count()); - for mark in ["%AI9_BeginArtStyles", "%AI5_BeginLayer", "\n6 () XW", "\n1 (Anon", "(BSLeftWidthsArray)", "0 /Bool (UseEvenOdd)", "Bb"] { - assert!(text.contains(mark), "{mark}"); + for mark in [ + "%AI9_BeginArtStyles", + "%AI9_BeginArtStyleList", + "%AI5_BeginLayer", + "\n6 () XW", + "\n1 (Anon", + "(BSLeftWidthsArray)", + "0 /Bool (UseEvenOdd)", + "(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/BasicFilter", + "Bb", + ] { + assert!(text.replace("\r\n", "\n").contains(mark), "{mark}"); } let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(text)].concat(); let (d, warnings, layered) = vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0); assert!(layered, "{warnings:?}"); let art = d.layers[0].children().unwrap(); assert_eq!(art.iter().map(|n| n.appearance.effects.len()).collect::>(), [1, 1, 1, 1, 1]); + assert!(d.graphic_style("Shaded").is_some() && d.graphic_style("Chained").is_some()); } /// Editing data with symbols (S2's art holds an instance of S1, F's holds non-native art), the diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 06a3f2935..bffb2e2ad 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -101,16 +101,17 @@ //! definitions are at most the reader's 4,096. In a symbol's art, an instance's matrix and //! a gradient's take the art to the canvas's space turned y up about art space's origin there. //! - Art styles, as the reader reads them (`style.rs`, `read/ops.rs`): a path, compound path or -//! group with live effects is written as its drawn look (`u`, its vector art as its geometry -//! effects leave it, without shadows, glows or feathers, `U`, its transparency, `0 0 Xd`, -//! `6 () XW`), then the object itself on `%_` lines (its points painted +//! group with live effects or linked to a graphic style is written as its drawn look (`u`, its +//! vector art as its geometry effects leave it, without shadows, glows or feathers, `U`, its +//! transparency, `0 0 Xd`, `6 () XW`), then the object itself on `%_` lines (its points painted //! with nothing, or with its gradient fill; a group with its members) with its name and opacity //! mask, then `1 (style) XW`. The styles are written in a non-printing block after the symbols //! (`%AI9_BeginArtStyles`, each `/KnownStyle :` with its name and a filter tree: a chain of the -//! stack of fills and strokes with the contents among them, the effects and the blend filter). A -//! fill filter fills by the non-zero rule (`0 /Bool (UseEvenOdd)`), so a filled object that fills by -//! the even-odd rule is written as its drawn look. A style is written only when the reader reads it -//! back as the object's appearance. +//! stack of fills and strokes with the contents among them, the effects and the blend filter), the +//! Graphic Styles panel's first; the panel's order follows the palette (`%AI9_BeginArtStyleList`). +//! A fill filter fills by the non-zero rule (`0 /Bool (UseEvenOdd)`), so a filled object that +//! fills by the even-odd rule is written as its drawn look. A style is written only when the +//! reader reads it back as the object's appearance (or the graphic style's). //! //! 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 @@ -145,8 +146,9 @@ //! gradient swatches, gradient swatches of more than 256 stops, swatches of patterns written as //! tiles and the placement of pattern swatches that move, turn or scale their pattern, the opacity and blending modes of single fills and strokes, opacity masks on //! layers and opacity masks nested too deep, and, on objects written without an art style, live -//! effects other than geometry effects left out; graphic styles, links to them and every art style -//! on a large canvas are left out. +//! effects other than geometry effects left out; graphic styles the reader wouldn't read, links to +//! graphic styles on objects not written with them, and every art style on a large canvas, are 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`]). @@ -228,7 +230,8 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result Result 0 { w.warnings.push(writer::LARGE_CANVAS_STYLES.to_string()); } - if graphic_styles { - w.warnings.push(writer::GRAPHIC_STYLES.to_string()); - } for layer in &doc.layers { w.layer(layer); } + if scale == 1.0 { + w.lost_links(links); + } // The symbols no instance uses too; their art adds definitions. w.symbols(); // The palette first: its gradient and pattern swatches add definitions. @@ -293,6 +297,7 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result Document { let mut merged = group(&mut doc, vec![a, b], false); merged.appearance = Appearance { effects: vec![fx("pathfinder.add", serde_json::json!({}))], ..Appearance::default() }.into(); add(&mut doc, named(merged, "Merged")); + // A graphic style of its own, and an object linked to one. + let mut fill_top = FillLayer::new(solid(Color::rgb(0.2, 0.2, 0.9))); + fill_top.opacity = 0.5; + fill_top.blend = BlendMode::Screen; + fill_top.effects = vec![fx("distort.twist", serde_json::json!({"angle": 30.0}))]; + let style_ap = Appearance { + items: vec![ + AppearanceItem::Fill(FillLayer::new(solid(Color::rgb(0.9, 0.9, 0.1)))), + AppearanceItem::Fill(fill_top), + AppearanceItem::Stroke(StrokeLayer::new(solid(Color::BLACK), 3.0)), + ], + effects: vec![fx("warp.arc", serde_json::json!({"bend": 30.0})), shadow], + // The contents over every fill and stroke. + contents_index: Some(3), + }; + let mut layered = vectorcraft_doc::GraphicStyle::new("Layered", style_ap); + layered.id = 9; + layered.opacity = 0.8; + doc.graphic_styles.push(layered); + let sunshine = doc.graphic_style("Sunshine").unwrap().clone(); + let mut linked_obj = Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(170.0, 130.0, 220.0, 170.0)), + sunshine.appearance_on(&Node::group(vectorcraft_doc::NodeId(0), vec![])).into_owned(), + ); + linked_obj.extras_mut().graphic_style = Some(sunshine.id); + add(&mut doc, named(linked_obj, "Sunny")); doc } @@ -5369,12 +5396,24 @@ fn near_lines(got: &str, want: &str) { } #[test] -fn objects_with_live_effects_are_read_back() { +fn objects_with_live_effects_and_graphic_styles_are_read_back() { let doc = styles_document(); let out = export_ai_editing(&doc, &opts()).unwrap(); - assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS || w == writer::PANEL_STYLES), "{:?}", out.warnings); let back = read_back(&doc); same_styles(&back, &doc); + // The panel's styles, in its order, with their appearance and transparency. + let names = |d: &Document| d.graphic_styles.iter().map(|g| g.name.clone()).collect::>(); + assert_eq!(names(&back), names(&doc)); + for (g, w) in back.graphic_styles.iter().zip(doc.graphic_styles.iter()) { + assert!(writer::same_appearance(&g.appearance, &w.appearance), "{}:\n got {:?}\n want {:?}", w.name, g.appearance, w.appearance); + assert_eq!( + (r3(f64::from(g.opacity)), g.blend, g.isolate, g.knockout), + (r3(f64::from(w.opacity)), w.blend, w.isolate, w.knockout), + "{}", + w.name + ); + } // A group's members come back as they were. let members = |d: &Document, name: &str| { let mut out = vec![]; @@ -5386,13 +5425,39 @@ fn objects_with_live_effects_are_read_back() { same_lines(&members(&back, "Merged"), &members(&doc, "Merged")); } +/// In a document with only a new document's graphic styles, an object linked to one of them keeps +/// its link: the panel's styles are written. +#[test] +fn a_link_to_a_built_in_graphic_style_is_read_back() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let sunshine = doc.graphic_style("Sunshine").unwrap().clone(); + let ap = sunshine.appearance_on(&Node::group(vectorcraft_doc::NodeId(0), vec![])).into_owned(); + // Without a link, a new document's styles are not written. + let mut plain = doc.clone(); + let unlinked = Node::path(plain.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 50.0)), ap.clone()); + plain.insert(Some(layer), usize::MAX, unlinked).unwrap(); + assert!(!program(&plain).contains("%AI9_BeginArtStyleList")); + let mut sunny = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 50.0)), ap); + sunny.extras_mut().graphic_style = Some(sunshine.id); + doc.insert(Some(layer), usize::MAX, named(sunny, "Sunny")).unwrap(); + assert_eq!(doc.graphic_styles, Document::new(1.0, 1.0).graphic_styles); + let back = read_back(&doc); + same_styles(&back, &doc); + let n = &back.layers[0].children().unwrap()[0]; + assert_eq!(n.extras().graphic_style.and_then(|id| back.graphic_style_by_id(id)).map(|g| g.name.as_str()), Some("Sunshine")); +} + #[test] fn art_styles_are_written_in_their_forms() { let p = program_shown(&styles_document()); - // The styles in a non-printing block after the symbols' and before the palette. - let styles = p.find("%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyles\n/KnownStyle :\n(Anon 1) /Name ,\n/ActiveStyle :\n").unwrap(); + // The styles in a non-printing block after the symbols' and before the palette; the panel's in + // its order first, then the objects' own. + let styles = p.find("%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyles\n/KnownStyle :\n(Default Graphic Style) /Name ,\n/ActiveStyle :\n").unwrap(); assert!(styles < p.find("%AI5_BeginPalette").unwrap(), "{p}"); - assert!(!p.contains("%AI9_BeginArtStyleList"), "{p}"); + let list = "%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyleList\n(Default Graphic Style)\n(Black Outline)\n(Heavy Ink)\n(Sunshine)\n(Layered)\n%AI9_EndArtStyleList\n%AI5_End_NonPrinting--\n"; + let at = p.find(list).unwrap(); + assert!(p.find("%AI5_EndPalette").unwrap() < at && at < p.find("%AI9_BeginDocumentData").unwrap(), "{p}"); // A drop shadow on the object, after its stack and before its transparency. assert!( p.contains( @@ -5406,6 +5471,8 @@ fn art_styles_are_written_in_their_forms() { assert!(p.contains("U\n0 1 0 2 0 Xy\n0 0 Xd\n6 () XW\n%_0 XR\n%_0 1 0 0 0 Xy\n%_10 -10 m\n"), "{p}"); assert!(p.contains("%_n\n%_/ArtDictionary :\n%_/XMLUID : (Shadowed) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Anon 1) XW\n"), "{p}"); assert!(p.contains("%_n\n%_/ArtDictionary :\n%_/XMLUID : (Offset) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Anon "), "{p}"); + // The object linked to a graphic style names it. + assert!(p.contains("%_/XMLUID : (Sunny) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Sunshine) XW\n"), "{p}"); // A group's members on `%_` lines, then its name and style. assert!(p.contains("6 () XW\n%_u\n"), "{p}"); assert!(p.contains("%_U\n%_/ArtDictionary :\n%_/XMLUID : (Merged) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Anon "), "{p}"); @@ -5425,8 +5492,9 @@ fn art_styles_are_written_in_their_forms() { } /// Objects whose appearance the reader wouldn't read with their style are written as their -/// drawn look, with the warnings they were written with before; and a document without effects has -/// no art styles. +/// drawn look, with the warnings they were written with before; graphic styles it wouldn't read +/// are left out with a warning; and a document without effects or styles of its own has no art +/// styles. #[test] fn appearances_the_reader_wouldnt_read_are_their_drawn_look() { let mut doc = Document::new(200.0, 100.0); @@ -5454,10 +5522,41 @@ fn appearances_the_reader_wouldnt_read_are_their_drawn_look() { assert!(!p.contains("%AI9_BeginArtStyles") && !p.lines().any(|l| l.starts_with("1 (")), "{p}"); let back = read_back(&doc); assert!(back.layers[0].children().unwrap().iter().all(|n| n.appearance.effects.is_empty())); + // A graphic style with a gradient: left out, the others listed. + let ramp = Gradient { stops: vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)], ..Gradient::default() }; + let graded = Appearance::basic(Paint::Gradient(Box::new(GradientPaint::new(ramp))), Paint::None, 0.0); + doc.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Graded", graded)); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings); + let p = String::from_utf8(out.program).unwrap().replace("\r\n", "\n"); + assert!(p.contains("(Sunshine)\n%AI9_EndArtStyleList") && !p.contains("(Graded)"), "{p}"); // Nothing styled: no art styles. assert!(!program(&sample()).contains("ArtStyle")); } +/// A graphic style's opacity is read back within the written number's precision: the style and an +/// object's link to it stay. +#[test] +fn a_graphic_styles_opacity_is_compared_at_the_written_precision() { + let mut doc = Document::new(200.0, 100.0); + let ap = Appearance::basic(solid(Color::BLACK), Paint::None, 1.0); + let mut g = vectorcraft_doc::GraphicStyle::new("Fine", ap.clone()); + g.id = 1; + g.opacity = 0.123456; + *doc.graphic_styles = vec![g]; + let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 50.0)), ap); + n.opacity = 0.123456; + n.extras_mut().graphic_style = Some(1); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(back.graphic_styles.len(), 1); + assert!((back.graphic_styles[0].opacity - 0.123456).abs() < 1e-3); + assert_eq!(back.layers[0].children().unwrap()[0].extras().graphic_style, Some(back.graphic_styles[0].id)); +} + /// A compound shape draws as one area, which an art style's object wouldn't keep: it and a group /// holding one are written as their drawn look, with a warning. #[test] @@ -5488,6 +5587,45 @@ fn compound_shapes_are_not_written_with_an_art_style() { } } +/// Graphic styles the reader's style list wouldn't hold (a name over its limit, styles past its +/// count) are left out with a warning, and an object linked to one has no link. +#[test] +fn graphic_styles_past_the_lists_limits_are_left_out() { + let plain = || Appearance::basic(solid(Color::BLACK), Paint::None, 1.0); + let styled = |name: String, id: u32| { + let mut g = vectorcraft_doc::GraphicStyle::new(name, plain()); + g.id = id; + g + }; + // A name one byte over the limit, among others. + let mut doc = Document::new(200.0, 100.0); + let long = "n".repeat(1025); + *doc.graphic_styles = vec![styled("Before".into(), 1), styled(long.clone(), 2), styled("After".into(), 3)]; + let mut linked = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 50.0)), plain()); + linked.extras_mut().graphic_style = Some(2); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, linked).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings); + assert!(!shown(&out.program).contains(&long)); + let back = read_back(&doc); + let names: Vec<&str> = back.graphic_styles.iter().map(|g| g.name.as_str()).collect(); + assert_eq!(names, ["Before", "After"]); + assert_eq!(back.layers[0].children().unwrap()[0].extras().graphic_style, None); + // A name at the limit is kept. + let at_limit = "n".repeat(1024); + doc.graphic_styles[1].name = at_limit.clone(); + assert!(read_back(&doc).graphic_styles.iter().any(|g| g.name == at_limit)); + // Styles past the count. + let mut many = Document::new(200.0, 100.0); + *many.graphic_styles = (1..=4097).map(|i| styled(format!("Style {i}"), i)).collect(); + let out = export_ai_editing(&many, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings); + let back = read_back(&many); + assert_eq!(back.graphic_styles.len(), 4096); + assert_eq!(back.graphic_styles.last().map(|g| g.name.as_str()), Some("Style 4096")); +} + /// A member of a group whose Transform effect moves it is written where baking put it, not with /// its own effects where it was. #[test] @@ -5620,7 +5758,8 @@ fn read_one(make: impl FnOnce(&mut Document) -> Node) -> (Arc, Vec /// A filled path or compound path with live effects that fills by the even-odd rule is written as its /// drawn look with a warning, since a style's fill fills by the non-zero rule. A stroke-only object -/// keeps its style and its own fill rule. +/// keeps its style and its own fill rule. A filled even-odd object linked to a graphic style isn't +/// written with that style, and its link is left out with a warning. #[test] fn a_filled_even_odd_object_is_written_as_its_drawn_look() { let mut doc = Document::new(300.0, 100.0); @@ -5649,9 +5788,28 @@ fn a_filled_even_odd_object_is_written_as_its_drawn_look() { let back = read_back(&doc); same_styles(&back, &doc); assert!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::Compound { rule: FillRule::EvenOdd, .. })); + + let mut doc = Document::new(200.0, 100.0); + let ap = Appearance::basic(black_fill(), Paint::None, 0.0); + let mut g = vectorcraft_doc::GraphicStyle::new("Solid", ap.clone()); + g.id = 7; + doc.graphic_styles.push(g); + let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap); + n.extras_mut().graphic_style = Some(7); + if let NodeKind::Path { rule, .. } = &mut n.kind { + *rule = FillRule::EvenOdd; + } + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings); + let back = read_back(&doc); + let n = &back.layers[0].children().unwrap()[0]; + assert!(matches!(n.kind, NodeKind::Path { rule: FillRule::EvenOdd, .. }), "{n:?}"); + assert_eq!(n.extras().graphic_style, None); } -/// A compound path filled by the non-zero rule keeps its effects and its gradient fill. +/// A compound path filled by the non-zero rule keeps its effects, its gradient fill and its link to +/// a graphic style. #[test] fn a_compound_path_keeps_its_style() { let mut doc = Document::new(300.0, 100.0); @@ -5669,8 +5827,16 @@ fn a_compound_path_keeps_its_style() { let ap = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(graded, Paint::None, 0.0) }; let n = named(non_zero(ring(&mut doc, ap)), "Graded"); put(&mut doc, n); + let sunshine = doc.graphic_style("Sunshine").unwrap().clone(); + let ap = sunshine.appearance_on(&Node::group(vectorcraft_doc::NodeId(0), vec![])).into_owned(); + let mut linked_ring = named(ring(&mut doc, ap), "Linked"); + if let NodeKind::Compound { rule, .. } = &mut linked_ring.kind { + *rule = FillRule::NonZero; + } + linked_ring.extras_mut().graphic_style = Some(sunshine.id); + put(&mut doc, linked_ring); let out = export_ai_editing(&doc, &opts()).unwrap(); - assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS || w == writer::STYLE_LINKS), "{:?}", out.warnings); let back = read_back(&doc); same_styles(&back, &doc); assert!(back.layers[0].children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Compound { .. }))); @@ -5696,36 +5862,136 @@ fn a_group_holding_an_image_is_written_as_its_drawn_look() { assert!(im.link.is_some()); } -/// Graphic styles and links to them are left out with a warning: an object linked to a style -/// keeps its effects in a style of its own. A new document's styles without a link don't warn. +/// A link to a graphic style that isn't written warns; one that is written doesn't. #[test] -fn graphic_styles_and_links_are_left_out_with_a_warning() { - let warns = |doc: &Document| export_ai_editing(doc, &opts()).unwrap().warnings.iter().any(|w| w == writer::GRAPHIC_STYLES); - assert!(!warns(&Document::new(200.0, 100.0))); - // A style of the document's own. - let mut own = Document::new(200.0, 100.0); - own.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Mine", Appearance::basic(black_fill(), Paint::None, 0.0))); - assert!(warns(&own)); - assert!(!shown(&export_ai_editing(&own, &opts()).unwrap().program).contains("ArtStyle")); - assert_eq!(read_back(&own).graphic_style("Mine"), None); - // A link to a new document's style, on an object with an effect. - let mut linked = Document::new(200.0, 100.0); - let id = linked.graphic_style("Sunshine").unwrap().id; - let mut n = plain_rect(&mut linked, "Linked", vec![drop_shadow()]); - n.extras_mut().graphic_style = Some(id); - put(&mut linked, n); - assert!(warns(&linked)); - let back = read_back(&linked); - let n = &back.layers[0].children().unwrap()[0]; - assert_eq!(n.extras().graphic_style, None); - assert_eq!(n.appearance.effects.len(), 1); +fn a_link_left_out_warns() { + let warns = |make: &dyn Fn(&mut Document, u32) -> Node| { + let mut doc = Document::new(200.0, 100.0); + let ap = Appearance { + items: vec![AppearanceItem::Fill(FillLayer::new(black_fill())), AppearanceItem::Fill(FillLayer::new(solid(Color::WHITE)))], + ..Appearance::default() + }; + let mut layered = vectorcraft_doc::GraphicStyle::new("Layered", ap); + layered.id = 7; + doc.graphic_styles.push(layered); + let mut plain = vectorcraft_doc::GraphicStyle::new("Plain", Appearance::basic(black_fill(), Paint::None, 0.0)); + plain.id = 8; + doc.graphic_styles.push(plain); + let n = make(&mut doc, 7); + put(&mut doc, n); + let warned = export_ai_editing(&doc, &opts()).unwrap().warnings.iter().any(|w| w == writer::STYLE_LINKS); + (warned, read_back(&doc)) + }; + let linked = |mut n: Node, id: u32| { + n.extras_mut().graphic_style = Some(id); + n + }; + // A style of two fills: a path isn't read with it. + let (warned, back) = warns(&|doc, id| { + let ap = doc.graphic_style_by_id(id).unwrap().appearance.clone(); + linked(Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap), id) + }); + assert!(warned); + assert_eq!(back.layers[0].children().unwrap()[0].extras().graphic_style, None); + assert!(back.graphic_style("Layered").is_some()); + // An image, a symbol instance and a knockout shape are never written with a style. + let (warned, _) = warns(&|doc, _| { + let art = square(doc, Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK); + doc.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); + linked(Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((50.0, 20.0)) }), 8) + }); + assert!(warned); + let (warned, _) = warns(&|doc, _| { + let mut n = plain_rect(doc, "Out", vec![]); + n.knockout_shape = true; + linked(n, 8) + }); + assert!(warned); + // A group with a member the style can't hold. + let (warned, _) = warns(&|doc, _| { + let member = plain_rect(doc, "Member", vec![drop_shadow()]); + linked(group(doc, vec![member], false), 8) + }); + assert!(warned); + // A group with an effect and a member linked to a style: the group keeps its effect, and the + // member's link is left out. + let (warned, back) = warns(&|doc, _| { + let member = linked(plain_rect(doc, "Member", vec![]), 8); + let mut g = group(doc, vec![member], false); + g.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::default() }.into(); + g + }); + assert!(warned); + let g = &back.layers[0].children().unwrap()[0]; + assert_eq!(g.appearance.effects.len(), 1, "{g:?}"); + assert_eq!(g.children().unwrap()[0].extras().graphic_style, None); + // A group whose link isn't written: its member keeps its own. + let (warned, back) = warns(&|doc, id| { + let plain = doc.graphic_style_by_id(id + 1).unwrap().appearance.clone(); + let member = linked(Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), plain), id + 1); + linked(group(doc, vec![member], false), id) + }); + assert!(warned); + let g = &back.layers[0].children().unwrap()[0]; + assert_eq!(g.extras().graphic_style, None); + let member = g.children().unwrap()[0].extras().graphic_style.unwrap(); + assert_eq!(back.graphic_style_by_id(member).map(|s| s.name.as_str()), Some("Plain")); // A link in a symbol's art. - let mut in_symbol = Document::new(200.0, 100.0); - let id = in_symbol.graphic_style("Sunshine").unwrap().id; - let mut art = square(&mut in_symbol, Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK); - art.extras_mut().graphic_style = Some(id); - in_symbol.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); - assert!(warns(&in_symbol)); + let (warned, _) = warns(&|doc, _| { + let art = linked(square(doc, Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK), 8); + doc.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); + Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((50.0, 20.0)) }) + }); + assert!(warned); + // A link that is kept. + let (warned, back) = warns(&|doc, id| { + let plain = doc.graphic_style_by_id(id + 1).unwrap().appearance.clone(); + linked(Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), plain), id + 1) + }); + assert!(!warned); + let id = back.layers[0].children().unwrap()[0].extras().graphic_style.unwrap(); + assert_eq!(back.graphic_style_by_id(id).map(|g| g.name.as_str()), Some("Plain")); +} + +/// A panel with no style written warns that a new document's styles come back. +#[test] +fn an_empty_graphic_styles_panel_warns() { + let mut doc = Document::new(200.0, 100.0); + doc.graphic_styles.clear(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::PANEL_EMPTY), "{:?}", out.warnings); + assert!(!shown(&out.program).contains("ArtStyle")); + assert_eq!(read_back(&doc).graphic_styles, Document::new(1.0, 1.0).graphic_styles); + // Every style left out. + let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]; + let graded = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + doc.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Graded", Appearance::basic(graded, Paint::None, 0.0))); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::PANEL_EMPTY) && out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings); + // A new document's styles aren't written and don't warn. + let out = export_ai_editing(&Document::new(200.0, 100.0), &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::PANEL_EMPTY), "{:?}", out.warnings); +} + +/// A second style of one name is left out with a warning, and an object linked to it has no link. +#[test] +fn a_graphic_style_of_a_name_another_has_is_left_out() { + let mut doc = Document::new(200.0, 100.0); + let style = |id: u32, c: Color| { + let mut g = vectorcraft_doc::GraphicStyle::new("Twin", Appearance::basic(solid(c), Paint::None, 0.0)); + g.id = id; + g + }; + *doc.graphic_styles = vec![style(1, Color::BLACK), style(2, Color::WHITE)]; + let mut n = styled_rect(&mut doc, "Second", Rect::new(10.0, 10.0, 50.0, 40.0), solid(Color::WHITE), Paint::None, vec![]); + n.extras_mut().graphic_style = Some(2); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::PANEL_STYLES) && out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(back.graphic_styles.len(), 1); + assert_eq!(back.graphic_styles[0].appearance.fill().and_then(|f| f.paint.color()), Some(Color::BLACK)); + assert_eq!(back.layers[0].children().unwrap()[0].extras().graphic_style, None); } /// A path or compound path with no points writes nothing, with or without a style; a group with no @@ -5850,6 +6116,93 @@ fn every_effect_setting_written_on_an_object_is_read_back() { assert!(matches!(&spot.appearance.fill().unwrap().paint, Paint::Solid { swatch: Some(s), .. } if s == "Ink"), "{spot:?}"); } +/// Each effect a graphic style can hold, in a style of its own, reads back with its settings, whatever +/// the writer's conversions to the file's units. +#[test] +fn every_effect_a_graphic_style_holds_is_read_back() { + use serde_json::json; + let mut cases = vec![ + ( + "distort.transform", + json!({"scaleH": 50.0, "scaleV": 150.0, "moveH": 12.5, "moveV": -7.5, "rotate": 30.0, "copies": 2, "reference": 8, "reflectX": true, "objects": false, "strokes": true, "patterns": true}), + ), + ("distort.freeDistort", json!({"corners": [[0.1, 0.05], [0.9, 0.1], [1.0, 1.0], [0.0, 0.8]]})), + ("distort.puckerBloat", json!({"amount": -40.0})), + ("distort.puckerBloat", json!({"amount": 60.0})), + ("distort.twist", json!({"angle": -45.0})), + ("distort.zigZag", json!({"size": 7.5, "relative": false, "ridges": 6, "points": "corner"})), + ("distort.zigZag", json!({"size": 25.0, "relative": true, "ridges": 3, "points": "smooth"})), + ("blur.gaussian", json!({"radius": 2.5})), + ]; + let warps = [ + "arc", + "arcLower", + "arcUpper", + "arch", + "bulge", + "shellLower", + "shellUpper", + "flag", + "wave", + "fish", + "rise", + "fisheye", + "inflate", + "squeeze", + "twist", + ]; + let ids: Vec = warps.iter().map(|w| format!("warp.{w}")).collect(); + for id in &ids { + cases.push((id, json!({"bend": 30.0, "horizontal": -10.0, "vertical": 20.0, "orientation": "vertical"}))); + cases.push((id, json!({"bend": -30.0, "horizontal": 0.0, "vertical": 0.0, "orientation": "horizontal"}))); + } + for (id, params) in cases { + let effect = fx(id, params.clone()); + let mut doc = Document::new(200.0, 100.0); + let mut g = vectorcraft_doc::GraphicStyle::new( + "Held", + Appearance { effects: vec![effect.clone()], ..Appearance::basic(black_fill(), Paint::None, 0.0) }, + ); + g.id = 3; + *doc.graphic_styles = vec![g]; + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{id} {params}: {:?}", out.warnings); + let back = read_back(&doc); + let got = &back.graphic_styles[0].appearance.effects; + assert_eq!(got.len(), 1, "{id} {params}"); + assert_eq!(got[0].id, effect.id); + let (g, w) = (vectorcraft_effects::merged_params(&got[0].id, &got[0].params), vectorcraft_effects::merged_params(&effect.id, &effect.params)); + for (key, value) in w.as_object().unwrap() { + // Random isn't written, and a style with it isn't read; its seed is the default. + let read = g.get(key).unwrap_or(&serde_json::Value::Null); + match value { + serde_json::Value::Array(rows) => { + let got = read.as_array().unwrap(); + assert_eq!(got.len(), rows.len(), "{id}"); + for (r, w) in got.iter().zip(rows) { + for (r, w) in r.as_array().unwrap().iter().zip(w.as_array().unwrap()) { + assert!((r.as_f64().unwrap() - w.as_f64().unwrap()).abs() < 6e-3, "{id} {key}: {read} for {value}"); + } + } + } + serde_json::Value::Number(v) => assert!((read.as_f64().unwrap() - v.as_f64().unwrap()).abs() < 6e-3, "{id} {key}: {read} for {v}"), + serde_json::Value::String(v) => assert_eq!(read.as_str().map(str::to_ascii_lowercase), Some(v.to_ascii_lowercase()), "{id} {key}"), + _ => assert_eq!(read, value, "{id} {key}"), + } + } + } + // A Zig Zag with its 0 to 1 settings written under the size's own key, and a turn in radians. + let mut doc = Document::new(200.0, 100.0); + let effects = vec![ + fx("distort.zigZag", json!({"size": 7.5, "relative": false, "ridges": 6, "points": "corner"})), + fx("distort.transform", json!({"moveV": -7.5, "rotate": 30.0})), + ]; + doc.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Forms", Appearance { effects, ..Appearance::default() })); + let p = program_shown(&doc); + assert!(p.contains("7.5 /Real (amount) ,") && p.contains("0 /Real (roundness) ,"), "{p}"); + assert!(p.contains("7.5 /Real (moveV_Pts) ,") && p.contains("0.5236 /Real (rotate_Radians) ,"), "{p}"); +} + /// Every kind of appearance a style isn't written for is written as its drawn look, with a warning. #[test] fn appearances_written_as_their_drawn_look() { @@ -5991,6 +6344,60 @@ fn appearances_written_as_their_drawn_look() { } } +/// A link to a graphic style whose Transform moves the outline is kept on a path the reader reads +/// that style on, and left out with a warning on a path with an opacity mask, which keeps its mask. +#[test] +fn a_link_to_a_style_that_moves_the_outline_is_kept_where_the_reader_reads_it() { + let make = |masked: bool| { + let mut doc = Document::new(200.0, 100.0); + let moved = fx("distort.transform", serde_json::json!({"copies": 0, "scaleH": 50.0})); + let ap = Appearance { effects: vec![moved], ..Appearance::basic(black_fill(), Paint::None, 0.0) }; + let mut g = vectorcraft_doc::GraphicStyle::new("Moved", ap.clone()); + g.id = 9; + doc.graphic_styles.push(g); + let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap); + n.extras_mut().graphic_style = Some(9); + if masked { + let art = square(&mut doc, Rect::new(10.0, 10.0, 50.0, 25.0), Color::gray(0.5)); + n.extras_mut().mask = Some(OpacityMask { art: Arc::new(art), clip: true, invert: false, disabled: false, linked: true }.into()); + } + put(&mut doc, n); + doc + }; + let doc = make(false); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings); + let back = read_back(&doc); + let n = &back.layers[0].children().unwrap()[0]; + assert_eq!(n.extras().graphic_style.and_then(|id| back.graphic_style_by_id(id)).map(|g| g.name.as_str()), Some("Moved")); + + let doc = make(true); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings); + let back = read_back(&doc); + let n = &back.layers[0].children().unwrap()[0]; + assert_eq!(n.extras().graphic_style, None); + assert!(n.extras().mask.is_some(), "{n:?}"); +} + +/// Twist and Transform on an object that isn't linked to a graphic style are not written as a style +/// of its own: the object reads back without live effects. +#[test] +fn distort_effects_on_an_unlinked_object_write_no_style() { + let cases = [fx("distort.twist", serde_json::json!({"angle": 30.0})), fx("distort.transform", serde_json::json!({"copies": 0, "moveH": 5.0}))]; + for e in cases { + let what = e.id.clone(); + let mut doc = Document::new(200.0, 100.0); + let n = plain_rect(&mut doc, "D", vec![e]); + put(&mut doc, n); + let p = shown(&export_ai_editing(&doc, &opts()).unwrap().program); + assert!(!p.contains("%AI9_BeginArtStyles") && !p.contains("(Anon 1) XW"), "{what}:\n{p}"); + let back = read_back(&doc); + let n = &back.layers[0].children().unwrap()[0]; + assert!(n.appearance.effects.is_empty(), "{what}: {n:?}"); + } +} + /// A gradient fill that fits its object is placed on the outline its geometry effects draw: an /// object with Offset Path reads back with the gradient a plain object of the drawn size has. #[test] @@ -6289,14 +6696,26 @@ fn a_styled_objects_gradient_is_placed_as_a_plain_objects_is() { } } -/// On a large canvas an object with an effect warns and comes back as its drawn look at its place. +/// On a large canvas a document with only graphic styles, an object linked to one and an object with +/// an effect all warn, and the object comes back as its drawn look at its place. #[test] fn a_large_canvas_writes_every_object_as_its_look() { let warns = |doc: &Document| export_ai_editing(doc, &opts()).unwrap().warnings.iter().any(|w| w == writer::LARGE_CANVAS_STYLES); + let mut styles_only = Document::new(20000.0, 100.0); + styles_only.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Mine", Appearance::basic(black_fill(), Paint::None, 0.0))); + assert!(warns(&styles_only)); + assert_eq!(read_back(&styles_only).graphic_style("Mine"), None); let mut plain = Document::new(20000.0, 100.0); let n = plain_rect(&mut plain, "None", vec![]); put(&mut plain, n); assert!(!warns(&plain)); + let mut linked_doc = Document::new(20000.0, 100.0); + let id = linked_doc.graphic_style("Sunshine").unwrap().id; + let mut n = plain_rect(&mut linked_doc, "Linked", vec![]); + n.extras_mut().graphic_style = Some(id); + put(&mut linked_doc, n); + assert!(warns(&linked_doc)); + assert_eq!(read_back(&linked_doc).layers[0].children().unwrap()[0].extras().graphic_style, None); let mut shaded = Document::new(20000.0, 100.0); let n = plain_rect(&mut shaded, "Shaded", vec![drop_shadow()]); put(&mut shaded, n); diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 89fd5abb7..231b67ce8 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -21,9 +21,9 @@ mod styles; mod symbols; mod texts; +pub(super) use styles::{LARGE_CANVAS_STYLES, style_links, styled_objects}; #[cfg(test)] -pub(super) use styles::same_appearance; -pub(super) use styles::{GRAPHIC_STYLES, LARGE_CANVAS_STYLES, has_graphic_styles, styled_objects}; +pub(super) use styles::{PANEL_EMPTY, PANEL_STYLES, STYLE_LINKS, same_appearance}; pub(super) use images::PAYLOAD; #[cfg(test)] diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index de74bc99e..7dce5f6d8 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -1,17 +1,18 @@ -//! Art styles: objects with live effects a plain path can't hold, written as their drawn look, the -//! object itself on `%_` lines and `1 (style) XW`, and the styles they name in -//! `%AI9_BeginArtStyles` (see [`crate::ai_editing`] for the form). Each style is read back before it -//! is written ([`crate::import::read_art_style`]: the `.ai` reader's style parser and its reading of -//! a graphic style, with the object's own gradient): one that reads back differently is not -//! written, and the object is written as its drawn look alone. +//! Art styles: objects whose appearance a plain path can't hold, written as their drawn look, the +//! object itself on `%_` lines and `1 (style) XW`; the styles they name and the Graphic Styles +//! panel's styles in `%AI9_BeginArtStyles`, and the panel's order in `%AI9_BeginArtStyleList` (see +//! [`crate::ai_editing`] for the form). Each style is read back before it is written +//! ([`crate::import::read_art_style`]: the `.ai` reader's style parser and its reading of a graphic +//! style, with the object's own gradient): one that reads back differently is not written, and the +//! object is written as its drawn look alone. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fmt::Write; use std::sync::Arc; use serde_json::Value; use vectorcraft_color::{BlendMode, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Knockout, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, GraphicStyle, Knockout, Node, NodeId, NodeKind, StrokeLayer}; use vectorcraft_geom::{BezPath, FillRule, Rect, Shape}; use super::{Writer, Xy, blend_code, compound_path, hide}; @@ -19,34 +20,36 @@ use crate::ai_ops; use crate::ps; pub(in crate::ai_editing) const LARGE_CANVAS_STYLES: &str = - "on a large canvas, objects with live effects are written as their drawn look in the editing data for other apps"; -pub(in crate::ai_editing) const GRAPHIC_STYLES: &str = "graphic styles and links to them are left out of the editing data for other apps"; + "on a large canvas, objects with live effects are written as their drawn look and graphic styles are left out of the editing data for other apps"; +pub(in crate::ai_editing) const PANEL_STYLES: &str = "graphic styles with gradient or pattern fills, with fills, strokes or effects the editing data can't hold as a graphic style, with a name another style has, with names over 1,024 bytes, or past 4,096 styles, are left out of the editing data for other apps"; +pub(in crate::ai_editing) const PANEL_EMPTY: &str = + "a Graphic Styles panel with no style written opens from the editing data with a new document's styles"; +pub(in crate::ai_editing) const STYLE_LINKS: &str = "links to graphic styles are left out of the editing data for other apps on type, images, symbol instances, knockout shapes and other objects that aren't written with their graphic style"; -/// Does `doc` have graphic styles other than a new document's, or an object in its layers, opacity -/// masks or symbols linked to a graphic style? -pub(in crate::ai_editing) fn has_graphic_styles(doc: &Document) -> bool { - fn linked(n: &Node) -> bool { - n.extras().graphic_style.is_some() - || n.extras().mask.as_ref().is_some_and(|m| linked(&m.art)) - || n.children().is_some_and(|c| c.iter().any(|m| linked(m))) +/// How many objects of `doc`'s layers, opacity masks and symbols are linked to a graphic style. +pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize { + fn count(n: &Node) -> usize { + let own = usize::from(n.extras().graphic_style.is_some()); + let mask = n.extras().mask.as_ref().map_or(0, |m| count(&m.art)); + own + mask + n.children().map_or(0, |c| c.iter().map(|m| count(m)).sum()) } - doc.graphic_styles != Document::new(1.0, 1.0).graphic_styles || doc.layers.iter().any(|l| linked(l)) || doc.symbols.iter().any(|s| linked(&s.art)) + doc.layers.iter().map(|l| count(l)).sum::() + doc.symbols.iter().map(|s| count(&s.art)).sum::() } /// The objects of `doc`'s layers that may be written with an art style ([`Writer::styled_object`]), -/// by id: paths and compound paths with points and groups, with live effects, without Opacity & -/// Mask Define Knockout Shape (the reader takes an object's transparency from its look, which -/// doesn't carry it). A group's members are paths, compound paths or such groups, none with effects -/// or anything baking changes. +/// by id: paths and compound paths with points and groups, with live effects or linked to a graphic +/// style, without Opacity & Mask Define Knockout Shape (the reader takes an object's transparency +/// from its look, which doesn't carry it). A group's members are paths, compound paths or such +/// groups, none with effects or anything baking changes. pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> HashMap> { fn walk(nodes: &[Arc], out: &mut HashMap>) { for n in nodes { if candidate(n) { out.insert(n.id, n.clone()); - continue; } - // Baking a container with effects of its own changes its members, which keep their ids: - // they are written as baking left them. + // A group's members are written with a style of their own only when the group is written + // without one. Baking a container with effects of its own changes its members, which + // keep their ids: they are written as baking left them. if let NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } = &n.kind && !bakes_members(n) { @@ -70,7 +73,8 @@ fn bakes_members(n: &Node) -> bool { /// Whether `n` may be written with an art style ([`styled_objects`]). fn candidate(n: &Node) -> bool { - if n.appearance.effects.is_empty() || n.knockout_shape { + let styled = !n.appearance.effects.is_empty() || n.extras().graphic_style.is_some(); + if !styled || n.knockout_shape { return false; } match &n.kind { @@ -97,25 +101,39 @@ fn plain_member(n: &Node) -> bool { } } -/// The art styles objects name, each with its text after its name (`/ActiveStyle :` to `/Def ;`). +/// The art styles written: the Graphic Styles panel's (listed, in its order) and those objects +/// name, each with its text after its name (`/ActiveStyle :` to `/Def ;`). #[derive(Default)] pub(in crate::ai_editing) struct Styles { /// Every style, in the order written: its name and text. known: Vec<(String, String)>, - /// The name of each style by its text. + /// The panel's styles written, by the graphic style's id. + panel: HashMap, + /// The panel's styles written, in its order. + listed: Vec, + /// The names styles have, and the name of each object style by its text. + names: HashSet, by_text: HashMap, next_anon: u64, + /// How many objects were written with the graphic style they are linked to. + pub(super) links_written: usize, } impl Styles { /// The name of an object style of `text`: a style with the same text already written, else a - /// new one named `Anon` and a number. + /// new one named `Anon` and a number no other style has. fn object_style(&mut self, text: String) -> String { if let Some(name) = self.by_text.get(&text) { return name.clone(); } - self.next_anon += 1; - let name = format!("Anon {}", self.next_anon); + let name = loop { + self.next_anon += 1; + let name = format!("Anon {}", self.next_anon); + if !self.names.contains(&name) { + break name; + } + }; + self.names.insert(name.clone()); self.by_text.insert(text.clone(), name.clone()); self.known.push((name.clone(), text)); name @@ -287,16 +305,30 @@ impl Writer<'_> { (!line.is_empty()).then_some(line) } - /// An appearance item's basic filter. `None` for an item with transparency or effects of its - /// own, which the reader doesn't read on an object. + /// An appearance item's filter: its basic filter, or a chain of it, its effects and its + /// transparency. fn item_filter(&mut self, s: &mut String, item: &AppearanceItem, on: Option<(&BezPath, FillRule)>) -> Option<()> { - if item.opacity() < 1.0 || item.blend() != BlendMode::Normal || !item.effects().is_empty() { - return None; - } + let plain = item.opacity() >= 1.0 && item.blend() == BlendMode::Normal && item.effects().is_empty(); + let mut basic_text = String::new(); + // In a chain, the chain holds the item's visibility. + let visible = item.visible() || !plain; match item { - AppearanceItem::Fill(f) => self.fill_filter(s, f, item.visible(), on), - AppearanceItem::Stroke(st) => self.stroke_filter(s, st, item.visible()), + AppearanceItem::Fill(f) => self.fill_filter(&mut basic_text, f, visible, on)?, + AppearanceItem::Stroke(st) => self.stroke_filter(&mut basic_text, st, visible)?, } + if plain { + s.push_str(&basic_text); + return Some(()); + } + let _ = write!(s, "/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n{} /Visible ,\n", u8::from(item.visible())); + s.push_str(&basic_text); + for e in item.effects() { + s.push_str(&self.effect_filter(e)?); + } + let opacity = if item.opacity().is_finite() { item.opacity().clamp(0.0, 1.0) } else { 1.0 }; + blend_filter(s, Xy { blend: item.blend(), opacity, ..Xy::OPAQUE }); + s.push_str(";\n /Part ,\n"); + Some(()) } /// The text of a style of appearance `ap` and transparency `xy` (after its name): the chain @@ -338,7 +370,13 @@ impl Writer<'_> { let p = vectorcraft_effects::merged_params(&e.id, &e.params); let mut d = Entries::default(); let id = e.id.as_str(); - let name = if id.starts_with("stylize.") { self.stylize_entries(id, &p, &mut d)? } else { Self::path_entries(id, &p, &mut d)? }; + let name = if id.starts_with("stylize.") { + self.stylize_entries(id, &p, &mut d)? + } else if id.starts_with("warp.") || id.starts_with("distort.") || id.starts_with("blur.") { + Self::distort_entries(id, &p, &mut d)? + } else { + Self::path_entries(id, &p, &mut d)? + }; let mut s = String::new(); basic(&mut s, name, true, e.visible); s.push_str("/Dictionary : /NotRecorded ,\n"); @@ -461,9 +499,140 @@ impl Writer<'_> { }) } - /// The style object `orig` (`n` before baking) is written with; `None` when the reader wouldn't - /// read the object as it is with it. A gradient fill is placed on `n`'s outline, as the object - /// draws it. A style that isn't written leaves no gradient definition or warning behind. + /// A Warp, Distort & Transform or Gaussian Blur effect's entries ([`Self::effect_filter`]) and + /// its filter's name. + fn distort_entries(id: &str, p: &Value, d: &mut Entries) -> Option<&'static str> { + Some(match id { + id if id.starts_with("warp.") => { + let style = crate::import::warp_number(id.strip_prefix("warp.")?)?; + d.int("DeformStyle", style); + d.real("DeformValue", num(p, "bend")? / 100.0); + d.real("DeformHoriz", num(p, "horizontal")? / 100.0); + d.real("DeformVert", num(p, "vertical")? / 100.0); + d.bool("Rotate", word(p, "orientation").as_deref() == Some("vertical")); + "Adobe Deform" // brand-ok: the filter's name in the files + } + "distort.transform" => { + d.real("scaleH_Factor", num(p, "scaleH")? / 100.0); + d.real("scaleV_Factor", num(p, "scaleV")? / 100.0); + d.real("moveH_Pts", num(p, "moveH")?); + // Art space has y up. + d.real("moveV_Pts", 0.0 - num(p, "moveV")?); + d.real("rotate_Radians", num(p, "rotate")?.to_radians()); + d.int("numCopies", num(p, "copies")? as i64); + d.int("pinPoint", num(p, "reference")? as i64); + d.bool("reflectX", flag(p, "reflectX")?); + d.bool("reflectY", flag(p, "reflectY")?); + d.bool("transformObjects", flag(p, "objects")?); + d.bool("scaleLines", flag(p, "strokes")?); + d.bool("transformPatterns", flag(p, "patterns")?); + d.bool("randomize", false); + "Adobe Transform" // brand-ok: the filter's name in the files + } + "distort.freeDistort" => { + let corners = p.get("corners")?.as_array()?; + let corner = |i: usize| -> Option<(f64, f64)> { + let c = corners.get(i)?.as_array()?; + Some((c.first()?.as_f64()?, c.get(1)?.as_f64()?)) + }; + // The source frame is the unit square (y up); destination corners 0, 1, 3, 2 are + // the catalog's top left, top right, bottom right, bottom left (y down). + for (i, (h, v)) in [(0.0, 1.0), (1.0, 1.0), (0.0, 0.0), (1.0, 0.0)].into_iter().enumerate() { + d.real(&format!("src{i}h"), h); + d.real(&format!("src{i}v"), v); + } + for (i, from) in [0, 1, 3, 2].into_iter().enumerate() { + let (x, y) = corner(from)?; + d.real(&format!("dst{i}h"), x); + d.real(&format!("dst{i}v"), 1.0 - y); + } + "Adobe Free Distort" // brand-ok: the filter's name in the files + } + "distort.puckerBloat" => { + d.real("d_factor", num(p, "amount")?); + "Adobe Punk and Bloat" // brand-ok: the filter's name in the files + } + "distort.twist" => { + d.real("angle", num(p, "angle")?); + "Adobe Twirl" // brand-ok: the filter's name in the files + } + "distort.zigZag" => { + let relative = flag(p, "relative")?; + d.real("absoluteness", if relative { 0.0 } else { 1.0 }); + d.real(if relative { "relAmount" } else { "amount" }, num(p, "size")?); + d.real("ridges", num(p, "ridges")?); + d.real("roundness", if word(p, "points").as_deref() == Some("smooth") { 1.0 } else { 0.0 }); + "Adobe Zigzag" // brand-ok: the filter's name in the files + } + "blur.gaussian" => { + d.real("blur", num(p, "radius")?); + "Adobe PSL Gaussian Blur" // brand-ok: the filter's name in the files + } + _ => return None, + }) + } + + /// The Graphic Styles panel's styles that the reader reads as they are: each checked, and + /// the others left out with a warning. Their names are taken before the art's styles are. + pub(in crate::ai_editing) fn panel_styles(&mut self) { + let doc = self.doc; + // A new document's styles are what the reader gives a file without a style list; they are + // still written when an object is linked to one of them, so that it keeps its link. + let linked = self.styled.values().any(|n| n.extras().graphic_style.is_some()); + if !linked && doc.graphic_styles == Document::new(1.0, 1.0).graphic_styles { + return; + } + for g in doc.graphic_styles.iter() { + self.styles.names.insert(g.name.clone()); + } + let mut readable: Vec<(&GraphicStyle, String)> = vec![]; + let mut seen: HashSet<&str> = HashSet::new(); + for g in doc.graphic_styles.iter() { + if readable.len() >= crate::import::MAX_LISTED || g.name.len() > crate::import::MAX_LISTED_NAME || seen.contains(g.name.as_str()) { + self.warn(PANEL_STYLES); + continue; + } + let xy = Xy { blend: g.blend, opacity: g.opacity.clamp(0.0, 1.0), isolate: g.isolate, knockout: g.knockout, shape: false }; + let text = self.style_text(&g.appearance, xy, None).filter(|t| panel_reads(g, t)); + let Some(text) = text else { + self.warn(PANEL_STYLES); + continue; + }; + seen.insert(g.name.as_str()); + readable.push((g, text)); + } + // The list as the reader reads it: names too long, and styles past its count, aren't. + let list: String = readable.iter().map(|(g, _)| ps::bytes(g.name.as_bytes()) + "\n").collect(); + let listed: HashSet = crate::import::read_style_list(list.as_bytes()).into_iter().collect(); + for (g, text) in readable { + if !listed.contains(&g.name) { + self.warn(PANEL_STYLES); + continue; + } + if g.id != 0 { + self.styles.panel.insert(g.id, g.name.clone()); + } + self.styles.listed.push(g.name.clone()); + self.styles.known.push((g.name.clone(), text)); + } + // The reader gives a file without a style list a new document's styles. + if self.styles.listed.is_empty() { + self.warn(PANEL_EMPTY); + } + } + + /// Warn when fewer objects were written with their graphic style than `links` are linked to one. + pub(in crate::ai_editing) fn lost_links(&mut self, links: usize) { + if self.styles.links_written < links { + self.warn(STYLE_LINKS); + } + } + + /// The style object `orig` (`n` before baking) is written with: the graphic style it is linked + /// to when it has that style's appearance and transparency and the panel's style is written, + /// else a style of its own; `None` when the reader wouldn't read the object as it is with + /// either. A gradient fill is placed on `n`'s outline, as the object draws it. A style of its own + /// that isn't written leaves no gradient definition or warning behind. pub(super) fn object_style(&mut self, n: &Node, orig: &Node) -> Option { if let NodeKind::Group { children, .. } = &orig.kind && bakes_members(orig) @@ -471,6 +640,21 @@ impl Writer<'_> { { return None; } + let doc = self.doc; + if let Some(id) = orig.extras().graphic_style + && let Some(name) = self.styles.panel.get(&id).cloned() + && let Some(g) = doc.graphic_style_by_id(id) + && g.transparency_matches(orig) + && g.appearance_on(orig).approx_eq(&orig.appearance) + && let Some(text) = self.styles.known.iter().find(|(n, _)| *n == name).map(|(_, t)| t.clone()) + && object_reads(orig, &text) + { + self.styles.links_written += 1; + return Some(name); + } + if orig.appearance.effects.is_empty() || !own_style_holds(&orig.appearance) { + return None; + } let graded = orig.appearance.items.iter().any(|i| matches!(i.paint(), Paint::Gradient(_))); let saved = (graded.then(|| self.gradient_mark()), self.warnings.len()); let path = drawn_outline(n, orig); @@ -583,6 +767,20 @@ impl Writer<'_> { } s.push_str("%AI9_EndArtStyles\n%AI5_End_NonPrinting--\n"); } + + /// The panel's styles written, in its order, in a non-printing block: + /// `%AI9_BeginArtStyleList`, a name on each line, `%AI9_EndArtStyleList`. + pub(in crate::ai_editing) fn art_style_list(&self, s: &mut String) { + if self.styles.listed.is_empty() { + return; + } + s.push_str("%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyleList\n"); + for name in &self.styles.listed { + s.push_str(&ps::bytes(name.as_bytes())); + s.push('\n'); + } + s.push_str("%AI9_EndArtStyleList\n%AI5_End_NonPrinting--\n"); + } } /// The outline a path's or compound path's style places a gradient fill on. @@ -609,15 +807,38 @@ fn object_rule(n: &Node) -> FillRule { } } +/// Can appearance `ap` be written as a style of an object's own: fills and strokes that paint without +/// transparency or effects of their own, and no Warp, Distort & Transform or Blur effect? Those +/// are written only in the Graphic Styles panel's styles. +fn own_style_holds(ap: &Appearance) -> bool { + let plain = |i: &AppearanceItem| i.opacity() >= 1.0 && i.blend() == BlendMode::Normal && i.effects().is_empty(); + let panel_only = |e: &Effect| ["warp.", "distort.", "blur."].iter().any(|p| e.id.starts_with(p)); + ap.items.iter().filter(|i| !i.paint().is_none()).all(plain) && !ap.effects.iter().any(panel_only) +} + +/// Does the reader read graphic style `g` from style text `text` as it is? +fn panel_reads(g: &GraphicStyle, text: &str) -> bool { + let section = known_style("x", text); + let Some(read) = crate::import::read_art_style(section.as_bytes(), "x", None) else { return false }; + // The opacity within the written number's precision. + read.transparency.is_some_and(|t| (t.0 - g.opacity).abs() <= 1e-4 && (t.1, t.2, t.3) == (g.blend, g.isolate, g.knockout)) + && same_appearance(&read.appearance, &g.appearance) +} + /// Does the reader read object `n` written with style text `text` as it is: a path or compound /// path with a style it reads on any object, filled by the non-zero rule when the style fills it, -/// or a group with no fills or strokes of its own? +/// or a group with no fills or strokes of its own, with effects that move the outline only where the +/// reader reads them ([`crate::import::style_moves_with`])? fn object_reads(n: &Node, text: &str) -> bool { let section = known_style("x", text); let own = n.appearance.fill().map(|f| &f.paint).filter(|p| matches!(p, Paint::Gradient(_))); let Some(mut read) = crate::import::read_art_style(section.as_bytes(), "x", own) else { return false }; let group = matches!(n.kind, NodeKind::Group { .. }); - if !read.simple || (group && !read.appearance.items.is_empty()) || (!group && read.pathfinder) { + if !read.simple + || (group && !read.appearance.items.is_empty()) + || (!group && read.pathfinder) + || !crate::import::style_moves_with(section.as_bytes(), "x", n) + { return false; } if !group { diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index 4b5b47c28..32395447f 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -70,7 +70,9 @@ use graphics::{GState, Out}; use interp::{Fault, Interp}; use obj::PsError; -pub(crate) use ai::{MAX_GRADIENT_STOPS, MAX_SYMBOLS, read_art_style, symbol_art_member}; +pub(crate) use ai::{ + MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, read_art_style, read_style_list, style_moves_with, symbol_art_member, warp_number, +}; pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES}; pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, is_loss, layered_ai, layered_ai_from}; pub(crate) use native::{MAX_TEXT, scale_document}; diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index a0720d3c1..93e9295e6 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -41,9 +41,10 @@ pub use read::{ BindingRef, Brushes, IMAGE_TOO_LARGE, PaletteSwatches, Structure, artboards, bindings_note, brushes, graphic_styles, palette, read, read_beside, variables, }; -pub(crate) use read::{MAX_SYMBOLS, join_and, read_art_style, slot_frame, slot_of, symbol_art_member}; +pub(crate) use read::{MAX_SYMBOLS, join_and, read_art_style, read_style_list, slot_frame, slot_of, style_moves_with, symbol_art_member}; pub(crate) use read::is_look_slot; +pub(crate) use style::{MAX_LISTED, MAX_LISTED_NAME, warp_number}; #[cfg(test)] mod tests; diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 3386f5098..dff6a9a2b 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -136,7 +136,7 @@ pub(crate) use art::join_and; use art::preview_png; pub use document::{BindingRef, Brushes, PaletteSwatches, VariableRead, artboards, bindings_note, brushes, graphic_styles, palette, variables}; pub(crate) use document::{slot_frame, slot_of}; -pub(crate) use ops::read_art_style; +pub(crate) use ops::{read_art_style, read_style_list, style_moves_with}; pub use types::IMAGE_TOO_LARGE; use types::{BLEND_BUDGET, FLIP, Frame, GState, Graph, Kind, PatternHead, PendingEnvelope, Place, Raster, SymbolHead}; #[cfg(test)] diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 1be4f716e..0b23aea4b 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -1131,6 +1131,13 @@ pub(crate) struct StyleRead { pub simple: bool, /// It has Effect › Pathfinder effects, which only a group with no fills or strokes is read with. pub pathfinder: bool, + /// The opacity, blending mode, isolation and knockout its blend filter gives. + pub transparency: Option<(f32, BlendMode, bool, Knockout)>, +} + +/// The names a style list section gives graphic styles, as the reader reads them. +pub(crate) fn read_style_list(section: &[u8]) -> Vec { + crate::import::ai::style::art_style_list(section).iter().map(|raw| obj::text(raw)).filter(|name| !name.is_empty()).collect() } /// Art style `name` of `section` as the reader reads it ([`StyleRead`]): [`appearance_from_art_style`], @@ -1149,7 +1156,14 @@ pub(crate) fn read_art_style(section: &[u8], name: &str, own_gradient: Option<&P } } let appearance = appearance_from_art_style(&st)?; - Some(StyleRead { appearance, simple: st.simple, pathfinder: st.pathfinder }) + Some(StyleRead { appearance, simple: st.simple, pathfinder: st.pathfinder, transparency: st.transparency }) +} + +/// Is art style `name` of `section` read on `object` as far as an effect that moves the outline +/// goes: the style has none, or `object` is one [`moves_with`] reads it on, as [`styled`] checks? +/// `false` when the section doesn't define it. +pub(crate) fn style_moves_with(section: &[u8], name: &str, object: &Node) -> bool { + crate::import::ai::style::art_styles(section).remove(name).is_some_and(|st| !st.moves_outline() || moves_with(object, &st)) } /// A style's stroke as an appearance item (`None` for a fill). diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 9699e3ac6..24fa7ea7d 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -113,6 +113,12 @@ pub(super) fn warp_style(n: i64) -> Option<&'static str> { id.strip_prefix("warp.") } +/// The number `DeformStyle` gives warp style `style` (`arc`, `arcLower`, …), from 1. +pub(crate) fn warp_number(style: &str) -> Option { + let at = WARPS.iter().position(|id| id.strip_prefix("warp.") == Some(style))?; + i64::try_from(at + 1).ok() +} + /// The Effect › Pathfinder effects by the `Command` number of their filter. `Command` 0 is Add /// (https://community.adobe.com/questions-652/liveeffect-xml-effect-is-added-under-the-object-795137); /// the numbers 1 to 11 are VectorCraft's own reader rules, learned from saved files. Trap (12) isn't @@ -312,9 +318,9 @@ impl Default for Filter { const MAX_FILTERS: usize = 4096; /// Most entries read from a style list. -const MAX_LISTED: usize = 4096; +pub(crate) const MAX_LISTED: usize = 4096; /// The longest style name read from a style list, in bytes. -const MAX_LISTED_NAME: usize = 1024; +pub(crate) const MAX_LISTED_NAME: usize = 1024; /// The style list (`%AI9_BeginArtStyleList`, between its markers): one literal string per style, /// the Graphic Styles panel's order. Each name is its string's bytes, escapes undone; other tokens, diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index da9a83ad7..55c21688f 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -1187,6 +1187,15 @@ fn convert_to_shape_settings_that_arent_read_keep_the_drawn_look() { assert!(!art_styles(data.as_bytes())["Odd"].plain); } +#[test] +fn warp_numbers_are_the_dialogs_order() { + for n in 1..=15 { + let style = warp_style(n).unwrap(); + assert_eq!(warp_number(style), Some(n), "{style}"); + } + assert_eq!((warp_style(0), warp_style(16), warp_number("arcs"), warp_number("")), (None, None, None, None)); +} + /// A style as a writer checks it: the reader's appearance, with a gradient fill as the object's own, /// and nothing for styles it doesn't read. #[test] @@ -1200,6 +1209,7 @@ fn a_style_is_read_as_a_writer_checks_it() { let got = read(&data, Some(&own)).unwrap(); assert_eq!(got.appearance.items.len(), 1); assert_eq!(got.appearance.items[0].paint(), &own); + assert_eq!(got.transparency.map(|t| (t.0, t.1)), Some((0.5, BlendMode::Multiply))); assert!(got.simple && !got.pathfinder); // A gradient that isn't the object's own, a pattern, another name, two gradients: nothing. assert!(read(&data, None).is_none()); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9984686f4..fc9a822e6 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -93,8 +93,13 @@ private editing data. VectorCraft: group's effects change its members' outlines, a member's strokes are plain lines, and its fills are not pattern fills written as tiles or gradients that fit their object. The look holds the object's vector art, without the shadow, glow or feather. Other objects with live effects are written as their vector art with their geometry effects applied, and their other effects, such as - Drop Shadow, the glows and Feather, are left out with a warning. Graphic styles and links to them are left out with a warning. No art style is written on a large - canvas. + Drop Shadow, the glows and Feather, are left out with a warning. A path, compound path or group linked to a graphic style names it when it has the style's + appearance and the style is written; other links to graphic styles, those on a styled group's members included, are + left out with a warning. The Graphic Styles panel's styles are written in its order, with their fills, strokes, transparency and effects (Warp, + Transform, Free Distort, Pucker & Bloat, Twist, Zig Zag and Gaussian Blur too). Styles with gradient or pattern fills, + with fills, strokes or effects the reader doesn't read back, with a name another style has, with names over 1,024 + bytes and past 4,096 styles are left out with a warning. A panel with no style written opens with a new document's + styles, with a warning. No art style is written on a large canvas. Point type, area type (with its rows, columns, gutters and first baseline, threaded frames included) and type on a path are written as text: a text object for each, naming its story in the text document, with the characters, fonts, sizes, leading, tracking, baseline shift, From 600ce68bc7d69481c588120cf3c883f04317d1f4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 12:28:17 -0800 Subject: [PATCH 07/13] Docs: only filled even-odd paths lose their live effects in a saved .ai A stroke-only path or compound path that uses the even-odd rule keeps its live effects in the editing copy; the art-style writer leaves out only paths that fill by the even-odd rule. --- README.md | 2 +- ROADMAP.md | 2 +- docs/gaps.md | 2 +- docs/roadmap.md | 2 +- docs/target-app-parity.md | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3a34bb7f9..d71e5b23a 100644 --- a/README.md +++ b/README.md @@ -253,7 +253,7 @@ The scores are self-assessed from our code against public documentation; [target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them. **What's missing:** -- reliable exchange of .ai files (layer structure in real files; the editing copy a save writes keeps live effects only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas) and whose appearance and effects the art-style writer supports (see [docs/file-format-parity.md](docs/file-format-parity.md)), leaves out other objects' effects other than geometry effects, and writes live objects as their art); +- reliable exchange of .ai files (layer structure in real files; the editing copy a save writes keeps live effects only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports (see [docs/file-format-parity.md](docs/file-format-parity.md)), leaves out other objects' effects other than geometry effects, and writes live objects as their art); - an interaction-fidelity pass covering every tool's modifiers and small behaviours; - 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials); - filter thumbnails and a preview area inside the Effect Gallery (the gallery itself and all 57 Photoshop-style raster filters, approximated, are in); diff --git a/ROADMAP.md b/ROADMAP.md index 3f2882bc4..0f9267e0e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,7 +24,7 @@ and the work list in [`docs/gaps.md`](docs/gaps.md). **Why alpha:** the core workflows exist end to end (drawing and path editing, Pathfinder, paint and appearance, type, SVG/PDF/EPS, print, export) and feature depth is ~75%, but a professional can't yet switch: Illustrator's own `.ai` files open with a wrong layer structure in real cases and are saved back with an editing copy that -keeps live effects only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas) and whose appearance and effects the art-style writer supports (see [docs/file-format-parity.md](docs/file-format-parity.md)), and writes live objects as their art, the interaction details have never been checked side by side, and some machines fail to start the +keeps live effects only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports (see [docs/file-format-parity.md](docs/file-format-parity.md)), and writes live objects as their art, the interaction details have never been checked side by side, and some machines fail to start the app. Beta needs those closed (the beta list in [gaps.md](docs/gaps.md)). It passes the core-workflow gate: all six core workflows (path drawing and editing, paint and style, type, layers/artboards/export, print production, file exchange) work end to end on macOS with save and reopen, and only the last is partial, without diff --git a/docs/gaps.md b/docs/gaps.md index e09887bc8..e5e57ebd7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -204,7 +204,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall path's own Calligraphic brush settings, and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). A PDF-compatible `.ai` carries an editing copy of the art in the form VectorCraft's reader reads (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks, clipping, type as text and symbols), with live effects kept only on paths, compound paths and groups that - qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas) and whose appearance and effects the art-style writer supports, other objects' effects other than geometry effects left out, and live objects written as their art + qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, other objects' effects other than geometry effects left out, and live objects written as their art ([file-format-parity.md](file-format-parity.md)). Save As `.ai` › Version: Legacy (version 7) writes the PostScript form from the published version 7 specification, keeping layers (names, order, visibility, lock), groups, compound paths, clipping groups, gradients and diff --git a/docs/roadmap.md b/docs/roadmap.md index 1c177d294..d01c002ae 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -89,7 +89,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V | Set type: point and area type, Character and Paragraph styles, threading, outlines | yes | type core 80%; styles, threading, Create Outlines and font matching tested ([type-parity.md](type-parity.md)); justification options missing (not blocking) | 0 | | Organise layers and artboards and export assets (Export for Screens, SVG, PNG, PDF per artboard) | yes | Layers panel rework, Artboards panel and tool, Export for Screens and Asset Export done; Art Optimized supersample closes ghost borders (#983 / #1037) | 0 | | Print production: CMYK and spot colour, bleed and marks, PDF/X out, separations preview | yes | PDF/X-1a/3/4, presets, marks and bleed, ICC output intent, Separations Preview, Print with PostScript output | 0 | -| Exchange files with Illustrator users: open their `.ai`/EPS/PDF, hand back a file they can open | partial (doesn't block alpha) | `.ai`, EPS and PDF open as editable art, but real files can get the wrong layer structure (#951); Save As `.ai` is PDF-compatible with an editing copy of the art (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks, clipping, type as text and symbols, with live effects kept only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas) and whose appearance and effects the art-style writer supports ([file-format-parity.md](file-format-parity.md)), other objects' effects other than geometry effects left out, and live objects written as their art; #868), or legacy version 7, which keeps layers, groups and type as text, one artboard per file, and waits for users' testing. EPS legacy mesh corners, vector opacity masks and raster print-page comparison now preserve editable art under synthetic regressions. This is the beta main-format gap ([gaps.md](gaps.md) G9) | 10–20 (for beta) | +| Exchange files with Illustrator users: open their `.ai`/EPS/PDF, hand back a file they can open | partial (doesn't block alpha) | `.ai`, EPS and PDF open as editable art, but real files can get the wrong layer structure (#951); Save As `.ai` is PDF-compatible with an editing copy of the art (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks, clipping, type as text and symbols, with live effects kept only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports ([file-format-parity.md](file-format-parity.md)), other objects' effects other than geometry effects left out, and live objects written as their art; #868), or legacy version 7, which keeps layers, groups and type as text, one artboard per file, and waits for users' testing. EPS legacy mesh corners, vector opacity masks and raster print-page comparison now preserve editable art under synthetic regressions. This is the beta main-format gap ([gaps.md](gaps.md) G9) | 10–20 (for beta) | Hindi interface follow-up (2026-11-06): the catalog and zero/one plural rule are implemented; native wording review, layout checks and a web Devanagari font remain. [Notes](localization-hi.md). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 04feefc13..9710d9e16 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -201,7 +201,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged) | | Guides, grids, smart guides, snapping, rulers | 3 | 80% | "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented; video rulers (View › Rulers › Show Video Rulers, #2849) done, score unchanged | @@ -283,7 +283,7 @@ The discounts, with the evidence: #868), guides lost (#779), slower opening (#758), live effects, brushes and symbols as their drawn look (#637). VectorCraft's PDF-compatible `.ai` carries an editing copy of the art (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks, clipping, type as text and symbols), with live effects kept only - on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule, no large canvas) and whose appearance and effects the art-style writer supports, other objects' effects other than geometry + on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, other objects' effects other than geometry effects left out, and live objects written as their art; the legacy `.ai` of version 7 it also writes keeps layers, groups and type as text (one artboard per file, no transparency) but hasn't been tested by users yet (#868). That blocks beta (the main-format rule) for people who hand files back to Illustrator users, but it doesn't stop someone opening and reworking their own archive. From 188142a792e5c2e1e7495210d28ea21d56402053 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 12:49:12 -0800 Subject: [PATCH 08/13] .ai save: a symbol first defined inside a drawn look warns of its art's effects A symbol whose first instance is written in the drawn look of an object with a style (through a member's scatter brush) was defined while the look's flag was set, so the warning for the effects its art loses was left out, and the cached definition kept it out for later instances. The definition now writes its art with the flag cleared and restores it afterwards. --- crates/eps/src/ai_editing/tests.rs | 42 +++++++++++++++++++++ crates/eps/src/ai_editing/writer/symbols.rs | 4 ++ 2 files changed, 46 insertions(+) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 41a4a789b..6cdf6da64 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -4573,6 +4573,48 @@ fn symbols_painting_patterns_through_brush_art_are_placed_as_art() { assert_eq!(instances(&back.layers[0]), 0, "the instance is its art"); } +/// A symbol first defined from the drawn look of a group with a style, through a member's scatter +/// brush, still warns that its art's own effects are left out, and so does a later instance of it. +#[test] +fn a_symbol_defined_inside_a_drawn_look_warns_of_its_own_effects() { + let mut doc = Document::new(200.0, 100.0); + let mut shadowed = square(&mut doc, Rect::new(0.0, 0.0, 6.0, 6.0), Color::BLACK); + shadowed.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(black_fill(), Paint::None, 0.0) }.into(); + doc.symbols.push(Symbol { dynamic: false, name: "Shadowed".into(), art: Arc::new(shadowed) }); + let dot = square(&mut doc, Rect::new(0.0, 0.0, 6.0, 6.0), Color::BLACK); + let placed = instance(&mut doc, "Shadowed", Affine::translate((8.0, 0.0))); + let art = group(&mut doc, vec![dot, placed], false); + let brush = vectorcraft_brush::Brush { + name: "Scattered".into(), + kind: vectorcraft_brush::BrushKind::Scatter(vectorcraft_brush::Scatter { + art, + size: (100.0, 100.0), + spacing: (100.0, 100.0), + scatter: (0.0, 0.0), + rotation: (0.0, 0.0), + colorization: vectorcraft_brush::Colorization::None, + ..Default::default() + }), + }; + vectorcraft_brush::store(&mut doc, &[brush]); + let mut line = + Node::path(doc.alloc_id(), shapes::line((10.0, 50.0).into(), (90.0, 50.0).into()), Appearance::basic(Paint::None, solid(Color::BLACK), 1.0)); + line.appearance.stroke_mut().unwrap().brush = Some("Scattered".into()); + let edge = square(&mut doc, Rect::new(10.0, 60.0, 30.0, 80.0), LEAF); + let mut styled = group(&mut doc, vec![line, edge], false); + styled.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::default() }.into(); + put(&mut doc, named(styled, "Styled")); + let out = export_ai_editing(&doc, &opts()).unwrap(); + let p = shown(&out.program); + assert!(p.contains("%AI14_BeginSymbol") && p.contains("1 (Anon 1) XW"), "{p}"); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + + let later = instance(&mut doc, "Shadowed", Affine::translate((150.0, 20.0))); + put(&mut doc, later); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); +} + /// A symbol whose stroke is written as its outline (an outside stroke): a scaled instance, a scaled /// instance of a symbol holding it and a turned and mirrored instance are written as their art, /// which keeps the stroke the canvas draws; a moved instance stays an instance. diff --git a/crates/eps/src/ai_editing/writer/symbols.rs b/crates/eps/src/ai_editing/writer/symbols.rs index ccec95d42..bfc1ccc3c 100644 --- a/crates/eps/src/ai_editing/writer/symbols.rs +++ b/crates/eps/src/ai_editing/writer/symbols.rs @@ -203,6 +203,8 @@ impl Writer<'_> { if sym.dynamic || doc.symbol_export_type(name) != SymbolExportType::default() || doc.symbol_nine_slice(name).is_some() { self.warn(SYMBOL_OPTIONS); } + // The art's effects are its own, not those of a drawn look that first places it. + let in_look = std::mem::take(&mut self.in_look); self.defining_symbols.push(name.to_string()); let mut used = vec![]; vectorcraft_doc::swatches::walk_with_masks(&sym.art, &mut |m| { @@ -216,6 +218,7 @@ impl Writer<'_> { if self.symbol_defs.len() + self.defining_symbols.len() > MAX_SYMBOLS { self.defining_symbols.pop(); self.warn(SYMBOL_COUNT); + self.in_look = in_look; return false; } // The art is written in the symbol's space, from the state `0 A` and `0 Xw` set, from the @@ -291,6 +294,7 @@ impl Writer<'_> { (self.wrote_fixed, self.inner_scale), ) = saved; self.doc = page_doc; + self.in_look = in_look; let art = std::mem::replace(&mut self.body, art); self.defining_symbols.pop(); let text = format!("%AI14_BeginSymbol\n{}\n0 A\n0 Xw\n0 Ae\n{art}%AI10_EndSymbol\n", ps::bytes(name.as_bytes())); From 66b4c171d4c5ecce50e838559e50aa8783ba217a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 14:52:43 -0800 Subject: [PATCH 09/13] .ai save: the large-canvas warning says what the editing copy keeps On a large canvas no art style is written, so objects with live effects are written as their vector art with their geometry effects applied, and their shadows, glows and feathers are left out. The warning now says that, as file-format-parity.md does, instead of "written as their drawn look". --- crates/eps/src/ai_editing/writer/styles.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index de74bc99e..ffd4b4fcf 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -18,8 +18,7 @@ use super::{Writer, Xy, blend_code, compound_path, hide}; use crate::ai_ops; use crate::ps; -pub(in crate::ai_editing) const LARGE_CANVAS_STYLES: &str = - "on a large canvas, objects with live effects are written as their drawn look in the editing data for other apps"; +pub(in crate::ai_editing) const LARGE_CANVAS_STYLES: &str = "on a large canvas, objects with live effects are written as their vector art with their geometry effects applied, without shadows, glows or feathers, in the editing data for other apps"; pub(in crate::ai_editing) const GRAPHIC_STYLES: &str = "graphic styles and links to them are left out of the editing data for other apps"; /// Does `doc` have graphic styles other than a new document's, or an object in its layers, opacity From a1842028daa7ea708fd0c74a8961f03959dd2e35 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 14:52:43 -0800 Subject: [PATCH 10/13] Tests: compare the RGB values of colors in the art-style round trip art_styles_write_the_same_bytes_again failed on Windows (MSVC) on the Pink swatch: the first save wrote its CMYK equivalent as 0 0.9317 0.1527 0 and the save of the reopened file as 0 0.9389 0.1629 0. The writer computes the CMYK equivalent of an RGB color from its RGB values, and the save of the reopened file computes it from the RGB values read back at four decimals; for this out-of-gamut color the Generic CMYK conversion moves by more than the test's 0.002 there. On macOS the two saves of this document differ by at most 0.0016 in these numbers. The test now blanks the CMYK equivalents of RGB colors (the first four numbers of `c m y k r g b Xa` and of an RGB gradient stop's `c m y k r g b 2 ... Bs`) and compares every other number, the RGB values included. With them blanked, the two saves are identical on macOS. The large-canvas test's comment also says what such objects are written as. --- crates/eps/src/ai_editing/tests.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index fca9eef02..a26463c96 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -5549,7 +5549,8 @@ fn effects_in_a_styled_objects_mask_are_warned_about() { } /// On a large canvas no art style is written (the reader scales the styles of a large canvas): -/// objects with effects are their drawn look, with a warning. +/// objects with effects are written as their vector art with their geometry effects applied, with a +/// warning. #[test] fn a_large_canvas_writes_no_art_styles() { let mut doc = Document::new(20000.0, 100.0); @@ -5577,7 +5578,23 @@ fn art_styles_write_the_same_bytes_again() { assert_eq!(private_data(&doc, &opts()).unwrap().0, private_data(&doc, &opts()).unwrap().0); // What is read back is written as it was. let again = export_ai_editing(&read_back(&doc), &opts()).unwrap().program; - near_lines(&shown(&again), &shown(&first.program)); + near_lines(&rgb_without_cmyk(&shown(&again)), &rgb_without_cmyk(&shown(&first.program))); +} + +/// `program` with the CMYK equivalents of RGB colors blanked out: the first four numbers of an RGB +/// color's `c m y k r g b Xa` (or `XA`) and of an RGB gradient stop's `c m y k r g b 2 ... Bs`. A +/// save of the reopened file computes them again from the RGB values read back at four decimals, +/// and on some platforms the CMYK conversion moves them by more than [`near_lines`] allows. The RGB +/// values stay compared. +fn rgb_without_cmyk(program: &str) -> String { + let blank = |line: &str| { + let (prefix, rest) = line.strip_prefix("%_").map_or(("", line), |r| ("%_", r)); + let t: Vec<&str> = rest.split(' ').collect(); + let color = t.len() == 8 && matches!(t[7], "Xa" | "XA"); + let stop = t.len() > 8 && t[7] == "2" && t.last() == Some(&"Bs"); + if color || stop { format!("{prefix}_ _ _ _ {}", t[4..].join(" ")) } else { line.to_string() } + }; + program.lines().map(blank).collect::>().join("\n") } /// `n` on the first layer of `doc`. From 7ca34c522dca3c1d77ba20c02026699e4ae39411 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 15:49:34 -0800 Subject: [PATCH 11/13] .ai save: the large-canvas warning names every effect the editing copy leaves out On a large canvas no art style is written, so objects with live effects keep only their geometry effects in the editing data for other apps: blurs are left out as well as shadows, glows and feathers. The warning now says that no art styles are written and that only geometry effects are kept. The comment on rgb_without_cmyk in art_styles_write_the_same_bytes_again now gives the cause of the Windows difference: the Generic CMYK separation of the default Pink swatch, at the edge of the press gamut, lands on C0 M93.9 Y16.3 or C0 M93.2 Y15.3 when its RGB changes in the last float bits, and the platform's float math decides which. The comment also says that named and spot RGB colors are not blanked. --- crates/eps/src/ai_editing/tests.rs | 12 +++++++----- crates/eps/src/ai_editing/writer/styles.rs | 3 ++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index a26463c96..f501555d8 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -5581,11 +5581,13 @@ fn art_styles_write_the_same_bytes_again() { near_lines(&rgb_without_cmyk(&shown(&again)), &rgb_without_cmyk(&shown(&first.program))); } -/// `program` with the CMYK equivalents of RGB colors blanked out: the first four numbers of an RGB -/// color's `c m y k r g b Xa` (or `XA`) and of an RGB gradient stop's `c m y k r g b 2 ... Bs`. A -/// save of the reopened file computes them again from the RGB values read back at four decimals, -/// and on some platforms the CMYK conversion moves them by more than [`near_lines`] allows. The RGB -/// values stay compared. +/// `program` with the CMYK equivalents of plain RGB colors blanked out: the first four numbers of +/// `c m y k r g b Xa` (or `XA`) and of an RGB gradient stop's `c m y k r g b 2 ... Bs`. Named and +/// spot RGB colors (`... (name) tint Xk`, `Xx` and spot stops) are not blanked; the document this +/// is used on has none. The Generic CMYK separation of a color at the edge of the press gamut can +/// land on one of two results that differ by up to 0.01 when the RGB changes in its last float +/// bits, as it does between the first save and the save of the reopened file, and the platform's +/// float math decides which. The RGB values stay compared. fn rgb_without_cmyk(program: &str) -> String { let blank = |line: &str| { let (prefix, rest) = line.strip_prefix("%_").map_or(("", line), |r| ("%_", r)); diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index ffd4b4fcf..8307ea302 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -18,7 +18,8 @@ use super::{Writer, Xy, blend_code, compound_path, hide}; use crate::ai_ops; use crate::ps; -pub(in crate::ai_editing) const LARGE_CANVAS_STYLES: &str = "on a large canvas, objects with live effects are written as their vector art with their geometry effects applied, without shadows, glows or feathers, in the editing data for other apps"; +pub(in crate::ai_editing) const LARGE_CANVAS_STYLES: &str = + "on a large canvas, no art styles are written: objects with live effects keep only their geometry effects in the editing data for other apps"; pub(in crate::ai_editing) const GRAPHIC_STYLES: &str = "graphic styles and links to them are left out of the editing data for other apps"; /// Does `doc` have graphic styles other than a new document's, or an object in its layers, opacity From 396784777227b93479cc8b8b9cac91a0a56f3a01 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 19:18:10 -0800 Subject: [PATCH 12/13] .ai save: the large-canvas warning says geometry effects are applied to the art On a large canvas no art style is written, so the editing data holds an object's geometry effects only as the artwork they made: the effects can no longer be edited when the file is opened again. The warning now says that the geometry effects are applied to the artwork and can no longer be edited, and that the object's other effects are left out. --- crates/eps/src/ai_editing/writer/styles.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index b33805754..1f10bb4c8 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -19,7 +19,7 @@ use super::{Writer, Xy, blend_code, compound_path, hide}; use crate::ai_ops; use crate::ps; -pub(in crate::ai_editing) const LARGE_CANVAS_STYLES: &str = "on a large canvas, no art styles or graphic styles are written, and objects with live effects keep only their geometry effects in the editing data for other apps"; +pub(in crate::ai_editing) const LARGE_CANVAS_STYLES: &str = "on a large canvas, no art styles or graphic styles are written: objects with live effects are written with their geometry effects applied to the artwork, where they can no longer be edited, and their other effects are left out of the editing data for other apps"; pub(in crate::ai_editing) const PANEL_STYLES: &str = "graphic styles with gradient or pattern fills, with fills, strokes or effects the editing data can't hold as a graphic style, with a name another style has, with names over 1,024 bytes, or past 4,096 styles, are left out of the editing data for other apps"; pub(in crate::ai_editing) const PANEL_EMPTY: &str = "a Graphic Styles panel with no style written opens from the editing data with a new document's styles"; From 647f81933dcece2ac6e22c06a6f0421e6f652618 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 20:29:18 -0800 Subject: [PATCH 13/13] .ai save: the large-canvas warning says geometry effects are applied to the art On a large canvas no art style is written, so the editing data holds an object's geometry effects only as the artwork they made: the effects can no longer be edited when the file is opened again. The warning now says that the geometry effects are applied to the artwork and can no longer be edited, and that the object's other effects are left out. --- crates/eps/src/ai_editing/writer/styles.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index 8307ea302..ace7e6b0f 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -18,8 +18,7 @@ use super::{Writer, Xy, blend_code, compound_path, hide}; use crate::ai_ops; use crate::ps; -pub(in crate::ai_editing) const LARGE_CANVAS_STYLES: &str = - "on a large canvas, no art styles are written: objects with live effects keep only their geometry effects in the editing data for other apps"; +pub(in crate::ai_editing) const LARGE_CANVAS_STYLES: &str = "on a large canvas, no art styles or graphic styles are written: objects with live effects are written with their geometry effects applied to the artwork, where they can no longer be edited, and their other effects are left out of the editing data for other apps"; pub(in crate::ai_editing) const GRAPHIC_STYLES: &str = "graphic styles and links to them are left out of the editing data for other apps"; /// Does `doc` have graphic styles other than a new document's, or an object in its layers, opacity