M1.9: agent integrations — MCP tools add_text, apply_effect, pathfinder, transform, create_graph, text_wrap, export {selection, outlineText}; CLI convert (IN → OUT, effects kept) and info; SVG export Convert to Outlines (text as glyph paths); TextObject pathEffect key camelCase

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-02 07:50:20 -04:00
1 parent 1d493c7f06
commit 9b0fe0ca5d
12 files changed
+438 -9

No files matched your search

Generated
+1
View File
@@ -4226,6 +4226,7 @@ dependencies = [
"vectorcraft-effects",
"vectorcraft-geom",
"vectorcraft-testkit",
"vectorcraft-text",
]
[[package]]
+52
View File
@@ -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<Value> =
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),
+1 -1
View File
@@ -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")]
+19 -4
View File
@@ -21,7 +21,15 @@ pub fn specs() -> Vec<CommandSpec> {
),
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<Vec<u8>> {
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<Vec<u8>> {
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<Value> {
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<Value> {
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) => {
+54
View File
@@ -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"));
}
+240 -1
View File
@@ -260,17 +260,125 @@ pub fn tool_definitions() -> Vec<Value> {
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<Value, String> {
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<Value, String> {
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<Value, String> {
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<ToolResult, String> {
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<ToolResult, Str
return Err(format!("unknown export format `{fmt}` (svg, png, pdf, jpg, webp, vectorcraft)"));
}
let scale = a.get("scale").and_then(Value::as_f64).unwrap_or(1.0);
if a.get("selection").and_then(Value::as_bool) == Some(true) {
return j(exec(
b,
"document.exportSelection",
json!({"path": path, "format": fmt, "scale": scale, "outlineText": a.get("outlineText")}),
)?);
}
if a.get("outlineText").and_then(Value::as_bool) == Some(true) {
return j(exec(b, "document.export", json!({"path": path, "format": fmt, "scale": scale, "outlineText": true}))?);
}
// The desktop app exports native files through Save (which also sets the document path).
if fmt == "vectorcraft" && b.has_ui() {
return j(b.call("app.save", json!({"path": path}))?);
}
j(b.call("app.export", json!({"format": fmt, "path": path, "scale": scale}))?)
}
"add_text" => 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)")),
+1
View File
@@ -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 }
+32
View File
@@ -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!("<g{id}{a}>"));
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("</g>");
}
fn text(&mut self, n: &Node, t: &TextObject) {
let first = t.first_style();
let lead = first.effective_leading();
+11 -1
View File
@@ -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,
}
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ fn all_options() -> Vec<ExportOptions> {
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() });
+19
View File
@@ -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<Rect>, 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("<text"));
let s = export(&d, &ExportOptions { outline_text: true, ..Default::default() });
assert!(!s.contains("<text") && s.contains("<path") && s.contains("#ff0000"), "{s}");
// The outlines come back as paths spanning the text's width.
let back = import(&s).unwrap();
let b = art(&back).iter().filter_map(|n| n.geometric_bounds()).reduce(|a, b| a.union(b)).unwrap();
assert!(b.width() > 80.0 && b.x0 >= 9.0, "{b:?}");
}
+7 -1
View File
@@ -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