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.