mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
1 parent
1d493c7f06
commit
9b0fe0ca5d
12 files changed
+438
-9
No files matched your search
Generated
+1
@@ -4226,6 +4226,7 @@ dependencies = [
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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)")),
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user