Text wrap: an out-of-range or non-number offset is refused

object.textWrap.make and object.textWrap.options kept an offset outside
-1000 to 1000 pt at the nearer end of the range, and read an offset that
is not a number, or an invert that is not true or false, as if it had
been left out. Both commands now return an error for such a value and
leave the wrap options and the undo history unchanged. An omitted or
null value still takes the default (6 pt and no invert for Make, the
current options for Text Wrap Options), and Text Wrap Options with only
null values reads the current options. A stored offset outside the range
is kept when only Invert Wrap changes.

The MCP text_wrap tool checks offset, invert and release before it
changes the selection, so a refused call changes nothing, and a release
that is not true or false is an error instead of making a wrap.
This commit is contained in:
dotCooCoo
2026-11-14 05:12:58 -08:00
parent a47d7eec91
commit b562fc1c54
7 changed files with 136 additions and 23 deletions
+81 -11
View File
@@ -8,7 +8,7 @@ use serde_json::{Value, json};
use vectorcraft_doc::{Document, NodeId, NodeKind, TextWrap};
use super::typecmd::refresh_bounds;
use super::{CommandSpec, EngineError, Result, Session, bool_or, checked_ids_or_selection, cmd, f64_or, has_selection};
use super::{CommandSpec, EngineError, Result, Session, bad, checked_ids_or_selection, cmd, has_selection, number_or, switch_or};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Text Wrap"],
None,
"{offset?: pt (6), invert?: bool, ids?} area type below the selected objects (same layer) wraps around them; a negative offset (to -1000) lets the text that far into them → {ids}",
"{offset?: pt (6), invert?: bool, ids?} area type below the selected objects (same layer) wraps around them; offset -1000 to 1000 (a negative one lets the text that far into them); an offset outside that range or not a number, or an invert that is not true or false, is an error → {ids}",
has_selection,
make
),
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Text Wrap Options…",
["Object", "Text Wrap"],
None,
"{offset?: pt, invert?: bool, ids?} set the options of the selected wrap objects (offset -1000 to 1000; a negative one lets text into the objects); no options → the current {offset, invert}",
"{offset?: pt, invert?: bool, ids?} set the options of the selected wrap objects (offset -1000 to 1000; a negative one lets text into the objects); an offset outside that range or not a number, or an invert that is not true or false, is an error; no options (or null ones) → the current {offset, invert}",
has_selection,
options
),
@@ -37,14 +37,23 @@ pub fn specs() -> Vec<CommandSpec> {
/// The Offset a wrap object takes, in points.
pub const TEXT_WRAP_OFFSET_RANGE: std::ops::RangeInclusive<f64> = -1000.0..=1000.0;
fn wrap_param(p: &Value, base: TextWrap) -> TextWrap {
let (lo, hi) = (*TEXT_WRAP_OFFSET_RANGE.start(), *TEXT_WRAP_OFFSET_RANGE.end());
TextWrap { offset: f64_or(p, "offset", base.offset).clamp(lo, hi), invert: bool_or(p, "invert", base.invert) }
/// The options `p` gives, with `base` for an omitted or null `offset` or `invert`. An `offset` that is
/// not a number or lies outside [`TEXT_WRAP_OFFSET_RANGE`], or an `invert` that is not true or false,
/// is an error. `base`'s offset is kept as it is.
fn wrap_param(p: &Value, base: TextWrap, cmd: &str) -> Result<TextWrap> {
let key = "offset";
let given = p.get(key).is_some_and(|v| !v.is_null());
let offset = number_or(p, key, base.offset, cmd)?;
if given && !TEXT_WRAP_OFFSET_RANGE.contains(&offset) {
let (lo, hi) = (TEXT_WRAP_OFFSET_RANGE.start(), TEXT_WRAP_OFFSET_RANGE.end());
return Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}")));
}
Ok(TextWrap { offset, invert: switch_or(p, "invert", base.invert, cmd)? })
}
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());
let w = wrap_param(p, TextWrap::default(), "object.textWrap.make")?;
s.edit("Make Text Wrap", |d, _| {
for id in &ids {
let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?;
@@ -86,10 +95,10 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
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() {
if ["offset", "invert"].iter().all(|k| p.get(k).is_none_or(Value::is_null)) {
return Ok(json!({ "offset": current.offset, "invert": current.invert }));
}
let w = wrap_param(p, current);
let w = wrap_param(p, current, "object.textWrap.options")?;
s.edit("Text Wrap Options", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id)
@@ -212,11 +221,72 @@ mod tests {
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.execute("object.textWrap.options", &json!({})).unwrap()["offset"], json!(6.0));
assert!(beside(&s).iter().all(|x| (x - 206.0).abs() < 1e-6), "{:?}", beside(&s));
// An offset past -1000 is kept at -1000: the whole object lets text through.
assert_eq!(s.execute("object.textWrap.options", &json!({"offset": -5000})).unwrap()["offset"], json!(-1000.0));
// An offset of -1000 lets the text through the whole object.
assert_eq!(s.execute("object.textWrap.options", &json!({"offset": -1000})).unwrap()["offset"], json!(-1000.0));
assert!(lines(&s, t).iter().all(|l| l.0 < 1.0 && l.1 > 300.0));
}
/// An offset that is not a number or lies outside -1000 to 1000 pt, or an invert that is not
/// true or false, is an error for Make and Options, and leaves the options and the history as
/// they were. Omitted and null values take the default, and the range's ends are accepted.
#[test]
fn text_wrap_commands_refuse_bad_offsets_and_inverts_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();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
let wrap = |s: &Session| s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().wrap;
let undo = |s: &Session| s.doc().unwrap().history.undo.len();
let refused = [
(json!({"offset": "12"}), "`offset` must be a number"),
(json!({"offset": true}), "`offset` must be a number"),
(json!({"offset": [12]}), "`offset` must be a number"),
(json!({"offset": {"pt": 12}}), "`offset` must be a number"),
(json!({"offset": 1000.5}), "`offset` must be from -1000 to 1000"),
(json!({"offset": -1000.5}), "`offset` must be from -1000 to 1000"),
(json!({"offset": 5000}), "`offset` must be from -1000 to 1000"),
(json!({"offset": 12, "invert": "true"}), "`invert` must be true or false"),
(json!({"offset": 12, "invert": 1}), "`invert` must be true or false"),
(json!({"invert": "false"}), "`invert` must be true or false"),
];
let start = undo(&s);
for (p, msg) in &refused {
let e = s.execute("object.textWrap.make", p).unwrap_err().to_string();
assert!(e.contains(msg), "make {p}: {e}");
assert!(wrap(&s).is_none(), "make {p} made a wrap");
assert_eq!(undo(&s), start, "make {p} added history");
}
// Omitted and null values: Make's 6 pt default.
s.execute("object.textWrap.make", &json!({"offset": null, "invert": null})).unwrap();
assert_eq!(wrap(&s), Some(vectorcraft_doc::TextWrap { offset: 6.0, invert: false }));
s.execute("object.textWrap.options", &json!({"offset": 40, "invert": true})).unwrap();
let before = undo(&s);
for (p, msg) in &refused {
let e = s.execute("object.textWrap.options", p).unwrap_err().to_string();
assert!(e.contains(msg), "options {p}: {e}");
assert_eq!(wrap(&s), Some(vectorcraft_doc::TextWrap { offset: 40.0, invert: true }), "options {p}");
assert_eq!(undo(&s), before, "options {p} added history");
}
// Null options read the current ones without an undo step; a null one keeps its value.
assert_eq!(s.execute("object.textWrap.options", &json!({"offset": null, "invert": null})).unwrap(), json!({"offset": 40.0, "invert": true}));
assert_eq!(undo(&s), before);
assert_eq!(
s.execute("object.textWrap.options", &json!({"offset": null, "invert": false})).unwrap(),
json!({"offset": 40.0, "invert": false})
);
// The range's ends are accepted by both commands.
for (command, offset) in
[("object.textWrap.options", 1000), ("object.textWrap.options", -1000), ("object.textWrap.make", 1000), ("object.textWrap.make", -1000)]
{
s.execute(command, &json!({ "offset": offset })).unwrap();
assert_eq!(wrap(&s).unwrap().offset, f64::from(offset), "{command} {offset}");
}
// A stored offset outside the range (from a file) is kept when only Invert Wrap changes.
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
d.node_mut(NodeId(id)).unwrap().extras_mut().wrap = Some(vectorcraft_doc::TextWrap { offset: 5000.0, invert: false });
assert_eq!(s.execute("object.textWrap.options", &json!({"invert": true})).unwrap(), json!({"offset": 5000.0, "invert": true}));
}
#[test]
fn text_flows_on_both_sides_of_a_centred_object() {
let mut s = Session::new();
+21
View File
@@ -1597,6 +1597,27 @@ fn text_effect_pathfinder_transform_graph_and_wrap_tools() {
let ds = d.to_string();
// The wrap object carries the options and the area type resolved its shape.
assert!(ds.contains("\"wrap\":{\"invert\":false,\"offset\":8.0}") && ds.contains("\"wrap\":[{\"invert\":false,\"offset\":8.0"), "{area}");
// An offset outside -1000 to 1000 pt, an invert or a release that is not true or false is an
// error, with release too, and the wrap and the selection stay as they were.
let r = call(&mut s, 20, "run_command", json!({"command": "select.set", "params": {"ids": [g]}}));
assert_eq!(r["isError"], false, "{r}");
for (id, args) in [
(21, json!({"ids": [c], "offset": 5000})),
(22, json!({"ids": [c], "offset": "8"})),
(23, json!({"ids": [c], "invert": "true"})),
(24, json!({"ids": [c], "release": "true"})),
(25, json!({"ids": [c], "release": true, "offset": -5000})),
] {
let r = call(&mut s, id, "text_wrap", args.clone());
assert_eq!(r["isError"], true, "{args}: {r}");
assert!(doc_json(&mut s).to_string().contains("\"wrap\":{\"invert\":false,\"offset\":8.0}"), "{args}");
}
// The graph is still the selection: a wrap made without ids goes on it, not on the circle.
let r = call(&mut s, 26, "text_wrap", json!({"offset": 9}));
assert_eq!(r["isError"], false, "{r}");
let made: Value = serde_json::from_str(&text_of(&r)).unwrap();
assert_eq!(made["ids"], json!([g]), "{r}");
call(&mut s, 27, "text_wrap", json!({"release": true}));
call(&mut s, 15, "text_wrap", json!({"ids": [c], "release": true}));
assert!(!doc_json(&mut s).to_string().contains("\"offset\":8.0"));
}
+1 -1
View File
@@ -507,7 +507,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"text_wrap",
"Text wrap",
"Make the selected (or `ids`) objects wrap objects: area type below them in the same layer flows around them (offset in pt, invert = flow inside). `release: true` removes the wrap.",
"Make the selected (or `ids`) objects wrap objects: area type below them in the same layer flows around them (offset in pt, invert = flow inside). `release: true` removes the wrap. An offset outside -1000 to 1000 or not a number, or an invert or release that is not true or false, is an error and changes nothing.",
obj(
json!({
"ids": {"type": "array", "items": {"type": "integer"}},
+25 -7
View File
@@ -19,6 +19,29 @@ pub(super) const MAX_SIDE_WINDOW: &str =
/// The `traceViews` argument of the artboard renders.
pub(super) const TRACE_VIEWS: &str = "Draw Image Trace objects in the view set with imageTrace.setView (outlines, the source image…), as the app's window does; by default they draw their tracing result, as exports do";
/// The `text_wrap` tool's `release` and its `offset` and `invert` for `object.textWrap.make`, checked
/// before the tool changes the selection: an omitted or null value is left out, an `offset` that is
/// not a number or lies outside [`vectorcraft_engine::TEXT_WRAP_OFFSET_RANGE`] is an error, and so is
/// an `invert` or `release` that is not true or false.
fn text_wrap_args(a: &Args) -> Result<(bool, Value), String> {
let mut p = json!({});
for k in ["offset", "invert", "release"] {
let Some(v) = a.get(k).filter(|v| !v.is_null()) else { continue };
if k == "offset" {
let range = vectorcraft_engine::TEXT_WRAP_OFFSET_RANGE;
let n = v.as_f64().ok_or_else(|| format!("`{k}` must be a number, not {v}"))?;
if !range.contains(&n) {
return Err(format!("`{k}` must be from {} to {}", range.start(), range.end()));
}
} else if !v.is_boolean() {
return Err(format!("`{k}` must be true or false"));
}
p[k] = v.clone();
}
let release = p.as_object_mut().and_then(|m| m.remove("release")).is_some_and(|v| v == true);
Ok((release, p))
}
/// `traceViews`: false when absent.
pub(super) fn trace_views(a: &Args) -> Result<bool, String> {
match a.get("traceViews") {
@@ -260,16 +283,11 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, Str
j(exec(b, "graph.create", p)?)
}
"text_wrap" => {
let (release, p) = text_wrap_args(a)?;
select_ids(b, a)?;
if a.get("release").and_then(Value::as_bool) == Some(true) {
if release {
return j(exec(b, "object.textWrap.release", json!({}))?);
}
let mut p = json!({});
for k in ["offset", "invert"] {
if let Some(v) = a.get(k) {
p[k] = v.clone();
}
}
j(exec(b, "object.textWrap.make", p)?)
}
"undo" => j(exec(b, "edit.undo", json!({}))?),
+6 -3
View File
@@ -517,7 +517,7 @@ mod tests {
}
/// Text Wrap Options › Offset is a distance, and its range is the one `object.textWrap.options`
/// keeps an Offset to.
/// accepts: the command takes the range's ends and refuses an Offset outside them.
#[test]
fn text_wrap_offset_is_a_distance_within_the_commands_range() {
assert!(lengths("object.textWrap.options").contains(&"offset"));
@@ -531,8 +531,11 @@ mod tests {
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
s.execute("select.all", &json!({})).unwrap();
s.execute("object.textWrap.make", &json!({})).unwrap();
for (sent, end) in [(5000.0, *range.end()), (-5000.0, *range.start())] {
assert_eq!(s.execute("object.textWrap.options", &json!({ "offset": sent })).unwrap()["offset"], json!(end));
for end in [*range.end(), *range.start()] {
assert_eq!(s.execute("object.textWrap.options", &json!({ "offset": end })).unwrap()["offset"], json!(end));
}
for sent in [5000.0, -5000.0] {
assert!(s.execute("object.textWrap.options", &json!({ "offset": sent })).is_err(), "{sent}");
}
}
+1 -1
View File
@@ -660,7 +660,7 @@ sampler.
| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). In remote desktop mode, `run_command` and `invoke_menu` can both call `pathfinder.repeat` with `{}`. Successful panel operations, direct `object.pathfinder.*` commands, and `command.batch` steps (including Actions panel playback) set its history. Repeat applies it to the current selection using current Pathfinder Options. Trap, compound-shape edits, live effects and `action.batch` do not set history. With no history it returns "no Pathfinder operation applied yet". |
| `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. `rotate` is in degrees counter-clockwise; a positive `shear` moves the top to the right. With `copy`, the first step duplicates. |
| `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. An empty CSV cell or a `null` in `rows` is a blank value; a number in straight quotes is a label. Edit later with `graph.setData` / `graph.setType` via `run_command`. `graph.column {design, columnType: vertical\|uniform\|repeating\|sliding, each, fractions: chop\|scale}` draws the columns or bars of the series picked with Group Selection (or `seriesIndexes`, or all of them) with a graph design saved by `graph.design`; with no `design` it returns the series' current design and options. Type in the design that holds a `%` followed by two digits shows each column's value, with at least the first digit's places before the decimal point and the second digit's places after it. |
| `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. |
| `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. `offset` is in points, from -1000 to 1000 (default 6). An offset outside that range or not a number, or an `invert` or `release` that is not true or false, is an error and changes nothing; `object.textWrap.make` and `object.textWrap.options` refuse the same values. |
| `undo` / `redo` | `{}` | Many calls that change nothing record no undo step, for example `object.align` with nothing to move, `object.move` by 0 without `copy` and `object.showAll` with nothing hidden. `undo` (`edit.undo`) after such a call undoes the step before it. `inspect_document` lists the undo steps as `history`. `run_command` with `edit.clearHistory` removes every undo and redo step of the active document and returns `{cleared}`, the number of steps removed; the document stays as it is, and the command records no undo step. `run_command` with `edit.newDocumentFromState` and `{index?, created?}` makes a new document holding the art as it was at that state and makes it the active document, and returns `{index, title}`. `index` is a row of the History panel, with 0 the oldest state kept, and an omitted `index` uses the current state. An `index` that names no state is an error and changes nothing. `created` is File Info's created date in Unix seconds or null, and defaults to now. The new document takes the next `Untitled-N` name, is marked unsaved, has nothing selected and has no steps to undo or redo. It is out of symbol, opacity-mask and pattern editing, and the art drawn in a pattern's tile is stored in the pattern, as `object.pattern.done` stores it. The source document and its steps stay as they are, except that typing or a drag in progress is first kept as a step. When a command keeps a drag whose preview Real-time Drawing off had not applied yet (`edit.undo`, `edit.redo`, `edit.clearHistory`, `edit.newDocumentFromState` and the other commands that end a drag first), and applying that preview gives a warning, the command's result carries it as `previewWarning`, and as `warning` too when the command gives no warning of its own; the app's status bar shows `warning`. While the Type tool edits type, `undo` and `redo` put its caret, with no characters selected, at the end of the text the step changed: after the text it puts back or retypes, or where the text it takes out was. A step that changes no characters, such as a formatting change, leaves the caret and the selection where they are. |
For example, create editable Japanese area type with the engine's vertical writing mode:
+1
View File
@@ -1324,6 +1324,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
The Character panel menu's Show Font Height Options shows a Set Font Height Reference dropdown under Font Size (`text.setFormat` or `text.setRangeStyle` `{fontHeightReference}`). Each reference shows an icon of the height it measures before its name, in the open list and in the closed field (#6304): a frame around a letter for Em Box, a bar from the baseline to the top of a capital for Cap Height and of a lowercase x for x-Height, and a frame inset in the em square for ICF Box. With Em Box, the default, the font size is the size of the em square; with Cap Height, x-Height or ICF Box, the characters are set at the em size at which the font's OS/2 cap height, its x height or the height of its ideographic character face equals the font size. With a font that has no such value, or one outside 0.1 to 2 em, the size measures the em box. SVG, PDF and legacy `.ai` export and the CSS Properties rules write the em size the characters are drawn at, and DXF export writes 0.7 times that em size as each TEXT entity's height (group 40, the height of its capitals). Behavior reference: [Vary font height](https://web.archive.org/web/20251118174722/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/vary-font-height.html).
The Every-line Composer composes a paragraph whose characters differ in line metrics (type size, font, leading or vertical scale, or an inline graphic taller than the text) in area type of any shape, rows, columns and text wrap included. In a frame that is not a rectangle, or with text wrap, each line it tries is measured where the lines above it put it, across the line's own height, so a taller line gets the room of the narrower part of the frame, or of the span beside a wrap object, that it reaches. When the frame cannot hold all of such a paragraph, the lines it holds are still composed with the whole paragraph, the lines past the frame's end measured as wide as the last line the frame holds (VectorCraft's own rule); they overflow or go on in the next threaded frame, which composes them again. Such a paragraph is broken with the Single-line Composer when the frame has no room for its first line or when the search passes VectorCraft's own limits. Behavior reference: [the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/set-hyphenation-and-line-breaks.html), and for text that flows on between threaded objects, [the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/create-text-threads-between-objects.html).
A text wrap object's Offset may be negative as well as positive ([the help page](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=307)). A negative Offset (to -1000 pt) shrinks the wrap object by that distance, measured from each edge itself and from the edges of its holes, so lines beside it, above it and below it run that far into it, and an object narrower than twice the distance no longer moves text aside. With Invert Wrap, a negative Offset keeps the text that far inside the object. These are VectorCraft's own rules for the shape of the shrunk object. Text Wrap Options, `object.textWrap.make` and `object.textWrap.options` and the MCP `text_wrap` tool take the negative value, and layout uses it (`crates/text/src/layout/region.rs` tests, `cmd::textwrap::tests::text_wrap_options_negative_offset_reflows_and_round_trips`).
`object.textWrap.make`, `object.textWrap.options` and the MCP `text_wrap` tool refuse an Offset outside -1000 to 1000 pt, VectorCraft's own range, or one that is not a number, and an Invert Wrap value that is not true or false, with an error and no change or undo step; an omitted or null value takes the default (`cmd::textwrap::tests::text_wrap_commands_refuse_bad_offsets_and_inverts_without_editing`).
In area type of a shape other than a rectangle, each line's whole band, ascent to descent, stays inside the frame: where an edge pinches in within the band, the line ends at the narrowest point, and a line runs beside a hole that starts and ends within its band, not across it. In area type of any shape, a line also stays clear of a text wrap object that reaches into any part of its band, and inside an Invert Wrap object over its whole band (`crates/text/src/layout/region.rs` tests).
In area type whose frame has several contours, a line runs across an edge two contours share only when their edges lie exactly on one line, so a gap between contours keeps lines from crossing it, even a gap one unit in the last place wide near the edge of the canvas; an edge divided by an anchor exactly on it still lets lines cross (`layout::region::separate_slanted_contours_keep_a_four_ulp_gap_near_canvas_limit`, `shared_slanted_edge_still_joins_near_canvas_limit`, `area_type_sets_no_line_across_a_four_ulp_gap_between_slanted_contours`).
With Invert Wrap, the spans a line keeps inside a wrap shape of many parts (a comb of 500 teeth) are found by stepping through the line's spans and the shape's spans once for each piece of the band, so the work grows with the number of spans, not with its square (`crates/text/src/layout/region.rs` tests).