From c567ecc264aa8bc2bc2bb071487a40f337bd0433 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 21:21:35 -0800 Subject: [PATCH] .ai save: text wrap fixes for cut-away clipping fills and symbols without room Two faults in the editing data writer's text wrap handling: - A clipping path inside art cut to an outer clipping object is written as its fill and its stroke. The stroke's text wrap options were dropped whenever the path had a fill, so when the outer object cut the fill away entirely, the stroke was written without them and the options were lost. The stroke now drops them only after the fill writes an object. - When a symbol definition had no room left under the object limit, `define_symbol` returned without restoring the drawn-art counter it had cleared. A live instance inside drawn art (a stained instance's art) then left the counter at zero, and the art around it subtracted from zero: a panic in a debug build. That path now restores the counter as the other exits do. --- crates/eps/src/ai_editing/tests.rs | 50 ++++++++++++++++++++- crates/eps/src/ai_editing/writer.rs | 14 +++--- crates/eps/src/ai_editing/writer/symbols.rs | 1 + 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 967c982ad4..1fd776cc46 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -1187,6 +1187,32 @@ fn a_symbol_definition_is_written_only_with_room_for_its_instance() { } } +/// A live instance inside a stained instance's art, whose definition has no room left, is left out +/// without upsetting the art around it: with room for any number of objects the export finishes, +/// within that room. +#[test] +fn a_definition_without_room_inside_drawn_art_is_left_out() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let leaf = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Leaf".into(), art: Arc::new(leaf) }); + let inner = instance(&mut doc, "Leaf", Affine::translate((5.0, 5.0))); + let side = square(&mut doc, Rect::new(20.0, 0.0, 30.0, 10.0), Color::gray(0.5)); + let art = group(&mut doc, vec![inner, side], false); + doc.symbols.push(Symbol { dynamic: false, name: "Branch".into(), art: Arc::new(art) }); + let mut stained = instance(&mut doc, "Branch", Affine::translate((50.0, 10.0))); + stained.appearance = Appearance::basic(solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into(); + doc.insert(Some(layer), usize::MAX, stained).unwrap(); + add_rect(&mut doc, Rect::new(150.0, 10.0, 160.0, 20.0)); + let read = crate::import::objects_read(&export_ai_editing(&doc, &opts()).unwrap().program).unwrap(); + for max in 0..read { + let out = export_within(&doc, &opts(), max).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "art is left out with room for {max} of {read} objects"); + let objects = crate::import::objects_read(&out.program).unwrap(); + assert!(objects <= max, "{objects} objects read with room for {max}"); + } +} + /// The writer's limit is the reader's, which its warning names. #[test] fn the_object_limit_is_the_readers() { @@ -10017,7 +10043,8 @@ fn text_wrap_options_of_type_and_envelopes_are_read_back() { } /// A clipping path inside art cut to a clipping object is written as its fills and its strokes, -/// two objects: its text wrap options are written once, whether it has both or only strokes. +/// two objects: its text wrap options are written once, whether it has both or only strokes, and +/// with its strokes when an outer clipping object cuts its fills away. #[test] fn a_clipping_path_written_as_its_paint_keeps_one_text_wrap() { for fill in [solid(Color::gray(0.5)), Paint::None] { @@ -10036,4 +10063,25 @@ fn a_clipping_path_written_as_its_paint_keeps_one_text_wrap() { assert_eq!(text.matches("(AI11TextWrapOffset)").count(), 1, "{text}"); same_wraps(&wraps(read_back(&doc).layers[0].children().unwrap()), &[(None, 5.0, true)]); } + + // The outer clipping object ends at x = 100 and the clipping path's fill starts at x = 101: the + // fill is cut away and writes nothing, and its 4 pt stroke, which reaches x = 99, keeps the + // options, whether or not the fill's lock state is written before it. + for locked in [false, true] { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut cutter = clipping(&mut doc, shapes::rectangle(Rect::new(101.0, 20.0, 150.0, 80.0))); + cutter.appearance = Appearance::basic(solid(Color::gray(0.5)), solid(Color::BLACK), 4.0).into(); + cutter.locked = locked; + let cutter = wrapped(cutter, 5.0, true); + let member = square(&mut doc, Rect::new(110.0, 30.0, 140.0, 60.0), Color::rgb(1.0, 0.0, 0.0)); + let inner = group(&mut doc, vec![cutter, member], true); + let frame = square(&mut doc, Rect::new(0.0, 0.0, 100.0, 100.0), Color::gray(0.9)); + let frame = group(&mut doc, vec![frame], false); + let outer = group(&mut doc, vec![frame, inner], true); + doc.insert(Some(layer), usize::MAX, outer).unwrap(); + let text = program(&doc); + assert_eq!(text.matches("(AI11TextWrapOffset)").count(), 1, "locked {locked}: {text}"); + same_wraps(&wraps(read_back(&doc).layers[0].children().unwrap()), &[(None, 5.0, true)]); + } } diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 879eb9d9f5..b69d919304 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -1037,22 +1037,24 @@ impl<'a> Writer<'a> { /// A container's members, the first one's outline cutting the others: its fills painted /// behind them (cut to the region outside too), its strokes over them, not cut by it. Its text - /// wrap options are written once: with its fills, or without fills with its strokes. + /// wrap options are written once: with its fills, or, when they write nothing, with its strokes. fn clip_contents(&mut self, children: &[Arc]) { let Some((first, rest)) = children.split_first() else { return }; // Nothing to clip by hides the clipped art. let Some((outline, rule)) = vectorcraft_effects::clip_outline(first) else { return }; let mut paint = first.clip_paint(); - if let (Some(_), Some(stroke)) = (&paint.fill, &mut paint.stroke) - && stroke.extras().wrap.is_some() - { - stroke.extras_mut().wrap = None; - } let outer = self.clip.clone(); let region = self.cut(&PathData::from_bezpath(&outline), rule); self.clip = Some(region); if let Some(fill) = &paint.fill { + let objects = self.objects; self.node(fill); + if self.objects > objects + && let Some(stroke) = &mut paint.stroke + && stroke.extras().wrap.is_some() + { + stroke.extras_mut().wrap = None; + } } for c in rest { self.node(c); diff --git a/crates/eps/src/ai_editing/writer/symbols.rs b/crates/eps/src/ai_editing/writer/symbols.rs index 86d7c4694d..a96b4d1831 100644 --- a/crates/eps/src/ai_editing/writer/symbols.rs +++ b/crates/eps/src/ai_editing/writer/symbols.rs @@ -233,6 +233,7 @@ impl Writer<'_> { if !self.take_objects(1) { self.defining_symbols.pop(); self.keep_since(before); + (self.in_look, self.drawn) = (in_look, drawn); return false; } // The art is written in the symbol's space, from the state `0 A` and `0 Xw` set, from the