From 0c272519fbde474a5a3879cce6f0d0eaaef48f72 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 5 Nov 2026 07:59:38 -0800 Subject: [PATCH] .ai import: symbols and their instances open live The reader measured a symbol's definition only to find the page art an instance covered, so every file with a placed symbol instance opened with that instance as drawn art, or as its page. - Each `%AI14_BeginSymbol` definition is read by a reader of its own into a symbol of the document, with its art (in the symbol's space, y down), the registration point its dictionary gives (`AI13PatternRegistrationType`, 1 to 9 from the top left) and the instances of other symbols in its art. A definition whose art isn't read whole isn't kept: type, placed files, non-native art, pattern fills, a gradient or an image the reader can't read or place, and anything else the reader doesn't read. Its instances come in as the page draws them, within the box of the art that is read. A definition that holds an instance of a symbol the file doesn't have isn't kept either, nor is a definition holding an instance of one of those, and their instances aren't read. A definition after the first layer isn't read. - `%AI24_BeginSymbolList` orders the symbols as the Symbols panel lists them. A name read twice is numbered ("Dot 2"), and instances name the first symbol with the file's name. - `/SymbolInstance` with `/SymbolRef` and `/RTransformMatrix` comes in as a live instance, with the hide and lock set before it and the opacity, blending mode, isolation and knockout of the `Xy` after it, as a group's. An instance with appearance changes of its own (`AI19SymbolAppearanceMap`), of a symbol that isn't read, or placed only by `/Matrix` or `/Transform`, comes in as the page draws it. - Symbol art takes the gradients and art styles read before it, and an object in it linked to a listed graphic style keeps the link. - In a symbol's art, the matrices of instances, gradients and images are read in the canvas's space, y up, about the canvas point of the ruler origin. Without `%AI3_TemplateBox` an image can't be placed, so the symbol isn't kept. - At most 4,096 definitions are read, and a symbol list names at most 16,384 symbols. Tests: reader tests for each case above, and fuzz cases for damaged and hostile symbol data. --- crates/engine/tests/import_fuzz.rs | 117 ++++++ crates/eps/src/import/ai/read.rs | 40 +- crates/eps/src/import/ai/read/art.rs | 90 ++++- crates/eps/src/import/ai/read/containers.rs | 212 ++++++++++- crates/eps/src/import/ai/read/directives.rs | 269 ++++++++++++-- crates/eps/src/import/ai/read/document.rs | 64 +++- crates/eps/src/import/ai/read/ops.rs | 26 +- crates/eps/src/import/ai/read/types.rs | 24 ++ crates/eps/src/import/ai/tests.rs | 385 ++++++++++++++++++++ docs/file-format-parity.md | 15 +- docs/target-app-parity.md | 1 + 11 files changed, 1170 insertions(+), 73 deletions(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 8671e165d..46ca4a078 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3621,6 +3621,114 @@ proptest! { Some(d) })?; } + + /// A `.ai` whose symbols are damaged or hostile: their definitions, the symbol list and the + /// instances (on the page and in a symbol's art), cut short or edited. + #[test] + fn ai_symbols_never_panic(cut in 0usize..2_000, edits in prop::collection::vec((0usize..2_000, prop::sample::select(vec!['0', '9', '-', '.', 'e', ' ', '\n', '(', ')', '/', ':', ';', ',', '%', 'u', 'U', 'A', 'Z'])), 0..12)) { + let text = mutate_text(&symbols_text(), cut, &edits); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat(); + 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))?; + } +} + +/// Editing data with two symbols (S2's art holds an instance of S1), the symbol list and a layer of +/// instances, as the format writes them. +fn symbols_text() -> String { + use vectorcraft_testkit::ai; + let def = |name: &str, art: &str, reg: u32| { + format!( + "%AI14_BeginSymbol\n({name})\n0 A\n0 Xw\n0 Ae\nu\n{art}U\n%_/ArtDictionary :\n%_{reg} /Int (AI13PatternRegistrationType) ,\n%_;\n%_\n9 () XW\n%AI10_EndSymbol\n" + ) + }; + let inst = |name: &str, m: &str| format!("/SymbolInstance :\n({name}) /SymbolRef ,\n{m} /RTransformMatrix\n;\n9 () XW\n"); + let defs = def("S1", "0 1 1 0 1 0 0 Xa\n-2 -2 m\n2 -2 L\n2 2 L\n-2 2 L\nf\n", 5) + &def("S2", &inst("S1", "1 0 0 -1 -8081 8237"), 1); + let list = "%AI24_BeginSymbolList\n(S2)\n(S1)\n%AI24_EndSymbolList\n"; + let page = ai::layer( + "L", + &format!( + "1 Xw\n{}0 Xw\n{}1 0.5 0 2 0 Xy\n0 0 Xd\n6 () XW\n0 Ae\nq\n{}10 10 m\n30 10 L\n30 30 L\nh\nW\nn\nQ\n", + inst("S1", "1 0 0 -1 20 50"), + inst("S2", "2 0 0 -2 100 50"), + inst("S1", "1 0 0 -1 20 20") + ), + ); + let data = ai::editing_data( + 200.0, + 100.0, + &format!("%AI5_Begin_NonPrinting\n{defs}%AI5_End_NonPrinting--\n%AI5_Begin_NonPrinting\n{list}%AI5_End_NonPrinting--\n{page}"), + ); + data.replacen("%%EndComments", "%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n%%EndComments", 1) +} + +/// The data [`ai_symbols_never_panic`] damages, as it is, opens with its symbols and instances live. +#[test] +fn the_symbols_fuzz_data_opens_live() { + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&symbols_text())].concat(); + let (doc, warnings, layered) = vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0); + assert!(layered, "{warnings:?}"); + let names: Vec<&str> = doc.symbols.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, ["S2", "S1"]); + let mut instances = 0; + doc.walk(|n| instances += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::SymbolInstance { .. }))); + assert_eq!(instances, 3); +} + +/// Symbols the reader must stop at: sections that don't end, references and matrices it can't +/// use, definitions nested deeper than it reads, and symbols that hold each other. +#[test] +fn ai_symbol_limits_never_panic() { + use vectorcraft_testkit::ai; + let inst = |name: &str, m: &str| format!("/SymbolInstance :\n{name} /SymbolRef ,\n{m} /RTransformMatrix\n;\n"); + let def = |name: &str, art: &str| format!("%AI14_BeginSymbol\n({name})\n0 A\n0 Xw\n0 Ae\nu\n{art}U\n%AI10_EndSymbol\n"); + let mut nested = String::new(); + for i in 0..400 { + nested.push_str(&format!("%AI14_BeginSymbol\n(N{i})\n0 Ae\nu\n")); + } + for _ in 0..400 { + nested.push_str("U\n%AI10_EndSymbol\n"); + } + let mut chain = String::new(); + for i in 0..300 { + chain.push_str(&def(&format!("C{i}"), &inst(&format!("(C{})", i + 1), "1 0 0 -1 0 0"))); + } + let cases = [ + format!("%AI14_BeginSymbol\n(Open)\n0 Ae\nu\n{}", ai::layer("L", &inst("(Open)", "1 0 0 -1 20 50"))), + format!("%AI24_BeginSymbolList\n(A)\n(B)\n{}", ai::layer("L", "")), + format!( + "{}{}", + def("S", "-2 -2 m\n2 2 L\nS\n"), + ai::layer("L", &(inst("", "1 0 0 -1 20 50") + &inst("/SymbolRef", "1 0 0 -1 20 50") + &inst("(S)", ""))) + ), + format!( + "{}{}", + def("S", "-2 -2 m\n2 2 L\nS\n"), + ai::layer( + "L", + &(inst("(S)", "1 0 0") + &inst("(S)", "1 0 0 -1 20 50 7") + &inst("(S)", "1e400 0 0 -1 20 50") + &inst("(S)", "0 0 0 0 20 50")) + ) + ), + format!("{nested}{}", ai::layer("L", &inst("(N0)", "1 0 0 -1 20 50"))), + format!("{chain}{}", ai::layer("L", &inst("(C0)", "1 0 0 -1 20 50"))), + format!( + "{}{}{}", + def("S1", &inst("(S2)", "1 0 0 -1 0 0")), + def("S2", &inst("(S1)", "1 0 0 -1 0 0")), + ai::layer("L", &inst("(S1)", "1 0 0 -1 20 50")) + ), + format!("{}{}", def("S1", &inst("(S2)", "1 0 0 -1 0 0")), def("S2", &inst("(S1)", "1 0 0 -1 0 0"))), + ]; + for body in cases { + let data = ai::editing_data(200.0, 100.0, &body).replacen("%%EndComments", "%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n%%EndComments", 1); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&data)].concat(); + survive("hostile .ai symbols", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0)) + .unwrap(); + survive("hostile .ai symbols, trusted", || { + Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0) + }) + .unwrap(); + } } /// The data [`ai_objects_on_path_never_panics`] damages, as it is, opens with its Objects on Path group. @@ -4329,6 +4437,15 @@ fn arb_ai_token() -> impl Strategy { "\n%\n", "\n%AI5_EndPlace\n", "\n%%PageTrailer\n", + "\n%AI14_BeginSymbol\n(S1)\n0 A 0 Xw 0 Ae\n", + "\n%AI10_EndSymbol\n", + "\n%AI24_BeginSymbolList\n(S1)\n", + "\n%AI24_EndSymbolList\n", + "/SymbolInstance :", + "(S1) /SymbolRef ,", + "/RTransformMatrix", + "5 /Int (AI13PatternRegistrationType) ,", + "; (AI19SymbolAppearanceMap) ,", ]) .prop_map(str::to_string), ] diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 567c659e7..2cf9dc736 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -87,8 +87,18 @@ //! style's appearance and transparency is linked to it. A `1` naming a style the list doesn't //! have is the object's own appearance, not a link. //! -//! Sections the reader doesn't need (symbols, styles, filters) are skipped; -//! type, symbols, fills with a pattern it can't read, placed files without a preview and operators +//! - Symbols: `%AI14_BeginSymbol` (or `%AI10_`), `(name)`, `0 A 0 Xw 0 Ae`, the art (in the +//! symbol's space, y down), a dictionary whose `AI13PatternRegistrationType` is the registration +//! point (1 to 9, from the top left, row by row), `%AI10_EndSymbol`; each is read by a reader of +//! its own into the document's symbols. `%AI24_BeginSymbolList` (or `%AI10_`) lists their names +//! in the Symbols panel's order. An instance: `/SymbolInstance : (name) /SymbolRef , a b c d tx +//! ty /RTransformMatrix ;`, the matrix taking the symbol's art to art space; in a symbol's art, +//! it takes it to art space about the canvas point of art space's origin, as a pattern's +//! gradients are. Its hide and lock come before it (`Xw`, `A`) and its transparency (`Xy`) after +//! it. +//! +//! Sections the reader doesn't need (styles, filters) are skipped; type, instances of symbols it +//! can't read, fills with a pattern it can't read, placed files without a preview and operators //! it doesn't know are errors. So is data that ends inside a layer, a group or a compound path. use std::collections::{BTreeSet, HashMap, HashSet}; @@ -127,7 +137,7 @@ 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 use types::IMAGE_TOO_LARGE; -use types::{BLEND_BUDGET, FLIP, Frame, GState, Graph, Kind, PatternHead, PendingEnvelope, Place, Raster}; +use types::{BLEND_BUDGET, FLIP, Frame, GState, Graph, Kind, PatternHead, PendingEnvelope, Place, Raster, SymbolHead}; #[cfg(test)] pub use types::{GRAPH_PLAIN, GRAPH_UNENDED, LINKED_PLACEHOLDERS, NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, VERSIONED_UNENDED}; pub(super) use types::{blend_mode, blend_name}; @@ -405,7 +415,24 @@ struct Reader<'a> { /// What layers that don't show have that this reader doesn't read. hidden_unread: BTreeSet, unread: Vec<(NodeId, Vec)>, + /// The art boxes (symbol space) of symbols whose definition can be measured but not read + /// whole, by their name in the file: their instances come in as the page draws them. symbol_bounds: HashMap, + /// The symbols read, by their name in the file → their name in the document. + symbol_names: HashMap, + /// The names the symbols read have in the document, and for each name read more than once, + /// the number its next copy is tried with. + symbol_taken: HashSet, + symbol_suffix: HashMap, + /// The art boxes (symbol space) of symbols read, by their name in the file, once measured for + /// an instance that comes in as the page draws it. + symbol_art_bounds: HashMap>, + /// The symbol list's names, in the Symbols panel's order (`%AI24_BeginSymbolList`). + symbol_list: Option>, + /// The symbols read were checked for instances of symbols the file doesn't have. + symbols_checked: bool, + /// This reader reads a symbol's definition (see [`Reader::define_symbol`]). + symbol: Option, /// Depth shared by pattern and symbol readers. subreader_depth: usize, pending_unread_fill: bool, @@ -600,6 +627,13 @@ impl<'a> Reader<'a> { hidden_unread: BTreeSet::new(), unread: Vec::new(), symbol_bounds: HashMap::new(), + symbol_names: HashMap::new(), + symbol_taken: HashSet::new(), + symbol_suffix: HashMap::new(), + symbol_art_bounds: HashMap::new(), + symbol_list: None, + symbols_checked: false, + symbol: None, subreader_depth: 0, pending_unread_fill: false, pending_unread_stroke: false, diff --git a/crates/eps/src/import/ai/read/art.rs b/crates/eps/src/import/ai/read/art.rs index d9176869c..7eb55ad11 100644 --- a/crates/eps/src/import/ai/read/art.rs +++ b/crates/eps/src/import/ai/read/art.rs @@ -16,6 +16,7 @@ impl<'a> Reader<'a> { if self.pattern.is_some() { self.unreadable("type in a pattern", false); } + self.left_out_of_symbol(); // `1 /FreeUndo`: type in a drawn look. Its story is one of another text document // (`/AI11UndoFreeTextDocument`), which counts its stories on its own, so its slot has none. // In the look of a text object that has an appearance, the object itself follows the look, @@ -46,6 +47,11 @@ impl<'a> Reader<'a> { /// group. Without data, or with a PDF that can't be read, nothing is drawn. pub(super) fn foreign_art(&mut self, o: &Obj) -> Result<(), String> { let Some(text) = self.foreign_data.take() else { return Ok(()) }; + // Where a symbol's art puts it isn't known. + if self.symbol.is_some() { + self.left_out_of_symbol(); + return Ok(()); + } // One `%` starts each line; the rest, `_` included, is data. let mut encoded = String::with_capacity(text.len()); for line in text.split(|b| matches!(b, b'\r' | b'\n')) { @@ -165,6 +171,9 @@ impl<'a> Reader<'a> { self.path_kinds.clear(); self.points = 0; self.clip_next = false; + if let Some(head) = self.symbol.as_mut() { + (head.missing_fill, head.missing_stroke) = (false, false); + } } /// Paint the current path (`op`), as a guide when `guide`. @@ -177,6 +186,7 @@ impl<'a> Reader<'a> { self.set_instance(&i); } let (fill_paint, stroke_paint) = (self.pending_fill.take(), self.pending_stroke.take()); + let missing = self.symbol.as_mut().map_or((false, false), |h| (std::mem::take(&mut h.missing_fill), std::mem::take(&mut h.missing_stroke))); if bp.elements().is_empty() { return Ok(()); } @@ -196,6 +206,10 @@ impl<'a> Reader<'a> { "b" => (true, true), _ => (false, false), }; + // A symbol's art painted with a gradient the file doesn't define is left out. + if (missing.0 && fill) || (missing.1 && stroke) { + self.left_out_of_symbol(); + } // A gradient instance's paint replaces the missing pattern. let unread = (fill && self.pending_unread_fill && fill_paint.is_none()) || (stroke && self.pending_unread_stroke && stroke_paint.is_none()); let mut items = Vec::new(); @@ -353,15 +367,20 @@ impl<'a> Reader<'a> { pub(super) fn set_instance(&mut self, i: &Instance) { let mut to_doc = self.space(); // In a pattern's definition a gradient's `Xm` and `Bm` are written y down about the canvas point of art space's - // origin, as the file's PDF part draws them. - if self.pattern.is_some() + // origin, as the file's PDF part draws them; in a symbol's, as the instances in its art are. + if (self.pattern.is_some() || self.symbol.is_some()) && i.has_matrix() && let Some(t) = self.template { let (cx, cy) = ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0); to_doc *= Affine::new([1.0, 0.0, 0.0, -1.0, CANVAS_CENTRE - cx, CANVAS_CENTRE + cy]); } - match i.paint(&self.gradients, &self.global_names, to_doc) { + let paint = i.paint(&self.gradients, &self.global_names, to_doc); + if let Some(head) = self.symbol.as_mut() { + let missing = if i.stroke { &mut head.missing_stroke } else { &mut head.missing_fill }; + *missing = paint.is_none(); + } + match paint { Some(p) if i.stroke => self.pending_stroke = Some(p), Some(p) => self.pending_fill = Some(p), None => { @@ -421,22 +440,22 @@ impl<'a> Reader<'a> { self.image_link = None; let space = self.raster.as_ref().and_then(|r| r.space.clone()).unwrap_or_default(); let Some((m, n)) = Self::image_operands(&vals) else { - self.warn("an image VectorCraft can't read was left out"); + self.image_left_out("an image VectorCraft can't read was left out"); return Ok(()); }; let [_, _, _, _, w, h, bits, kind, alpha, _, binary, rest @ ..] = n.as_slice() else { - self.warn("an image VectorCraft can't read was left out"); + self.image_left_out("an image VectorCraft can't read was left out"); return Ok(()); }; // ImageMask 1: the image is transparent and takes a color (version 7 specification §8.1). let image_mask = rest.first() == Some(&1.0); let (w, h) = (*w as u64, *h as u64); if w.saturating_mul(h) > MAX_PIXELS { - self.warn(IMAGE_TOO_LARGE); + self.image_left_out(IMAGE_TOO_LARGE); return Ok(()); } if w == 0 || h == 0 || ![1.0, 8.0].contains(bits) { - self.warn("an image VectorCraft can't read was left out"); + self.image_left_out("an image VectorCraft can't read was left out"); return Ok(()); } let channels: usize = match space.as_str() { @@ -462,7 +481,7 @@ impl<'a> Reader<'a> { // An alpha channel follows the colour samples, one byte a pixel. let alpha_len = if *alpha > 0.0 { (w * h) as usize } else { 0 }; let (Some(samples), opacity) = (data.get(..need), data.get(need..need.saturating_add(alpha_len))) else { - self.warn("an image whose data is cut short was left out"); + self.image_left_out("an image whose data is cut short was left out"); return Ok(()); }; let opacity = opacity.filter(|_| alpha_len > 0); @@ -503,12 +522,12 @@ impl<'a> Reader<'a> { } } let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, rgba) else { - self.warn("an image VectorCraft can't read was left out"); + self.image_left_out("an image VectorCraft can't read was left out"); return Ok(()); }; let mut png = vec![]; if img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() { - self.warn("an image VectorCraft can't read was left out"); + self.image_left_out("an image VectorCraft can't read was left out"); return Ok(()); } self.add_raster(ImageBlob::new("image/png", png), None, &m, (w as u32, h as u32), None) @@ -529,7 +548,7 @@ impl<'a> Reader<'a> { let mut png = vec![]; let written = image::GrayImage::from_raw(w, h, gray).map(|img| img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)); if !matches!(written, Some(Ok(()))) { - self.warn("an image VectorCraft can't read was left out"); + self.image_left_out("an image VectorCraft can't read was left out"); return Ok(()); } let paint = self.mask_paint(); @@ -556,25 +575,29 @@ impl<'a> Reader<'a> { let path = self.image_link.take(); let operands = Self::image_operands(&vals); let (Some(path), Some((m, n))) = (path, operands) else { - self.warn("an image VectorCraft can't read was left out"); + self.image_left_out("an image VectorCraft can't read was left out"); return Ok(()); }; let (&[a, b, c, d, tx, ty], &[_, _, _, _, w, h, bits, ..]) = (m.as_slice(), n.as_slice()) else { - self.warn("an image VectorCraft can't read was left out"); + self.image_left_out("an image VectorCraft can't read was left out"); return Ok(()); }; if !(1.0..=MAX_SIDE).contains(&w) || !(1.0..=MAX_SIDE).contains(&h) || ![1.0, 8.0].contains(&bits) { - self.warn("an image VectorCraft can't read was left out"); + self.image_left_out("an image VectorCraft can't read was left out"); return Ok(()); } let (w, h) = (w as u32, h as u32); - let xf = self.space() * Affine::new([a, b, c, d, tx, ty]) * FLIP; + let Some(to_doc) = self.image_space() else { + self.image_left_out("an image VectorCraft can't read was left out"); + return Ok(()); + }; + let xf = to_doc * Affine::new([a, b, c, d, tx, ty]) * FLIP; if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { - self.warn("an image VectorCraft can't read was left out"); + self.image_left_out("an image VectorCraft can't read was left out"); return Ok(()); } let Some(png) = missing_placeholder() else { - self.warn("an image VectorCraft can't read was left out"); + self.image_left_out("an image VectorCraft can't read was left out"); return Ok(()); }; // The placeholder is all the document has of the file: what a missing link shows. @@ -603,13 +626,16 @@ impl<'a> Reader<'a> { // downwards, (u, -v): pixels → (u, -v) → art space. Flipping the matrix's result instead // gives the same place only when the image isn't rotated (b = c = 0). let [a, b, c, d, tx, ty] = m else { - self.warn("an image VectorCraft can't read was left out"); + self.image_left_out("an image VectorCraft can't read was left out"); + return Ok(()); + }; + let Some(to_doc) = self.image_space() else { + self.image_left_out("an image VectorCraft can't read was left out"); return Ok(()); }; - let to_doc = self.space(); let xf = to_doc * Affine::new([*a, *b, *c, *d, *tx, *ty]) * FLIP; if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { - self.warn("an image VectorCraft can't read was left out"); + self.image_left_out("an image VectorCraft can't read was left out"); return Ok(()); } let mask = alpha.and_then(|a| self.alpha_mask(a, w, h, xf)); @@ -619,6 +645,24 @@ impl<'a> Reader<'a> { self.add_image_colorized(im, mask, colorize, false) } + /// An image that isn't read is left out, with `warning`; in a symbol's art, the symbol isn't + /// read whole, so its instances come in as the page draws them. + fn image_left_out(&mut self, warning: &str) { + if self.symbol.is_some() { + self.left_out_of_symbol(); + } else { + self.warn(warning); + } + } + + /// Note art of a symbol's definition that is left out: the symbol isn't kept, and its + /// instances come in as the page draws them, within the box of the art that is read. + pub(super) fn left_out_of_symbol(&mut self) { + if let Some(head) = self.symbol.as_mut() { + head.left_out = true; + } + } + /// An image's alpha channel (`w` × `h` bytes) as a grayscale image over the image (`xf`): its /// opacity mask, as in PDF import. `None` when it hides nothing. fn alpha_mask(&mut self, alpha: &[u8], w: u32, h: u32, xf: Affine) -> Option { @@ -665,6 +709,12 @@ impl<'a> Reader<'a> { /// shows the preview the data keeps of it, as a linked image whose file is missing does. One /// without a preview, a path or a matrix isn't read. pub(super) fn end_place(&mut self, hidden: bool) -> Result<(), String> { + // Where a symbol's art puts a placed file's pixels isn't known. + if self.symbol.is_some() { + self.place = None; + self.left_out_of_symbol(); + return Ok(()); + } let (m, b, path, png, w, h, folder) = match self.place.take() { Some(Place { broken: true, .. }) => { self.unreadable("placed files", hidden); diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index 5c58988bd..f6ac4a00a 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -1,7 +1,9 @@ use super::document::slot_of; use super::ops::{paints_apart, path_rule}; use super::types::{Frame, GState, Kind, MAX_DEPTH, MAX_NODES, PendingEnvelope}; -use super::{AppearanceItem, Arc, BezPath, BlendMode, FillRule, HashSet, LayerColor, Node, NodeId, NodeKind, Obj, Paint, PathEl, Rc, Reader, V, obj}; +use super::{ + AppearanceItem, Arc, BezPath, BlendMode, FillRule, HashMap, HashSet, LayerColor, Node, NodeId, NodeKind, Obj, Paint, PathEl, Rc, Reader, V, obj, +}; use vectorcraft_doc::TextWrap; impl<'a> Reader<'a> { @@ -13,6 +15,7 @@ impl<'a> Reader<'a> { } if matches!(kind, Kind::Layer(_)) { self.ensure_space(); + self.check_symbols(); } let base = if matches!(kind, Kind::Obj(_)) { self.stack.len() } else { self.frames.last().map_or(0, |f| f.base) }; self.frames.push(Frame { @@ -548,24 +551,141 @@ impl<'a> Reader<'a> { Ok(Some(id)) } + /// A symbol instance (`/SymbolInstance`): the symbol its `SymbolRef` names, placed by its + /// `RTransformMatrix`, hidden and locked as the state says. In a symbol's art the matrix is + /// written about the canvas point of art space's origin, and the name is the file's until the + /// symbols are checked ([`Self::check_symbols`]). An instance of a symbol that wasn't read, one + /// whose appearance differs from its symbol's, or one placed only by a `Matrix` or `Transform`, + /// comes in as the page draws it, within the symbol's box placed by the matrix when the box is + /// known. fn symbol_instance(&mut self, o: &Obj) -> Result<(), String> { - let bounds = o.get("SymbolRef").and_then(|v| v.iter().find_map(V::text)).and_then(|name| self.symbol_bounds.get(&name).copied()); - let matrix = o - .get("Matrix") - .or_else(|| o.get("Transform")) - .map(|v| v.iter().find_map(|item| if let V::Arr(a) = item { Some(Self::nums(a)) } else { None }).unwrap_or_else(|| Self::nums(v))); - let box_at_canvas = match (bounds, matrix.as_deref()) { - (Some(b), Some([a, c, d, e, x, y])) if [*a, *c, *d, *e, *x, *y].iter().all(|n| n.is_finite()) => { - Some(self.space().transform_rect_bbox(super::Affine::new([*a, *c, *d, *e, *x, *y]).transform_rect_bbox(b))) - } + let name = o.text("SymbolRef"); + let affine = |n: &[f64]| match n { + [a, b, c, d, e, f] if [a, b, c, d, e, f].iter().all(|v| v.is_finite()) => Some(super::Affine::new([*a, *b, *c, *d, *e, *f])), _ => None, }; - match box_at_canvas { - Some(b) => self.unread_box("symbols", b, self.obj_hidden).map(|_| ()), - None => { - self.unreadable("symbols", self.obj_hidden); - Ok(()) + let live = affine(&o.nums("RTransformMatrix")); + let boxed_only = live.is_none(); + let matrix = live.or_else(|| { + o.get("Matrix") + .or_else(|| o.get("Transform")) + .map(|v| v.iter().find_map(|item| if let V::Arr(a) = item { Some(Self::nums(a)) } else { None }).unwrap_or_else(|| Self::nums(v))) + .and_then(|n| affine(&n)) + }); + let hidden = self.obj_hidden; + let place = if self.symbol.is_some() { + self.canvas_origin().map(|o| super::Affine::new([1.0, 0.0, 0.0, -1.0, o.x, o.y])) + } else { + Some(self.space()) + }; + let (Some(name), Some(m), Some(place)) = (name, matrix, place) else { + self.unreadable("symbols", hidden); + return Ok(()); + }; + let xf = place * m; + let usable = xf.determinant().abs() >= 1e-12 && xf.as_coeffs().iter().all(|v| v.is_finite()); + let symbol = if self.symbol.is_some() { Some(name.clone()) } else { self.symbol_names.get(&name).cloned() }; + match symbol.filter(|_| usable && !boxed_only && !overrides(o)) { + Some(symbol) => { + // Its transparency is the `Xy` after it, as a group's is. + let gs = self.gs.clone(); + let node = self.new_node(NodeKind::SymbolInstance { symbol, xf }, &gs); + self.add(node, hidden) } + None => { + let bounds = self.symbol_art_bounds(&name).or_else(|| self.symbol_bounds.get(&name).copied()); + match bounds.map(|b| xf.transform_rect_bbox(b)).filter(|_| usable && self.symbol.is_none()) { + Some(b) => self.unread_box("symbols", b, hidden).map(|_| ()), + None => { + self.unreadable("symbols", hidden); + Ok(()) + } + } + } + } + } + + /// The art box (symbol space) of the symbol read under `name` (its name in the file), measured + /// once. + fn symbol_art_bounds(&mut self, name: &str) -> Option { + if let Some(b) = self.symbol_art_bounds.get(name) { + return *b; + } + let named = self.symbol_names.get(name)?; + let boxes = self.doc.symbol_boxes(); + let b = self.doc.symbols.iter().find(|s| &s.name == named).and_then(|s| s.art.visual_bounds_with(&boxes)); + drop(boxes); + self.symbol_art_bounds.insert(name.to_string(), b); + b + } + + /// Check the symbols read for instances in their art of symbols the file doesn't have, before + /// the first layer: a symbol with one isn't kept, and so on for the symbols holding its + /// instances. The instances in the art kept then name the symbols as the document does. + pub(super) fn check_symbols(&mut self) { + if std::mem::replace(&mut self.symbols_checked, true) { + return; + } + // Each symbol's name in the file (none for one named as an earlier one), and the names its + // instances use. + let files: HashMap<&str, &str> = self.symbol_names.iter().map(|(file, named)| (named.as_str(), file.as_str())).collect(); + let defs: Vec<(Option, Vec)> = self + .doc + .symbols + .iter() + .map(|s| { + let mut uses = vec![]; + symbol_uses(&s.art, &mut uses, 0); + (files.get(s.name.as_str()).map(|f| f.to_string()), uses.into_iter().map(str::to_string).collect()) + }) + .collect(); + // A symbol goes when one of its instances names a symbol that isn't kept: first those + // naming one the file doesn't have, then, one at a time, those using a symbol that went. + let mut kept: Vec = vec![true; defs.len()]; + let mut users: HashMap<&str, Vec> = HashMap::new(); + let mut going: Vec = vec![]; + for (i, (_, uses)) in defs.iter().enumerate() { + for u in uses { + users.entry(u.as_str()).or_default().push(i); + } + if uses.iter().any(|u| !self.symbol_names.contains_key(u)) { + going.push(i); + } + } + while let Some(i) = going.pop() { + match kept.get_mut(i) { + Some(k) if *k => *k = false, + _ => continue, + } + if let Some(file) = defs.get(i).and_then(|(file, _)| file.as_deref()) { + going.extend(users.get(file).into_iter().flatten().copied().filter(|j| kept.get(*j).copied().unwrap_or(false))); + } + } + drop(users); + if kept.iter().any(|k| !k) { + let left: Vec<(Option, String)> = defs + .iter() + .zip(&self.doc.symbols) + .zip(&kept) + .filter(|(_, k)| !**k) + .map(|(((file, _), s), _)| (file.clone(), s.name.clone())) + .collect(); + let mut i = 0; + self.doc.symbols.retain(|_| { + i += 1; + kept.get(i - 1).copied().unwrap_or(false) + }); + // Their art's box isn't known (the instances that made them go have none), so their + // instances have no box either. + for (file, named) in left { + self.doc.set_symbol_registration(&named, vectorcraft_doc::SymbolRegistration::CENTER); + let Some(file) = file else { continue }; + self.symbol_names.remove(&file); + } + } + let names = self.symbol_names.clone(); + for s in &mut self.doc.symbols { + rename_uses(Arc::make_mut(&mut s.art), &names, 0); } } @@ -793,6 +913,68 @@ pub(super) fn drop_commented_copies(layers: &mut [Arc], hidden: &HashSet(node: &'n Node, out: &mut Vec<&'n str>, depth: usize) { + if depth > MAX_DEPTH { + return; + } + if let NodeKind::SymbolInstance { symbol, .. } = &node.kind { + out.push(symbol); + } + if let Some(m) = &node.extras().mask { + symbol_uses(&m.art, out, depth + 1); + } + for c in node.children().into_iter().flatten() { + symbol_uses(c, out, depth + 1); + } +} + +/// The instances in `node`, and in the art of its opacity masks, named by `names` (a name in the +/// file → the symbol's in the document). +fn rename_uses(node: &mut Node, names: &HashMap, depth: usize) { + if depth > MAX_DEPTH { + return; + } + if let NodeKind::SymbolInstance { symbol, .. } = &mut node.kind + && let Some(named) = names.get(symbol.as_str()) + { + symbol.clone_from(named); + } + if let Some(m) = node.extras_if_any().and_then(|e| e.mask.as_mut()) { + rename_uses(Arc::make_mut(&mut m.art), names, depth + 1); + } + for c in node.children_mut().into_iter().flatten() { + rename_uses(Arc::make_mut(c), names, depth + 1); + } +} + +/// Whether a symbol instance's dictionary carries appearance of its own (a dynamic symbol's +/// edits), which isn't read. +fn overrides(o: &Obj) -> bool { + const KEYS: [&str; 5] = [ + "AI19SymbolAppearanceMap", + "AI19SymbolOverriddenSymbolMap", + "AI Local Art of Symbol Instance", + "AI15 Layer In Symbol", + "AI15 Color Of Layer In Symbol", + ]; + // In a dictionary or array nested in it, as a keyed entry's value, in an array or among the + // values closed without a key. + fn has(o: &Obj, depth: usize) -> bool { + depth <= 8 + && (o.entries.iter().any(|(k, v)| k.as_deref().is_some_and(|k| KEYS.contains(&k)) || v.iter().any(|v| in_value(v, depth + 1))) + || o.content.iter().any(|v| in_value(v, depth + 1))) + } + fn in_value(v: &V, depth: usize) -> bool { + match v { + V::Obj(c) => has(c, depth), + V::Arr(items) => depth <= 8 && items.iter().any(|v| in_value(v, depth + 1)), + _ => false, + } + } + has(o, 0) +} + /// Close the subpath `bp` ends with, if it has one open. pub(super) fn close(bp: &mut BezPath) { if bp.elements().last().is_some_and(|e| !matches!(e, PathEl::ClosePath)) { diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index 5e47e0225..c5f64079c 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -1,10 +1,13 @@ +use std::collections::HashMap; + use super::super::brush; use super::art::{four, sane, two}; use super::types::{ - FLIP, GState, Kind, MAX_HEX, MAX_NOTE, MAX_PATTERNS, MAX_PLACED_FOLDER, MAX_STACK, MAX_SUBREADERS, PatternHead, Place, Raster, SKIPPED, - VERSIONED_UNENDED, + FLIP, GState, Kind, MAX_DEPTH, MAX_HEX, MAX_LISTED, MAX_NODES, MAX_NOTE, MAX_PATTERNS, MAX_PLACED_FOLDER, MAX_STACK, MAX_SUBREADERS, MAX_SYMBOLS, + PatternHead, Place, Raster, SKIPPED, SymbolHead, VERSIONED_UNENDED, }; -use super::{Arc, Global, Lexer, Node, PatternDef, Reader, Rect, Tok, V, marker, obj}; +use super::{Arc, Global, Lexer, Node, NodeKind, PatternDef, Reader, Rect, Tok, V, marker, obj}; +use vectorcraft_doc::{NodeId, Symbol, SymbolRegistration}; impl<'a> Reader<'a> { pub(super) fn push(&mut self, v: V) -> Result<(), String> { @@ -85,7 +88,7 @@ impl<'a> Reader<'a> { } let m = marker(line); // A pattern's definition. One inside another's isn't read, and the outer tile is then incomplete. - if m == "BeginPattern" { + if m == "BeginPattern" && self.symbol.is_none() { let section = self.section("EndPattern")?; if self.pattern.is_some() { self.unsupported.insert("patterns inside patterns".into()); @@ -96,7 +99,12 @@ impl<'a> Reader<'a> { } if m == "BeginSymbol" { let section = self.section("EndSymbol")?; - self.symbol_bounds(section); + self.define_symbol(section); + return Ok(()); + } + if m == "BeginSymbolList" { + let section = self.section("EndSymbolList")?; + self.symbol_list(section); return Ok(()); } // Brushes and their art (those in a pattern's or a brush's art aren't read). @@ -504,32 +512,165 @@ impl<'a> Reader<'a> { } } - /// Read a definition's box for an instance whose art must come from the page. - fn symbol_bounds(&mut self, section: &[u8]) { - if self.symbol_bounds.len() >= MAX_PATTERNS || self.subreader_depth >= MAX_SUBREADERS { + /// A symbol's definition (`section`: what follows `%AI14_BeginSymbol`), read by a reader of its + /// own into a symbol of the document, its art in the symbol's space (y down), named as the file + /// names it unless another symbol has that name. A definition inside another one or inside a + /// pattern's isn't read, nor is one after the first layer (the symbols were checked there, see + /// [`Reader::check_symbols`]). One it can't read whole (an operator it doesn't know, type, unbalanced + /// groups, art it leaves out) isn't kept: its instances come in as the page draws them, within + /// its art's box when that can be measured. + fn define_symbol(&mut self, section: &[u8]) { + if self.symbol.is_some() + || self.pattern.is_some() + || self.symbols_checked + || self.subreader_depth >= MAX_SUBREADERS + || self.doc.symbols.len() + self.symbol_bounds.len() >= MAX_SYMBOLS + { return; } - let mut lex = super::Lexer::new(section); - let name = match lex.next_token().map(|t| t.tok) { - Some(Tok::Str(bytes)) => super::obj::text(&bytes), + let name = match Lexer::new(section).next_token().map(|t| t.tok) { + Some(Tok::Str(bytes)) => obj::text(&bytes), _ => return, }; let mut sub = Reader::new(section); sub.subreader_depth = self.subreader_depth + 1; - sub.cropmarks = self.cropmarks; + sub.symbol = Some(SymbolHead::default()); sub.template = self.template; + // The gradients, art styles and patterns defined so far and the names given, lent. + sub.gradients = std::mem::take(&mut self.gradients); + sub.global_names = std::mem::take(&mut self.global_names); + sub.styles = std::mem::take(&mut self.styles); + sub.pattern_tiles = std::mem::take(&mut self.pattern_tiles); + sub.v7_patterns.clone_from(&self.v7_patterns); sub.nodes = self.nodes; let read = sub.open(Kind::Group, false).and_then(|()| sub.run()); self.nodes = sub.nodes; - if read.is_err() || !sub.unsupported.is_empty() || !sub.unknown.is_empty() || sub.frames.len() != 1 { + self.gradients = std::mem::take(&mut sub.gradients); + self.global_names = std::mem::take(&mut sub.global_names); + self.styles = std::mem::take(&mut sub.styles); + self.pattern_tiles = std::mem::take(&mut sub.pattern_tiles); + // Art stopped by a trailer, or with an array, type, image, placed file, gradient mesh or + // gradient still open at the end (as [`Reader::run_string`] checks), isn't whole. + let open = sub.done + || sub.stack.iter().any(|v| matches!(v, V::Mark)) + || !sub.marks.is_empty() + || sub.text.is_some() + || sub.raster.is_some() + || sub.place.is_some() + || sub.mesh.is_some() + || sub.gradient_def.is_some(); + let head = sub.symbol.take().unwrap_or_default(); + let registration = head.registration; + let measured = read.is_ok() && !open && sub.unsupported.is_empty() && sub.unknown.is_empty() && sub.frames.len() == 1; + let Some(frame) = sub.frames.pop().filter(|_| measured) else { return }; + let mut kids = frame.children; + // Where a symbol's art puts a pattern's tiles isn't known. + let whole = !head.left_out && sub.unread.is_empty() && sub.hidden_unread.is_empty() && !kids.iter().any(|n| paints_pattern(n, 0)); + if !whole { + let boxes = self.doc.symbol_boxes(); + let bounds = kids.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.visual_bounds_with(&boxes))); + drop(boxes); + if let Some(b) = bounds.filter(|b| sane(*b) && b.area() > 0.0) { + self.symbol_bounds.insert(name, b); + } return; } - let Some(frame) = sub.frames.pop() else { return }; - let bounds = frame.children.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.visual_bounds())); - let Some(bounds) = bounds.map(|b| sub.space().inverse().transform_rect_bbox(b)) else { return }; - if sane(bounds) && bounds.area() > 0.0 { - self.symbol_bounds.insert(name, bounds); + let art = match (kids.len(), kids.pop()) { + (1, Some(one)) => one, + (_, last) => Node::group(self.doc.alloc_id(), kids.into_iter().chain(last).map(Arc::new).collect()), + }; + // The group a definition holds its art in, when it holds one object and isn't linked to a + // graphic style. + let linked = sub.style_links.iter().any(|(id, _)| *id == art.id); + let art = match symbol_art_member(&art).filter(|_| !linked).cloned() { + Some(m) => Arc::unwrap_or_clone(m), + None => art, + }; + let read_art = art; + let mut art = self.doc.reid(&read_art); + self.reid_masks(&mut art, 0); + // The objects of the art linked to graphic styles, by their ids in the document. + if !sub.style_links.is_empty() { + let mut ids = HashMap::new(); + pair_ids(&read_art, &art, &mut ids, 0); + for (id, style) in &sub.style_links { + if let Some(new) = ids.get(id) + && self.style_links.len() < MAX_NODES + { + self.style_links.push((*new, style.clone())); + } + } } + let named = self.unique_symbol_name(if name.is_empty() { "Symbol" } else { &name }); + self.doc.symbols.push(Symbol { name: named.clone(), art: Arc::new(art), dynamic: false }); + if let Some(reg) = registration.and_then(symbol_registration) { + self.doc.set_symbol_registration(&named, reg); + } + self.symbol_names.entry(name).or_insert(named); + for (key, blob) in sub.doc.images { + self.doc.images.entry(key).or_insert(blob); + } + self.spots.extend(sub.spots); + self.stop_spots.extend(sub.stop_spots); + self.cmyk_colors += sub.cmyk_colors; + self.rgb_colors += sub.rgb_colors; + self.drawn_looks += sub.drawn_looks; + for w in &sub.warnings { + self.warn(w); + } + } + + /// `base`, or the first free "`base` N" (N from 2) when a symbol read has that name, as + /// [`vectorcraft_doc::Document::unique_symbol_name`] names it, without looking at every symbol + /// for each N. + fn unique_symbol_name(&mut self, base: &str) -> String { + let mut named = base.to_string(); + if self.symbol_taken.contains(&named) { + let next = self.symbol_suffix.entry(named.clone()).or_insert(2); + while self.symbol_taken.contains(&format!("{base} {next}")) { + *next += 1; + } + named = format!("{base} {next}"); + *next += 1; + } + self.symbol_taken.insert(named.clone()); + named + } + + /// Give the art of the opacity masks in `n` (a copy of a symbol's art with ids of this + /// document, [`vectorcraft_doc::Document::reid`], which leaves masks as they are) ids of this + /// document too. + fn reid_masks(&mut self, n: &mut Node, depth: usize) { + if depth > MAX_DEPTH { + return; + } + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { + let mut art = self.doc.reid(&m.art); + self.reid_masks(&mut art, depth + 1); + m.art = Arc::new(art); + } + for c in n.children_mut().into_iter().flatten() { + self.reid_masks(Arc::make_mut(c), depth + 1); + } + } + + /// The symbol list (`section`: what follows `%AI24_BeginSymbolList`): the symbols' names in the + /// Symbols panel's order. The first list is kept. + fn symbol_list(&mut self, section: &[u8]) { + if self.symbol.is_some() || self.pattern.is_some() || self.symbol_list.is_some() { + return; + } + let mut lex = Lexer::new(section); + let mut names = vec![]; + while let Some(t) = lex.next_token() { + if let Tok::Str(bytes) = t.tok { + if names.len() >= MAX_LISTED { + break; + } + names.push(obj::text(&bytes)); + } + } + self.symbol_list = Some(names); } /// A plug-in object (`section`: what follows `%AI8_BeginPluginObject`): the brush manager's @@ -788,6 +929,64 @@ fn ps_string(v: &[u8]) -> Option { None } +/// The one member of `n` when `n` is a plain group holding only it (not clipping, unnamed, shown, +/// unlocked, opaque, Normal, not isolated, knockout neutral, no knockout shape, appearance, opacity +/// mask or other extras such as object attributes): a symbol's art written as such a group +/// (definitions hold a group) is that member. +pub(crate) fn symbol_art_member(n: &Node) -> Option<&Arc> { + let NodeKind::Group { children, clip: false } = &n.kind else { return None }; + let [one] = children.as_slice() else { return None }; + let plain = n.name.as_deref().is_none_or(str::is_empty) + && n.visible + && !n.locked + && n.opacity == 1.0 + && n.blend == vectorcraft_color::BlendMode::Normal + && !n.isolate + && n.knockout == vectorcraft_doc::Knockout::Neutral + && !n.knockout_shape + && n.appearance.items.is_empty() + && n.appearance.effects.is_empty() + && *n.extras() == vectorcraft_doc::NodeExtras::default(); + plain.then_some(one) +} + +/// Whether `n`, an object in it or the art of an opacity mask in it is filled or stroked with a +/// pattern. +fn paints_pattern(n: &Node, depth: usize) -> bool { + if depth > MAX_DEPTH { + return true; + } + let pattern = |p: &super::Paint| matches!(p, super::Paint::Pattern { .. }); + n.appearance.items.iter().any(|item| match item { + super::AppearanceItem::Fill(f) => pattern(&f.paint), + super::AppearanceItem::Stroke(s) => pattern(&s.paint), + }) || n.extras().mask.as_ref().is_some_and(|m| paints_pattern(&m.art, depth + 1)) + || n.children().into_iter().flatten().any(|c| paints_pattern(c, depth + 1)) +} + +/// Each id of `old` and its descendants → the id of the object in its place in `new`, a copy of +/// `old` with new ids ([`vectorcraft_doc::Document::reid`]). +fn pair_ids(old: &Node, new: &Node, ids: &mut HashMap, depth: usize) { + ids.insert(old.id, new.id); + if depth > MAX_DEPTH { + return; + } + if let (Some(a), Some(b)) = (old.extras().mask.as_ref(), new.extras().mask.as_ref()) { + pair_ids(&a.art, &b.art, ids, depth + 1); + } + if let (Some(a), Some(b)) = (old.children(), new.children()) { + for (a, b) in a.iter().zip(b) { + pair_ids(a, b, ids, depth + 1); + } + } +} + +/// The registration point `AI13PatternRegistrationType` `v` gives a symbol: 1 to 9 from the top +/// left, row by row (5 the center); `None` for other numbers. +pub(super) fn symbol_registration(v: f64) -> Option { + (v.fract() == 0.0 && (1.0..=9.0).contains(&v)).then(|| SymbolRegistration(v as u8 - 1)) +} + #[cfg(test)] mod tests { use super::*; @@ -805,9 +1004,11 @@ mod tests { assert_eq!(ps_string(longest.as_bytes()).map(|s| s.len()), Some(MAX_STRING)); } + /// Definitions nested in a definition aren't read, however deep, and none is read past the + /// limit of nested readers. #[test] fn deeply_nested_symbol_definitions_stop_at_subreader_limit() { - let mut definition = String::from("(Outer) 0 Ae\n"); + let mut definition = String::from("(Outer) 0 A 0 Xw 0 Ae\n"); for i in 0..300 { definition.push_str(&format!("%AI14_BeginSymbol\n(S{i}) 0 Ae\n")); } @@ -817,8 +1018,34 @@ mod tests { } let mut reader = Reader::new(b""); reader.subreader_depth = MAX_SUBREADERS; - reader.symbol_bounds(definition.as_bytes()); - assert!(reader.symbol_bounds.is_empty()); + reader.define_symbol(definition.as_bytes()); + assert!(reader.doc.symbols.is_empty()); + reader.subreader_depth = 0; + reader.define_symbol(definition.as_bytes()); + let names: Vec<&str> = reader.doc.symbols.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, ["Outer"]); + } + + /// Definitions past the limit aren't read, however they are named. + #[test] + fn symbol_definitions_stop_at_the_limit_even_with_one_name() { + let mut reader = Reader::new(b""); + for _ in 0..MAX_SYMBOLS + 10 { + reader.define_symbol(b"(Same) 0 A 0 Xw 0 Ae\nu\nU\n"); + } + assert_eq!(reader.doc.symbols.len(), MAX_SYMBOLS); + assert_eq!(reader.doc.symbols.last().map(|s| s.name.as_str()), Some(format!("Same {MAX_SYMBOLS}").as_str())); + } + + /// The registration points 1 to 9 run from the top left, row by row; other numbers give none. + #[test] + fn symbol_registration_numbers_run_row_by_row() { + assert_eq!(symbol_registration(1.0), Some(SymbolRegistration(0))); + assert_eq!(symbol_registration(5.0), Some(SymbolRegistration::CENTER)); + assert_eq!(symbol_registration(9.0), Some(SymbolRegistration(8))); + for v in [0.0, 10.0, 2.5, -1.0, f64::NAN] { + assert_eq!(symbol_registration(v), None); + } } #[test] diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index d615a09b5..2408b11cc 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -7,7 +7,7 @@ use super::{ Affine, Arc, Artboard, CANVAS_CENTRE, ColorMode, Document, Global, HashMap, Node, NodeId, NodeKind, Obj, OpacityMask, Paint, Point, Reader, Rect, Structure, Swatch, Unit, V, obj, }; -use vectorcraft_doc::{DataSet, DataValue, GraphicStyle, Guide, ImageBlob, PatternDef, TextWrap, Variable, VariableKind, Variables}; +use vectorcraft_doc::{DataSet, DataValue, GraphicStyle, Guide, ImageBlob, PatternDef, Symbol, TextWrap, Variable, VariableKind, Variables}; /// `%AI5_RulerUnits` → [`Unit`] (Illustrator 7 spec table 3; centimeters is 4; pixels is 6 in later files). fn ruler_unit(v: u8) -> Option { @@ -576,6 +576,13 @@ impl<'a> Reader<'a> { f.children.pop(); return; } + // The dictionary after a symbol's art: its registration point. + if self.frames.len() == 1 + && let Some(head) = self.symbol.as_mut() + && let Some(r) = o.nums("AI13PatternRegistrationType").first() + { + head.registration = Some(*r); + } self.constraint_art(o); // A blend's key object or spine. if let Some(kind) = o.text("ShapeBlendPathType") @@ -725,9 +732,50 @@ impl<'a> Reader<'a> { } /// Art space → where the art being read goes: the document, or while a pattern's definition is - /// read, the pattern's space (y down). + /// read, the pattern's space (y down). A symbol's definition is written in the symbol's space. pub(crate) fn space(&mut self) -> Affine { - if self.pattern.is_some() { FLIP } else { self.ensure_space() } + if self.pattern.is_some() { + FLIP + } else if self.symbol.is_some() { + Affine::IDENTITY + } else { + self.ensure_space() + } + } + + /// The space an image's matrix is in → where the art being read goes: [`Self::space`], and in a + /// symbol's art (outside a pattern's definition) the canvas's space turned y up about art space's + /// origin there, as an instance's matrix in it is; `None` there without a template box. + pub(super) fn image_space(&mut self) -> Option { + if self.pattern.is_none() && self.symbol.is_some() { + return self.canvas_origin().map(|o| Affine::new([1.0, 0.0, 0.0, -1.0, o.x, o.y])); + } + Some(self.space()) + } + + /// The canvas point of art space's origin, from the template box's center (the canvas's): what + /// a pattern's gradients and the instances in a symbol's art are written about. + pub(super) fn canvas_origin(&self) -> Option { + let t = self.template?; + let (cx, cy) = ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0); + let origin = Point::new(CANVAS_CENTRE - cx, CANVAS_CENTRE + cy); + (origin.x.is_finite() && origin.y.is_finite()).then_some(origin) + } + + /// The symbols in the symbol list's order, those it doesn't name after them as they were + /// defined; names it has twice or that no symbol has don't count. + fn order_symbols(&mut self) { + let Some(list) = self.symbol_list.take() else { return }; + let mut slots: Vec> = std::mem::take(&mut self.doc.symbols).into_iter().map(Some).collect(); + let at: HashMap = slots.iter().enumerate().filter_map(|(i, s)| s.as_ref().map(|s| (s.name.clone(), i))).collect(); + let mut ordered = Vec::with_capacity(slots.len()); + for name in &list { + if let Some(s) = self.symbol_names.get(name).and_then(|named| at.get(named)).and_then(|i| slots.get_mut(*i)).and_then(Option::take) { + ordered.push(s); + } + } + ordered.extend(slots.into_iter().flatten()); + self.doc.symbols = ordered; } /// The swatches of the palette, `[None]` first, in its order, and its color groups; a gradient @@ -834,6 +882,8 @@ impl<'a> Reader<'a> { } let to_doc = self.ensure_space(); let artboard = self.first_board(); + self.check_symbols(); + self.order_symbols(); drop_commented_copies(&mut self.doc.layers, &self.hidden_ids); super::on_path::apply(&mut self.doc, &self.on_path); let mut doc = std::mem::replace(&mut self.doc, Document::new(1.0, 1.0)); @@ -947,6 +997,9 @@ impl<'a> Reader<'a> { let links: HashMap = self.style_links.iter().filter_map(|(node, name)| Some((*node, *ids.get(name)?))).collect(); if !links.is_empty() { link_styles(&mut doc.layers, &links, &styles, 0); + for symbol in &mut doc.symbols { + link_styles(std::slice::from_mut(&mut symbol.art), &links, &styles, 0); + } } doc.graphic_styles = styles.into(); } @@ -969,6 +1022,11 @@ impl<'a> Reader<'a> { doc.variables = variables.variables.into(); } let (bindings, bindings_skipped) = (variables.bindings, variables.bindings_skipped); + // Symbols that hold instances of themselves can't be drawn: those instances go before the + // layers are compared with the page. + if !doc.symbols.is_empty() { + doc.limit_depth_noting(&mut warnings); + } Ok(Structure { doc, to_doc, diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 0b27ee840..dc3089024 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -520,10 +520,16 @@ impl<'a> Reader<'a> { self.gs.fill = p; self.pending_fill = None; self.pending_unread_fill = false; + if let Some(head) = self.symbol.as_mut() { + head.missing_fill = false; + } } else { self.gs.stroke = p; self.pending_stroke = None; self.pending_unread_stroke = false; + if let Some(head) = self.symbol.as_mut() { + head.missing_stroke = false; + } } } @@ -585,14 +591,18 @@ impl<'a> Reader<'a> { let gs = &self.gs; let state = (gs.opacity, gs.blend, gs.isolate, gs.knockout, gs.knockout_shape); let Some(Frame { kind: Kind::Layer(_), children, .. }) = self.frames.last_mut() else { return }; - let Some(n) = children.last_mut().filter(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Compound { .. })) else { return }; + let Some(n) = + children.last_mut().filter(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Compound { .. } | NodeKind::SymbolInstance { .. })) + else { + return; + }; (n.opacity, n.blend, n.isolate, n.knockout, n.knockout_shape) = before; self.layer_state = Some(state); } - /// `mode opacity isolate knockout shape Xy`; after a group's end, the group's too, and right - /// after a gradient mesh's `/End X!` or a blend's record (before their `Xd` and `6 () XW`), - /// theirs, unless the layer's `LB` follows: then it is the layer's (see `end_layer`). + /// `mode opacity isolate knockout shape Xy`; after a group's end or a symbol instance, theirs + /// too, and right after a gradient mesh's `/End X!` or a blend's record (before their `Xd` and + /// `6 () XW`), theirs, unless the layer's `LB` follows: then it is the layer's (see `end_layer`). fn transparency(&mut self, vals: &[V], mesh_ended: bool) { let n = Self::nums(vals); let [mode, opacity, rest @ ..] = n.as_slice() else { return }; @@ -622,6 +632,12 @@ impl<'a> Reader<'a> { // The layer's instead when the layer's `7 () XW` follows (`layer_state_block`). self.container_undo = in_layer.then_some(before); self.layer_state = None; + } else if let Some(n) = self.last_object().filter(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })) { + // A symbol instance's, as a group's. + let before = (n.opacity, n.blend, n.isolate, n.knockout, n.knockout_shape); + (n.opacity, n.blend, n.isolate, n.knockout, n.knockout_shape) = (gs.opacity, gs.blend, gs.isolate, gs.knockout, gs.knockout_shape); + self.container_undo = in_layer.then_some(before); + self.layer_state = None; } else { // After a layer's art and before its `LB`: the layer's own state (#2003). Kept until // an operator that isn't part of the state block (or the `LB` that takes it) says the @@ -969,7 +985,9 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p use crate::import::ai::style::StyleItem; // A text object's slot (an empty group standing for it) takes the style as the type will. let slot = slot_of(object.name.as_deref()).is_some(); + // A symbol instance draws its symbol's art: it keeps its drawn look. if !st.plain + || matches!(object.kind, NodeKind::SymbolInstance { .. }) || (!slot && !st.simple) || (object.is_container() && !slot && !matches!(object.kind, NodeKind::Compound { .. }) && !st.items.is_empty()) || (st.pathfinder diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index b29301307..09f752db5 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -40,6 +40,12 @@ pub(crate) const FLIP: Affine = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); /// Most patterns read (others can't fill art). 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; + +/// Most names a symbol list orders. +pub(super) const MAX_LISTED: usize = 4 * MAX_SYMBOLS; + /// Most characters of a path's note read (the version 7 specification allows 254, §5.3.1). pub(super) const MAX_NOTE: usize = 1024; @@ -227,6 +233,22 @@ pub(super) struct PatternHead { pub(super) tile: Option, } +/// What a reader of a symbol's definition has read of the symbol besides its art. +#[derive(Debug, Default)] +pub(super) struct SymbolHead { + /// `AI13PatternRegistrationType` in the dictionary after the art. + pub(super) registration: Option, + /// Art the reader left out (type, a placed file, non-native art, an image it can't read or + /// place, a gradient it can't read): the symbol isn't kept, and its instances come in as the + /// page draws them, within the box of the rest. + pub(super) left_out: bool, + /// A gradient instance the reader can't make a paint of (a gradient the file doesn't define, + /// or one it can't place) is the next path's fill or stroke: the art is left out when that + /// path paints it. + pub(super) missing_fill: bool, + pub(super) missing_stroke: bool, +} + /// An image's operands, from `XN` and before `XI`. #[derive(Debug, Default)] pub(super) struct Raster { @@ -314,6 +336,8 @@ pub(super) const IGNORED: &[&str] = &[ ]; /// Sections skipped whole, by their markers without the `AI_` prefix: begin → end. +/// Patterns, symbols and the symbol list are read before this list is looked at; their markers +/// stay here for sections of the same kind nested in them to be skipped. pub(super) const SKIPPED: &[(&str, &str)] = &[ ("BeginPattern", "EndPattern"), ("BeginBrushPattern", "EndBrushPattern"), diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 87cda22b3..0a1938dff 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -3953,6 +3953,362 @@ fn sections_it_skips() { %_/Document :\n%_/Binary : /ASCII85Decode ,\n%_(]<[%\n%;;;~>\n%_; (Profile) ,\n%_;\n"; let doc = read_ok(&format!("{body}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")))).doc; assert_eq!(art(&doc).len(), 1); + // A symbol whose art can't be read isn't kept. + assert!(doc.symbols.is_empty()); +} + +/// Symbol definitions as the editing data writes them, in a section that doesn't print. +fn symbols(defs: &str) -> String { + format!("%AI5_Begin_NonPrinting\n{defs}%AI5_End_NonPrinting--\n") +} + +/// A symbol named `name` whose art is `art` (in the symbol's space, y down), with the registration +/// point `registration` (1 to 9 from the top left, row by row). +fn symbol(name: &str, art: &str, registration: u32) -> String { + format!( + "%AI14_BeginSymbol\n({name})\n0 A\n0 Xw\n0 Ae\nu\n{art}U\n%_/ArtDictionary :\n%_{registration} /Int (AI13PatternRegistrationType) ,\n%_;\n%_\n9 () XW\n%AI10_EndSymbol\n" + ) +} + +/// An instance of the symbol named `name`, `m` taking the symbol's art to art space. +fn instance(name: &str, m: [f64; 6]) -> String { + let [a, b, c, d, e, f] = m; + format!("/SymbolInstance :\n({name}) /SymbolRef ,\n{a} {b} {c} {d} {e} {f} /RTransformMatrix\n;\n") +} + +/// A red square of 4 points about the origin, as a symbol's art. +fn dot() -> String { + format!("{RED}{}f\n", rect_path(-2.0, -2.0, 2.0, 2.0)) +} + +/// The visual box of `n` in `doc`, its instances measured by their symbols' art. +fn instance_box(doc: &Document, n: &Node) -> Rect { + n.visual_bounds_with(&doc.symbol_boxes()).expect("a box") +} + +#[test] +fn a_symbol_comes_in_once_and_its_instances_stay_live() { + let body = symbols(&symbol("Dot", &dot(), 5)) + + &layer(&(instance("Dot", [1.0, 0.0, 0.0, -1.0, 20.0, 50.0]) + &instance("Dot", [2.0, 0.0, 0.0, -2.0, 100.0, 50.0]))); + let mut doc = read_ok(&body).doc; + assert_eq!(doc.symbols.len(), 1); + assert_eq!(doc.symbols[0].name, "Dot"); + assert!(doc.symbol_registration("Dot").is_center()); + let a = art(&doc); + assert_eq!(a.len(), 2); + let xfs: Vec<_> = a + .iter() + .map(|n| if let NodeKind::SymbolInstance { symbol, xf } = &n.kind { (symbol.clone(), *xf) } else { panic!("{:?}", n.kind) }) + .collect(); + assert_eq!(xfs[0].0, "Dot"); + assert_ne!(xfs[0].1, xfs[1].1); + assert_eq!(instance_box(&doc, &a[0]), Rect::new(18.0, 48.0, 22.0, 52.0)); + assert_eq!(instance_box(&doc, &a[1]), Rect::new(96.0, 46.0, 104.0, 54.0)); + // Editing the symbol's art changes both instances. + let wider = read_ok(&(symbols(&symbol("Dot", &format!("{RED}{}f\n", rect_path(-4.0, -2.0, 4.0, 2.0)), 5)) + &layer(""))).doc; + doc.symbols[0].art = wider.symbols[0].art.clone(); + assert_eq!(instance_box(&doc, &a[0]), Rect::new(16.0, 48.0, 24.0, 52.0)); + assert_eq!(instance_box(&doc, &a[1]), Rect::new(92.0, 46.0, 108.0, 54.0)); +} + +#[test] +fn an_instance_in_a_symbols_art_is_placed_from_the_canvas() { + // The template box's center is (100.5, 50.5): art space's origin is at canvas (8091, 8242). + let nested = instance("Dot", [1.0, 0.0, 0.0, -1.0, -8081.0, 8237.0]); + let defs = symbols(&(symbol("Dot", &dot(), 5) + &symbol("Pair", &nested, 1))); + let body = format!("%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n{defs}{}", layer(&instance("Pair", [1.0, 0.0, 0.0, -1.0, 50.0, 50.0]))); + let doc = read_ok(&body).doc; + let names: Vec<&str> = doc.symbols.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, ["Dot", "Pair"]); + assert_eq!(doc.symbol_registration("Pair"), vectorcraft_doc::SymbolRegistration(0)); + // Dot sits at (10, 5) in Pair's art, which the page instance puts at (50, 50). + let a = art(&doc); + assert_eq!(instance_box(&doc, &a[0]), Rect::new(58.0, 53.0, 62.0, 57.0)); +} + +#[test] +fn an_image_in_a_symbols_art_is_placed_from_the_canvas() { + // The template box's center is (100.5, 50.5): art space's origin is at canvas (8091, 8242). The + // image's matrix is in the canvas's space turned y up about that point, as an instance's in a + // symbol's art is: its top left pixel goes to (-5, -2.5) in the symbol's art. + let m = "[ 5 0 0 5 -8096 8244.5 ]"; + let raster = format!( + "%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceRGB XN\n{m} 2 1 0 Xh\n{m} 0 0 2 1 2 1 8 3 0 0 1 0 4 4 0 0\n\ + %%BeginData: 6\rXI\nABCDEF%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN\n" + ); + let body = format!("%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n{}{}", symbols(&symbol("Pic", &raster, 5)), layer("")); + let doc = read_ok(&body).doc; + assert_eq!(doc.symbols.len(), 1); + let NodeKind::Image(im) = &doc.symbols[0].art.kind else { panic!("{:?}", doc.symbols[0].art.kind) }; + for ((u, v), want) in [((0.0, 0.0), (-5.0, -2.5)), ((2.0, 0.0), (5.0, -2.5)), ((0.0, 1.0), (-5.0, 2.5))] { + let got = im.xf * Point::new(u, v); + assert!((got - Point::new(want.0, want.1)).hypot() < 1e-9, "pixel ({u}, {v}): {got:?}, want {want:?}"); + } + // A linked image (`XG` and `XF`) is placed the same way. + let linked = raster7("(a.tif) 0 XG\n", m, (2, 1), "XF", ""); + let doc = read_ok(&format!("%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n{}{}", symbols(&symbol("Link", &linked, 5)), layer(""))).doc; + let NodeKind::Image(link) = &doc.symbols[0].art.kind else { panic!("{:?}", doc.symbols[0].art.kind) }; + assert!(link.link.is_some()); + assert!((link.xf * Point::new(0.0, 0.0) - Point::new(-5.0, -2.5)).hypot() < 1e-9, "{:?}", link.xf); + // Without a template box the image can't be placed, so the symbol isn't read whole: its + // instance comes in as the page draws it. + let group = format!("0 Ae\nu\n{}{RED}{}f\nU\n", instance("Pic", [1.0, 0.0, 0.0, -1.0, 20.0, 50.0]), rect_path(10.0, 0.0, 14.0, 4.0)); + let r = read_ok(&(symbols(&symbol("Pic", &raster, 5)) + &layer(&group))); + assert!(r.doc.symbols.is_empty(), "{:?}", r.doc.symbols.iter().map(|s| &s.name).collect::>()); + assert_eq!(r.unread.len(), 1, "{:?}", r.warnings); +} + +#[test] +fn an_instance_keeps_its_hide_lock_transparency_and_clip() { + let m = [1.0, 0.0, 0.0, -1.0, 20.0, 50.0]; + let art_ = format!( + "1 Xw\n{}0 Xw\n1 A\n{}0 A\n{}1 0.5 1 1 1 Xy\n0 0 Xd\n6 () XW\n0 Ae\nq\n{}{}h\nW\nn\nQ\n0 0.25 0 0 0 Xy\n{}", + instance("Dot", m), + instance("Dot", m), + instance("Dot", m), + instance("Dot", m), + rect_path(19.0, 49.0, 21.0, 51.0), + instance("Dot", m), + ); + let doc = read_ok(&(symbols(&symbol("Dot", &dot(), 5)) + &layer(&art_))).doc; + let a = art(&doc); + assert_eq!(a.len(), 5); + assert!(!a[0].visible && !a[0].locked); + assert!(a[1].visible && a[1].locked); + // An instance's transparency is the `Xy` after it, as a group's is; one set before it isn't its. + assert_eq!((a[2].opacity, a[2].blend, a[2].isolate, a[2].knockout, a[2].knockout_shape), (0.5, BlendMode::Multiply, true, Knockout::On, true)); + assert_eq!((a[1].opacity, a[1].blend, a[1].isolate, a[1].knockout), (1.0, BlendMode::Normal, false, Knockout::Neutral)); + assert_eq!((a[3].opacity, a[4].opacity), (0.25, 1.0)); + let NodeKind::Group { children, clip: true } = &a[3].kind else { panic!("a clipping group: {:?}", a[3].kind) }; + assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. })); + assert!(matches!(children[1].kind, NodeKind::SymbolInstance { .. })); +} + +/// The art of an opacity mask in a symbol's art takes ids of the document, as the rest of the art +/// does: no two objects of the document, in its layers or its symbols, share an id. +#[test] +fn a_symbols_opacity_mask_art_takes_ids_of_the_document() { + fn ids(n: &Node, out: &mut Vec) { + out.push(n.id); + if let Some(m) = n.extras().mask.as_ref() { + ids(&m.art, out); + } + for c in n.children().into_iter().flatten() { + ids(c, out); + } + } + let square = rect_path(-2.0, -2.0, 2.0, 2.0); + let mask_art: String = format!("1 g\n{square}f\n").lines().map(|l| format!("%_{l}\n")).collect(); + let mask = format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{mask_art}%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_; (AI9_Mask) ,\n%_;\n"); + let defs = symbols(&symbol("Masked", &format!("{RED}{square}f\n{mask}{}", dot()), 5)); + let shapes: String = (0..6).map(|i| format!("{RED}{}f\n", rect_path(f64::from(i) * 10.0, 0.0, f64::from(i) * 10.0 + 4.0, 4.0))).collect(); + let doc = read_ok(&(defs + &layer(&format!("{shapes}{}", instance("Masked", [1.0, 0.0, 0.0, -1.0, 20.0, 50.0]))))).doc; + let mut all = Vec::new(); + for n in doc.layers.iter().chain(doc.symbols.iter().map(|s| &s.art)) { + ids(n, &mut all); + } + assert!(doc.symbols.iter().any(|s| s.art.children().into_iter().flatten().any(|c| c.extras().mask.is_some())), "the mask is read"); + let unique: std::collections::HashSet<_> = all.iter().copied().collect(); + assert_eq!(unique.len(), all.len(), "{all:?}"); +} + +/// A layer's `Xy` right after its last object, an instance, before the layer's `0 0 Xd` and +/// `7 () XW`, is the layer's; the instance's own state block (`6 () XW`) keeps it the instance's. +#[test] +fn a_layers_state_after_its_last_symbol_instance_is_the_layers() { + let last = instance("Dot", [1.0, 0.0, 0.0, -1.0, 20.0, 50.0]); + let doc = read_ok(&(symbols(&symbol("Dot", &dot(), 5)) + &layer(&format!("{last}1 0.6 0 2 0 Xy\n0 0 Xd\n7 () XW\n")))).doc; + assert_eq!((doc.layers[0].opacity, doc.layers[0].blend), (0.6, BlendMode::Multiply)); + assert_eq!((art(&doc)[0].opacity, art(&doc)[0].blend), (1.0, BlendMode::Normal)); + let doc = read_ok(&(symbols(&symbol("Dot", &dot(), 5)) + &layer(&format!("{last}1 0.6 0 2 0 Xy\n0 0 Xd\n6 () XW\n")))).doc; + assert_eq!(doc.layers[0].opacity, 1.0); + assert_eq!((art(&doc)[0].opacity, art(&doc)[0].blend), (0.6, BlendMode::Multiply)); +} + +#[test] +fn the_symbol_list_orders_the_symbols_and_names_stay_apart() { + let defs = symbol("A", &dot(), 5) + &symbol("B", &dot(), 5) + &symbol("A", &dot(), 5) + &symbol("C", &dot(), 9); + for list in [ + "%AI24_BeginSymbolList\n(C)\n(Ghost)\n(B)\n(C)\n%AI24_EndSymbolList\n", + "%AI10_BeginSymbolList\n(C)\n(Ghost)\n(B)\n(C)\n%AI10_EndSymbolList\n", + ] { + let doc = read_ok(&(symbols(&format!("{defs}{list}")) + &layer(&instance("A", [1.0, 0.0, 0.0, -1.0, 20.0, 50.0])))).doc; + let names: Vec<&str> = doc.symbols.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, ["C", "B", "A", "A 2"], "{list}"); + assert_eq!(doc.symbol_registration("C"), vectorcraft_doc::SymbolRegistration(8)); + // An instance names the first symbol the file gives that name. + assert!(matches!(&art(&doc)[0].kind, NodeKind::SymbolInstance { symbol, .. } if symbol == "A")); + } +} + +/// A symbol whose art is filled with a pattern isn't kept: its instances come in as the page draws +/// them, within the box of its art. The pattern keeps the name it was numbered with. +#[test] +fn a_symbol_with_a_pattern_fill_isnt_kept() { + let pattern = DOTS.replace("(Dots)", "(Red\\n1)"); + let lawn = symbol("Lawn", &format!("(Red\\n1) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(-2.0, -2.0, 2.0, 2.0)), 5); + let r = read_ok(&format!("{pattern}{}{}", symbols(&lawn), layer(&instance("Lawn", [1.0, 0.0, 0.0, -1.0, 20.0, 50.0])))); + assert_eq!(r.doc.patterns.iter().map(|p| p.name.as_str()).collect::>(), ["Red 2"]); + assert!(r.doc.symbols.is_empty()); + assert_unread(&r, "symbols"); + let (id, _) = &r.unread[0]; + assert_eq!(r.doc.node(*id).and_then(|n| n.geometric_bounds()), Some(Rect::new(18.0, 48.0, 22.0, 52.0))); + // A pattern chosen and then replaced by a color before the art is painted fills nothing. + let solid = symbol("Solid", &format!("(Red\\n1) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{RED}{}f\n", rect_path(-2.0, -2.0, 2.0, 2.0)), 5); + let r = read_ok(&format!("{pattern}{}{}", symbols(&solid), layer(&instance("Solid", [1.0, 0.0, 0.0, -1.0, 20.0, 50.0])))); + assert_eq!(r.doc.symbols.len(), 1); + assert!(r.unread.is_empty()); +} + +#[test] +fn an_instance_of_a_symbol_it_doesnt_have_is_the_unread_object() { + let missing = instance("Absent", [1.0, 0.0, 0.0, -1.0, 20.0, 50.0]); + // Alone on the layer, the file isn't read: the page has no box for it. + let e = read(stream(&(symbols(&symbol("Dot", &dot(), 5)) + &layer(&missing))).as_bytes()).unwrap_err(); + assert!(e.contains("symbols"), "{e}"); + // In a group, the group is the unread object. + let group = format!("0 Ae\nu\n{missing}{RED}{}f\nU\n", rect_path(10.0, 0.0, 14.0, 4.0)); + let r = read_ok(&(symbols(&symbol("Dot", &dot(), 5)) + &layer(&group))); + assert_unread(&r, "symbols"); + // Nor is a symbol whose art holds an instance of one the file doesn't have: the page has no box + // for its instances. + let holder = symbol("Holder", &(dot() + &instance("Absent", [1.0, 0.0, 0.0, -1.0, 0.0, 0.0])), 5); + let body = + format!("%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n{}{}", symbols(&holder), layer(&instance("Holder", [1.0, 0.0, 0.0, -1.0, 20.0, 50.0]))); + let e = read(stream(&body).as_bytes()).unwrap_err(); + assert!(e.contains("symbols"), "{e}"); + // An instance whose matrix doesn't have six finite numbers isn't read either. + let bad = "/SymbolInstance :\n(Dot) /SymbolRef ,\n1 0 0 -1 20 /RTransformMatrix\n;\n"; + let r = read_ok(&(symbols(&symbol("Dot", &dot(), 5)) + &layer(&format!("0 Ae\nu\n{bad}U\n")))); + assert_unread(&r, "symbols"); +} + +#[test] +fn an_instance_whose_appearance_differs_from_its_symbols_comes_in_as_the_page_draws_it() { + let edited = "/SymbolInstance :\n(Dot) /SymbolRef ,\n1 0 0 -1 20 50 /RTransformMatrix\n,\n/Dictionary :\n; (AI19SymbolAppearanceMap) ,\n;\n"; + let r = read_ok(&(symbols(&symbol("Dot", &dot(), 5)) + &layer(edited))); + assert_unread(&r, "symbols"); + let (id, _) = &r.unread[0]; + assert_eq!(r.doc.node(*id).and_then(|n| n.geometric_bounds()), Some(Rect::new(18.0, 48.0, 22.0, 52.0)), "the box is the symbol's, placed"); + // The appearance changes are found in a dictionary that an array holds as a value without a key. + let in_array = "/SymbolInstance :\n(Dot) /SymbolRef ,\n1 0 0 -1 20 50 /RTransformMatrix\n,\n\ + /Array :\n/Dictionary :\n/Dictionary :\n; (AI19SymbolAppearanceMap) ,\n;\n;\n; (Edits) ,\n;\n"; + let r = read_ok(&(symbols(&symbol("Dot", &dot(), 5)) + &layer(in_array))); + assert_unread(&r, "symbols"); + // So does an instance of a symbol whose art isn't read whole, within that art's box. + let partial = symbol("Partial", &format!("{}f\n1 Xw\n1 2 frobnicate\n0 Xw\n", rect_path(-2.0, -2.0, 2.0, 2.0)), 5); + let r = read_ok(&(symbols(&partial) + &layer(&instance("Partial", [1.0, 0.0, 0.0, -1.0, 20.0, 50.0])))); + assert!(r.doc.symbols.is_empty()); + assert_unread(&r, "symbols"); + let (id, _) = &r.unread[0]; + assert_eq!(r.doc.node(*id).and_then(|n| n.geometric_bounds()), Some(Rect::new(18.0, 48.0, 22.0, 52.0))); +} + +/// A symbol whose art holds an image that is left out (no matrix, a matrix with no area, data cut +/// short) isn't kept, so its instances don't draw it without the image. +#[test] +fn a_symbol_with_an_image_it_leaves_out_isnt_kept() { + for (what, image) in [ + ("no matrix", raster7("", "", (1, 1), "XI", "%FF0000\n")), + ("a matrix with no area", raster7("", "[ 0 0 0 0 0 0 ]", (1, 1), "XI", "%FF0000\n")), + ("data cut short", raster7("", "[ 1 0 0 1 0 0 ]", (2, 2), "XI", "%FF0000\n")), + ("no end", raster7("", "[ 1 0 0 1 0 0 ]", (1, 1), "XI", "%FF0000\n").replace("%AI5_EndRaster\n", "")), + ("art after a trailer", format!("%%PageTrailer\n{}", raster7("", "[ 1 0 0 1 0 0 ]", (1, 1), "XI", "%FF0000\n"))), + ] { + // With a template box, so that the image could be placed. + let defs = symbols(&symbol("Photo", &(dot() + &image), 5)); + let group = format!("0 Ae\nu\n{}{RED}{}f\nU\n", instance("Photo", [1.0, 0.0, 0.0, -1.0, 20.0, 50.0]), rect_path(10.0, 0.0, 14.0, 4.0)); + let r = read_ok(&format!("%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n{defs}{}", layer(&group))); + assert!(r.doc.symbols.is_empty(), "{what}"); + assert_eq!(r.unread.len(), 1, "{what}: {:?}", r.warnings); + } +} + +/// A symbol's art written as a group holding one object stays that group when the group is tagged +/// (`XT`), so the tag isn't lost. +#[test] +fn a_symbols_tagged_group_keeps_its_tag() { + let def = format!( + "%AI14_BeginSymbol\n(Tagged)\n0 A\n0 Xw\n0 Ae\nu\n{}U\n/AdobeURL (https://example.com/a) XT\n%_/ArtDictionary :\n%_5 /Int (AI13PatternRegistrationType) ,\n%_;\n%_\n9 () XW\n%AI10_EndSymbol\n", + dot() + ); + let doc = read_ok(&(symbols(&def) + &layer(&instance("Tagged", [1.0, 0.0, 0.0, -1.0, 20.0, 50.0])))).doc; + let art = &doc.symbols[0].art; + assert!(matches!(art.kind, NodeKind::Group { .. }), "{:?}", art.kind); + assert_eq!(tags(art).0, "https://example.com/a"); +} + +/// A definition whose art a trailer stops isn't kept, though the art before the trailer is whole. +#[test] +fn a_symbol_cut_off_by_a_trailer_isnt_kept() { + let cut = format!("%AI14_BeginSymbol\n(Cut)\n0 A\n0 Xw\n0 Ae\n{}%%PageTrailer\n{}%AI10_EndSymbol\n", dot(), dot()); + let group = format!("0 Ae\nu\n{}{RED}{}f\nU\n", instance("Cut", [1.0, 0.0, 0.0, -1.0, 20.0, 50.0]), rect_path(10.0, 0.0, 14.0, 4.0)); + let r = read_ok(&(symbols(&cut) + &layer(&group))); + assert!(r.doc.symbols.is_empty(), "{:?}", r.doc.symbols.iter().map(|s| &s.name).collect::>()); + assert_unread(&r, "symbols"); +} + +/// A symbol whose art holds type, a placed file, non-native art, an image it can't place or a +/// gradient the file doesn't define isn't kept. Its instances come in as the page draws them, within the box of the rest of its art, an +/// instance right on a layer as well as one in a group. +#[test] +fn a_symbols_art_it_leaves_out_gives_its_instances_the_box_of_the_rest() { + let typed = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n".to_string(); + let placed = "%AI5_BeginPlace\n%AI5_EndPlace\n".to_string(); + let foreign = + "/ForeignObject :\n1 /Version ,\n2 0 0 2 10 20 /RTransform ,\n10 20 /Origin ,\n1 2 5 8 /Bounds ,\n/Data ,\n%,u@!!/MSk8%41#ocdN=10d&\n%1H@\n;\n" + .to_string(); + let image = raster7("", "[ 1 0 0 1 0 0 ]", (1, 1), "XI", "%FF0000\n"); + let gradient = format!("Bb\n1 (Nowhere) 0 0 0 10 1 0 0 1 0 0 Bg\n{}f\n0 BB\n", rect_path(-2.0, -2.0, 2.0, 2.0)); + let template = "%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n"; + for (what, left, head) in [ + ("type", typed, template), + ("a placed file", placed, template), + ("non-native art", foreign, template), + ("an image without a template box", image, ""), + ("a gradient the file doesn't define", gradient, template), + ] { + let defs = symbols(&symbol("Mixed", &(dot() + &left), 5)); + let body = format!("{head}{defs}{}", layer(&instance("Mixed", [1.0, 0.0, 0.0, -1.0, 20.0, 50.0]))); + let r = read(stream(&body).as_bytes()).unwrap_or_else(|e| panic!("{what}: {e}")); + assert!(r.doc.symbols.is_empty(), "{what}"); + assert_unread(&r, "symbols"); + let (id, _) = &r.unread[0]; + assert_eq!(r.doc.node(*id).and_then(|n| n.geometric_bounds()), Some(Rect::new(18.0, 48.0, 22.0, 52.0)), "{what}"); + } + // A gradient the file doesn't define on a path that isn't painted paints nothing. + // Nor does one on a path that a group's start drops, for the paths after it. + let square = rect_path(-2.0, -2.0, 2.0, 2.0); + for (what, unpainted) in [ + ("not painted", format!("Bb\n1 (Nowhere) 0 0 0 10 1 0 0 1 0 0 Bg\n{square}n\n0 BB\n")), + ("dropped", format!("Bb\n1 (Nowhere) 0 0 0 10 1 0 0 1 0 0 Bg\n{square}0 BB\nu\n{square}f\nU\n")), + ] { + let defs = symbols(&symbol("Kept", &(dot() + &unpainted), 5)); + let r = read_ok(&format!("{template}{defs}{}", layer(&instance("Kept", [1.0, 0.0, 0.0, -1.0, 20.0, 50.0])))); + assert_eq!(r.doc.symbols.len(), 1, "{what}: {:?}", r.warnings); + assert!(r.unread.is_empty(), "{what}"); + } +} + +#[test] +fn symbols_that_hold_each_other_lose_the_instances_that_close_the_loop() { + let m = [1.0, 0.0, 0.0, -1.0, 0.0, 0.0]; + let defs = symbols(&(symbol("S1", &instance("S2", m), 5) + &symbol("S2", &instance("S1", m), 5))); + let r = + read_ok(&format!("%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n{defs}{}", layer(&(dot() + &instance("S1", [1.0, 0.0, 0.0, -1.0, 20.0, 50.0]))))); + assert!(r.warnings.contains(&vectorcraft_doc::depth::symbols_note()), "{:?}", r.warnings); + assert!(!r.doc.clone().limit_depth(), "nothing is left that draws itself"); +} + +/// A definition after the first layer isn't read: the symbols were checked before that layer, and +/// one read later could hold an instance of a symbol the file doesn't have. +#[test] +fn a_symbol_defined_after_the_first_layer_is_not_read() { + let late = symbols(&symbol("Late", &(dot() + &instance("Absent", [1.0, 0.0, 0.0, -1.0, 0.0, 0.0])), 5)); + let group = format!("0 Ae\nu\n{}{RED}{}f\nU\n", instance("Late", [1.0, 0.0, 0.0, -1.0, 20.0, 50.0]), rect_path(10.0, 0.0, 14.0, 4.0)); + let r = read_ok(&format!("%AI3_TemplateBox: 100.5 50.5 100.5 50.5\n{}{late}{}", layer(&dot()), layer(&group))); + assert!(r.doc.symbols.is_empty(), "{:?}", r.doc.symbols.iter().map(|s| &s.name).collect::>()); + assert_unread(&r, "symbols"); } /// A filled square at x = `10 × i` (5 pt wide), to tell the alternatives of versioned content apart. @@ -4573,6 +4929,35 @@ fn a_style_name_outside_ascii_links_its_objects() { assert_eq!(art(&r.doc)[0].extras().graphic_style, Some(1)); } +#[test] +fn an_object_in_a_symbols_art_with_a_listed_style_is_linked() { + let defs = styles_and_list(&[fill_style("Blue", "0 0 0 0 0 0 1 Xa\n")], &["Blue"]); + let body = + format!("{defs}{}{}", symbols(&symbol("Tile", &styled_square("Blue"), 5)), layer(&instance("Tile", [1.0, 0.0, 0.0, -1.0, 20.0, 50.0]))); + let r = read_ok(&body); + assert_eq!(style_names(&r.doc), [("Blue".to_string(), 1)]); + let tile = &r.doc.symbols[0].art; + assert!(matches!(tile.kind, NodeKind::Path { .. }), "{tile:#?}"); + assert_eq!(fill(tile).color(), Some(Color::rgb(0.0, 0.0, 1.0))); + assert_eq!(tile.extras().graphic_style, Some(1), "{tile:#?}"); + // A symbol's art written as a group holding one object stays that group when the group is + // linked to the style. + let empty = "/KnownStyle :\n(Empty) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n"; + let defs = styles_and_list(&[empty.to_string()], &["Empty"]); + let square = rect_path(0.0, 0.0, 10.0, 10.0); + let hidden = square.replace('\n', "\n%_"); + // The styled group is the definition's own group, without one around it. + let group = format!( + "%AI14_BeginSymbol\n(Group)\n0 A\n0 Xw\n0 Ae\nu\n{RED}{square}f\nU\n%_u\n%_{hidden}f\n%_U\n1 (Empty) XW\n\ + %_/ArtDictionary :\n%_5 /Int (AI13PatternRegistrationType) ,\n%_;\n%_\n%AI10_EndSymbol\n" + ); + let body = format!("{defs}{}{}", symbols(&group), layer(&instance("Group", [1.0, 0.0, 0.0, -1.0, 20.0, 50.0]))); + let art = &read_ok(&body).doc.symbols[0].art; + assert!(matches!(art.kind, NodeKind::Group { .. }), "{art:#?}"); + assert_eq!(art.extras().graphic_style, Some(1), "{art:#?}"); +} + #[test] fn without_a_style_list_the_default_graphic_styles_stay() { let defaults = style_names(&Document::new(1.0, 1.0)); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 308b7b5ea..488743f44 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -104,18 +104,18 @@ private editing data. VectorCraft: opens a file with that prolog from its program, as the format. - **Known failures in users' files:** layer structure flattened or multiplied (#951: 109 layers instead of 14; #868: round trip with Illustrator 2018 loses layers and groups), some files 4× slower to open since the - editing data is read whole (#758), live effects, brushes and symbols come in as their drawn look (#637; - pattern fills are read since #1025); legacy `.ai` without the prolog opens since #1027, its legacy type - (`To` … `TO`) since #868; guides open as ruler guides and guide paths since #5057 (#779). + editing data is read whole (#758), live effects and brushes come in as their drawn look (#637; + pattern fills are read since #1025, and symbols and their instances open live); legacy `.ai` without the + prolog opens since #1027, its legacy type (`To` … `TO`) since #868; guides open as ruler guides and guide paths since #5057 (#779). 14; #868: round trip with Illustrator 2018 loses layers and groups), guides and non-printing construction lines lost (#779), some files 4× slower to open since the editing data is read whole (#758), unmapped live - effects, brushes and symbols come in as their drawn look (#637; pattern fills are read since #1025); legacy `.ai` + effects and brushes come in as their drawn look (#637; pattern fills are read since #1025); legacy `.ai` without the prolog opens since #1027, its legacy type (`To` … `TO`) since #868. lines lost (#779), some files 4× slower to open since the editing data is read whole (#758), live effects - that are not read or have settings that are not read, brushes and symbols come in as their drawn look (#637; pattern fills are read since #1025); legacy `.ai` + that are not read or have settings that are not read, and brushes come in as their drawn look (#637; pattern fills are read since #1025); legacy `.ai` without the prolog opens since #1027, its legacy type (`To` … `TO`) since #868. - lines lost (#779), some files 4× slower to open since the editing data is read whole (#758), live effects, - brushes and symbols come in as their drawn look (#637; pattern fills are read since #1025); legacy `.ai` + lines lost (#779), some files 4× slower to open since the editing data is read whole (#758), live effects + and brushes come in as their drawn look (#637; pattern fills are read since #1025); legacy `.ai` without the prolog opens since #1027, its legacy type (`To` … `TO`) since #868, and its placed EPS files in the version 7 form (`` ` `` … `~` with no `%AI5_BeginPlace`) are read like other placed files without a preview: as the page's art in their box. @@ -150,6 +150,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke or Convert to Shape comes in as itself with them and their saved settings; one with more, other effects, an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. ## Format by format diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2a4902a36..01b4f5a4b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1206,6 +1206,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A `.ai` or PDF whose editing data holds a readable variable library opens with its variable definitions, data-set rows, text, visibility and linked-file values, active row and lock in the Variables panel, whether its layers are read or it comes in as its PDF part; graph values and malformed entries are skipped with a note. With its layers read, the objects its text, visibility and image variables are bound to (type for a text variable, any object for a visibility variable, a linked image for an image variable, found by their XML ids) come in bound, so selecting a data set changes them; locked and hidden objects, objects in a locked or hidden layer or group and objects on a locked artboard keep their bindings, and a data set leaves the locked ones as they are. Bindings whose object didn't come in or is of another kind are skipped with a note, as are graph bindings and every binding of a file that comes in as its PDF part (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai` or EPS opened from its editing data keeps each artboard's center mark, cross hairs, video safe areas and video ruler pixel aspect ratio, and the file's Document Raster Effects Settings; the author comes from the header's `%%For` (the editing data's, else an EPS's own) when the file's File Info gives none, the header's custom colors that no swatch has come in as spot swatches, a named color the file defines takes the place of a default swatch of that name when the file has no palette, and an older file's `%%TemplateBox` places its artboard. A value of another type or out of range keeps its default (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). A `.ai` opened from its editing data opens its Objects on Path groups live, with their path, their objects in order along it, Pivot and Rotate, and their art where the file draws it; a group whose spacing has been dragged, or with options an Objects on Path group doesn't hold, opens as a plain group (`crates/eps/src/import/ai/tests.rs`). + `.ai` symbol definitions and placed instances open as live symbols, in the Symbols panel's order; an instance of a symbol that isn't read, or whose appearance differs from its symbol's, comes in as the page draws it (`crates/eps/src/import/ai/tests.rs`). A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art. A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. Pattern brushes and brushes that can't be read are left out with a note; brushed paths still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, a path or type object with Offset Path, Outline Object or Outline Stroke on the whole object opens as itself with those effects live, in their order and shown or hidden; on a fill or stroke of type they stay on that fill or stroke. Offset Path keeps its Offset, Joins and Miter Limit, and Outline Stroke takes its width from the stroke. An Offset Path with a setting missing, out of range or not read keeps the drawn art, as does a path with a Path effect on one fill or stroke (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, an object with a Tweak that moves nothing or Pucker & Bloat at 0 opens as itself with the effect live. A path, compound path or group whose only effect is Transform without copies, or a Free Distort whose corners make a parallelogram, opens as itself with it live, and so does an unstroked path or compound path with straight segments whose only effect is Twist. A gradient or pattern, an image, type, a clipping group, an opacity mask or effects inside the object, or a stroke with a Transform that scales, rotates or reflects, keep the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/ai/read/ops.rs`).