M7.4: Object → Text Wrap (Make, Release, Text Wrap Options: offset, invert): area type flows around wrap objects above it in the same layer, re-resolved after every edit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 22:48:49 -04:00
parent 0ae572be93
commit 4745f39c6c
12 changed files with 417 additions and 33 deletions

View File

@ -40,7 +40,7 @@ _Last updated: 2026-10-01._
- **Geometry and effects:** - **Geometry and effects:**
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. - Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below.
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
- **Type:** Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. - **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline.
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks. - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
- **Advanced art:** live Blends (steps/distance/smooth colour, spine), Envelope Distort (warp/mesh/top object), Gradient Mesh, Shape Builder, Live Paint, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). - **Advanced art:** live Blends (steps/distance/smooth colour, spine), Envelope Distort (warp/mesh/top object), Gradient Mesh, Shape Builder, Live Paint, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize. - **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
@ -61,7 +61,7 @@ _Last updated: 2026-10-01._
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 🟡 Export for Screens (PNG/JPG/WebP/SVG/PDF × scales) done; headless CLI/MCP export every format; system clipboard: copy puts SVG markup on it, paste takes SVG from other apps (Ctrl/Cmd+C/X/V now also work off macOS); live effects now survive SVG/PDF/clipboard export (geometry baked, SVG filters for shadows/glows/blur/feather); PDF raster effects, PNG/PDF clipboard flavours, EPS/DXF pending | 7–11 | | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 🟡 Export for Screens (PNG/JPG/WebP/SVG/PDF × scales) done; headless CLI/MCP export every format; system clipboard: copy puts SVG markup on it, paste takes SVG from other apps (Ctrl/Cmd+C/X/V now also work off macOS); live effects now survive SVG/PDF/clipboard export (geometry baked, SVG filters for shadows/glows/blur/feather); PDF raster effects, PNG/PDF clipboard flavours, EPS/DXF pending | 7–11 |
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 10–20 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 10–20 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Shape Builder edge erase, large-offset bug open) | 3–6 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Shape Builder edge erase, large-offset bug open) | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font done; Tabs panel, text wrap, spell check, vertical type pending | 10–17 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, Text Wrap (offset, invert; follows edits), Type on a Path effects done; Tabs panel, spell check, vertical type, wrap on both sides of an object pending | 9–15 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass pending | 8–12 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass pending | 8–12 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder; SVG Filters, Document Raster Effects Settings, 3D pending | 24–38 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder; SVG Filters, Document Raster Effects Settings, 3D pending | 24–38 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 8–15 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 8–15 |

View File

@ -35,7 +35,9 @@ pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, Gradient
pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask}; pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask};
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType}; pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
pub use selection::{AnchorRef, Selection}; pub use selection::{AnchorRef, Selection};
pub use text::{AreaOptions, CharStyle, FirstBaseline, Justify, ParaStyle, PathEffect, TextKind, TextObject, TextRun, TextStyleDef}; pub use text::{
AreaOptions, CharStyle, FirstBaseline, Justify, ParaStyle, PathEffect, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, WrapShape,
};
pub use vectorcraft_color as color; pub use vectorcraft_color as color;
pub use vectorcraft_geom as geom; pub use vectorcraft_geom as geom;

View File

@ -263,6 +263,9 @@ pub struct Node {
/// Image Trace object: `{preset, params}` it was traced with (the Image Trace panel shows them). /// Image Trace object: `{preset, params}` it was traced with (the Image Trace panel shows them).
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub trace: Option<Box<serde_json::Value>>, pub trace: Option<Box<serde_json::Value>>,
/// Object → Text Wrap: area type below this object (in the same layer) flows around it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wrap: Option<crate::text::TextWrap>,
pub kind: NodeKind, pub kind: NodeKind,
} }
@ -305,6 +308,7 @@ impl Node {
appearance: Appearance::default(), appearance: Appearance::default(),
mask: None, mask: None,
trace: None, trace: None,
wrap: None,
kind, kind,
} }
} }

View File

@ -136,6 +136,30 @@ pub enum FirstBaseline {
Fixed, Fixed,
} }
/// Text Wrap Options of a wrap object (Object → Text Wrap).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct TextWrap {
/// Distance kept between the text and the object, in points (Illustrator's default 6 pt).
pub offset: f64,
/// Invert Wrap: text flows inside the object instead of around it.
pub invert: bool,
}
impl Default for TextWrap {
fn default() -> Self {
Self { offset: 6.0, invert: false }
}
}
/// A resolved wrap shape on an area text object: the outline of a wrap object in text space.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct WrapShape {
pub path: PathData,
#[serde(flatten)]
pub wrap: TextWrap,
}
/// Type on a Path effect: how each glyph is oriented on the path (Type → Type on a Path). /// Type on a Path effect: how each glyph is oriented on the path (Type → Type on a Path).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@ -234,6 +258,9 @@ pub struct TextObject {
/// Type on a Path effect (type on a path only). /// Type on a Path effect (type on a path only).
#[serde(default, skip_serializing_if = "crate::skip::is_default")] #[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub path_effect: PathEffect, pub path_effect: PathEffect,
/// Wrap objects above this area type, resolved by the engine after each edit (text space).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub wrap: Vec<WrapShape>,
/// Cached layout bounds in text space, filled in by the layout engine (not serialized). /// Cached layout bounds in text space, filled in by the layout engine (not serialized).
#[serde(skip)] #[serde(skip)]
pub cached_bounds: Option<Rect>, pub cached_bounds: Option<Rect>,
@ -248,6 +275,7 @@ impl TextObject {
para: ParaStyle::default(), para: ParaStyle::default(),
area: AreaOptions::default(), area: AreaOptions::default(),
path_effect: PathEffect::default(), path_effect: PathEffect::default(),
wrap: Vec::new(),
cached_bounds: None, cached_bounds: None,
} }
} }

View File

@ -132,6 +132,7 @@ fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
knockout: false, knockout: false,
mask: None, mask: None,
trace: None, trace: None,
wrap: None,
kind, kind,
..n.clone() ..n.clone()
}) })

View File

@ -30,6 +30,7 @@ mod recolor;
mod select; mod select;
mod textedit; mod textedit;
pub mod textstyles; pub mod textstyles;
pub(crate) mod textwrap;
pub(crate) mod threads; pub(crate) mod threads;
pub(crate) mod typecmd; pub(crate) mod typecmd;
mod typemenu; mod typemenu;
@ -159,6 +160,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(fonts::specs()); v.extend(fonts::specs());
v.extend(help::specs()); v.extend(help::specs());
v.extend(threads::specs()); v.extend(threads::specs());
v.extend(textwrap::specs());
v.extend(docmenu::specs()); v.extend(docmenu::specs());
v.extend(docinfo::specs()); v.extend(docinfo::specs());
v.extend(panelcmds::specs()); v.extend(panelcmds::specs());

View File

@ -338,6 +338,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
para: Default::default(), para: Default::default(),
area: Default::default(), area: Default::default(),
path_effect: Default::default(), path_effect: Default::default(),
wrap: Vec::new(),
cached_bounds: None, cached_bounds: None,
}; };
refresh_bounds(&mut t); refresh_bounds(&mut t);
@ -373,6 +374,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
para: Default::default(), para: Default::default(),
area: Default::default(), area: Default::default(),
path_effect: Default::default(), path_effect: Default::default(),
wrap: Vec::new(),
cached_bounds: None, cached_bounds: None,
}; };
for r in &mut h.runs { for r in &mut h.runs {

View File

@ -0,0 +1,251 @@
//! 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::edit::selected_roots;
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 targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
match p.get("ids").and_then(Value::as_array) {
Some(a) => Ok(a.iter().filter_map(Value::as_u64).map(NodeId).collect()),
None => selected_roots(s),
}
}
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 = targets(s, p)?;
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.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;
targets(s, p)?.into_iter().filter(|i| d.node(*i).is_some_and(|n| n.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.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 = targets(s, p)?;
let current = {
let d = &s.doc()?.doc;
ids.iter().find_map(|i| d.node(*i).and_then(|n| n.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.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.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.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.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()
}
#[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);
}
}

View File

@ -573,6 +573,8 @@ impl Session {
let doc = Arc::make_mut(&mut st.doc); let doc = Arc::make_mut(&mut st.doc);
let result = match f(doc, &mut st.selection) { let result = match f(doc, &mut st.selection) {
Ok(v) => { Ok(v) => {
// Text Wrap: area type follows its wrap objects; then threads re-flow.
cmd::textwrap::refresh(Arc::make_mut(&mut st.doc));
// Threaded text re-flows when any of its frames changed. // Threaded text re-flows when any of its frames changed.
if !st.doc.text_threads.is_empty() { if !st.doc.text_threads.is_empty() {
cmd::threads::reflow(&before, Arc::make_mut(&mut st.doc)); cmd::threads::reflow(&before, Arc::make_mut(&mut st.doc));

View File

@ -697,6 +697,7 @@ fn text_fallback(im: &mut Importer, svg: &str, w: f64, h: f64, layer_mode: bool)
para: Default::default(), para: Default::default(),
area: Default::default(), area: Default::default(),
path_effect: Default::default(), path_effect: Default::default(),
wrap: Vec::new(),
cached_bounds: None, cached_bounds: None,
}; };
obj.para.justify = justify; obj.para.justify = justify;

View File

@ -89,7 +89,7 @@ pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLay
match &t.kind { match &t.kind {
TextKind::Point => flow(&mut cx, &paras, &t.para, None), TextKind::Point => flow(&mut cx, &paras, &t.para, None),
TextKind::Area { frame } => { TextKind::Area { frame } => {
let regions = Region::cells(&frame.to_bezpath(), opts); let regions = Region::cells(&frame.to_bezpath(), opts, &t.wrap);
cx.out.frames = regions.iter().map(|r| r.cell).collect(); cx.out.frames = regions.iter().map(|r| r.cell).collect();
flow(&mut cx, &paras, &t.para, Some(&regions)); flow(&mut cx, &paras, &t.para, Some(&regions));
} }
@ -124,21 +124,94 @@ struct Region {
polys: Vec<Vec<Point>>, polys: Vec<Vec<Point>>,
/// The frame is its own bounding rectangle (spans need no polygon intersection). /// The frame is its own bounding rectangle (spans need no polygon intersection).
rect: bool, rect: bool,
/// Text Wrap shapes: (polygons, offset, invert).
wraps: Vec<(Vec<Vec<Point>>, f64, bool)>,
}
/// Flattened closed polygons of a path.
fn polygons(path: &BezPath) -> Vec<Vec<Point>> {
let mut polys: Vec<Vec<Point>> = Vec::new();
kurbo::flatten(path, 0.1, |el| match el {
PathEl::MoveTo(p) => polys.push(vec![p]),
PathEl::LineTo(p) => {
if let Some(v) = polys.last_mut() {
v.push(p);
}
}
_ => {}
});
polys.retain(|p| p.len() >= 3);
polys
}
/// Inside intervals (even-odd) of the horizontal line at `y` through `polys`.
fn poly_intervals(polys: &[Vec<Point>], y: f64) -> Vec<(f64, f64)> {
let mut xs = Vec::new();
for poly in polys {
for i in 0..poly.len() {
let a = poly[i];
let b = poly[(i + 1) % poly.len()];
if (a.y <= y) != (b.y <= y) {
xs.push(a.x + (y - a.y) / (b.y - a.y) * (b.x - a.x));
}
}
}
xs.sort_by(f64::total_cmp);
xs.chunks_exact(2).map(|c| (c[0], c[1])).collect()
}
/// Union of sorted-or-not intervals.
fn union_intervals(mut v: Vec<(f64, f64)>) -> Vec<(f64, f64)> {
v.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut out: Vec<(f64, f64)> = Vec::with_capacity(v.len());
for (a, b) in v {
match out.last_mut() {
Some(l) if a <= l.1 => l.1 = l.1.max(b),
_ => out.push((a, b)),
}
}
out
}
/// `a` minus `b` (both unions of disjoint intervals).
fn subtract_intervals(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> {
let mut out = vec![];
for &(mut s, e) in a {
for &(c, d) in b {
if d <= s || c >= e {
continue;
}
if c > s {
out.push((s, c));
}
s = s.max(d);
}
if e > s {
out.push((s, e));
}
}
out
}
/// `a` ∩ `b`.
fn intersect_intervals(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> {
let mut out = vec![];
for &(s, e) in a {
for &(c, d) in b {
let (x, y) = (s.max(c), e.min(d));
if y > x {
out.push((x, y));
}
}
}
out
} }
impl Region { impl Region {
fn cells(path: &BezPath, opts: &LayoutOptions) -> Vec<Region> { fn cells(path: &BezPath, opts: &LayoutOptions, wrap: &[vectorcraft_doc::WrapShape]) -> Vec<Region> {
let mut polys: Vec<Vec<Point>> = Vec::new(); let polys = polygons(path);
kurbo::flatten(path, 0.1, |el| match el { let wraps: Vec<(Vec<Vec<Point>>, f64, bool)> =
PathEl::MoveTo(p) => polys.push(vec![p]), wrap.iter().map(|w| (polygons(&w.path.to_bezpath()), w.wrap.offset.max(0.0), w.wrap.invert)).filter(|w| !w.0.is_empty()).collect();
PathEl::LineTo(p) => {
if let Some(v) = polys.last_mut() {
v.push(p);
}
}
_ => {}
});
polys.retain(|p| p.len() >= 3);
let bbox = path.bounding_box(); let bbox = path.bounding_box();
let rect = polys.len() == 1 && { let rect = polys.len() == 1 && {
let area: f64 = polys[0].iter().zip(polys[0].iter().cycle().skip(1)).map(|(a, b)| a.x * b.y - b.x * a.y).sum::<f64>().abs() * 0.5; let area: f64 = polys[0].iter().zip(polys[0].iter().cycle().skip(1)).map(|(a, b)| a.x * b.y - b.x * a.y).sum::<f64>().abs() * 0.5;
@ -155,7 +228,7 @@ impl Region {
let x0 = bbox.x0 + c as f64 * (cw + gutter); let x0 = bbox.x0 + c as f64 * (cw + gutter);
let y0 = bbox.y0 + r as f64 * (rh + gutter); let y0 = bbox.y0 + r as f64 * (rh + gutter);
let cell = if rows * cols == 1 { bbox } else { Rect::new(x0, y0, x0 + cw, y0 + rh) }; let cell = if rows * cols == 1 { bbox } else { Rect::new(x0, y0, x0 + cw, y0 + rh) };
out.push(Region { cell, inset: opts.inset.max(0.0), polys: polys.clone(), rect }); out.push(Region { cell, inset: opts.inset.max(0.0), polys: polys.clone(), rect, wraps: wraps.clone() });
} }
} }
out out
@ -168,20 +241,16 @@ impl Region {
self.cell.y1 - self.inset self.cell.y1 - self.inset
} }
/// Inside intervals (even-odd) of the horizontal line at `y`. /// Inside intervals (even-odd) of the horizontal line at `y`, minus the wrap objects (or
/// inside them, for Invert Wrap). Offsets grow each wrap shape vertically and horizontally.
fn intervals(&self, y: f64) -> Vec<(f64, f64)> { fn intervals(&self, y: f64) -> Vec<(f64, f64)> {
let mut xs = Vec::new(); let mut iv = if self.polys.is_empty() || self.rect { vec![(self.cell.x0, self.cell.x1)] } else { poly_intervals(&self.polys, y) };
for poly in &self.polys { for (polys, off, invert) in &self.wraps {
for i in 0..poly.len() { let ys = if *off > 0.0 { vec![y - off, y - off * 0.5, y, y + off * 0.5, y + off] } else { vec![y] };
let a = poly[i]; let w = union_intervals(ys.into_iter().flat_map(|y| poly_intervals(polys, y)).map(|(a, b)| (a - off, b + off)).collect());
let b = poly[(i + 1) % poly.len()]; iv = if *invert { intersect_intervals(&iv, &w) } else { subtract_intervals(&iv, &w) };
if (a.y <= y) != (b.y <= y) {
xs.push(a.x + (y - a.y) / (b.y - a.y) * (b.x - a.x));
}
}
} }
xs.sort_by(f64::total_cmp); iv
xs.chunks_exact(2).map(|c| (c[0], c[1])).collect()
} }
/// Widest horizontal span inside the frame (and the cell) over the band `top..bottom`. /// Widest horizontal span inside the frame (and the cell) over the band `top..bottom`.
@ -190,11 +259,16 @@ impl Region {
let (a, b) = (a.max(self.cell.x0) + self.inset, b.min(self.cell.x1) - self.inset); let (a, b) = (a.max(self.cell.x0) + self.inset, b.min(self.cell.x1) - self.inset);
(b > a).then_some((a, b)) (b > a).then_some((a, b))
}; };
if self.polys.is_empty() || self.rect { let plain = self.polys.is_empty() || self.rect;
if plain && self.wraps.is_empty() {
return (self.cell.width() > 0.0).then_some((self.cell.x0, self.cell.x1)).and_then(clip); return (self.cell.width() > 0.0).then_some((self.cell.x0, self.cell.x1)).and_then(clip);
} }
let fb = self.polys.iter().flatten().fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |r, p| r.union_pt(*p)); let fb = if plain {
let clamp = |y: f64| y.clamp(fb.y0 + 1e-4, fb.y1 - 1e-4); self.cell
} else {
self.polys.iter().flatten().fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |r, p| r.union_pt(*p))
};
let clamp = |y: f64| if plain { y } else { y.clamp(fb.y0 + 1e-4, fb.y1 - 1e-4) };
// Sample the band densely enough for curved frames (circles, blobs). // Sample the band densely enough for curved frames (circles, blobs).
let samples = 5; let samples = 5;
let mut rows: Vec<Vec<(f64, f64)>> = let mut rows: Vec<Vec<(f64, f64)>> =

View File

@ -792,7 +792,14 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
c("Expand", "imageTrace.expand"), c("Expand", "imageTrace.expand"),
], ],
), ),
sub("Text Wrap", vec![todo("Make"), todo("Release"), todo("Text Wrap Options…")]), sub(
"Text Wrap",
vec![
c("Make", "object.textWrap.make"),
c("Release", "object.textWrap.release"),
c("Text Wrap Options…", "object.textWrap.options"),
],
),
Sep, Sep,
sub( sub(
"Clipping Mask", "Clipping Mask",
@ -1220,6 +1227,16 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
} }
return; return;
} }
// Text Wrap Options: a dialog with the selected wrap object's current values.
if id == "object.textWrap.options" && p.as_object().is_none_or(|o| o.is_empty()) {
match app.session.execute(id, &json!({})) {
Ok(fields) => {
let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Text Wrap Options", "params": fields}));
}
Err(e) => app.status(e.to_string()),
}
return;
}
// Help links: the command returns the URL; a menu click opens it (agents just get the URL). // Help links: the command returns the URL; a menu click opens it (agents just get the URL).
if matches!(id, "help.discord" | "help.website" | "help.appPage" | "help.github") { if matches!(id, "help.discord" | "help.website" | "help.appPage" | "help.github") {
app.open_link(id); app.open_link(id);