diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 057a9df6a6..418e454b92 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -78,6 +78,7 @@ impl Document { units_from_file: _, index: _, guide_cache: _, + inline_cycles: _, } = self; *version == other.version && *title == other.title @@ -262,6 +263,7 @@ impl Document { units_from_file: false, index: Default::default(), guide_cache: Default::default(), + inline_cycles: Default::default(), }; let id = d.alloc_id(); d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0)))); diff --git a/crates/doc/src/inline.rs b/crates/doc/src/inline.rs index c5bf1972d8..a8e3955015 100644 --- a/crates/doc/src/inline.rs +++ b/crates/doc/src/inline.rs @@ -2,10 +2,13 @@ //! from the document's symbols, which the text layout needs to size and place it. use std::borrow::Cow; +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, RwLock, Weak}; use vectorcraft_geom::{Affine, Rect}; -use crate::{Document, NodeId, NodeKind, TextObject}; +use crate::{Document, Node, NodeId, NodeKind, Symbol, TextObject}; /// The document key under which the app records each symbol's natural size (`{name: [w, h]}`). pub const SYMBOL_SIZES: &str = "symbolSizes"; @@ -39,9 +42,27 @@ impl Document { usable(Some(self.symbol_natural_xf(name).transform_rect_bbox(b))) } + /// The art bounds an inline graphic of the symbol named `name` takes: [`Self::symbol_art_bounds`], + /// or `None` (an empty slot) when the symbol's art shows the symbol itself inline, directly or + /// through the inline graphics in other symbols' art, so that its size would depend on itself. + pub fn inline_art_bounds(&self, name: &str) -> Option { + if self.inline_cycles().contains(name) { None } else { self.symbol_art_bounds(name) } + } + + /// The names of the symbols that reach themselves through the inline graphics in symbol art, + /// found once while the symbols stay as they are. + fn inline_cycles(&self) -> Arc> { + if let Some(found) = self.inline_cycles.get(&self.symbols) { + return found; + } + let found = Arc::new(InlineGraph::of(self).cyclic); + self.inline_cycles.set(&self.symbols, &found); + found + } + /// Does `t` have inline graphics whose resolved bounds differ from the document's symbols? fn inline_stale(&self, t: &TextObject) -> bool { - t.runs.iter().filter_map(|r| r.inline.as_ref()).any(|a| a.bounds != self.symbol_art_bounds(&a.symbol)) + stale_by(t, &|n| self.inline_art_bounds(n)) } /// `t` with its inline graphics' art bounds resolved from this document's symbols (borrowed @@ -56,54 +77,421 @@ impl Document { Cow::Owned(t) } - /// Fill in the art bounds of `t`'s inline graphics; returns whether any changed (the text's - /// cached layout bounds are then cleared). + /// Fill in the art bounds of `t`'s inline graphics ([`Self::inline_art_bounds`]); returns + /// whether any changed (the text's cached layout is then cleared). pub fn resolve_text_inline(&self, t: &mut TextObject) -> bool { - let mut changed = false; - for r in &mut t.runs { - if let Some(a) = &mut r.inline { - let b = self.symbol_art_bounds(&a.symbol); - if a.bounds != b { - a.bounds = b; - changed = true; - } - } - } - if changed { - t.cached_bounds = None; - t.cached_ink = None; - } - changed + resolve_runs(t, &|n| self.inline_art_bounds(n)) } - /// Resolve the inline graphics of every text object in the layers (after opening a document - /// or changing its symbols). Returns the ids of the texts that changed: their cached layout - /// bounds were cleared and need recomputing. + /// Resolve the inline graphics of every text object in the document (after opening a document + /// or changing its symbols), without laying the type out again: see + /// [`Self::resolve_inline_art_with`]. pub fn resolve_inline_art(&mut self) -> Vec { - let mut stale = vec![]; - self.walk(|n| { - if let NodeKind::Text(t) = &n.kind - && t.runs.iter().any(|r| r.inline.is_some()) - && self.inline_stale(t) - { - stale.push(n.id); - } - }); + self.resolve_inline_art_with(&mut |_| {}) + } + + /// Resolve the inline graphics of every text object in the document: in the layers, the art + /// of symbols, graph designs and pattern tiles, and the opacity masks and live objects in + /// them. Each text whose inline graphics changed has its cached layout cleared and is passed + /// to `layout` to be laid out again. The symbols are resolved first, each after the symbols its + /// inline graphics show, so a symbol's art is measured with its type already laid out; a + /// symbol that shows itself inline ([`Self::inline_art_bounds`]) is shown as an empty slot. + /// Returns the ids of the texts in the layers that changed. + pub fn resolve_inline_art_with(&mut self, layout: &mut dyn FnMut(&mut TextObject)) -> Vec { let mut out = vec![]; - for id in stale { - let Some(mut t) = self.node(id).and_then(|n| match &n.kind { - NodeKind::Text(t) => Some(t.clone()), - _ => None, - }) else { - continue; - }; - if self.resolve_text_inline(&mut t) - && let Some(n) = self.node_mut(id) - { - n.kind = NodeKind::Text(t); - out.push(id); + let mut unused = vec![]; + let graph = InlineGraph::of(self); + let measured = Measured::default(); + for &i in &graph.order { + let mut art = self.symbols[i].art.clone(); + if resolve_tree(&mut art, &|n| measured.get(n, || graph.bounds(self, n)), layout, &mut unused) { + self.symbols[i].art = art; + } + } + for i in 0..self.layers.len() { + let mut layer = self.layers[i].clone(); + if resolve_tree(&mut layer, &|n| measured.get(n, || graph.bounds(self, n)), layout, &mut out) { + self.layers[i] = layer; + } + } + for i in 0..self.graph_designs.len() { + let mut art = self.graph_designs[i].art.clone(); + if resolve_tree(&mut art, &|n| measured.get(n, || graph.bounds(self, n)), layout, &mut unused) { + self.graph_designs[i].art = art; + } + } + for i in 0..self.patterns.len() { + for j in 0..self.patterns[i].art.len() { + let mut art = self.patterns[i].art[j].clone(); + if resolve_tree(&mut art, &|n| measured.get(n, || graph.bounds(self, n)), layout, &mut unused) { + self.patterns[i].art[j] = art; + } } } out } } + +/// Whether `t` has inline graphics whose bounds differ from what `bounds` gives their symbols. +fn stale_by(t: &TextObject, bounds: &dyn Fn(&str) -> Option) -> bool { + t.runs.iter().filter_map(|r| r.inline.as_ref()).any(|a| a.bounds != bounds(&a.symbol)) +} + +/// Gives `t`'s inline graphics the bounds `bounds` gives their symbols; returns whether any changed +/// (the text's cached layout is then cleared). +fn resolve_runs(t: &mut TextObject, bounds: &dyn Fn(&str) -> Option) -> bool { + let mut changed = false; + for a in t.runs.iter_mut().filter_map(|r| r.inline.as_mut()) { + let b = bounds(&a.symbol); + if a.bounds != b { + a.bounds = b; + changed = true; + } + } + if changed { + t.cached_bounds = None; + t.cached_baselines.clear(); + t.cached_ink = None; + } + changed +} + +/// Resolves the inline graphics of the type in tree `n` (opacity mask art included) with +/// `bounds`, passing each text that changed to `layout` and adding its id to `changed`; returns +/// whether the tree changed. Each node is visited once, and only the nodes on the way to changed +/// type are copied. +fn resolve_tree( + n: &mut Arc, + bounds: &dyn Fn(&str) -> Option, + layout: &mut dyn FnMut(&mut TextObject), + changed: &mut Vec, +) -> bool { + let mut copied = false; + if matches!(&n.kind, NodeKind::Text(t) if stale_by(t, bounds)) { + let node = Arc::make_mut(n); + if let NodeKind::Text(t) = &mut node.kind + && resolve_runs(t, bounds) + { + layout(t); + changed.push(node.id); + } + copied = true; + } + let mask = n.extras().mask.as_deref().and_then(|m| { + let mut art = m.art.clone(); + resolve_tree(&mut art, bounds, layout, changed).then_some(art) + }); + let mut kids = vec![]; + for (i, c) in n.children().into_iter().flatten().enumerate() { + let mut c = c.clone(); + if resolve_tree(&mut c, bounds, layout, changed) { + kids.push((i, c)); + } + } + if mask.is_none() && kids.is_empty() { + return copied; + } + let node = Arc::make_mut(n); + if let Some(art) = mask + && let Some(m) = node.extras_if_any().and_then(|e| e.mask.as_deref_mut()) + { + m.art = art; + } + if let Some(ch) = node.children_mut() { + for (i, c) in kids { + ch[i] = c; + } + } + true +} + +/// The art bounds of each symbol, measured once in a pass of [`Document::resolve_inline_art_with`]. +/// A symbol is first asked for after its own type is resolved (the pass goes in +/// [`InlineGraph::order`]), so its measurement stays current for the rest of the pass. +#[derive(Default)] +struct Measured(RefCell>>); + +impl Measured { + /// The bounds kept for `name`, or `measure`'s, kept for later. + fn get(&self, name: &str, measure: impl FnOnce() -> Option) -> Option { + if let Some(&b) = self.0.borrow().get(name) { + return b; + } + let b = measure(); + self.0.borrow_mut().insert(name.to_owned(), b); + b + } +} + +/// The names of the symbols the inline graphics in tree `n` show (opacity mask art included). +fn inline_symbols(n: &Node) -> Vec { + fn collect(n: &Node, out: &mut Vec) { + if let NodeKind::Text(t) = &n.kind { + out.extend(t.runs.iter().filter_map(|r| r.inline.as_ref().map(|a| a.symbol.clone()))); + } + if let Some(m) = n.extras().mask.as_deref() { + collect(&m.art, out); + } + for c in n.children().into_iter().flatten() { + collect(c, out); + } + } + let mut out = vec![]; + collect(n, &mut out); + out +} + +/// Each symbol name of `d` → the index of the first symbol of that name (the one a name finds). +fn symbol_index(d: &Document) -> HashMap<&str, usize> { + let mut index = HashMap::with_capacity(d.symbols.len()); + for (i, s) in d.symbols.iter().enumerate() { + index.entry(s.name.as_str()).or_insert(i); + } + index +} + +/// The symbols of a document as their inline graphics depend on each other. +struct InlineGraph { + /// Every symbol index, each after the symbols its art shows inline (except along a cycle). + order: Vec, + /// The names of the symbols that reach themselves through inline graphics. + cyclic: HashSet, +} + +impl InlineGraph { + /// The graph of `d`'s symbols: each symbol's art is walked once, and the order and cycles are + /// found in time linear in the symbols and their inline references (Tarjan's strongly + /// connected components, without recursion). + fn of(d: &Document) -> Self { + let index = symbol_index(d); + let edges: Vec> = d + .symbols + .iter() + .map(|s| { + let mut e: Vec = inline_symbols(&s.art).iter().filter_map(|n| index.get(n.as_str()).copied()).collect(); + e.sort_unstable(); + e.dedup(); + e + }) + .collect(); + let (order, cyclic) = components(&edges); + let cyclic = order.iter().filter(|&&i| cyclic[i]).map(|&i| d.symbols[i].name.clone()).collect(); + Self { order, cyclic } + } + + /// [`Document::inline_art_bounds`] with the cycles found in this graph. + fn bounds(&self, d: &Document, name: &str) -> Option { + if self.cyclic.contains(name) { None } else { d.symbol_art_bounds(name) } + } +} + +/// The nodes of the graph `edges` (node → the nodes it depends on), each after the nodes it +/// depends on except within a cycle, and whether each node is on a cycle (a component of more than +/// one node, or an edge to itself). +fn components(edges: &[Vec]) -> (Vec, Vec) { + const UNSEEN: usize = usize::MAX; + let n = edges.len(); + let (mut index, mut low, mut on_stack) = (vec![UNSEEN; n], vec![0; n], vec![false; n]); + let (mut stack, mut order, mut cyclic) = (vec![], Vec::with_capacity(n), vec![false; n]); + let mut next = 0; + // The nodes being visited, each with the position of its next edge. + let mut calls: Vec<(usize, usize)> = vec![]; + for root in 0..n { + if index[root] != UNSEEN { + continue; + } + index[root] = next; + low[root] = next; + next += 1; + stack.push(root); + on_stack[root] = true; + calls.push((root, 0)); + while let Some(top) = calls.last_mut() { + let v = top.0; + if let Some(&w) = edges[v].get(top.1) { + top.1 += 1; + if index[w] == UNSEEN { + index[w] = next; + low[w] = next; + next += 1; + stack.push(w); + on_stack[w] = true; + calls.push((w, 0)); + } else if on_stack[w] { + low[v] = low[v].min(index[w]); + } + continue; + } + calls.pop(); + if let Some(&(u, _)) = calls.last() { + low[u] = low[u].min(low[v]); + } + if low[v] == index[v] { + let start = order.len(); + while let Some(w) = stack.pop() { + on_stack[w] = false; + order.push(w); + if w == v { + break; + } + } + if order.len() - start > 1 || edges[v].contains(&v) { + for &w in &order[start..] { + cyclic[w] = true; + } + } + } + } + } + (order, cyclic) +} + +/// The symbols [`Document::inline_cycles`] last found, with the name of each symbol and a weak +/// pointer to its art. While a weak pointer to an art's allocation is held, nothing else can be at +/// that address, and the art can't be changed in place (`Arc::get_mut` refuses it and +/// `Arc::make_mut` moves it), so any edit inside a symbol's art, and any symbol added, removed, +/// renamed or replaced, gives symbols the cache does not match. Clones of a document share it. +#[derive(Default)] +pub(crate) struct InlineCycles(RwLock>>); + +/// The name and art of each symbol the cycles were found in, and the names of the symbols on one. +type Found = (Vec<(String, Weak)>, Arc>); + +impl InlineCycles { + /// The cached names, when they were found in `symbols`. + fn get(&self, symbols: &[Symbol]) -> Option>> { + let g = self.0.read().ok()?; + let (from, found) = &**g.as_ref()?; + let same = from.len() == symbols.len() + && from.iter().zip(symbols).all(|((name, art), s)| *name == s.name && std::ptr::eq(art.as_ptr(), Arc::as_ptr(&s.art))); + same.then(|| found.clone()) + } + + /// Keep `found`, found in `symbols`. + fn set(&self, symbols: &[Symbol], found: &Arc>) { + if let Ok(mut g) = self.0.write() { + *g = Some(Arc::new((symbols.iter().map(|s| (s.name.clone(), Arc::downgrade(&s.art))).collect(), found.clone()))); + } + } +} + +impl Clone for InlineCycles { + fn clone(&self) -> Self { + InlineCycles(RwLock::new(self.0.read().ok().and_then(|g| g.clone()))) + } +} + +/// A cache, not document content: two documents are equal whatever their caches hold. +impl PartialEq for InlineCycles { + fn eq(&self, _: &Self) -> bool { + true + } +} + +impl std::fmt::Debug for InlineCycles { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("InlineCycles") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The position of each node in `order`. + fn positions(order: &[usize]) -> Vec { + let mut at = vec![0; order.len()]; + for (p, &i) in order.iter().enumerate() { + at[i] = p; + } + at + } + + #[test] + fn components_put_dependencies_first_and_find_every_cycle_member() { + // 0 → 1 → 2 → 0 is a cycle, and 3, reached from 1, joins it through 2. + // 4 depends on 0 and is on no cycle; 5 shows itself. + let edges = vec![vec![1], vec![2, 3], vec![0], vec![2], vec![0], vec![5]]; + let (order, cyclic) = components(&edges); + let mut sorted = order.clone(); + sorted.sort_unstable(); + assert_eq!(sorted, [0, 1, 2, 3, 4, 5]); + assert_eq!(cyclic, [true, true, true, true, false, true]); + let at = positions(&order); + assert!(at[4] > at[0], "{order:?}"); + // A chain: each node after the one it depends on. + let (order, cyclic) = components(&[vec![], vec![0], vec![1]]); + assert_eq!(order, [0, 1, 2]); + assert!(cyclic.iter().all(|c| !c)); + } + + /// Type at the bottom of 2,000 nested groups is checked once, not once for each group above it, + /// and only the groups on the way to it are copied. + #[test] + fn resolving_deeply_nested_type_visits_each_node_once() { + use std::cell::Cell; + use vectorcraft_geom::Point; + + let mut t = TextObject::point(Point::ZERO, "Type", crate::CharStyle::default()); + t.runs.push(crate::TextRun::new("", crate::CharStyle::default())); + t.runs[1].inline = Some(crate::InlineArt { symbol: "S".into(), scale: 1.0, baseline_shift: 0.0, bounds: None }); + let depth = 2_000; + let mut tree = Arc::new(Node::new(NodeId(0), NodeKind::Text(Box::new(t)))); + for i in 1..=depth { + let side = Arc::new(Node::group(NodeId(depth + i), vec![])); + tree = Arc::new(Node::group(NodeId(i), vec![side, tree])); + } + let before = tree.clone(); + let asked = Cell::new(0); + let r = Rect::new(0.0, 0.0, 10.0, 20.0); + let bounds = |_: &str| { + asked.set(asked.get() + 1); + Some(r) + }; + let (mut laid_out, mut changed) = (0, vec![]); + assert!(resolve_tree(&mut tree, &bounds, &mut |_| laid_out += 1, &mut changed)); + assert!(asked.get() <= 2, "bounds asked {} times for one inline graphic", asked.get()); + assert_eq!(laid_out, 1); + assert_eq!(changed, [NodeId(0)]); + // The bottom of the new tree holds the resolved type; the side groups are shared. + let (mut new, mut old) = (&tree, &before); + while let (Some(n), Some(o)) = (new.children(), old.children()) { + assert!(Arc::ptr_eq(&n[0], &o[0])); + (new, old) = (&n[1], &o[1]); + } + let NodeKind::Text(t) = &new.kind else { panic!("the type is at the bottom") }; + assert_eq!(t.runs[1].inline.as_ref().and_then(|a| a.bounds), Some(r)); + // Nothing left to change: the tree is kept and the type is checked once. + asked.set(0); + let kept = tree.clone(); + assert!(!resolve_tree(&mut tree, &bounds, &mut |_| laid_out += 1, &mut changed)); + assert!(Arc::ptr_eq(&tree, &kept)); + assert_eq!(asked.get(), 1); + } + + #[test] + fn a_symbol_is_measured_once_in_a_pass() { + let measured = Measured::default(); + let count = std::cell::Cell::new(0); + let measure = |b: Option| { + count.set(count.get() + 1); + b + }; + let r = Some(Rect::new(0.0, 0.0, 1.0, 1.0)); + for _ in 0..3 { + assert_eq!(measured.get("A", || measure(r)), r); + assert_eq!(measured.get("Missing", || measure(None)), None); + } + assert_eq!(count.get(), 2); + } + + #[test] + fn components_handle_a_long_chain_without_recursion() { + let n = 200_000; + let edges: Vec> = (0..n).map(|i| if i + 1 < n { vec![i + 1] } else { vec![] }).collect(); + let (order, cyclic) = components(&edges); + assert_eq!(order.first(), Some(&(n - 1))); + assert_eq!(order.last(), Some(&0)); + assert!(cyclic.iter().all(|c| !c)); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 63a2676f77..e6986ad00e 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -362,6 +362,10 @@ pub struct Document { /// The ruler guides as [`Document::ruler_guides`] lists them: a cache, never saved. #[serde(skip)] guide_cache: document_guides::GuideCache, + /// The symbols that show themselves inline ([`Document::inline_art_bounds`]): a cache, never + /// saved. + #[serde(skip)] + inline_cycles: inline::InlineCycles, } fn ppi72() -> f64 { diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 005427ded8..7c8f664cf3 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -1136,37 +1136,6 @@ fn font_list(_: &mut Session, p: &Value) -> Result { } } -/// Gives the inline graphics in the type of [`art_texts_of`] the current bounds of their symbols' -/// art, and lays out again the type whose inline graphics changed. A symbol drawn inline in the -/// art of another symbol takes one more round, so there is at most one round per symbol. -fn resolve_art_inline(doc: &mut Document) { - for _ in 0..=doc.symbols.len() { - let names: BTreeSet = - art_texts_of(doc).iter().flat_map(|(_, t, _)| t.runs.iter().filter_map(|r| r.inline.as_ref().map(|a| a.symbol.clone()))).collect(); - let bounds: BTreeMap> = names - .into_iter() - .map(|name| { - let b = doc.symbol_art_bounds(&name); - (name, b) - }) - .collect(); - let stale = |t: &TextObject| t.runs.iter().any(|r| r.inline.as_ref().is_some_and(|a| bounds.get(&a.symbol).is_some_and(|b| *b != a.bounds))); - let mut changed = false; - edit_art_texts_of(doc, &stale, &mut |t| { - for a in t.runs.iter_mut().filter_map(|r| r.inline.as_mut()) { - if let Some(b) = bounds.get(&a.symbol) { - a.bounds = *b; - } - } - refresh_bounds(t); - changed = true; - }); - if !changed { - break; - } - } -} - pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result { let db = vectorcraft_text::FontDb::global(); #[cfg(not(target_arch = "wasm32"))] @@ -1188,8 +1157,7 @@ pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result { d.change_unsaved(|doc| { let doc = std::sync::Arc::make_mut(doc); edit_art_texts_of(doc, &|_| true, &mut refresh_bounds); - resolve_art_inline(doc); - doc.resolve_inline_art(); + doc.resolve_inline_art_with(&mut refresh_bounds); for id in texts { if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { refresh_bounds(t); diff --git a/crates/engine/src/cmd/inline.rs b/crates/engine/src/cmd/inline.rs index 14264fc809..db3e929952 100644 --- a/crates/engine/src/cmd/inline.rs +++ b/crates/engine/src/cmd/inline.rs @@ -22,16 +22,14 @@ pub fn specs() -> Vec { } /// Inline graphics follow their symbols: after an edit of a document that has (or had) symbols, -/// resolve the inline art of every text again (only texts whose art changed are touched). +/// resolve the inline art of every text again, in the layers and in the art of symbols, graph +/// designs, pattern tiles and opacity masks, and lay out again the texts whose art changed (a +/// symbol's type before the type that shows that symbol inline). pub(crate) fn refresh(before: &Document, d: &mut Document) { if before.symbols.is_empty() && d.symbols.is_empty() { return; } - for id in d.resolve_inline_art() { - if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { - refresh_bounds(t); - } - } + d.resolve_inline_art_with(&mut refresh_bounds); } fn insert_inline(s: &mut Session, p: &Value) -> Result { @@ -85,7 +83,7 @@ fn insert_inline(s: &mut Session, p: &Value) -> Result { } let caret = s.edit("Insert Inline Symbol", |d, _| { let mut art = InlineArt { scale, baseline_shift: shift, ..InlineArt::new(symbol.clone()) }; - art.bounds = d.symbol_art_bounds(&symbol); + art.bounds = d.inline_art_bounds(&symbol); let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; let style = edit::insertion_style(&t.runs, a, b); let caret = edit::replace_range_styled(&mut t.runs, a, b, &[TextRun::inline(art, style)]); diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 0d472ef81d..b9af6dadf5 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -270,6 +270,9 @@ impl Session { for tree in text_trees_mut(doc) { each_text_where(tree, &mut uncached, &mut cmd::typecmd::refresh_bounds_for_first_frame); } + // The symbols' type is measured now: the inline graphics showing those symbols take their + // art's measured size, a symbol's type before the type that shows that symbol inline. + doc.resolve_inline_art_with(&mut cmd::typecmd::refresh_bounds_for_first_frame); } /// Measure the type in open document `index` that `stale` picks (in its trees as @@ -287,12 +290,9 @@ impl Session { each_text_where(tree, &mut stale, &mut cmd::typecmd::refresh_bounds_for_first_frame); } // Inline graphics take their size from their symbols' art, whose type may just have - // been measured again: the text holding one whose size changed is measured again too. - if !doc.resolve_inline_art().is_empty() { - for tree in text_trees_mut(doc) { - each_text_where(tree, &mut |t| t.cached_bounds.is_none(), &mut cmd::typecmd::refresh_bounds_for_first_frame); - } - } + // been measured again: the text holding one whose size changed is measured again too, + // a symbol's type before the type that shows that symbol inline. + doc.resolve_inline_art_with(&mut cmd::typecmd::refresh_bounds_for_first_frame); }); } diff --git a/crates/engine/src/tests/inline.rs b/crates/engine/src/tests/inline.rs index 4b14b74be6..f7c72956ad 100644 --- a/crates/engine/src/tests/inline.rs +++ b/crates/engine/src/tests/inline.rs @@ -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 { + 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 { + 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] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c50a45b639..e10a4b6174 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1246,6 +1246,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Kerning across style runs:** characters that differ only in fill, stroke, underline, strikethrough, overprint, character style name, leading, baseline shift, rotation, Character Alignment, kerning, or tracking that keeps or drops ligatures alike are shaped together, so restyling one character keeps the font's kerning pairs with its neighbors. A character with manual kerning that follows one with automatic kerning keeps the font's kerning with it (unless All Caps or Small Caps sets it as two letters, as ß becomes SS), and its value replaces the font's kerning after it. A ligature does not form across a change of style, and a change of font, size or OpenType features still shapes the characters apart. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests/typo.rs`, `crates/text/src/tests/bidi.rs`. - **Manual kerning in right-to-left text:** a manual kerning value on a Hebrew or Arabic character changes the space between it and the next character in reading order, the one drawn to its left, under Auto and Optical kerning. The character before it keeps its place, and the caret between the pair stays beside the next character. The side the space goes on is VectorCraft's own rule. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html). Test: `rtl_manual_kerning_spaces_the_character_from_the_next_in_reading_order`. - **Manual kerning on inline graphics:** a manual kerning value on an inline graphic's character spaces it from the next character in reading order by the same distance as on a letter of its run's size, in left-to-right, right-to-left and vertical type; Auto and Optical kerning still apply no font pairs to it. This is VectorCraft's own rule. Test: `manual_kerning_on_an_inline_graphic_spaces_it_like_a_letter`. +- **Inline graphics in symbol art follow their symbols:** when a symbol changes, type with an inline graphic of it is laid out again wherever the type is: in the layers, in the art of other symbols, graph designs and pattern tiles, and in opacity masks. A symbol's type is laid out before the type that shows that symbol inline, so a symbol whose art holds an inline graphic, its instances and the inline graphics that show it take its new size. Opening and reverting a document and Refresh Font List resolve them in the same order. An inline graphic of a symbol whose art shows that symbol inline, directly or through other symbols, is an empty slot. This is VectorCraft's own rule. Tests: `inline_graphics_in_symbol_art_follow_their_symbol`, `inline_graphics_in_masks_and_pattern_tiles_follow_their_symbol`, `symbols_are_resolved_after_the_symbols_they_show_inline`, `a_symbol_that_shows_itself_inline_is_an_empty_slot` in `crates/engine/src/tests/inline.rs`. - **Recent font pickers (#3515):** The shared Character, Properties, Glyphs and Find Font family picker lists recently applied families first, once each, in most-recent order and limited by Preferences › Type › Number of Recent Fonts. Search and filters apply to both sections; unavailable families are omitted. Section headings cannot be selected or previewed. Confirmed character-range font edits update the same saved history; previews and failed edits do not. Requirement: [issue #3515](https://github.com/storytold/vectorcraft/issues/3515). - **Show samples on white (a VectorCraft addition):** Preferences › Type › Show samples on white (`fontSamplesOnWhite`, off by default) draws each family's sample in every font menu, and in Type → Font in the in-window menu bar, in dark type on a white area in every interface brightness. A highlighted row's color shows above and below the white area, and the names and the rest of the menu keep the interface's colors. The checkbox is dimmed while Enable in-menu font previews is off. Tests: `crates/ui-egui/src/tests/font_menu.rs`, `crates/ui-egui/src/font_menu/tests.rs`, `crates/ui-egui/src/prefs_dialog/tests.rs`. - **Find Font: Replace With Font From:** a dropdown under Replace With Font chooses the list the replacement font comes from: Document lists the families the document uses, once each; Recent lists the recently applied families that are installed (fonts applied with Find Font's Change and Change All included), newest first, as many as Preferences › Type › Number of Recent Fonts allows; System, the default, lists the installed families available in the font menu. The dialog opens on System with Source Sans 3 chosen, or the font menu's first family when Source Sans 3 is not installed. Choosing a list keeps the chosen font when the list has it and it is installed; otherwise Document and Recent choose the list's first installed font, and System chooses Source Sans 3 or the font menu's first family. Change and Change All are unavailable when the list has no installed font (Recent before any font was applied) and when the chosen family is not installed or is not in the shown list. Behavior reference: [Find and replace fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-and-replace-fonts.html) (public documentation). Tests: `crates/ui-egui/src/find_font.rs`.