vectorcraft/crates/engine/src/cmd/textwrap.rs

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);
}
}