M7: inline symbols in text

Document symbols placed inline in a run of type (beyond Illustrator; like
InDesign inline anchored objects): TextRun.inline holds the symbol, scale and
shift; the run is one U+FFFC laid out as a glyph sized to the art, centred on
the cap height. text.insertInline and Type > Insert Inline Symbol; editing
keeps the graphic whole; canvas, SVG (<use>) and PDF draw the art; Create
Outlines turns it into a symbol instance.
This commit is contained in:
haven
2026-10-08 15:45:34 -07:00
parent 106abe639f
commit b843fe8af3
49 changed files with 1474 additions and 100 deletions
+2 -2
View File
@@ -18,10 +18,10 @@ use super::edit::selected_roots;
use super::*;
use crate::EngineError;
const SIZES_KEY: &str = "symbolSizes";
const SIZES_KEY: &str = vectorcraft_doc::SYMBOL_SIZES;
const CURRENT_SYMBOL: &str = "currentSymbol";
/// Half the instance box `Node::geometric_bounds` uses for symbol instances.
const HALF: f64 = 10.0;
const HALF: f64 = vectorcraft_doc::SYMBOL_HALF;
pub fn specs() -> Vec<CommandSpec> {
vec![
+109
View File
@@ -0,0 +1,109 @@
//! Inline graphics in text: document symbols placed in a run of text, flowing like a glyph
//! (beyond Illustrator; InDesign's inline anchored objects). See [`vectorcraft_doc::InlineArt`].
use serde_json::{Value, json};
use vectorcraft_doc::text::INLINE_MAX_SCALE;
use vectorcraft_doc::{Document, InlineArt, NodeId, NodeKind, TextRun};
use vectorcraft_text::edit;
use super::typecmd::refresh_bounds;
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"text.insertInline",
"Insert Inline Symbol",
["Type"],
None,
"{id?: text id (default: the text the Type tool edits), at?: byte offset (default: the Type tool's selection, which it replaces; clamped to the text), symbol?: symbol name (default: the Symbols panel's current symbol), scale?: art height ÷ font size = 1 (0 < scale ≤ 100), shift?: pt raise = 0} place a document symbol inline in the text as one character (U+FFFC) that flows like a glyph: its art is scaled to `scale` × the font size tall, its vertical centre on the middle of the cap height raised by `shift`. Typing, Backspace and Delete over it remove it whole → {id, caret}",
has_doc,
insert_inline
)]
}
/// 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).
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);
}
}
}
fn insert_inline(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.insertInline";
let tool = s.tool_options();
let editing = tool.get("editing").and_then(Value::as_u64).map(NodeId);
let id = match p.get("id") {
None | Some(Value::Null) => editing.ok_or_else(|| bad(C, "give `id` (no text is being edited)"))?,
Some(_) => id_param(p, "id").ok_or_else(|| bad(C, "`id` must be a node id"))?,
};
let doc = &s.doc()?.doc;
let NodeKind::Text(t) = &doc.node(id).ok_or(EngineError::NoNode(id))?.kind else {
return Err(bad(C, format!("node {} is not text", id.0)));
};
let len = edit::runs_len(&t.runs);
let from_tool = editing == Some(id);
let (a, b) = match p.get("at") {
None | Some(Value::Null) if from_tool => {
let g = |k: &str| tool.get(k).and_then(Value::as_u64).map_or(len, |v| (v as usize).min(len));
let (x, y) = (g("start"), g("end"));
(x.min(y), x.max(y))
}
None | Some(Value::Null) => (len, len),
Some(v) => {
let at = v.as_u64().ok_or_else(|| bad(C, "`at` must be a byte offset"))?.min(len as u64) as usize;
(at, at)
}
};
let symbol = match str_param(p, "symbol") {
Some(n) if doc.symbols.iter().any(|s| s.name == n) => n.to_string(),
Some(n) => return Err(bad(C, format!("no symbol named `{n}`"))),
None if p.get("symbol").is_some_and(|v| !v.is_null()) => return Err(bad(C, "`symbol` must be a symbol name")),
None => current_symbol(doc).ok_or_else(|| EngineError::Other("the document has no symbols".into()))?,
};
let scale = match p.get("scale") {
None | Some(Value::Null) => 1.0,
Some(v) => v
.as_f64()
.filter(|k| k.is_finite() && *k > 0.0 && *k <= INLINE_MAX_SCALE)
.ok_or_else(|| bad(C, "`scale` must be a number in (0, 100]"))?,
};
let shift = match p.get("shift") {
None | Some(Value::Null) => 0.0,
Some(v) => v.as_f64().filter(|k| k.is_finite() && k.abs() <= 1e5).ok_or_else(|| bad(C, "`shift` must be a number of points"))?,
};
// A typing session ends first: the insertion is a step of its own.
let typing = tool.get("typing").and_then(Value::as_bool) == Some(true);
if typing && from_tool {
s.set_tool_option("commitTyping", &Value::Bool(true));
s.commit_interaction()?;
}
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);
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)]);
refresh_bounds(t);
Ok(caret)
})?;
if from_tool {
s.set_tool_option("select", &json!({"start": caret, "end": caret}));
}
Ok(json!({"id": id.0, "caret": caret}))
}
/// The Symbols panel's current symbol, else the first one.
fn current_symbol(d: &Document) -> Option<String> {
d.unknown
.get("currentSymbol")
.and_then(Value::as_str)
.filter(|n| d.symbols.iter().any(|s| s.name == *n))
.map(str::to_string)
.or_else(|| d.symbols.first().map(|s| s.name.clone()))
}
+2
View File
@@ -29,6 +29,7 @@ pub(crate) mod gradient;
pub(crate) mod graph;
mod halftone;
pub mod help;
pub(crate) mod inline;
mod layer;
mod layerpanel;
pub mod links;
@@ -218,6 +219,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(colormgmt::specs());
v.extend(typemenu::specs());
v.extend(textedit::specs());
v.extend(inline::specs());
v.extend(textstyles::specs());
v.extend(fonts::specs());
v.extend(help::specs());
+1 -1
View File
@@ -468,7 +468,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
vertical: p.get("vertical").and_then(Value::as_bool).unwrap_or(false),
kind,
xf: Affine::IDENTITY,
runs: vec![TextRun { text, style }],
runs: vec![TextRun { text, style, inline: None }],
para: super::create::new_type_para(),
paras: Vec::new(),
area: Default::default(),
+1 -1
View File
@@ -219,7 +219,7 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
let first_para = paras.first().cloned().unwrap_or_default();
for id in &out {
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
t.runs = vec![TextRun { text: String::new(), style: style.clone() }];
t.runs = vec![TextRun { text: String::new(), style: style.clone(), inline: None }];
t.set_all_paras(first_para.clone());
refresh_bounds(t);
}
+11 -2
View File
@@ -216,7 +216,16 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
let NodeKind::Text(t) = &n.kind else { continue };
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let mut children = vec![];
for g in &lay.glyphs {
for (gi, g) in lay.glyphs.iter().enumerate() {
// Inline graphics become instances of their symbols, in place.
if let Some(ig) = lay.inlines.iter().find(|i| i.glyph == gi)
&& let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref())
{
let id = d.alloc_id();
let xf = t.xf * ig.xf * d.symbol_natural_xf(&art.symbol);
children.push(Arc::new(Node::new(id, NodeKind::SymbolInstance { symbol: art.symbol.clone(), xf })));
continue;
}
let path = PathData::from_bezpath(&g.outline).transformed(t.xf);
if path.is_empty() {
continue;
@@ -320,7 +329,7 @@ fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
let style = t.first_style();
// Every paragraph of the new text takes the first paragraph's attributes.
t.splice_paras(0, vectorcraft_text::edit::runs_len(&t.runs), &text);
t.runs = vec![vectorcraft_doc::TextRun { text: text.clone(), style }];
t.runs = vec![vectorcraft_doc::TextRun { text: text.clone(), style, inline: None }];
refresh_bounds(t);
}
Ok(())
+23 -6
View File
@@ -140,7 +140,8 @@ fn edit_runs<S: Default>(s: &mut Session, label: &str, ids: &[NodeId], f: impl F
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
let mut st = S::default();
let mut any = false;
for r in &mut t.runs {
// Inline graphics are art, not characters to rewrite.
for r in t.runs.iter_mut().filter(|r| r.inline.is_none()) {
let n = f(&r.text, &mut st);
if n != r.text {
r.text = n;
@@ -323,7 +324,18 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
}
} else if let Some((ri, bi)) = locate(&t.runs, b) {
t.splice_paras(b, b, "\n");
t.runs[ri].text.insert(bi, '\n');
// Never inside an inline graphic's run: at the end of the run before it, or a
// run of its own.
match t.runs.get(ri) {
Some(r) if r.inline.is_some() => match ri.checked_sub(1).and_then(|p| t.runs.get_mut(p)) {
Some(prev) if prev.inline.is_none() => prev.text.push('\n'),
_ => {
let style = t.runs.get(ri).map(|r| r.style.clone()).unwrap_or_default();
t.runs.insert(ri, TextRun::new("\n", style));
}
},
_ => t.runs[ri].text.insert(bi, '\n'),
}
}
}
// The point origin sits where the alignment anchors the first line.
@@ -481,7 +493,7 @@ pub(crate) fn fill_with_placeholder(t: &mut TextObject) {
}
_ => PLACEHOLDER.split(". ").next().unwrap_or(PLACEHOLDER).to_string() + ".",
};
t.runs = vec![TextRun { text, style }];
t.runs = vec![TextRun { text, style, inline: None }];
refresh_bounds(t);
}
@@ -531,8 +543,13 @@ fn insert_char(s: &mut Session, p: &Value) -> Result<Value> {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
match t.runs.last_mut() {
Some(r) => r.text.push_str(&text),
None => t.runs.push(TextRun { text: text.clone(), style: CharStyle::default() }),
Some(r) if r.inline.is_none() => r.text.push_str(&text),
// After an inline graphic: a run of its own, in its style.
Some(r) => {
let style = r.style.clone();
t.runs.push(TextRun::new(text.clone(), style));
}
None => t.runs.push(TextRun { text: text.clone(), style: CharStyle::default(), inline: None }),
}
refresh_bounds(t);
}
@@ -647,7 +664,7 @@ fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
fn strip_formatting(n: &mut vectorcraft_doc::Node) {
if let NodeKind::Text(t) = &mut n.kind {
let text = t.plain_text();
t.runs = vec![TextRun { text, style: CharStyle::default() }];
t.runs = vec![TextRun { text, style: CharStyle::default(), inline: None }];
t.set_all_paras(Default::default());
refresh_bounds(t);
}