Files
vectorcraft/crates/engine/src/cmd/textwrap.rs
T
arhammusheer 5beac4f122 #1244: smaller objects: Node 352 -> 152 bytes, NodeKind 200 -> 80, and a cheaper undo estimate
Rarely set fields move into one optional boxed NodeExtras, and large NodeKind payloads (images,
live shapes, blends, envelopes) are boxed. Every object, and every undo step that copies it,
shrinks: a 50k-path move keeps ~24.5 MB per step instead of ~40 MB. Files stay byte-identical
(Node keeps its flat JSON through a borrowed Serialize and a from-wire Deserialize, no flatten),
and loading is slightly faster. Undo and redo reuse their step's size estimate instead of
walking the document again (8.3 -> 5.6 ms for a 50k-path move).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc

# Conflicts:
#	crates/engine/src/cmd/graph.rs
#	crates/svg/src/export.rs
2026-10-12 11:02:32 +07:00

304 lines
13 KiB
Rust

//! Object → Text Wrap: area type flows around wrap objects stacked above it in the same layer.
//!
//! A wrap object carries [`TextWrap`] options (`Node::wrap`). After every edit [`refresh`]
//! resolves, for each area text object, the outlines of the wrap objects above it into
//! `TextObject::wrap` (text space); the layout engine subtracts them from each line's span.
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, TextKind, TextWrap, WrapShape};
use vectorcraft_geom::{PathData, shapes};
use super::typecmd::refresh_bounds;
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"object.textWrap.make",
"Make",
["Object", "Text Wrap"],
None,
"{offset?: pt (6), invert?: bool, ids?} area type below the selected objects (same layer) wraps around them → {ids}",
has_selection,
make
),
cmd!("object.textWrap.release", "Release", ["Object", "Text Wrap"], None, "{ids?} → {ids}", has_selection, release),
cmd!(
"object.textWrap.options",
"Text Wrap Options…",
["Object", "Text Wrap"],
None,
"{offset?: pt, invert?: bool, ids?} set the options of the selected wrap objects; no options → the current {offset, invert}",
has_selection,
options
),
]
}
fn wrap_param(p: &Value, base: TextWrap) -> TextWrap {
TextWrap { offset: f64_or(p, "offset", base.offset).clamp(-1000.0, 1000.0), invert: bool_or(p, "invert", base.invert) }
}
fn make(s: &mut Session, p: &Value) -> Result<Value> {
let ids = checked_ids_or_selection(s, p, "object.textWrap.make")?;
let w = wrap_param(p, TextWrap::default());
s.edit("Make Text Wrap", |d, _| {
for id in &ids {
let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?;
if matches!(n.kind, NodeKind::Layer { .. }) {
return Err(EngineError::Other("layers can't be wrap objects".into()));
}
n.extras_mut().wrap = Some(w);
}
Ok(())
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
fn release(s: &mut Session, p: &Value) -> Result<Value> {
let ids: Vec<NodeId> = {
let d = &s.doc()?.doc;
checked_ids_or_selection(s, p, "object.textWrap.release")?
.into_iter()
.filter(|i| d.node(*i).is_some_and(|n| n.extras().wrap.is_some()))
.collect()
};
if ids.is_empty() {
return Err(EngineError::Other("Text Wrap: select a wrap object".into()));
}
s.edit("Release Text Wrap", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
n.extras_mut().wrap = None;
}
}
Ok(())
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
fn options(s: &mut Session, p: &Value) -> Result<Value> {
let ids = checked_ids_or_selection(s, p, "object.textWrap.options")?;
let current = {
let d = &s.doc()?.doc;
ids.iter().find_map(|i| d.node(*i).and_then(|n| n.extras().wrap)).unwrap_or_default()
};
if p.get("offset").is_none() && p.get("invert").is_none() {
return Ok(json!({ "offset": current.offset, "invert": current.invert }));
}
let w = wrap_param(p, current);
s.edit("Text Wrap Options", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id)
&& !matches!(n.kind, NodeKind::Layer { .. })
{
n.extras_mut().wrap = Some(w);
}
}
Ok(())
})?;
Ok(json!({ "offset": w.offset, "invert": w.invert }))
}
/// Outline of a wrap object in document coordinates: its paths, or its bounds for other art.
fn outline(n: &Node, out: &mut PathData) {
match &n.kind {
NodeKind::Path { path, guide: false, .. } => {
out.subpaths.extend(path.subpaths.iter().filter(|sp| sp.closed || sp.anchors.len() > 2).cloned())
}
NodeKind::Compound { children, .. } | NodeKind::Group { children, clip: false } => {
for c in children.iter().filter(|c| c.visible) {
outline(c, out);
}
}
// A clip group wraps by its clipping path.
NodeKind::Group { children, clip: true } => {
if let Some(c) = children.first() {
outline(c, out);
}
}
_ => {
if let Some(b) = n.geometric_bounds() {
out.subpaths.extend(shapes::rectangle(b).subpaths);
}
}
}
}
enum Item<'a> {
Wrap(&'a Node),
Text(NodeId),
}
fn collect<'a>(n: &'a Node, out: &mut Vec<Item<'a>>) {
if !n.visible {
return;
}
if n.extras().wrap.is_some() && !matches!(n.kind, NodeKind::Layer { .. }) {
out.push(Item::Wrap(n));
return;
}
match &n.kind {
NodeKind::Text(t) if matches!(t.kind, TextKind::Area { .. }) => out.push(Item::Text(n.id)),
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
for c in children {
collect(c, out);
}
}
_ => {}
}
}
fn any_wrap_state(n: &Node) -> bool {
n.extras().wrap.is_some()
|| matches!(&n.kind, NodeKind::Text(t) if !t.wrap.is_empty())
|| n.children().is_some_and(|ch| ch.iter().any(|c| any_wrap_state(c)))
}
/// Re-resolve the wrap shapes of every area text object (called after each edit).
pub(crate) fn refresh(doc: &mut Document) {
if !doc.layers.iter().any(|l| any_wrap_state(l)) {
return;
}
let mut updates: Vec<(NodeId, Vec<WrapShape>)> = vec![];
for layer in &doc.layers {
let mut items = vec![];
collect(layer, &mut items);
for (i, it) in items.iter().enumerate() {
let Item::Text(id) = it else { continue };
let Some(NodeKind::Text(t)) = doc.node(*id).map(|n| &n.kind) else { continue };
let inv = t.xf.inverse();
let shapes: Vec<WrapShape> = items[i + 1..]
.iter()
.filter_map(|w| match w {
Item::Wrap(n) => {
let mut pd = PathData::default();
outline(n, &mut pd);
(!pd.is_empty()).then(|| {
pd.transform(inv);
WrapShape { path: pd, wrap: n.extras().wrap.unwrap_or_default() }
})
}
Item::Text(_) => None,
})
.collect();
if shapes != t.wrap {
updates.push((*id, shapes));
}
}
}
for (id, shapes) in updates {
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
t.wrap = shapes;
refresh_bounds(t);
}
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use vectorcraft_doc::NodeKind;
use crate::{NodeId, Session};
fn lines(s: &Session, id: u64) -> Vec<(f64, f64)> {
let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| n.kind.clone()) else { panic!() };
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).lines.iter().map(|l| l.avail).collect()
}
/// Invalid explicit ids must not turn a requested multi-object edit into a partial one.
#[test]
fn text_wrap_commands_reject_malformed_target_lists_without_editing() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 80, "height": 80})).unwrap()["id"].as_u64().unwrap();
let other = s.execute("shape.rectangle", &json!({"x": 120, "y": 10, "width": 80, "height": 80})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
let before = s.doc().unwrap().history.undo.len();
for command in ["object.textWrap.make", "object.textWrap.release", "object.textWrap.options"] {
for ids in [json!([id, "invalid"]), json!([id, 99999]), json!({"not": "an array"})] {
let e = s.execute(command, &json!({"ids": ids, "offset": 12})).unwrap_err().to_string();
assert!(e.contains("id") || e.contains("array"), "{command}: {e}");
assert_eq!(s.doc().unwrap().history.undo.len(), before, "{command} added history on error");
assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().wrap.is_none());
assert!(s.doc().unwrap().doc.node(NodeId(other)).unwrap().extras().wrap.is_none());
}
}
s.execute("object.textWrap.make", &json!({"ids": [id]})).unwrap();
assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().wrap.is_some());
assert!(s.doc().unwrap().doc.node(NodeId(other)).unwrap().extras().wrap.is_none());
s.execute("object.textWrap.options", &json!({"ids": [id], "offset": 12})).unwrap();
assert_eq!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().wrap.unwrap().offset, 12.0);
s.execute("object.textWrap.release", &json!({"ids": [id]})).unwrap();
assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().wrap.is_none());
}
#[test]
fn area_type_wraps_around_objects_above_and_follows_edits() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap();
let text = "word ".repeat(200);
let t = s.execute("text.create", &json!({"x": 0, "y": 0, "text": text, "size": 12, "area": {"width": 400, "height": 400}})).unwrap()["id"]
.as_u64()
.unwrap();
let plain = lines(&s, t);
assert!(plain.iter().all(|l| l.1 > 300.0));
// A square over the left half of the frame: lines next to it start right of it (+6 pt offset).
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 50, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
s.execute("object.textWrap.make", &json!({})).unwrap();
let wrapped = lines(&s, t);
assert!(wrapped.iter().any(|l| l.0 >= 205.0), "{wrapped:?}");
assert!(wrapped.iter().filter(|l| l.0 < 1.0).count() >= 2);
// Moving the wrap object away re-flows the text.
s.execute("object.move", &json!({"dx": 500, "dy": 0})).unwrap();
assert!(lines(&s, t).iter().all(|l| l.0 < 1.0));
s.execute("object.move", &json!({"dx": -500, "dy": 0})).unwrap();
// Options round-trip; Invert Wrap keeps text inside the object only.
assert_eq!(s.execute("object.textWrap.options", &json!({})).unwrap()["offset"], json!(6.0));
s.execute("object.textWrap.options", &json!({"invert": true, "offset": 0})).unwrap();
let inside = lines(&s, t);
assert!(inside.iter().all(|l| l.1 <= 200.0 + 1e-6), "{inside:?}");
// Release: back to the plain layout.
s.execute("object.textWrap.release", &json!({})).unwrap();
assert_eq!(lines(&s, t), plain);
// Objects below the text don't wrap it.
let below = s.execute("shape.rectangle", &json!({"x": 0, "y": 50, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [below]})).unwrap();
s.execute("object.textWrap.make", &json!({})).unwrap();
s.execute("object.arrange.sendToBack", &json!({})).unwrap();
assert_eq!(lines(&s, t), plain);
}
#[test]
fn text_flows_on_both_sides_of_a_centred_object() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap();
let t = s
.execute("text.create", &json!({"x": 0, "y": 0, "text": "word ".repeat(300), "size": 12, "area": {"width": 400, "height": 400}}))
.unwrap()["id"]
.as_u64()
.unwrap();
let r = s.execute("shape.ellipse", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
s.execute("object.textWrap.make", &json!({})).unwrap();
let Some(NodeKind::Text(tx)) = s.doc().unwrap().doc.node(NodeId(t)).map(|n| n.kind.clone()) else { panic!() };
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tx);
// Beside the circle each line band holds two lines: left of it and right of it.
let beside: Vec<_> = lay.lines.iter().filter(|l| l.baseline > 120.0 && l.baseline < 180.0).collect();
assert!(
beside.iter().any(|l| l.avail.1 < 150.0) && beside.iter().any(|l| l.avail.0 > 250.0),
"{:?}",
beside.iter().map(|l| l.avail).collect::<Vec<_>>()
);
let left = beside.iter().find(|l| l.avail.1 < 150.0).unwrap();
assert!(beside.iter().any(|l| l.avail.0 > 250.0 && (l.baseline - left.baseline).abs() < 1e-9), "same baseline on both sides");
// Reading order: the right-hand line follows the left-hand one.
let li = lay.lines.iter().position(|l| std::ptr::eq(l, *left)).unwrap();
assert!(lay.lines[li + 1].avail.0 > 250.0);
}
}