From 25a193e5a9e60f47c988853d31d752a31ea1ec17 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 22:03:38 -0700 Subject: [PATCH] Save As .ai: bound the symbol art the editing data writes Instances of symbols that hold instances of other symbols multiply, and the editing data wrote every level for each instance above it, limited only by nesting depth: eight levels of 100 instances each could hang a save or run out of memory. The writer now counts the objects of symbol art it writes, symbol instances and inline graphics alike, and leaves out instances past 16,000,000 objects in all, as the PDF part does, with a warning. --- crates/eps/src/ai_editing.rs | 5 +++-- crates/eps/src/ai_editing/tests.rs | 25 +++++++++++++++++++++++++ crates/eps/src/ai_editing/writer.rs | 21 +++++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 9f3c24fe3..e89584b8e 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -39,8 +39,9 @@ //! loss a warning: type as its outlines and its inline graphics' art, gradients as their average color, pattern fills as their //! tiles cut to the shape, clipping groups and clipping layers as their art cut to the clipping //! path (the clipping path's fill cut too, its stroke not), images as their outlines filled with -//! their average color, symbols and live objects as their art, strokes a plain line can't draw as -//! their filled outlines, several fills or strokes as a group of a path for each, gradient and +//! their average color, symbols and live objects as their art (symbol art past +//! [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects in all left out), strokes a plain line can't draw +//! as their filled outlines, several fills or strokes as a group of a path for each, gradient and //! pattern swatches, transparency and live effects other than geometry effects left out. //! //! The data streams hold the program compressed with Zstandard after `%AI24_ZStandard_Data`, with diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index a3acb5d2f..bede50515 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -651,6 +651,31 @@ fn inline_graphics_in_type_are_written_as_their_art() { assert!(at(&blue) < at(&red), "the stroke above the characters is over the inline graphic"); } +/// Nested symbols whose instances multiply stop at [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects of +/// symbol art in all, with a warning. +#[test] +fn nested_symbol_instances_stop_at_the_symbol_art_limit() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + // Empty paths: counted, but written as nothing, so the test stays small; and one filled square. + let empty = Arc::new(Node::path(doc.alloc_id(), PathData::new(vec![]), Appearance::default())); + let mut members = vec![empty; 4999]; + let square = shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)); + members.push(Arc::new(Node::path(doc.alloc_id(), square, Appearance::basic(solid(Color::gray(0.5)), Paint::None, 0.0)))); + let leaves = Node::group(doc.alloc_id(), members); + doc.symbols.push(Symbol { name: "Leaf".into(), art: Arc::new(leaves) }); + let leaf = Arc::new(Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Leaf".into(), xf: Affine::IDENTITY })); + let trees = Node::group(doc.alloc_id(), vec![leaf; 5000]); + doc.symbols.push(Symbol { name: "Tree".into(), art: Arc::new(trees) }); + let tree = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Tree".into(), xf: Affine::IDENTITY }); + doc.insert(Some(layer), usize::MAX, tree).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::SYMBOL_LIMIT), "{:?}", out.warnings); + // 5,001 objects for the tree and each leaf: 3,198 leaves fit in 16,000,000. + let text = String::from_utf8(out.program).unwrap(); + assert_eq!(text.matches("\r\nf\r\n").count(), 3198, "the squares of the leaves written"); +} + #[test] fn a_stained_symbol_instance_is_written_in_its_stained_colors() { let mut doc = Document::new(200.0, 100.0); diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 58947715c..1cf0390ca 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -36,6 +36,7 @@ const STROKES: &str = "strokes a plain line can't draw (inside or outside the pa pub(super) const TRANSPARENCY: &str = "transparency (opacity, blending modes, opacity masks) is left out of the editing data for other apps"; pub(super) const EFFECTS: &str = "live effects other than geometry effects are left out of the editing data for other apps"; const SWATCHES: &str = "gradient and pattern swatches are left out of the editing data for other apps"; +pub(super) const SYMBOL_LIMIT: &str = "symbol instances past 16,000,000 objects of symbol art in all are left out of the editing data for other apps"; const TEMPLATES: &str = "template layers are written as dimmed layers that don't print in the editing data for other apps"; pub(super) struct Writer<'a> { @@ -57,6 +58,8 @@ pub(super) struct Writer<'a> { next: HashMap, /// The document's brushes, read the first time a brushed stroke is written. brushes: Option>, + /// The objects of the symbol art written so far, within [`vectorcraft_doc::MAX_SYMBOL_NODES`]. + symbol_nodes: u64, } impl<'a> Writer<'a> { @@ -73,9 +76,23 @@ impl<'a> Writer<'a> { ids: HashSet::new(), next: HashMap::new(), brushes: None, + symbol_nodes: 0, } } + /// Whether the art of a symbol, `art`, may be written: the objects of the symbol art written + /// so far stay within [`vectorcraft_doc::MAX_SYMBOL_NODES`], as in the PDF part (instances of + /// symbols that hold instances multiply). Past it the art is left out, with a warning. + fn take_symbol(&mut self, art: &Node) -> bool { + let total = self.symbol_nodes.saturating_add(art.count() as u64); + if total > vectorcraft_doc::MAX_SYMBOL_NODES { + self.warn(SYMBOL_LIMIT); + return false; + } + self.symbol_nodes = total; + true + } + fn warn(&mut self, w: &str) { if !self.warnings.iter().any(|x| x == w) { self.warnings.push(w.to_string()); @@ -234,6 +251,7 @@ impl<'a> Writer<'a> { self.line("u"); if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol) && self.nest < MAX_NEST + && self.take_symbol(&sym.art) { // Stained by the instance's fill, as the canvas draws it. let mut art = vectorcraft_brush::instance_art(&sym.art, n); @@ -540,6 +558,9 @@ impl<'a> Writer<'a> { for ig in &layout.inlines { let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref()) else { continue }; let Some(sym) = doc.symbols.iter().find(|x| x.name == art.symbol) else { continue }; + if !self.take_symbol(&sym.art) { + continue; + } self.warn(LIVE); let mut art_node = (*sym.art).clone(); // Strokes scale with the art, as on the canvas.