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.
This commit is contained in:
dotCooCoo
2026-10-28 22:03:38 -07:00
parent 00fe031879
commit 25a193e5a9
3 changed files with 49 additions and 2 deletions
+3 -2
View File
@@ -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
+25
View File
@@ -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);
+21
View File
@@ -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<String, u64>,
/// The document's brushes, read the first time a brushed stroke is written.
brushes: Option<Vec<vectorcraft_brush::Brush>>,
/// 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.