From 9b0fe0ca5da1db7cff56bf8ae29912a636a163d4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 07:50:20 -0400 Subject: [PATCH] =?UTF-8?q?M1.9:=20agent=20integrations=20=E2=80=94=20MCP?= =?UTF-8?q?=20tools=20add=5Ftext,=20apply=5Feffect,=20pathfinder,=20transf?= =?UTF-8?q?orm,=20create=5Fgraph,=20text=5Fwrap,=20export=20{selection,=20?= =?UTF-8?q?outlineText};=20CLI=20convert=20(IN=20=E2=86=92=20OUT,=20effect?= =?UTF-8?q?s=20kept)=20and=20info;=20SVG=20export=20Convert=20to=20Outline?= =?UTF-8?q?s=20(text=20as=20glyph=20paths);=20TextObject=20pathEffect=20ke?= =?UTF-8?q?y=20camelCase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + apps/vectorcraft-cli/src/main.rs | 52 +++++++ crates/doc/src/text.rs | 2 +- crates/engine/src/cmd/file.rs | 23 ++- crates/mcp/src/tests.rs | 54 +++++++ crates/mcp/src/tools.rs | 241 ++++++++++++++++++++++++++++++- crates/svg/Cargo.toml | 1 + crates/svg/src/export.rs | 32 ++++ crates/svg/src/lib.rs | 12 +- crates/svg/tests/prop_svg.rs | 2 +- crates/svg/tests/roundtrip.rs | 19 +++ docs/mcp.md | 8 +- 12 files changed, 438 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1828b397..eed79a08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4226,6 +4226,7 @@ dependencies = [ "vectorcraft-effects", "vectorcraft-geom", "vectorcraft-testkit", + "vectorcraft-text", ] [[package]] diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 4a6a3c52..ef5f34a1 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -4,6 +4,8 @@ //! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] //! vectorcraft-cli run [--in file.vectorcraft|file.svg] [--cmd id [--params '{json}']]... [--export out.svg|.png|.pdf|.jpg|.webp|.vectorcraft]... [--scale 2] //! vectorcraft-cli commands +//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0] [--outline-text] +//! vectorcraft-cli info FILE //! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5] //! vectorcraft-cli perf [--paths 50000] //! ``` @@ -32,6 +34,13 @@ USAGE: vectorcraft-cli commands Print the command catalogue as JSON. + vectorcraft-cli convert IN OUT [--scale N] [--artboard I] [--outline-text] + Open IN (.vectorcraft, .svg, .pdf/.ai, images) and export OUT by extension (.svg, .pdf, .png, + .jpg, .webp, .vectorcraft). Live effects are kept; --outline-text writes SVG text as paths. + + vectorcraft-cli info FILE + Print a JSON summary: title, colour mode, units, artboards, object counts by kind, fonts. + vectorcraft-cli bench FILE [--size WxH] [--iters N] Render FILE (.vectorcraft/.svg) fitted to WxH (default 2880x1800) and print ms per frame (warm), multithreaded and single-threaded. @@ -47,6 +56,8 @@ fn main() -> ExitCode { Some("mcp") => mcp(&args[1..]), Some("run") => run(&args[1..]), Some("commands") => commands(), + Some("convert") => convert(&args[1..]), + Some("info") => info(&args[1..]), Some("bench") => bench(&args[1..]), Some("perf") => perf::run(&args[1..]), Some("-h" | "--help" | "help") | None => { @@ -106,6 +117,47 @@ fn commands() -> Result<(), String> { Ok(()) } +fn convert(args: &[String]) -> Result<(), String> { + let mut files = vec![]; + let (mut scale, mut artboard, mut outline_text) = (1.0f64, 0u64, false); + let mut it = args.iter(); + while let Some(a) = it.next() { + match a.as_str() { + "--scale" | "-s" => scale = it.next().and_then(|v| v.parse().ok()).ok_or("--scale needs a number")?, + "--artboard" | "-a" => artboard = it.next().and_then(|v| v.parse().ok()).ok_or("--artboard needs an index")?, + "--outline-text" => outline_text = true, + f => files.push(f.to_string()), + } + } + let [input, output] = <[String; 2]>::try_from(files).map_err(|_| "convert needs IN and OUT")?; + let mut h = Headless::new(); + h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))?; + let r = h + .call( + "engine.execute", + json!({"command": "document.export", "params": {"path": output, "scale": scale, "artboard": artboard, "outlineText": outline_text}}), + ) + .map_err(|e| format!("export {output}: {e}"))?; + println!("{r}"); + Ok(()) +} + +fn info(args: &[String]) -> Result<(), String> { + let file = args.first().ok_or("info needs a FILE")?; + let mut h = Headless::new(); + h.call("app.open", json!({"path": file})).map_err(|e| format!("open {file}: {e}"))?; + let base = h.call("engine.execute", json!({"command": "file.info", "params": {}}))?; + let doc = h.session.doc().map_err(|e| e.to_string())?.doc.clone(); + let mut kinds: std::collections::BTreeMap<&'static str, usize> = Default::default(); + doc.walk(|n| *kinds.entry(n.kind_label()).or_default() += 1); + let fonts = h.call("engine.execute", json!({"command": "text.fonts", "params": {}})).unwrap_or(Value::Null); + let artboards: Vec = + doc.artboards.iter().map(|a| json!({"name": a.name, "rect": [a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height()]})).collect(); + let v = json!({"file": file, "info": base, "artboards": artboards, "kinds": kinds, "fonts": fonts}); + println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); + Ok(()) +} + enum Step { Cmd(String, Value), Export(String), diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index ed67b31c..7aac5fcb 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -256,7 +256,7 @@ pub struct TextObject { #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub area: AreaOptions, /// Type on a Path effect (type on a path only). - #[serde(default, skip_serializing_if = "crate::skip::is_default")] + #[serde(default, rename = "pathEffect", skip_serializing_if = "crate::skip::is_default")] 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")] diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 50665599..af633080 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -21,7 +21,15 @@ pub fn specs() -> Vec { ), cmd!("document.save", "Save Document", [], None, "{path?} native .vectorcraft (default: the document's path) → {path}", has_doc, save), cmd!(query "document.serialize", "Serialize Document", [], None, "{format: vectorcraft|svg|pdf|png} → {dataBase64 | text}", has_doc, serialize), - cmd!("document.export", "Export Document", [], None, "{format: svg|png|pdf|vectorcraft, path, scale?: 1, artboard?: 0}", has_doc, export), + cmd!( + "document.export", + "Export Document", + [], + None, + "{format: svg|png|pdf|jpg|webp|vectorcraft, path, scale?: 1, artboard?: 0, outlineText?: bool (SVG: text as outlines)}", + has_doc, + export + ), cmd!( "document.exportSelection", "Export Selection…", @@ -114,9 +122,15 @@ pub(crate) fn encode(s: &Session, format: &str, scale: f64, artboard: usize) -> } fn encode_doc(doc: &vectorcraft_doc::Document, format: &str, scale: f64, artboard: usize) -> Result> { + encode_doc_with(doc, format, scale, artboard, false) +} + +/// `outline_text`: SVG text as glyph outlines (Illustrator's SVG Options → Fonts → Convert to Outlines). +fn encode_doc_with(doc: &vectorcraft_doc::Document, format: &str, scale: f64, artboard: usize, outline_text: bool) -> Result> { Ok(match format { "vectorcraft" => vectorcraft_format::save_file(doc), - "svg" => vectorcraft_svg::export(doc, &vectorcraft_svg::ExportOptions { artboard: Some(artboard), ..Default::default() }).into_bytes(), + "svg" => vectorcraft_svg::export(doc, &vectorcraft_svg::ExportOptions { artboard: Some(artboard), outline_text, ..Default::default() }) + .into_bytes(), "pdf" => vectorcraft_pdf::export(doc, &vectorcraft_pdf::PdfOptions::default()).map_err(|e| EngineError::Other(e.to_string()))?, "png" | "jpg" | "jpeg" | "webp" => { let r = doc.artboards.get(artboard).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?; @@ -145,7 +159,8 @@ fn export(s: &mut Session, p: &Value) -> Result { let f = str_param(p, "format") .map(str::to_string) .unwrap_or_else(|| std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_else(|| "png".into())); - let bytes = encode(s, &f, f64_or(p, "scale", 1.0), p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize)?; + let artboard = p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize; + let bytes = encode_doc_with(&s.doc()?.doc, &f, f64_or(p, "scale", 1.0), artboard, bool_or(p, "outlineText", false))?; std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; Ok(json!({ "path": path, "bytes": bytes.len() })) } @@ -172,7 +187,7 @@ fn export_selection(s: &mut Session, p: &Value) -> Result { ab.rect = bounds; ab.name = "Selection".into(); d.artboards = vec![ab]; - let bytes = encode_doc(&d, &f, f64_or(p, "scale", 1.0), 0)?; + let bytes = encode_doc_with(&d, &f, f64_or(p, "scale", 1.0), 0, bool_or(p, "outlineText", false))?; let b = [bounds.x0, bounds.y0, bounds.width(), bounds.height()]; match path { Some(path) => { diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index d3302a6e..f625104a 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -382,3 +382,57 @@ fn headless_backend_methods() { assert!(h.call("ui.tool.list", json!({})).unwrap().is_array()); assert!(h.call("ui.resize", json!({})).is_err()); } + +// ---------- workflow tools ---------- + +fn id_of(result: &Value) -> u64 { + let v: Value = serde_json::from_str(&text_of(result)).unwrap(); + v["id"].as_u64().unwrap_or_else(|| panic!("no id in {v}")) +} + +fn doc_json(s: &mut Server) -> Value { + let r = call(s, 900, "run_command", json!({"command": "document.json"})); + serde_json::from_str(&text_of(&r)).unwrap() +} + +#[test] +fn text_effect_pathfinder_transform_graph_and_wrap_tools() { + let mut s = server(); + // Text: point, area and on a path with an effect. + let t = id_of(&call(&mut s, 1, "add_text", json!({"text": "Hello", "x": 50, "y": 60, "size": 24}))); + assert!(t > 0); + let area = id_of(&call(&mut s, 2, "add_text", json!({"text": "word ".repeat(80), "x": 40, "y": 100, "width": 300, "height": 200}))); + let p = call(&mut s, 3, "draw_path", json!({"d": "M40 400 C150 300 250 500 360 400"})); + let pid = id_of(&p); + let ot = id_of(&call(&mut s, 4, "add_text", json!({"text": "On a wave", "path": pid, "mode": "onPath", "pathEffect": "skew"}))); + let d = doc_json(&mut s); + assert!(d.to_string().contains("\"pathEffect\":\"skew\""), "{ot}"); + // Effects: catalogue, then a drop shadow on the selection. + let cat = call(&mut s, 5, "apply_effect", json!({})); + assert!(text_of(&cat).contains("stylize.dropShadow")); + let r1 = id_of(&call(&mut s, 6, "draw_shape", json!({"shape": "rectangle", "x": 400, "y": 50, "width": 100, "height": 100}))); + let r2 = id_of(&call(&mut s, 7, "draw_shape", json!({"shape": "rectangle", "x": 450, "y": 100, "width": 100, "height": 100}))); + let fx = call(&mut s, 8, "apply_effect", json!({"effect": "stylize.dropShadow", "params": {"x": 4}, "ids": [r2]})); + assert!(fx.get("isError").is_none_or(|e| e == false), "{fx}"); + // Pathfinder unite over two ids. + let u = call(&mut s, 9, "pathfinder", json!({"operation": "unite", "ids": [r1, r2]})); + assert!(text_of(&u).contains("ids"), "{u}"); + // Transform: move + rotate on the selection. + let tr = call(&mut s, 10, "transform", json!({"dx": 10, "rotate": 45})); + assert!(text_of(&tr).contains("rotate")); + let bad = call(&mut s, 11, "transform", json!({})); + assert_eq!(bad["isError"], true); + // Graph. + let g = id_of(&call(&mut s, 12, "create_graph", json!({"type": "pie", "x": 50, "y": 450, "width": 200, "height": 150, "csv": ",A,B\nX,1,3"}))); + assert!(doc_json(&mut s).to_string().contains("\"kind\":\"pie\""), "{g}"); + // Text wrap: a circle over the area type. + let c = id_of(&call(&mut s, 13, "draw_shape", json!({"shape": "ellipse", "x": 40, "y": 120, "width": 120, "height": 120}))); + let w = call(&mut s, 14, "text_wrap", json!({"ids": [c], "offset": 8})); + assert!(w.get("isError").is_none_or(|e| e == false), "{w}"); + let d = doc_json(&mut s); + 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}"); + call(&mut s, 15, "text_wrap", json!({"ids": [c], "release": true})); + assert!(!doc_json(&mut s).to_string().contains("\"offset\":8.0")); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index beba0dfa..09b90610 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -260,17 +260,125 @@ pub fn tool_definitions() -> Vec { tool( "export", "Export", - "Export the active document: svg (artboard viewBox), png (rendered artboard) or vectorcraft (native).", + "Export the active document: svg (artboard viewBox), pdf, png/jpg/webp (rendered artboard) or vectorcraft (native). `selection: true` exports only the selected objects, cropped to their bounds. Live effects are kept (geometry baked, SVG filters for shadows/glows/blur).", obj( json!({ "format": {"type": "string", "enum": ["svg", "png", "pdf", "jpg", "webp", "vectorcraft"], "description": "Default: from the path's extension"}, "path": string("Destination file"), "scale": num("PNG pixels per point (default 1)"), + "selection": {"type": "boolean", "description": "Export only the selection, cropped to it"}, + "outlineText": {"type": "boolean", "description": "SVG: text as outlines (viewable without the fonts)"}, }), &["path"], ), false, ), + 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.", + obj( + json!({ + "text": {"type": "string"}, + "x": num("Baseline origin x (point/area type: left)"), + "y": num("First baseline y (area type: top)"), + "width": num("Area type frame width"), + "height": num("Area type frame height"), + "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"]}, + "size": num("Font size in pt"), + "font": {"type": "string", "description": "Font family"}, + "color": paint_schema("Text colour (#rrggbb)"), + }), + &["text"], + ), + false, + ), + tool( + "apply_effect", + "Apply live effect", + "Append a live effect (Effect menu) to the selection or `ids`. Omit `effect` to get the catalogue (ids, parameters, defaults). Examples: stylize.dropShadow {x, y, blur, opacity}, distort.roughen, warp.arc {bend}, path.offsetPath {offset}, pathfinder.add (on groups), blur.gaussian {radius}.", + obj( + json!({ + "effect": {"type": "string", "description": "Effect id (see the catalogue)"}, + "params": {"type": "object", "description": "Effect parameters; missing keys take Illustrator's dialog defaults"}, + "ids": {"type": "array", "items": {"type": "integer"}}, + }), + &[], + ), + false, + ), + tool( + "pathfinder", + "Pathfinder", + "Combine the selected (or `ids`) objects destructively, back to front: unite, minusFront, intersect, exclude, divide, trim, merge, crop, outline, minusBack. For a non-destructive version apply the pathfinder.* effect to a group.", + obj( + json!({ + "operation": {"type": "string", "enum": ["unite", "minusFront", "intersect", "exclude", "divide", "trim", "merge", "crop", "outline", "minusBack"]}, + "ids": {"type": "array", "items": {"type": "integer"}}, + }), + &["operation"], + ), + false, + ), + tool( + "transform", + "Transform", + "Move, rotate, scale, reflect or shear the selection (or `ids`); several may be combined and run in that order. Angles in degrees (counter-clockwise), scale in %. `origin` [x,y] defaults to the selection centre; `copy` transforms a copy.", + obj( + json!({ + "ids": {"type": "array", "items": {"type": "integer"}}, + "dx": num("Move right"), + "dy": num("Move down"), + "rotate": num("Rotation in degrees"), + "scale": num("Uniform scale in %"), + "scaleX": num("Horizontal scale in %"), + "scaleY": num("Vertical scale in %"), + "reflect": {"type": "string", "enum": ["vertical", "horizontal"]}, + "shear": num("Shear angle in degrees (horizontal axis)"), + "origin": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, + "copy": {"type": "boolean"}, + }), + &[], + ), + false, + ), + tool( + "create_graph", + "Create graph", + "Create a graph (Illustrator graph tools) in the plot rectangle. Data as `csv` (first row: empty cell then series names; then one row per category: label, values…) or `series`/`categories`/`rows`. Edit later with run_command graph.setData / graph.setType.", + obj( + json!({ + "type": {"type": "string", "enum": ["column", "stackedColumn", "bar", "stackedBar", "line", "area", "scatter", "pie", "radar"]}, + "x": num("Plot left"), + "y": num("Plot top"), + "width": num("Plot width"), + "height": num("Plot height"), + "csv": {"type": "string"}, + "series": {"type": "array", "items": {"type": "string"}}, + "categories": {"type": "array", "items": {"type": "string"}}, + "rows": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}}, + }), + &["x", "y", "width", "height"], + ), + false, + ), + 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.", + obj( + json!({ + "ids": {"type": "array", "items": {"type": "integer"}}, + "offset": num("Gap between text and object (default 6 pt)"), + "invert": {"type": "boolean"}, + "release": {"type": "boolean"}, + }), + &[], + ), + false, + ), tool("undo", "Undo", "Undo the last change (Edit → Undo).", empty(), false), tool("redo", "Redo", "Redo the last undone change (Edit → Redo).", empty(), false), ] @@ -383,6 +491,90 @@ fn draw_shape(b: &mut dyn Backend, a: &Args) -> Result { Ok(json!({"id": r.get("id"), "shape": shape, "paint": paint})) } +/// Select `ids` when given (tools that act on the selection). +fn select_ids(b: &mut dyn Backend, a: &Args) -> Result<(), String> { + if let Some(ids) = a.get("ids") { + exec(b, "select.set", json!({"ids": ids}))?; + } + Ok(()) +} + +fn add_text(b: &mut dyn Backend, a: &Args) -> Result { + let text = a.get("text").and_then(Value::as_str).ok_or("missing string argument `text`")?; + let mut style = json!({}); + for k in ["size", "font"] { + if let Some(v) = a.get(k) { + style[k] = v.clone(); + } + } + let r = if let Some(path) = a.get("path") { + let mut p = json!({"path": path, "mode": a.get("mode").cloned().unwrap_or(json!("onPath")), "text": text}); + for (k, v) in style.as_object().unwrap() { + p[k.as_str()] = v.clone(); + } + exec(b, "text.createInPath", p)? + } else { + let x = a.get("x").and_then(Value::as_f64).ok_or("give `x` and `y` (or a `path`)")?; + let y = a.get("y").and_then(Value::as_f64).ok_or("give `x` and `y` (or a `path`)")?; + let mut p = json!({"x": x, "y": y, "text": text}); + for (k, v) in style.as_object().unwrap() { + p[k.as_str()] = v.clone(); + } + if let Some(c) = a.get("color") { + p["color"] = c.clone(); + } + if let (Some(w), Some(h)) = (a.get("width"), a.get("height")) { + p["area"] = json!({"width": w, "height": h}); + } + 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")})) +} + +fn transform(b: &mut dyn Backend, a: &Args) -> Result { + select_ids(b, a)?; + // With `copy`, the first operation duplicates and the rest transform the copy (the new selection). + let copy = std::cell::Cell::new(a.get("copy").and_then(Value::as_bool).unwrap_or(false)); + let with = |mut p: Value| { + if let Some(o) = a.get("origin") { + p["origin"] = o.clone(); + } + p["copy"] = json!(copy.replace(false)); + p + }; + let mut done = vec![]; + if a.contains_key("dx") || a.contains_key("dy") { + let num = |k: &str| a.get(k).and_then(Value::as_f64).unwrap_or(0.0); + exec(b, "object.move", json!({"dx": num("dx"), "dy": num("dy"), "copy": copy.replace(false)}))?; + done.push("move"); + } + if let Some(r) = a.get("rotate").and_then(Value::as_f64) { + exec(b, "object.rotate", with(json!({"angle": r})))?; + done.push("rotate"); + } + let s = a.get("scale").and_then(Value::as_f64); + let (sx, sy) = (a.get("scaleX").and_then(Value::as_f64).or(s), a.get("scaleY").and_then(Value::as_f64).or(s)); + if sx.is_some() || sy.is_some() { + exec(b, "object.scale", with(json!({"sx": sx.unwrap_or(100.0), "sy": sy.unwrap_or(100.0)})))?; + done.push("scale"); + } + if let Some(axis) = a.get("reflect").and_then(Value::as_str) { + exec(b, "object.reflect", with(json!({"axis": axis})))?; + done.push("reflect"); + } + if let Some(sh) = a.get("shear").and_then(Value::as_f64) { + exec(b, "object.shear", with(json!({"angle": sh})))?; + done.push("shear"); + } + if done.is_empty() { + return Err("give at least one of dx/dy, rotate, scale/scaleX/scaleY, reflect, shear".into()); + } + Ok(json!({"applied": done})) +} + fn screenshot(b: &mut dyn Backend, a: &Args) -> Result { let path = a.get("path").and_then(Value::as_str); if a.get("window").and_then(Value::as_bool) == Some(true) { @@ -547,12 +739,59 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result j(add_text(b, a)?), + "apply_effect" => { + let Some(effect) = a.get("effect").and_then(Value::as_str) else { + let all = exec(b, "effect.list", json!({}))?; + return j(json!({"catalog": all.get("catalog")})); + }; + let mut p = json!({"effect": effect, "params": a.get("params").cloned().unwrap_or(json!({}))}); + if let Some(ids) = a.get("ids") { + p["ids"] = ids.clone(); + } + j(exec(b, "effect.apply", p)?) + } + "pathfinder" => { + select_ids(b, a)?; + j(exec(b, &format!("object.pathfinder.{}", req_str(a, "operation")?), json!({}))?) + } + "transform" => j(transform(b, a)?), + "create_graph" => { + let mut p = Value::Object(a.clone()); + if p.get("type").is_none() { + p["type"] = json!("column"); + } + j(exec(b, "graph.create", p)?) + } + "text_wrap" => { + select_ids(b, a)?; + if a.get("release").and_then(Value::as_bool) == Some(true) { + 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!({}))?), "redo" => j(exec(b, "edit.redo", json!({}))?), other => Err(format!("unknown tool `{other}` (see tools/list)")), diff --git a/crates/svg/Cargo.toml b/crates/svg/Cargo.toml index d0663ae1..97f496fa 100644 --- a/crates/svg/Cargo.toml +++ b/crates/svg/Cargo.toml @@ -13,6 +13,7 @@ vectorcraft-geom = { workspace = true } vectorcraft-color = { workspace = true } vectorcraft-doc = { workspace = true } vectorcraft-effects = { workspace = true } +vectorcraft-text = { workspace = true } kurbo = { workspace = true } usvg = { workspace = true } thiserror = { workspace = true } diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index ef35d136..ba71ddca 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -655,6 +655,7 @@ impl Writer<'_> { } self.shape(n, &d.join(" "), *rule, n.geometric_bounds()); } + NodeKind::Text(t) if self.opts.outline_text => self.text_outlines(n, t), NodeKind::Text(t) => self.text(n, t), NodeKind::Image(im) => { let href = match self.doc.images.get(&im.key) { @@ -816,6 +817,37 @@ impl Writer<'_> { p } + /// Text as glyph outlines: one compound path per run, painted like the run. + fn text_outlines(&mut self, n: &Node, t: &TextObject) { + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let m = self.xf * t.xf; + let mut runs: Vec<(usize, kurbo::BezPath)> = vec![]; + for g in &lay.glyphs { + match runs.last_mut() { + Some((r, bp)) if *r == g.run => bp.extend(g.outline.iter()), + _ => runs.push((g.run, g.outline.clone())), + } + } + let id = self.id_attr(n); + let a = self.attrs(&Self::node_props(n)); + self.line(&format!("")); + self.depth += 1; + for (r, mut bp) in runs { + let Some(run) = t.runs.get(r) else { continue }; + bp.apply_affine(m); + let pd = PathData::from_bezpath(&bp); + let st = &run.style; + // An unnamed id: the pieces carry no id attribute (the group has it). + let glyphs = + Node::path(NodeId(u64::MAX), pd.clone(), vectorcraft_doc::Appearance::basic(st.fill.clone(), st.stroke.clone(), st.stroke_width)); + let d = self.path_d(&pd, Affine::IDENTITY); + let bounds = pd.bounds(); + self.shape(&glyphs, &d, FillRule::NonZero, bounds); + } + self.depth -= 1; + self.line(""); + } + fn text(&mut self, n: &Node, t: &TextObject) { let first = t.first_style(); let lead = first.effective_leading(); diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index b6f135b6..932ce101 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -57,11 +57,21 @@ pub struct ExportOptions { pub minify: bool, /// Omit `width`/`height` so the SVG scales to its container. pub responsive: bool, + /// Fonts → Convert to Outlines: text becomes paths (portable, no font needed to view it). + pub outline_text: bool, } impl Default for ExportOptions { fn default() -> Self { - Self { artboard: Some(0), styling: Styling::PresentationAttributes, decimals: 3, object_ids: true, minify: false, responsive: false } + Self { + artboard: Some(0), + styling: Styling::PresentationAttributes, + decimals: 3, + object_ids: true, + minify: false, + responsive: false, + outline_text: false, + } } } diff --git a/crates/svg/tests/prop_svg.rs b/crates/svg/tests/prop_svg.rs index 0f917e36..4c95dfc8 100644 --- a/crates/svg/tests/prop_svg.rs +++ b/crates/svg/tests/prop_svg.rs @@ -15,7 +15,7 @@ fn all_options() -> Vec { let mut v = vec![]; for styling in [Styling::PresentationAttributes, Styling::InlineStyle, Styling::InternalCss] { for (minify, decimals, object_ids, responsive) in [(false, 3, true, false), (true, 1, false, true), (false, 7, true, true)] { - v.push(ExportOptions { artboard: Some(0), styling, decimals, object_ids, minify, responsive }); + v.push(ExportOptions { artboard: Some(0), styling, decimals, object_ids, minify, responsive, outline_text: false }); } } v.push(ExportOptions { artboard: None, ..Default::default() }); diff --git a/crates/svg/tests/roundtrip.rs b/crates/svg/tests/roundtrip.rs index 4fac33c9..f3159fe0 100644 --- a/crates/svg/tests/roundtrip.rs +++ b/crates/svg/tests/roundtrip.rs @@ -711,3 +711,22 @@ fn live_effects_export_as_geometry_and_filters() { let b = art(&back).iter().filter_map(|n| n.geometric_bounds()).fold(None, |a: Option, b| Some(a.map_or(b, |a| a.union(b)))).unwrap(); assert!((b.width() - 120.0).abs() < 1.0, "{b:?}"); } + +#[test] +fn outline_text_writes_glyph_paths() { + let mut d = Document::new(300.0, 100.0); + let mut st = CharStyle { size: 30.0, ..CharStyle::default() }; + st.fill = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let t = TextObject::point(Point::new(10.0, 50.0), "Hi there", st); + let id = d.alloc_id(); + let l = d.layers[0].id; + d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap(); + let plain = export(&d, &ExportOptions::default()); + assert!(plain.contains(" 80.0 && b.x0 >= 9.0, "{b:?}"); +} diff --git a/docs/mcp.md b/docs/mcp.md index 0d0f122f..305924c6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -59,7 +59,13 @@ or a full `paint.setFill` params object (`{"gradient": …}`, `{"swatch": "name" | `screenshot` | `{path?, scale?, artboard?, window?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard; `window:true` captures the app window (remote only). | | `open_file` | `{path}` | Opens `.vectorcraft` or `.svg` as a new active document. | | `save_file` | `{path?}` | Saves in the native `.vectorcraft` format. | -| `export` | `{path, format?, scale?}` | `svg`, `png` or `vectorcraft`. When `format` is omitted, it comes from the path's extension. | +| `export` | `{path, format?, scale?, selection?}` | `svg`, `pdf`, `png`, `jpg`, `webp` or `vectorcraft`. When `format` is omitted, it comes from the path's extension. `selection: true` exports the selected objects cropped to their bounds; `outlineText: true` writes SVG text as paths. Live effects are kept. | +| `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`). | +| `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. | +| `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. | +| `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types. Edit later with `graph.setData` / `graph.setType` via `run_command`. | +| `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. | | `undo` / `redo` | `{}` | | Errors (unknown tool, bad arguments, a disabled or failing command) come back as a normal result with