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:
parent
0ae572be93
commit
4745f39c6c
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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, ¶s, &t.para, None),
|
TextKind::Point => flow(&mut cx, ¶s, &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, ¶s, &t.para, Some(®ions));
|
flow(&mut cx, ¶s, &t.para, Some(®ions));
|
||||||
}
|
}
|
||||||
|
|
@ -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)>> =
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue