From 00fe03187993dbf6110346cc9e49fa581d244566 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 21:58:55 -0700 Subject: [PATCH] Save As .ai: write inline graphics in type as their art in the editing data Type is written as its outlines, and the outlines leave out inline graphics, so the editing data lost them while the PDF page drew them. Each inline graphic is now written as its symbol's art where the layout places it, over the characters and under the object's own fills and strokes above the Characters row, in the order the PDF page draws them. --- crates/eps/src/ai_editing.rs | 2 +- crates/eps/src/ai_editing/tests.rs | 34 +++++++++++++++++++ crates/eps/src/ai_editing/writer.rs | 52 ++++++++++++++++++++++++----- 3 files changed, 79 insertions(+), 9 deletions(-) diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index d87a00396..9f3c24fe3 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -36,7 +36,7 @@ //! the first artboard's top-left corner. On the canvas ([`CANVAS_SIZE`] points square, y down) the //! artboards together are centered; each artboard's `RulerOrigin` is its top-left corner there, and //! `%AI3_TemplateBox` holds the canvas's center in art space. What the form above can't hold is written as plain filled and stroked paths, each such -//! loss a warning: type as its outlines, gradients as their average color, pattern fills as their +//! 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 diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 696014fb9..a3acb5d2f 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -617,6 +617,40 @@ fn type_is_written_as_create_outlines_makes_it() { assert_eq!(rounded(group), rounded(&outlined)); } +#[test] +fn inline_graphics_in_type_are_written_as_their_art() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mark = Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), + Appearance::basic(solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0), + ); + doc.symbols.push(Symbol { name: "Mark".into(), art: Arc::new(mark) }); + let style = vectorcraft_doc::CharStyle { size: 18.0, fill: solid(Color::BLACK), ..vectorcraft_doc::CharStyle::default() }; + let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 70.0), "A", style.clone()); + t.runs.push(vectorcraft_doc::TextRun::inline(vectorcraft_doc::InlineArt::new("Mark"), style)); + let mut text = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + // A stroke of the object's own above the Characters row: drawn over the inline graphic. + let red = solid(Color::rgb(1.0, 0.0, 0.0)); + text.appearance = + Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(red.clone(), 3.0))], contents_index: Some(0), ..Appearance::default() } + .into(); + doc.insert(Some(layer), usize::MAX, text).unwrap(); + let back = read_back(&doc); + let mut all = vec![]; + paths(back.layers[0].children().unwrap(), &mut all); + let has = |p: &Node, c: &Paint| p.appearance.items.iter().any(|i| paint(i.paint()) == paint(c)); + let blue = solid(Color::rgb(0.0, 0.0, 1.0)); + let art: Vec<&Node> = all.iter().filter(|p| has(p, &blue)).collect(); + assert_eq!(art.len(), 1, "the inline graphic is written"); + // To the right of the letter, 18 pt high as the run's size makes it. + let b = art[0].visual_bounds().unwrap(); + assert!(b.x0 > 10.0 && (b.height() - 18.0).abs() < 0.01, "{b:?}"); + let at = |c: &Paint| all.iter().position(|p| has(p, c)).unwrap(); + assert!(at(&blue) < at(&red), "the stroke above the characters is over the inline graphic"); +} + #[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 24c0d57b8..58947715c 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -507,19 +507,55 @@ impl<'a> Writer<'a> { /// Type `n` as a group of what Type › Create Outlines makes of it /// ([`vectorcraft_effects::outline_text`]): each run's glyphs with their underline and - /// strikethrough, its color glyphs, and the object's own fills and strokes. + /// strikethrough, its color glyphs, and the object's own fills and strokes; then its inline + /// graphics as their symbols' art where the layout places them, as the PDF page draws them. fn text(&mut self, n: &Node) { self.warn(TYPE); self.line("u"); - if let Some(outlined) = vectorcraft_effects::outline_text(n) { - let state = self.state; - for c in outlined.children().map(Vec::as_slice).unwrap_or_default() { - self.node(c); - } - if let Some((locked, hidden)) = state { - self.state(locked, hidden); + let doc = self.doc; + let resolved = match &n.kind { + NodeKind::Text(t) => Some(doc.inline_resolved(t).into_owned()), + _ => None, + }; + let node = match &resolved { + Some(t) => Node { kind: NodeKind::Text(Box::new(t.clone())), ..n.clone() }, + None => n.clone(), + }; + let state = self.state; + let outlined = vectorcraft_effects::outline_text(&node); + let pieces = outlined.as_ref().and_then(|o| o.children()).map(Vec::as_slice).unwrap_or_default(); + // The object's fills and strokes above the Characters row, when they paint, are the last + // piece: the inline graphics go under it, as the PDF page draws them. + let above = !node.appearance.split_contents().1.is_empty() && { + let bare = Node { appearance: Default::default(), ..node.clone() }; + let glyphs = vectorcraft_effects::outline_text(&bare).and_then(|o| o.children().map(Vec::len)).unwrap_or_default(); + pieces.len() > glyphs + }; + let (under, over) = pieces.split_at(pieces.len() - usize::from(above)); + for c in under { + self.node(c); + } + if let Some(t) = resolved.as_ref().filter(|_| self.nest < MAX_NEST) { + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + 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 }; + self.warn(LIVE); + let mut art_node = (*sym.art).clone(); + // Strokes scale with the art, as on the canvas. + art_node.transform(t.xf * ig.xf * doc.symbol_natural_xf(&art.symbol), true); + (art_node.visible, art_node.locked, art_node.name) = (true, false, None); + self.nest += 1; + self.node(&art_node); + self.nest -= 1; } } + for c in over { + self.node(c); + } + if let Some((locked, hidden)) = state { + self.state(locked, hidden); + } self.line("U"); }