diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 01a25a66..84011870 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -74,7 +74,7 @@ pub fn specs() -> Vec { "Create Text", [], None, - "{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", + "{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", has_doc, text_create ), @@ -502,7 +502,9 @@ fn text_create(s: &mut Session, p: &Value) -> Result { // Lays the text out (and sizes an Auto Size frame). super::typecmd::refresh_bounds(&mut t); } - add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None) + let made = add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None)?; + let id = made.get("id").and_then(Value::as_u64).map(NodeId); + Ok(super::typecmd::with_overflow(s, id.as_slice(), made)) } /// The paragraph attributes new type gets (the Type tools, `text.create`): aligned to the start of diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 86585e09..d8816db4 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Area / Path Type", [], None, - "{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color? (as text.create), fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}", + "{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color? (as text.create), fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, overflow?: [id] (the text doesn't fit; left out when it fits)}", has_doc, create_in_path ), @@ -568,7 +568,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { sel.set([id]); Ok(id) })?; - Ok(json!({"id": id.0})) + Ok(super::typecmd::with_overflow(s, &[id], json!({"id": id.0}))) } /// What `type.step` changes. diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 5002b022..0b393522 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -39,7 +39,7 @@ pub fn specs() -> Vec { "Set Text", [], None, - "{id?|ids?, text} replace the contents of text objects (keeps the first run's style)", + "{id?|ids?, text} replace the contents of text objects (keeps the first run's style) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits)}", has_doc, set_text ), @@ -75,7 +75,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text)", + "{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits)}", has_doc, set_style ), @@ -203,6 +203,22 @@ pub(crate) fn refresh_bounds_of(d: &mut Document, ids: &[NodeId]) { } } +/// `result` with `overflow: [id…]`: the area type and type on a path among `ids` whose text +/// doesn't fit (the red overflow "+"; its lines past the frame aren't drawn). Left out when all of +/// it fits. The commands that make type or change what it holds answer with it, so the call that +/// pushed text out of its frame says so itself. +pub(crate) fn with_overflow(s: &Session, ids: &[NodeId], mut result: Value) -> Value { + let Ok(st) = s.doc() else { return result }; + let db = vectorcraft_text::FontDb::global(); + let fits = |t: &TextObject| matches!(t.kind, vectorcraft_doc::TextKind::Point) || !vectorcraft_text::layout(db, t).overflow; + let over = |id: &&NodeId| matches!(st.doc.node(**id).map(|n| &n.kind), Some(NodeKind::Text(t)) if !fits(t)); + let over: Vec = ids.iter().filter(over).map(|i| i.0).collect(); + if !over.is_empty() { + result["overflow"] = json!(over); + } + result +} + /// Text objects among `ids` and their descendants. fn text_ids(d: &Document, ids: &[NodeId]) -> Vec { let mut out = vec![]; @@ -373,7 +389,7 @@ fn set_text(s: &mut Session, p: &Value) -> Result { } Ok(()) })?; - Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) + Ok(with_overflow(s, &ids, json!({ "ids": ids.iter().map(|i| i.0).collect::>() }))) } fn justify_param(v: &str) -> Option { @@ -501,7 +517,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result { } Ok(()) })?; - Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) + Ok(with_overflow(s, &ids, json!({ "ids": ids.iter().map(|i| i.0).collect::>() }))) } /// Reshape area type's frame with `f` ([`TextObject::transform_area`], diff --git a/crates/engine/src/tests_areafit.rs b/crates/engine/src/tests_areafit.rs index 5467c8cb..0d2c8e9b 100644 --- a/crates/engine/src/tests_areafit.rs +++ b/crates/engine/src/tests_areafit.rs @@ -240,3 +240,31 @@ fn auto_height_follows_the_leading_model_and_character_alignment() { }; assert!(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "tight"); } + +/// #1471: the commands that make type or change what it holds say which text no longer fits, so +/// the call that pushed it out of its frame doesn't read as plain success. +#[test] +fn making_and_restyling_type_reports_its_overflow() { + let mut s = session(); + // A headline in a frame lower than its line draws nothing. + let low = s.execute("text.create", &json!({"x": 0, "y": 20, "text": "NEON DRIVE", "size": 60, "area": {"width": 400, "height": 60}})).unwrap(); + assert_eq!(low["overflow"], json!([low["id"]]), "{low}"); + // One that fits says nothing, nor does point type. + let fits = s.execute("text.create", &json!({"x": 0, "y": 200, "text": "NEON DRIVE", "size": 60, "area": {"width": 400, "height": 90}})).unwrap(); + assert!(fits.get("overflow").is_none(), "{fits}"); + let point = s.execute("text.create", &json!({"x": 0, "y": 400, "text": "NEON DRIVE", "size": 60})).unwrap(); + assert!(point.get("overflow").is_none(), "{point}"); + // Growing the type pushes it out; of the two restyled, only the area type is named. + let ids = json!([fits["id"], point["id"]]); + let grown = s.execute("text.setStyle", &json!({"ids": ids, "size": 96})).unwrap(); + assert_eq!((&grown["ids"], &grown["overflow"]), (&ids, &json!([fits["id"]])), "{grown}"); + let back = s.execute("text.setStyle", &json!({"ids": ids, "size": 60})).unwrap(); + assert!(back.get("overflow").is_none(), "{back}"); + // More words than the frame holds. + let long = s.execute("text.setText", &json!({"id": fits["id"], "text": STORY})).unwrap(); + assert_eq!(long["overflow"], json!([fits["id"]]), "{long}"); + // Type flowed into a path too small for it. + let frame = s.execute("shape.rectangle", &json!({"x": 500, "y": 300, "width": 60, "height": 20})).unwrap(); + let inside = s.execute("text.createInPath", &json!({"path": frame["id"], "mode": "area", "text": STORY, "size": 12, "fit": "none"})).unwrap(); + assert_eq!(inside["overflow"], json!([inside["id"]]), "{inside}"); +} diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 8f474ec4..d0395dae 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -728,3 +728,19 @@ fn drawing_gestures_snap_to_smart_guides() { let events = json!([{"kind": "down", "x": 300, "y": 420}, {"kind": "up", "x": 300, "y": 420}, {"kind": "move", "x": 151.5, "y": 431}, {"kind": "down", "x": 151.5, "y": 431}, {"kind": "up", "x": 151.5, "y": 431}]); assert_eq!(bounds(&mut s, 6, events, "pen"), [150.0, 420.0, 150.0, 11.0]); } + +/// #1471: add_text says when the text doesn't fit its frame, and takes area type's `fit`. +#[test] +fn add_text_reports_overflow_and_takes_a_fit() { + let mut s = server(); + let made = |r: &Value| serde_json::from_str::(&text_of(r)).unwrap(); + let headline = json!({"text": "NEON DRIVE", "x": 0, "y": 20, "width": 400, "height": 60, "size": 60}); + let low = made(&call(&mut s, 1, "add_text", headline.clone())); + assert_eq!(low["overflow"], true, "{low}"); + let mut auto = headline; + auto["fit"] = json!("autoHeight"); + let auto = made(&call(&mut s, 2, "add_text", auto)); + assert!(auto["id"].is_u64() && auto.get("overflow").is_none(), "{auto}"); + let point = made(&call(&mut s, 3, "add_text", json!({"text": "NEON DRIVE", "x": 0, "y": 300, "size": 60}))); + assert!(point.get("overflow").is_none(), "{point}"); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 09451e24..7ef11758 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -351,7 +351,7 @@ pub fn tool_definitions() -> Vec { tool( "add_text", "Add text", - "Create point type at (x, y) (the first baseline), area type when width/height are given, or type in/on an existing path (`path` id + `mode` area|onPath; `pathEffect` rainbow|skew|3dRibbon|stairStep|gravity). Returns the text object id.", + "Create point type at (x, y) (the first baseline), area type when width/height are given (`fit` autoHeight grows the frame to the text, shrinkText scales the text down to fit), or type in/on an existing path (`path` id + `mode` area|onPath; `pathEffect` rainbow|skew|3dRibbon|stairStep|gravity). Returns the text object id, with `overflow: true` when the text doesn't fit its frame or path (the lines past it aren't drawn: enlarge the frame, shrink the text or give `fit`).", obj( json!({ "text": {"type": "string"}, @@ -359,6 +359,8 @@ pub fn tool_definitions() -> Vec { "y": num("First baseline y (area type: top)"), "width": num("Area type frame width"), "height": num("Area type frame height"), + "fit": {"type": "string", "enum": ["none", "autoHeight", "shrinkText"], "description": "Area type: keep the frame (none), fit its height to the text (autoHeight) or scale overflowing text down (shrinkText)"}, + "fitMinPercent": num("shrinkText: the smallest scale in percent, 10 to 100 (default 50)"), "path": {"type": "integer", "description": "Existing path id to flow the text in (area) or along (onPath)"}, "mode": {"type": "string", "enum": ["area", "onPath"]}, "pathEffect": {"type": "string", "enum": ["rainbow", "skew", "3dRibbon", "stairStep", "gravity"]}, @@ -597,13 +599,27 @@ fn add_text(b: &mut dyn Backend, a: &Args) -> Result { } if let (Some(w), Some(h)) = (a.get("width"), a.get("height")) { p["area"] = json!({"width": w, "height": h}); + for k in ["fit", "fitMinPercent"] { + if let Some(v) = a.get(k) { + p["area"][k] = v.clone(); + } + } } exec(b, "text.create", p)? }; if let Some(e) = a.get("pathEffect").and_then(Value::as_str) { exec(b, "type.pathOptions", json!({"effect": e}))?; } - Ok(json!({"id": r.get("id")})) + let mut made = json!({"id": r.get("id")}); + // A path effect is applied after the text is made: ask again how it fits then. + let overflow = match r.get("id").filter(|_| a.get("pathEffect").is_some()) { + Some(id) => exec(b, "document.node", json!({"id": id, "summary": true}))?.get("overflow").and_then(Value::as_bool) == Some(true), + None => r.get("overflow").is_some(), + }; + if overflow { + made["overflow"] = json!(true); + } + Ok(made) } fn transform(b: &mut dyn Backend, a: &Args) -> Result { diff --git a/docs/mcp.md b/docs/mcp.md index e37e51d7..41f9f557 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -287,7 +287,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`, `.dxf`, `.emf`, `.wmf`, PNG, JPEG, GIF, WebP, TIFF, BMP, PSD/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). `run_command document.formats` lists the formats. | | `save_file` | `{path?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | -| `add_text` | `{text, x?, y?, width?, height?, path?, mode?, pathEffect?, size?, font?, color?}` | Point type at (x, y); area type with `width`/`height`; or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`). | +| `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?}` | Point type at (x, y); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | | `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. | | `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). | | `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. With `copy`, the first step duplicates. | @@ -2768,6 +2768,12 @@ command also accepts that object form. The query (and every reply) of `text.area `document.inspect` (and `document.node {summary: true}`) reports `overflow` for area type and type on a path (text past the end of the path), and `fit` and `fitScale` for area type. +Text that doesn't fit is drawn up to the last line that does, so a one-line headline in a frame lower than its line +draws nothing. The commands that make type or change what it holds say so in their own reply: `text.create`, +`text.createInPath`, `text.setText` and `text.setStyle` add `overflow: [id…]` (the area type and type on a path among +the objects they made or changed whose text doesn't fit), and the `add_text` tool adds `overflow: true`. The key is +left out when everything fits. + ```json {"name":"run_command","arguments":{"command":"text.areaOptions","params":{"fit":"shrinkText","fitMinPercent":60}}} ```