mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 12:24:19 +00:00
Document::symbol_art_bounds measured a symbol's art without the document, so an instance of another symbol in that art counted as the 20 x 20 square. It now measures the art through SymbolBoxes, so a nested instance takes its own symbol's art, and an inline graphic of the outer symbol is sized by it and follows it when that symbol changes. - The inline resolve order also follows the instances that size a symbol's art, so a symbol is measured after the type in the art of the symbols it holds is laid out again. - A symbol whose art reaches itself through an instance and an inline graphic is an empty slot. Only instances that the measurement uses count, so one a clipping path hides adds no dependency. - A missing symbol, a symbol without art, a symbol that holds an instance of itself, and nesting 32 deep still measure as the square. - SymbolBoxes finds symbols through a name index built once, and the inline resolve pass and inline_art_bounds share one index, instead of scanning the symbol list for each lookup.
524 lines
24 KiB
Rust
524 lines
24 KiB
Rust
//! Inline graphics in text: `text.insertInline`, editing over them, undo, following their symbol.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use serde_json::json;
|
||
use vectorcraft_color::{Color, Paint};
|
||
use vectorcraft_doc::text::INLINE_CHAR;
|
||
use vectorcraft_doc::{Node, NodeId, NodeKind, TextObject};
|
||
use vectorcraft_geom::{Affine, Rect};
|
||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||
|
||
use crate::*;
|
||
|
||
fn session() -> Session {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||
s
|
||
}
|
||
|
||
/// A symbol `name` made of a circle `d` points across (drawn in code), its instance deleted.
|
||
fn circle_symbol(s: &mut Session, name: &str, d: f64) {
|
||
s.execute("shape.ellipse", &json!({"x": 600, "y": 500, "width": d, "height": d})).unwrap();
|
||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||
s.execute("symbol.new", &json!({"name": name})).unwrap();
|
||
s.execute("edit.clear", &json!({})).unwrap();
|
||
}
|
||
|
||
fn text(s: &mut Session, t: &str) -> NodeId {
|
||
let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": t, "size": 20})).unwrap();
|
||
NodeId(r["id"].as_u64().unwrap())
|
||
}
|
||
|
||
fn obj(s: &Session, id: NodeId) -> TextObject {
|
||
match &s.doc().unwrap().doc.node(id).unwrap().kind {
|
||
NodeKind::Text(t) => (**t).clone(),
|
||
_ => panic!("not text"),
|
||
}
|
||
}
|
||
|
||
fn inline_count(t: &TextObject) -> usize {
|
||
t.runs.iter().filter(|r| r.inline.is_some()).count()
|
||
}
|
||
|
||
#[test]
|
||
fn insert_inline_places_a_symbol_as_one_character() {
|
||
let mut s = session();
|
||
circle_symbol(&mut s, "Tap", 10.0);
|
||
let id = text(&mut s, "Tap: add");
|
||
let before = obj(&s, id).bounds().unwrap();
|
||
let r = s.execute("text.insertInline", &json!({"id": id.0, "at": 0, "symbol": "Tap", "scale": 1.5, "shift": 1})).unwrap();
|
||
assert_eq!(r, json!({"id": id.0, "caret": INLINE_CHAR.len_utf8()}));
|
||
let t = obj(&s, id);
|
||
assert_eq!(t.plain_text(), format!("{INLINE_CHAR}Tap: add"));
|
||
let art = t.runs[0].inline.as_ref().unwrap();
|
||
assert_eq!((art.symbol.as_str(), art.scale, art.baseline_shift), ("Tap", 1.5, 1.0));
|
||
// Resolved from the symbol (a 10 pt circle), and the text grew by the art (30 pt wide).
|
||
let b = art.bounds.unwrap();
|
||
assert!((b.width() - 10.0).abs() < 0.5 && (b.height() - 10.0).abs() < 0.5, "{b:?}");
|
||
let after = t.bounds().unwrap();
|
||
assert!((after.width() - before.width() - 30.0).abs() < 1.0, "{before:?} → {after:?}");
|
||
// It takes the style of the text it sits in.
|
||
assert_eq!(t.runs[0].style.size, 20.0);
|
||
// text.getRange reports it.
|
||
let g = s.execute("text.getRange", &json!({"id": id.0, "start": 0, "end": 3})).unwrap();
|
||
assert_eq!(g["runs"][0]["inline"], json!({"symbol": "Tap", "scale": 1.5, "baseline_shift": 1.0}));
|
||
assert_eq!(g["text"], json!(INLINE_CHAR.to_string()));
|
||
// One undo step.
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(inline_count(&obj(&s, id)), 0);
|
||
s.execute("edit.redo", &json!({})).unwrap();
|
||
assert_eq!(inline_count(&obj(&s, id)), 1);
|
||
// Deleting any part of its character deletes it whole.
|
||
s.execute("text.editRange", &json!({"id": id.0, "start": 1, "end": 3})).unwrap();
|
||
let t = obj(&s, id);
|
||
assert_eq!(t.plain_text(), "Tap: add");
|
||
assert_eq!(inline_count(&t), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn insert_inline_rejects_junk() {
|
||
let mut s = session();
|
||
circle_symbol(&mut s, "Dot", 8.0);
|
||
let id = text(&mut s, "abc");
|
||
let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||
for p in [
|
||
json!({"id": id.0, "symbol": "Nope"}),
|
||
json!({"id": id.0, "symbol": 7}),
|
||
json!({"id": id.0, "symbol": "Dot", "scale": 0}),
|
||
json!({"id": id.0, "symbol": "Dot", "scale": -1}),
|
||
json!({"id": id.0, "symbol": "Dot", "scale": 1e9}),
|
||
json!({"id": id.0, "symbol": "Dot", "shift": "up"}),
|
||
json!({"id": id.0, "symbol": "Dot", "at": -3}),
|
||
json!({"id": "x", "symbol": "Dot"}),
|
||
json!({"id": rect["id"], "symbol": "Dot"}),
|
||
json!({"id": 999999, "symbol": "Dot"}),
|
||
json!({"symbol": "Dot"}),
|
||
] {
|
||
assert!(s.execute("text.insertInline", &p).is_err(), "{p}");
|
||
}
|
||
assert_eq!(inline_count(&obj(&s, id)), 0);
|
||
// Offsets past the end are clamped; mid-character offsets land on a boundary.
|
||
let r = s.execute("text.insertInline", &json!({"id": id.0, "at": 99, "symbol": "Dot"})).unwrap();
|
||
assert_eq!(r["caret"], json!(3 + INLINE_CHAR.len_utf8()));
|
||
s.execute("text.insertInline", &json!({"id": id.0, "at": 4, "symbol": "Dot"})).unwrap();
|
||
let t = obj(&s, id);
|
||
assert_eq!(t.plain_text(), format!("abc{INLINE_CHAR}{INLINE_CHAR}"));
|
||
assert_eq!(inline_count(&t), 2);
|
||
// A document without symbols has nothing to insert.
|
||
let mut s = session();
|
||
let id = text(&mut s, "abc");
|
||
assert!(s.execute("text.insertInline", &json!({"id": id.0})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn type_tool_inserts_at_its_caret_and_deletes_the_graphic_whole() {
|
||
let mut s = session();
|
||
circle_symbol(&mut s, "Mana", 10.0);
|
||
s.execute("symbol.setCurrent", &json!({"name": "Mana"})).unwrap();
|
||
let v = ViewInfo::default();
|
||
s.select_tool("type", v).unwrap();
|
||
for k in [PointerKind::Down, PointerKind::Up] {
|
||
s.pointer(&PointerEvent::new(k, 50.0, 300.0), v).unwrap();
|
||
}
|
||
let id = s.doc().unwrap().selection.objects[0];
|
||
s.tool_text("Pay ", v).unwrap();
|
||
// The menu item: no params, inside a typing session.
|
||
let r = s.execute("text.insertInline", &json!({})).unwrap();
|
||
assert_eq!(r["id"], json!(id.0));
|
||
assert_eq!(s.tool_options()["caret"], json!(4 + INLINE_CHAR.len_utf8()));
|
||
s.tool_text(" now", v).unwrap();
|
||
let t = obj(&s, id);
|
||
assert_eq!(t.plain_text(), format!("Pay {INLINE_CHAR} now"));
|
||
assert_eq!(inline_count(&t), 1);
|
||
assert_eq!(t.runs.iter().find(|r| r.inline.is_some()).unwrap().inline.as_ref().unwrap().symbol, "Mana");
|
||
// Back over " now", then the graphic: one Backspace removes it whole.
|
||
for _ in 0..5 {
|
||
s.tool_key(ToolKey::Backspace, Mods::default(), v).unwrap();
|
||
}
|
||
let t = obj(&s, id);
|
||
assert_eq!(t.plain_text(), "Pay ");
|
||
assert_eq!(inline_count(&t), 0);
|
||
// Undo brings it back.
|
||
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(inline_count(&obj(&s, id)), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn inline_graphics_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();
|
||
let w0 = obj(&s, id).bounds().unwrap().width();
|
||
// Redefine the symbol as a 2:1 ellipse: the graphic (20 pt tall) widens to 40 pt.
|
||
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": "Dot"})).unwrap();
|
||
let t = obj(&s, id);
|
||
let b = t.runs[1].inline.as_ref().unwrap().bounds.unwrap();
|
||
assert!((b.width() / b.height() - 2.0).abs() < 0.1, "{b:?}");
|
||
let w1 = t.bounds().unwrap().width();
|
||
assert!((w1 - w0 - 20.0).abs() < 1.5, "{w0} → {w1}");
|
||
// Deleting the symbol leaves an empty slot (nothing drawn), and doesn't crash rendering.
|
||
s.execute("symbol.delete", &json!({"name": "Dot"})).unwrap();
|
||
let t = obj(&s, id);
|
||
assert!(t.runs[1].inline.as_ref().unwrap().bounds.is_none());
|
||
let _ = s.execute("document.serialize", &json!({"format": "png"})).unwrap();
|
||
let _ = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
|
||
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 rectangle `r` filled black, without a stroke.
|
||
fn filled_rect(d: &mut vectorcraft_doc::Document, r: Rect) -> Arc<Node> {
|
||
let look = vectorcraft_doc::Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
|
||
Arc::new(Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(r), look))
|
||
}
|
||
|
||
/// An instance of symbol `name` placed by `xf`.
|
||
fn symbol_instance(d: &mut vectorcraft_doc::Document, name: &str, xf: Affine) -> Arc<Node> {
|
||
Arc::new(Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: name.into(), xf }))
|
||
}
|
||
|
||
/// Gives symbol `name` the art `art` in a group, adding the symbol when there is none of that name.
|
||
fn set_symbol_art(d: &mut vectorcraft_doc::Document, name: &str, art: Vec<Arc<Node>>) {
|
||
let art = Arc::new(Node::group(d.alloc_id(), art));
|
||
match d.symbols.iter_mut().find(|x| x.name == name) {
|
||
Some(x) => x.art = art,
|
||
None => d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: name.into(), art }),
|
||
}
|
||
}
|
||
|
||
/// An inline graphic of a symbol whose art holds an instance of another symbol is sized by that
|
||
/// symbol's art, and follows it when it changes, through undo, redo and a saved copy.
|
||
#[test]
|
||
fn an_inline_graphic_follows_the_art_of_a_symbol_nested_in_its_symbol() {
|
||
let mut s = session();
|
||
s.edit("Symbols", |d, _| {
|
||
let far = filled_rect(d, Rect::new(100.0, 0.0, 160.0, 20.0));
|
||
set_symbol_art(d, "Far", vec![far]);
|
||
let inner = symbol_instance(d, "Far", Affine::IDENTITY);
|
||
set_symbol_art(d, "Holder", vec![inner]);
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
let id = text(&mut s, "ab");
|
||
let w0 = obj(&s, id).bounds().unwrap().width();
|
||
s.execute("text.insertInline", &json!({"id": id.0, "at": 1, "symbol": "Holder", "scale": 1})).unwrap();
|
||
let wide = Some(Rect::new(100.0, 0.0, 160.0, 20.0));
|
||
let t = obj(&s, id);
|
||
assert_eq!(inline_bounds(&t), wide);
|
||
// 20 pt tall at 20 pt type and 3 times as wide: the text grows by 60 pt.
|
||
let w1 = t.bounds().unwrap().width();
|
||
assert!((w1 - w0 - 60.0).abs() < 1.5, "{w0} → {w1}");
|
||
// Far redefined as a 10 × 40 rectangle: the graphic is 20 pt tall and 5 pt wide.
|
||
s.edit("Redefine Far", |d, _| {
|
||
let tall = filled_rect(d, Rect::new(0.0, 0.0, 10.0, 40.0));
|
||
set_symbol_art(d, "Far", vec![tall]);
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
let tall = Some(Rect::new(0.0, 0.0, 10.0, 40.0));
|
||
let t = obj(&s, id);
|
||
assert_eq!(inline_bounds(&t), tall);
|
||
assert_eq!(s.doc().unwrap().doc.symbol_art_bounds("Holder"), tall);
|
||
let w2 = t.bounds().unwrap().width();
|
||
assert!((w2 - w0 - 5.0).abs() < 1.5, "{w0} → {w2}");
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(inline_bounds(&obj(&s, id)), wide);
|
||
assert!((obj(&s, id).bounds().unwrap().width() - w1).abs() < 1e-6);
|
||
s.execute("edit.redo", &json!({})).unwrap();
|
||
assert_eq!(inline_bounds(&obj(&s, id)), tall);
|
||
// 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);
|
||
assert_eq!(inline_bounds(&obj(&s2, id)), tall);
|
||
assert!((obj(&s2, id).bounds().unwrap().width() - w2).abs() < 1e-6);
|
||
}
|
||
|
||
/// Symbol C shows Holder inline, Holder holds an instance of B, and B shows A inline: with the
|
||
/// symbols listed C, Holder, B, A, a change to A reaches C through B's and Holder's new sizes.
|
||
#[test]
|
||
fn symbols_are_resolved_after_the_symbols_their_instances_show() {
|
||
let mut s = session();
|
||
circle_symbol(&mut s, "A", 10.0);
|
||
let t = text(&mut s, "z");
|
||
s.execute("text.insertInline", &json!({"id": t.0, "at": 0, "symbol": "A"})).unwrap();
|
||
s.execute("symbol.new", &json!({"name": "B", "ids": [t.0]})).unwrap();
|
||
s.execute("edit.clear", &json!({})).unwrap();
|
||
s.edit("Holder", |d, _| {
|
||
let inner = symbol_instance(d, "B", Affine::IDENTITY);
|
||
set_symbol_art(d, "Holder", vec![inner]);
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
let t = text(&mut s, "z");
|
||
s.execute("text.insertInline", &json!({"id": t.0, "at": 0, "symbol": "Holder"})).unwrap();
|
||
s.execute("symbol.new", &json!({"name": "C", "ids": [t.0]})).unwrap();
|
||
s.execute("edit.clear", &json!({})).unwrap();
|
||
s.edit("Reverse the symbols", |d, _| {
|
||
d.symbols.reverse();
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
let h0 = s.doc().unwrap().doc.symbol_art_bounds("Holder");
|
||
redefine_as_wide_ellipse(&mut s, "A");
|
||
let d = &s.doc().unwrap().doc;
|
||
assert_ne!(d.symbol_art_bounds("Holder"), h0);
|
||
assert_eq!(inline_bounds(&art_text(&s, "C")), d.symbol_art_bounds("Holder"));
|
||
}
|
||
|
||
/// Symbol A holds an instance of B, and B's type shows A inline: the size of each would depend on
|
||
/// itself, so both are empty slots, and stay so through later edits.
|
||
#[test]
|
||
fn a_symbol_that_shows_itself_through_an_instance_is_an_empty_slot() {
|
||
let mut s = session();
|
||
circle_symbol(&mut s, "A", 10.0);
|
||
let t = text(&mut s, "ab");
|
||
s.execute("text.insertInline", &json!({"id": t.0, "at": 1, "symbol": "A"})).unwrap();
|
||
s.execute("symbol.new", &json!({"name": "B", "ids": [t.0]})).unwrap();
|
||
s.execute("edit.clear", &json!({})).unwrap();
|
||
s.edit("A holds B", |d, _| {
|
||
let inner = symbol_instance(d, "B", Affine::IDENTITY);
|
||
set_symbol_art(d, "A", vec![inner]);
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
let line = text(&mut s, "x");
|
||
s.execute("text.insertInline", &json!({"id": line.0, "at": 0, "symbol": "A"})).unwrap();
|
||
for _ in 0..2 {
|
||
for name in ["A", "B"] {
|
||
assert_eq!(s.doc().unwrap().doc.inline_art_bounds(name), None, "{name}");
|
||
}
|
||
assert_eq!(inline_bounds(&art_text(&s, "B")), None);
|
||
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 instance, A is on no cycle.
|
||
redefine_as_wide_ellipse(&mut s, "A");
|
||
let d = &s.doc().unwrap().doc;
|
||
assert!(d.inline_art_bounds("A").is_some());
|
||
assert_eq!(inline_bounds(&art_text(&s, "B")), d.symbol_art_bounds("A"));
|
||
assert_eq!(inline_bounds(&obj(&s, line)), d.symbol_art_bounds("A"));
|
||
}
|
||
|
||
/// A list paragraph that starts with an inline graphic draws its bullet before the graphic, and
|
||
/// live SVG text writes the bullet.
|
||
#[test]
|
||
fn a_list_paragraph_starting_with_an_inline_graphic_keeps_its_bullet() {
|
||
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": 0, "symbol": "Dot", "scale": 1})).unwrap();
|
||
s.execute("text.setFormat", &json!({"ids": [id.0], "list": "bullet"})).unwrap();
|
||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &obj(&s, id));
|
||
assert_eq!(lay.markers.len(), 1);
|
||
let bullet = &lay.glyphs[lay.markers[0].glyphs.start];
|
||
assert!(bullet.origin.x < lay.inlines[0].bounds.x0, "the bullet comes before the graphic");
|
||
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string();
|
||
assert!(svg.contains('\u{2022}'), "{svg}");
|
||
}
|
||
|
||
#[test]
|
||
fn saved_documents_keep_inline_graphics() {
|
||
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": 2})).unwrap();
|
||
let doc = (*s.doc().unwrap().doc).clone();
|
||
let bytes = vectorcraft_format::save(&doc, false);
|
||
assert!(String::from_utf8_lossy(&bytes).contains("\"inline\""));
|
||
let mut back = vectorcraft_format::load(&bytes).unwrap();
|
||
// Bounds aren't saved: opening resolves them.
|
||
let mut s2 = Session::new();
|
||
back.title = "copy".into();
|
||
s2.add_document(back, None);
|
||
let t = obj(&s2, id);
|
||
let art = t.runs[1].inline.as_ref().unwrap();
|
||
assert_eq!((art.symbol.as_str(), art.scale), ("Dot", 2.0));
|
||
assert!(art.bounds.is_some());
|
||
assert_eq!(t.cached_bounds, obj(&s, id).cached_bounds);
|
||
}
|
||
|
||
#[test]
|
||
fn create_outlines_turns_inline_graphics_into_symbol_instances() {
|
||
let mut s = session();
|
||
circle_symbol(&mut s, "Dot", 10.0);
|
||
let id = text(&mut s, "a b");
|
||
s.execute("text.insertInline", &json!({"id": id.0, "at": 2, "symbol": "Dot"})).unwrap();
|
||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||
let r = s.execute("type.createOutlines", &json!({})).unwrap();
|
||
let g = NodeId(r["ids"][0].as_u64().unwrap());
|
||
let d = &s.doc().unwrap().doc;
|
||
let kids = d.node(g).unwrap().children().unwrap();
|
||
let inst = kids.iter().filter(|c| matches!(&c.kind, NodeKind::SymbolInstance { symbol, .. } if symbol == "Dot")).count();
|
||
assert_eq!(inst, 1);
|
||
// Between the outlines of `a` and `b`.
|
||
let pos = kids.iter().position(|c| matches!(c.kind, NodeKind::SymbolInstance { .. })).unwrap();
|
||
assert!(pos > 0 && pos + 1 < kids.len(), "{pos} of {}", kids.len());
|
||
}
|