Merge pull request #7223 from dotCooCoo/fix/inline-graphics-in-symbol-art

Type: inline graphics in symbol, mask and pattern art follow their symbols
This commit is contained in:
BFlat
2026-11-14 03:24:03 +07:00
committed by GitHub
8 changed files with 609 additions and 90 deletions
+159 -1
View File
@@ -2,7 +2,7 @@
use serde_json::json;
use vectorcraft_doc::text::INLINE_CHAR;
use vectorcraft_doc::{NodeId, NodeKind, TextObject};
use vectorcraft_doc::{Node, NodeId, NodeKind, TextObject};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use crate::*;
@@ -167,6 +167,164 @@ fn inline_graphics_follow_their_symbol() {
let _ = s.execute("document.serialize", &json!({"format": "pdf"})).unwrap();
}
/// The first text in tree `n`, its opacity mask art included.
fn first_text(n: &Node) -> Option<TextObject> {
if let NodeKind::Text(t) = &n.kind {
return Some((**t).clone());
}
if let Some(t) = n.extras().mask.as_deref().and_then(|m| first_text(&m.art)) {
return Some(t);
}
n.children().into_iter().flatten().find_map(|c| first_text(c))
}
/// The first text in the art of symbol `name`.
fn art_text(s: &Session, name: &str) -> TextObject {
let d = &s.doc().unwrap().doc;
first_text(&d.symbols.iter().find(|x| x.name == name).unwrap().art).unwrap()
}
/// The bounds of the first inline graphic in `t`.
fn inline_bounds(t: &TextObject) -> Option<vectorcraft_geom::Rect> {
t.runs.iter().find_map(|r| r.inline.as_ref()).unwrap().bounds
}
/// Redefines symbol `name` as a 40 × 20 ellipse.
fn redefine_as_wide_ellipse(s: &mut Session, name: &str) {
let e = s.execute("shape.ellipse", &json!({"x": 300, "y": 300, "width": 40, "height": 20})).unwrap();
s.execute("select.set", &json!({"ids": [e["id"]]})).unwrap();
s.execute("symbol.update", &json!({"name": name})).unwrap();
}
/// A symbol made from type with an inline graphic: when the symbol the graphic shows changes, the
/// type in the symbol's art is laid out again, the symbol's art takes its new size, and an inline
/// graphic of that symbol follows. Undo, redo and opening a saved copy keep it so.
#[test]
fn inline_graphics_in_symbol_art_follow_their_symbol() {
let mut s = session();
circle_symbol(&mut s, "Dot", 10.0);
let id = text(&mut s, "ab");
s.execute("text.insertInline", &json!({"id": id.0, "at": 1, "symbol": "Dot", "scale": 1})).unwrap();
s.execute("symbol.new", &json!({"name": "Holder", "ids": [id.0]})).unwrap();
let line = text(&mut s, "x");
s.execute("text.insertInline", &json!({"id": line.0, "at": 0, "symbol": "Holder", "scale": 1})).unwrap();
let held = art_text(&s, "Holder");
let w0 = held.cached_bounds.unwrap().width();
let holder0 = s.doc().unwrap().doc.symbol_art_bounds("Holder").unwrap();
redefine_as_wide_ellipse(&mut s, "Dot");
let d = &s.doc().unwrap().doc;
let held = art_text(&s, "Holder");
let b = inline_bounds(&held).unwrap();
assert!((b.width() / b.height() - 2.0).abs() < 0.1, "{b:?}");
assert_eq!(Some(b), d.symbol_art_bounds("Dot"));
let w1 = held.cached_bounds.unwrap().width();
assert!((w1 - w0 - 20.0).abs() < 1.5, "{w0} → {w1}");
assert!(held.cached_ink.is_some() && !held.cached_baselines.is_empty());
let holder1 = d.symbol_art_bounds("Holder").unwrap();
assert!(holder1.width() > holder0.width() + 1.0, "{holder0:?} → {holder1:?}");
assert_eq!(inline_bounds(&obj(&s, line)), Some(holder1), "an inline graphic of the holder follows it");
// Undo and redo.
s.execute("edit.undo", &json!({})).unwrap();
assert!((art_text(&s, "Holder").cached_bounds.unwrap().width() - w0).abs() < 1e-6);
s.execute("edit.redo", &json!({})).unwrap();
assert!((art_text(&s, "Holder").cached_bounds.unwrap().width() - w1).abs() < 1e-6);
// Opening a saved copy measures it the same way.
let doc = (*s.doc().unwrap().doc).clone();
let mut back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
back.title = "copy".into();
let mut s2 = Session::new();
s2.add_document(back, None);
let held2 = art_text(&s2, "Holder");
assert_eq!(inline_bounds(&held2), s2.doc().unwrap().doc.symbol_art_bounds("Dot"));
assert!((held2.cached_bounds.unwrap().width() - w1).abs() < 1e-6);
assert_eq!(inline_bounds(&obj(&s2, line)), s2.doc().unwrap().doc.symbol_art_bounds("Holder"));
}
/// Type with an inline graphic in an opacity mask and in a pattern tile follows its symbol.
#[test]
fn inline_graphics_in_masks_and_pattern_tiles_follow_their_symbol() {
let mut s = session();
circle_symbol(&mut s, "Dot", 10.0);
let masked = text(&mut s, "ab");
s.execute("text.insertInline", &json!({"id": masked.0, "at": 1, "symbol": "Dot"})).unwrap();
let owner = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 20, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap());
s.edit("Mask", |d, _| {
let art = d.remove(masked)?;
d.node_mut(owner).unwrap().extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new((*art).clone(), true)));
Ok(())
})
.unwrap();
let tiled = text(&mut s, "cd");
s.execute("text.insertInline", &json!({"id": tiled.0, "at": 1, "symbol": "Dot"})).unwrap();
s.execute("object.pattern.make", &json!({"ids": [tiled.0], "name": "Tile", "edit": false})).unwrap();
redefine_as_wide_ellipse(&mut s, "Dot");
let d = &s.doc().unwrap().doc;
let want = d.symbol_art_bounds("Dot");
let mask = first_text(&d.node(owner).unwrap().extras().mask.as_deref().unwrap().art).unwrap();
assert_eq!(inline_bounds(&mask), want);
let tile = d.patterns.iter().find(|p| p.name == "Tile").unwrap().art.iter().find_map(|n| first_text(n)).unwrap();
assert_eq!(inline_bounds(&tile), want);
}
/// Symbol C shows B inline and B shows A: with the symbols listed C, B, A, a change to A reaches C
/// through B's new size.
#[test]
fn symbols_are_resolved_after_the_symbols_they_show_inline() {
let mut s = session();
circle_symbol(&mut s, "A", 10.0);
for (name, shows) in [("B", "A"), ("C", "B")] {
let t = text(&mut s, "z");
s.execute("text.insertInline", &json!({"id": t.0, "at": 0, "symbol": shows})).unwrap();
s.execute("symbol.new", &json!({"name": name, "ids": [t.0]})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
}
s.edit("Reverse the symbols", |d, _| {
d.symbols.reverse();
Ok(())
})
.unwrap();
let b0 = s.doc().unwrap().doc.symbol_art_bounds("B");
redefine_as_wide_ellipse(&mut s, "A");
let d = &s.doc().unwrap().doc;
assert_eq!(inline_bounds(&art_text(&s, "B")), d.symbol_art_bounds("A"));
assert_ne!(d.symbol_art_bounds("B"), b0);
assert_eq!(inline_bounds(&art_text(&s, "C")), d.symbol_art_bounds("B"));
}
/// A symbol whose art shows the symbol itself inline, directly or through another symbol, is an
/// empty slot, and stays one through later edits.
#[test]
fn a_symbol_that_shows_itself_inline_is_an_empty_slot() {
let mut s = session();
for name in ["Loop", "P", "Q"] {
circle_symbol(&mut s, name, 10.0);
}
for (name, shows) in [("Loop", "Loop"), ("P", "Q"), ("Q", "P")] {
let t = text(&mut s, "ab");
s.execute("text.insertInline", &json!({"id": t.0, "at": 1, "symbol": shows})).unwrap();
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
s.execute("symbol.update", &json!({"name": name})).unwrap();
}
let line = text(&mut s, "x");
s.execute("text.insertInline", &json!({"id": line.0, "at": 0, "symbol": "P"})).unwrap();
for _ in 0..2 {
for name in ["Loop", "P", "Q"] {
assert_eq!(inline_bounds(&art_text(&s, name)), None, "{name}");
assert_eq!(s.doc().unwrap().doc.inline_art_bounds(name), None, "{name}");
}
assert_eq!(inline_bounds(&obj(&s, line)), None);
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
}
let _ = s.execute("document.serialize", &json!({"format": "png"})).unwrap();
// Redefined without the inline graphic, Q is on no cycle, and neither is P.
redefine_as_wide_ellipse(&mut s, "Q");
let d = &s.doc().unwrap().doc;
assert!(d.inline_art_bounds("P").is_some());
assert_eq!(inline_bounds(&obj(&s, line)), d.symbol_art_bounds("P"));
assert_eq!(inline_bounds(&art_text(&s, "P")), d.symbol_art_bounds("Q"));
assert_eq!(d.inline_art_bounds("Loop"), None);
}
/// A list paragraph that starts with an inline graphic draws its bullet before the graphic, and
/// live SVG text writes the bullet.
#[test]