mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
M4.13: export correctness and MCP export parity — document.export/serialize honour artboard, artboards or range ("1-3, 5") for PDF (one page per artboard) and return dataBase64 without a path; Export for Screens writes one page per PDF, ignores scale and @Nx suffixes for vector formats, names duplicate artboards -2/-3 and returns bytes without a folder; raster exports, thumbnails and Export Selection leave template layers out (RenderOptions.skip_templates); file.exportForScreens with params runs the engine; the MCP export tool takes artboard/range/options, lists formats from the engine and never retargets the document; CLI convert --range
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
4d503c0327
commit
45a7302601
14 files changed
+347
-90
No files matched your search
@@ -4,7 +4,7 @@
|
||||
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless]
|
||||
//! vectorcraft-cli run [--in FILE] [--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 convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text]
|
||||
//! vectorcraft-cli info FILE
|
||||
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
|
||||
//! vectorcraft-cli perf [--paths 50000]
|
||||
@@ -34,9 +34,11 @@ USAGE:
|
||||
vectorcraft-cli commands
|
||||
Print the command catalogue as JSON.
|
||||
|
||||
vectorcraft-cli convert IN OUT [--scale N] [--artboard I] [--outline-text]
|
||||
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text]
|
||||
Open IN (any readable format) and export OUT by extension (.svg, .pdf, .png, .jpg, .webp,
|
||||
.vectorcraft). Live effects are kept; --outline-text writes SVG text as paths.
|
||||
.vectorcraft). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
|
||||
unless one of them is given, the other formats the first. 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.
|
||||
@@ -124,12 +126,13 @@ fn commands() -> Result<(), String> {
|
||||
|
||||
fn convert(args: &[String]) -> Result<(), String> {
|
||||
let mut files = vec![];
|
||||
let (mut scale, mut artboard, mut outline_text) = (1.0f64, 0u64, false);
|
||||
let (mut scale, mut artboard, mut range, mut outline_text) = (1.0f64, None::<u64>, None::<String>, 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")?,
|
||||
"--artboard" | "-a" => artboard = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--artboard needs an index")?),
|
||||
"--range" | "-r" => range = Some(it.next().cloned().ok_or("--range needs artboards such as 1-3,5")?),
|
||||
"--outline-text" => outline_text = true,
|
||||
f => files.push(f.to_string()),
|
||||
}
|
||||
@@ -140,7 +143,7 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
let r = h
|
||||
.call(
|
||||
"engine.execute",
|
||||
json!({"command": "document.export", "params": {"path": output, "scale": scale, "artboard": artboard, "outlineText": outline_text}}),
|
||||
json!({"command": "document.export", "params": {"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text}}),
|
||||
)
|
||||
.map_err(|e| format!("export {output}: {e}"))?;
|
||||
println!("{r}");
|
||||
|
||||
@@ -54,6 +54,13 @@ fn convert_pdf_to_svg_and_info_on_ai() {
|
||||
assert_eq!(v["artboards"][0]["rect"][2], 120.0);
|
||||
// `run --in` takes it too.
|
||||
ok(&["run", "--in", &ai, "--cmd", "document.inspect"]);
|
||||
// A PDF keeps every artboard unless --artboard / --range names some.
|
||||
let all = tmp("all.pdf");
|
||||
ok(&["convert", &ai, &all]);
|
||||
assert_eq!(info(&all)["artboards"].as_array().unwrap().len(), 2);
|
||||
let second = tmp("second.pdf");
|
||||
ok(&["convert", &ai, &second, "--range", "2"]);
|
||||
assert_eq!(info(&second)["artboards"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -81,7 +81,7 @@ fn options<T: DeserializeOwned + Default>(f: &Format, p: &Value) -> Result<T> {
|
||||
}
|
||||
|
||||
/// Encode `doc` as `format` (an id or extension from [`super::FORMATS`]) with that format's
|
||||
/// options from `p` (see `document.formats`).
|
||||
/// options from `p` (see `document.formats`). Raster formats leave template layers out.
|
||||
pub fn encode(doc: &Document, format: &str, p: &Value) -> Result<Vec<u8>> {
|
||||
let f = super::writable(C, Some(format), None)?;
|
||||
let n = doc.artboards.len();
|
||||
@@ -93,7 +93,11 @@ pub fn encode(doc: &Document, format: &str, p: &Value) -> Result<Vec<u8>> {
|
||||
let opts = vectorcraft_svg::ExportOptions { artboard, outline_text: o.outline_text.unwrap_or(false), ..Default::default() };
|
||||
vectorcraft_svg::export(doc, &opts).into_bytes()
|
||||
}
|
||||
"pdf" => super::super::rasterfx::export_pdf(doc, &vectorcraft_pdf::PdfOptions::default()).map_err(|e| EngineError::Other(e.to_string()))?,
|
||||
"pdf" => {
|
||||
let o: ArtboardPick = options(f, p)?;
|
||||
let opts = vectorcraft_pdf::PdfOptions { artboards: boards(o.resolve(n))?, ..Default::default() };
|
||||
super::super::rasterfx::export_pdf(doc, &opts).map_err(|e| EngineError::Other(e.to_string()))?
|
||||
}
|
||||
"png" | "jpg" | "webp" => {
|
||||
let o: RasterOptions = options(f, p)?;
|
||||
let region = doc.artboards[boards(o.boards.one(n))?].rect;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
//! `document.export`, `document.serialize`, `document.exportSelection`, `document.exportForScreens`.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::Node;
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
|
||||
use super::super::*;
|
||||
use super::{create_dir, encode, writable, write_file, write_or_return};
|
||||
use super::{ArtboardPick, Format, create_dir, encode, writable, write_file, write_or_return};
|
||||
|
||||
pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?;
|
||||
@@ -18,10 +20,10 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("document.export", "missing path"))?;
|
||||
let f = writable("document.export", str_param(p, "format"), Some(path))?;
|
||||
let path = str_param(p, "path");
|
||||
let f = writable("document.export", str_param(p, "format"), path)?;
|
||||
let bytes = encode(&s.doc()?.doc, f.id, p)?;
|
||||
write_or_return(Some(path), &bytes, json!({ "format": f.id }))
|
||||
write_or_return(path, &bytes, json!({ "format": f.id }))
|
||||
}
|
||||
|
||||
/// `p` without its artboard choice (for documents made of one synthetic artboard).
|
||||
@@ -35,14 +37,16 @@ fn without_artboards(p: &Value) -> Value {
|
||||
q
|
||||
}
|
||||
|
||||
/// File → Export Selection: the selected objects alone, cropped to their visual bounds.
|
||||
/// File → Export Selection: the selected objects alone, cropped to their visual bounds. Objects on
|
||||
/// template layers are guides, not artwork, and are left out.
|
||||
pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.exportSelection";
|
||||
let path = str_param(p, "path");
|
||||
let f = writable(C, str_param(p, "format"), path)?;
|
||||
let ids = edit::selected_roots(s)?;
|
||||
let st = s.doc()?;
|
||||
let nodes: Vec<Arc<Node>> = ids.into_iter().filter_map(|id| st.doc.node(id).cloned().map(Arc::new)).collect();
|
||||
let on_template = |id| st.doc.layer_of(id).and_then(|l| st.doc.node(l)).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. }));
|
||||
let nodes: Vec<Arc<Node>> = ids.into_iter().filter(|id| !on_template(*id)).filter_map(|id| st.doc.node(id).cloned().map(Arc::new)).collect();
|
||||
let bounds = nodes.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)).ok_or_else(|| bad(C, "select something to export"))?;
|
||||
let mut d = (*st.doc).clone();
|
||||
let mut layer = Node::layer(d.alloc_id(), "Selection", vectorcraft_doc::LayerColor::Preset(0));
|
||||
@@ -58,39 +62,79 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
write_or_return(path, &bytes, json!({ "bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()] }))
|
||||
}
|
||||
|
||||
/// One Export for Screens format row: what to write, at which scale, with which file-name suffix.
|
||||
struct ScreenFormat {
|
||||
format: &'static Format,
|
||||
/// The row's own options (scale, quality…) for the encoder.
|
||||
options: Value,
|
||||
suffix: String,
|
||||
}
|
||||
|
||||
fn screen_format(row: &Value) -> Result<ScreenFormat> {
|
||||
const C: &str = "document.exportForScreens";
|
||||
if !row.is_object() {
|
||||
return Err(bad(C, "each format is an object {format, scale?, suffix?}"));
|
||||
}
|
||||
let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?;
|
||||
if format.id == "vectorcraft" {
|
||||
return Err(bad(C, "Export for Screens writes png, jpg, webp, svg or pdf"));
|
||||
}
|
||||
// Vector formats have no pixel size: scale doesn't apply and adds no @Nx suffix.
|
||||
let scale = if format.raster { f64_or(row, "scale", 1.0) } else { 1.0 };
|
||||
let suffix =
|
||||
str_param(row, "suffix").map(str::to_string).unwrap_or_else(|| if (scale - 1.0).abs() < 1e-9 { String::new() } else { format!("@{scale}x") });
|
||||
let mut options = without_artboards(row);
|
||||
if let Some(o) = options.as_object_mut() {
|
||||
o.insert("scale".into(), json!(scale));
|
||||
}
|
||||
Ok(ScreenFormat { format, options, suffix })
|
||||
}
|
||||
|
||||
/// File → Export for Screens: every chosen artboard in every format, one file each (a PDF holds
|
||||
/// its artboard alone). Artboards that share a name get `-2`, `-3`… instead of overwriting.
|
||||
pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.exportForScreens";
|
||||
let folder = str_param(p, "folder").ok_or_else(|| bad(C, "missing folder"))?;
|
||||
let doc = s.doc()?.doc.clone();
|
||||
let n = doc.artboards.len();
|
||||
let boards: Vec<usize> = match p.get("artboards").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(Value::as_u64).map(|v| v as usize).filter(|i| *i < n).collect(),
|
||||
None => (0..n).collect(),
|
||||
};
|
||||
let formats: Vec<(&str, f64, String)> = match p.get("formats").and_then(Value::as_array) {
|
||||
Some(a) => a
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let sc = f64_or(f, "scale", 1.0);
|
||||
let suffix = str_param(f, "suffix")
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| if (sc - 1.0).abs() < 1e-9 { String::new() } else { format!("@{sc}x") });
|
||||
(str_param(f, "format").unwrap_or("png"), sc, suffix)
|
||||
})
|
||||
.collect(),
|
||||
None => vec![("png", 1.0, String::new())],
|
||||
let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(C, e.to_string()))? } else { ArtboardPick::default() };
|
||||
let boards = pick.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect());
|
||||
let formats: Vec<ScreenFormat> = match p.get("formats").and_then(Value::as_array) {
|
||||
Some(rows) => rows.iter().map(screen_format).collect::<Result<_>>()?,
|
||||
None => vec![screen_format(&json!({}))?],
|
||||
};
|
||||
let prefix = str_param(p, "prefix").unwrap_or("");
|
||||
create_dir(folder)?;
|
||||
let folder = str_param(p, "folder");
|
||||
if let Some(dir) = folder {
|
||||
create_dir(dir)?;
|
||||
}
|
||||
let mut names = HashSet::new();
|
||||
let mut written = HashSet::new();
|
||||
let mut files = vec![];
|
||||
for b in boards {
|
||||
let name: String = doc.artboards[b].name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
|
||||
for (fmt, sc, suffix) in &formats {
|
||||
let f = writable(C, Some(fmt), None)?;
|
||||
let bytes = encode(&doc, f.id, &json!({ "artboard": b, "scale": sc }))?;
|
||||
let path = format!("{folder}/{prefix}{name}{suffix}.{}", f.extensions[0]);
|
||||
write_file(&path, &bytes)?;
|
||||
files.push(path);
|
||||
let base: String = doc.artboards[b].name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
|
||||
let mut name = base.clone();
|
||||
for i in 2.. {
|
||||
if names.insert(name.clone()) {
|
||||
break;
|
||||
}
|
||||
name = format!("{base}-{i}");
|
||||
}
|
||||
for sf in &formats {
|
||||
let file = format!("{prefix}{name}{}.{}", sf.suffix, sf.format.extensions[0]);
|
||||
if !written.insert(file.clone()) {
|
||||
continue; // the same file from two identical rows
|
||||
}
|
||||
let mut options = sf.options.clone();
|
||||
options["artboard"] = json!(b);
|
||||
let bytes = encode(&doc, sf.format.id, &options)?;
|
||||
match folder {
|
||||
Some(dir) => {
|
||||
let path = format!("{dir}/{file}");
|
||||
write_file(&path, &bytes)?;
|
||||
files.push(json!(path));
|
||||
}
|
||||
None => files.push(json!({ "name": file, "dataBase64": vectorcraft_format::base64_encode(&bytes) })),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({ "files": files }))
|
||||
|
||||
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export Document",
|
||||
[],
|
||||
None,
|
||||
"{path, format?: svg|pdf|png|jpg|webp|vectorcraft (default: from the path's extension, else png), artboard?: 0 (svg and raster), scale?: 1 (raster), quality?: 90 (jpg), outlineText?: bool (svg)} → {path, format, bytes}",
|
||||
"{path?, format?: svg|pdf|png|jpg|webp|vectorcraft (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" (1-based; PDF writes one page per artboard, default all; the other formats write one artboard), scale?: 1 (raster), quality?: 90 (jpg), outlineText?: bool (svg)} → {path, format, bytes}; no path → {dataBase64, format, bytes}. Never changes the document's path",
|
||||
has_doc,
|
||||
export::export
|
||||
),
|
||||
@@ -66,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export Selection…",
|
||||
["File"],
|
||||
None,
|
||||
"{path?, format?: png|jpg|webp|svg|pdf (default: from the extension, else png), scale?: 1, …the format's options} the selected objects cropped to their bounds → {path, bytes, bounds} (no path → {dataBase64, bounds})",
|
||||
"{path?, format?: png|jpg|webp|svg|pdf (default: from the extension, else png), scale?: 1, …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, bytes, bounds} (no path → {dataBase64, bounds})",
|
||||
has_selection,
|
||||
export::export_selection
|
||||
),
|
||||
@@ -84,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export for Screens",
|
||||
["File", "Export"],
|
||||
None,
|
||||
"{folder, artboards?: [index…] (default all), formats?: [{format: png|jpg|webp|svg|pdf, scale?: 1, suffix?: \"@2x\"}], prefix?} → {files: [...]}",
|
||||
"{folder?, artboards?: [index…] | range?: \"1-3\" (default all), formats?: [{format: png|jpg|webp|svg|pdf, scale?: 1 (raster only), suffix?: \"@2x\" (raster default: @{scale}x when scale ≠ 1)}], prefix?} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name get -2, -3…) → {files: [path…]}; no folder → {files: [{name, dataBase64}]}",
|
||||
has_doc,
|
||||
export::export_for_screens
|
||||
),
|
||||
@@ -159,6 +159,14 @@ const ARTBOARD: FormatOption = FormatOption {
|
||||
default: "0",
|
||||
description: "0-based artboard to export (`artboards: [i]` or `range: \"n\"` naming one artboard work too)",
|
||||
};
|
||||
const ARTBOARDS: FormatOption =
|
||||
FormatOption { name: "artboards", ty: "array", default: "null", description: "0-based artboards, one page each (default: all)" };
|
||||
const RANGE: FormatOption = FormatOption {
|
||||
name: "range",
|
||||
ty: "string",
|
||||
default: "null",
|
||||
description: "1-based artboards such as \"1-3, 5\" (wins over artboards and artboard)",
|
||||
};
|
||||
const SCALE: FormatOption = FormatOption { name: "scale", ty: "number", default: "1", description: "pixels per point (0.01–64)" };
|
||||
const QUALITY: FormatOption = FormatOption { name: "quality", ty: "integer", default: "90", description: "JPEG quality 1–100" };
|
||||
const OUTLINE_TEXT: FormatOption =
|
||||
@@ -192,7 +200,16 @@ pub const FORMATS: &[Format] = &[
|
||||
options: &[ARTBOARD, OUTLINE_TEXT],
|
||||
},
|
||||
reader("svgz", "SVG Compressed", &["svgz"], "image/svg+xml", false),
|
||||
Format { id: "pdf", label: "PDF", extensions: &["pdf"], mime: "application/pdf", read: true, write: true, raster: false, options: &[] },
|
||||
Format {
|
||||
id: "pdf",
|
||||
label: "PDF",
|
||||
extensions: &["pdf"],
|
||||
mime: "application/pdf",
|
||||
read: true,
|
||||
write: true,
|
||||
raster: false,
|
||||
options: &[ARTBOARD, ARTBOARDS, RANGE],
|
||||
},
|
||||
reader("ai", "PDF-compatible .ai", &["ai"], "application/pdf", false),
|
||||
reader("ait", "PDF-compatible .ait template", &["ait"], "application/pdf", false),
|
||||
Format { id: "png", label: "PNG", extensions: &["png"], mime: "image/png", read: true, write: true, raster: true, options: &[ARTBOARD, SCALE] },
|
||||
|
||||
@@ -173,3 +173,108 @@ fn ranges_parse_one_based() {
|
||||
assert!(parse_range(bad, 5).is_err(), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Pages in a PDF, counted by importing it (one artboard per page).
|
||||
fn pdf_pages(bytes: &[u8]) -> usize {
|
||||
vectorcraft_pdf::import(bytes).unwrap().artboards.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_export_honours_artboard_and_range() {
|
||||
let mut s = session(100.0, 80.0, 3);
|
||||
let pages = |s: &mut Session, p: Value| pdf_pages(&b64(&s.execute("document.export", &p).unwrap()));
|
||||
assert_eq!(pages(&mut s, json!({"format": "pdf"})), 3);
|
||||
assert_eq!(pages(&mut s, json!({"format": "pdf", "artboard": 1})), 1);
|
||||
assert_eq!(pages(&mut s, json!({"format": "pdf", "artboards": [0, 2]})), 2);
|
||||
assert_eq!(pages(&mut s, json!({"format": "pdf", "range": "2-3"})), 2);
|
||||
assert_eq!(pdf_pages(&b64(&s.execute("document.serialize", &json!({"format": "pdf", "artboard": 2})).unwrap())), 1);
|
||||
assert!(s.execute("document.export", &json!({"format": "pdf", "artboard": 3})).is_err());
|
||||
assert!(s.execute("document.export", &json!({"format": "pdf", "range": "1-4"})).is_err());
|
||||
// Single-image formats take one artboard.
|
||||
assert!(s.execute("document.export", &json!({"format": "png", "range": "1-2"})).is_err());
|
||||
let png = b64(&s.execute("document.export", &json!({"format": "png", "range": "2", "scale": 0.5})).unwrap());
|
||||
assert_eq!(image::load_from_memory(&png).unwrap().width(), 50);
|
||||
assert!(s.execute("document.export", &json!({"format": "png", "scale": "big"})).is_err(), "typed options reject a bad type");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_without_path_returns_bytes_and_never_retargets() {
|
||||
let mut s = session(100.0, 100.0, 1);
|
||||
let dir = tmp_dir("retarget");
|
||||
let doc_path = dir.join("doc.vectorcraft").to_string_lossy().to_string();
|
||||
s.execute("document.save", &json!({"path": doc_path})).unwrap();
|
||||
let copy = dir.join("copy.vectorcraft").to_string_lossy().to_string();
|
||||
let r = s.execute("document.export", &json!({"path": copy})).unwrap();
|
||||
assert_eq!(r["format"], "vectorcraft");
|
||||
assert!(vectorcraft_format::sniff(&std::fs::read(©).unwrap()));
|
||||
assert_eq!(s.doc().unwrap().path.as_deref(), Some(doc_path.as_str()), "exporting the native format keeps the document's path");
|
||||
let r = s.execute("document.export", &json!({"format": "jpg"})).unwrap();
|
||||
assert_eq!(&b64(&r)[..2], [0xFF, 0xD8]);
|
||||
assert!(r.get("path").is_none());
|
||||
assert!(s.execute("document.export", &json!({"path": dir.join("x.bmp").to_string_lossy()})).is_err(), "BMP is read-only");
|
||||
// A never-saved document hands its native bytes back.
|
||||
let mut s = session(10.0, 10.0, 1);
|
||||
let r = s.execute("document.save", &json!({})).unwrap();
|
||||
assert!(vectorcraft_format::sniff(&b64(&r)));
|
||||
assert_eq!(s.doc().unwrap().path, None);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A red square on a template layer and a blue one on a normal layer above it.
|
||||
fn template_doc() -> (Session, u64, u64) {
|
||||
let mut s = session(100.0, 100.0, 1);
|
||||
let template = s.doc().unwrap().doc.layers[0].id.0;
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
let red = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("layer.new", &json!({})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
|
||||
let blue = s.execute("shape.rectangle", &json!({"x": 50, "y": 0, "width": 50, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("layer.setProps", &json!({"id": template, "template": true})).unwrap();
|
||||
(s, red, blue)
|
||||
}
|
||||
|
||||
fn has_red(png: &[u8]) -> bool {
|
||||
image::load_from_memory(png).unwrap().to_rgba8().pixels().any(|p| p[0] > 200 && p[2] < 80 && p[3] > 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raster_exports_leave_template_layers_out() {
|
||||
let (mut s, red, blue) = template_doc();
|
||||
let png = b64(&s.execute("document.export", &json!({"format": "png"})).unwrap());
|
||||
assert!(!has_red(&png), "no red pixels from the template layer");
|
||||
assert!(image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(75, 50)[2] > 200, "the normal layer is exported");
|
||||
s.execute("select.set", &json!({"ids": [red, blue]})).unwrap();
|
||||
let r = s.execute("document.exportSelection", &json!({"format": "png"})).unwrap();
|
||||
assert!(!has_red(&b64(&r)));
|
||||
assert!(r["bounds"][0].as_f64().unwrap() > 40.0, "only the blue square (and its stroke) is exported: {}", r["bounds"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_for_screens_one_page_per_pdf_unique_names() {
|
||||
let mut s = session(100.0, 80.0, 3);
|
||||
s.execute("artboard.setProps", &json!({"index": 0, "name": "Icon"})).unwrap();
|
||||
s.execute("artboard.setProps", &json!({"index": 1, "name": "Icon"})).unwrap();
|
||||
let dir = tmp_dir("screens");
|
||||
let folder = dir.to_string_lossy().to_string();
|
||||
let r = s
|
||||
.execute(
|
||||
"document.exportForScreens",
|
||||
&json!({"folder": folder, "formats": [{"format": "pdf"}, {"format": "svg", "scale": 2}, {"format": "svg", "scale": 3}, {"format": "png", "scale": 2}]}),
|
||||
)
|
||||
.unwrap();
|
||||
let files: Vec<String> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect();
|
||||
let names: Vec<&str> = files.iter().map(|f| f.rsplit('/').next().unwrap()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
["Icon.pdf", "Icon.svg", "[email protected]", "Icon-2.pdf", "Icon-2.svg", "[email protected]", "Artboard-3.pdf", "Artboard-3.svg", "[email protected]"]
|
||||
);
|
||||
for f in files.iter().filter(|f| f.ends_with(".pdf")) {
|
||||
assert_eq!(pdf_pages(&std::fs::read(f).unwrap()), 1, "{f}");
|
||||
}
|
||||
// No folder: the files come back as bytes.
|
||||
let r = s.execute("document.exportForScreens", &json!({"range": "3", "formats": [{"format": "jpg", "scale": 0.5}]})).unwrap();
|
||||
assert_eq!(r["files"][0]["name"], "[email protected]");
|
||||
assert_eq!(image::load_from_memory(&b64(&r["files"][0])).unwrap().width(), 50);
|
||||
assert!(s.execute("document.exportForScreens", &json!({"formats": [{"format": "vectorcraft"}]})).is_err());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
@@ -28,7 +28,8 @@ const HOST_COMMANDS: &[(&str, &str, &str)] = &[
|
||||
("file.open", "Open…", "{path} open any readable file (see document.formats) as a new document; templates open untitled"),
|
||||
("file.save", "Save", "{path?} save as .vectorcraft (default: the document's path)"),
|
||||
("file.saveAs", "Save As…", "{path}"),
|
||||
("file.export", "Export…", "{path, format?, artboard?, scale?, …} = document.export"),
|
||||
("file.export", "Export…", "{path?, format?, artboard?, range?, scale?, …} = document.export (no path → dataBase64)"),
|
||||
("file.exportForScreens", "Export for Screens…", "{folder?, artboards? | range?, formats?, prefix?} = document.exportForScreens"),
|
||||
("tool.select", "Select Tool", "{tool} e.g. selection, directSelection, pen, rectangle, ellipse, polygon, star, lineSegment"),
|
||||
];
|
||||
|
||||
@@ -103,6 +104,7 @@ impl Headless {
|
||||
"file.open" => self.open(params),
|
||||
"file.save" | "file.saveAs" => self.save(params),
|
||||
"file.export" => self.export(params),
|
||||
"file.exportForScreens" => self.session.execute("document.exportForScreens", params).map_err(|e| e.to_string()),
|
||||
"tool.select" => self.select_tool(params),
|
||||
_ => self.session.execute(id, params).map_err(|e| e.to_string()),
|
||||
}
|
||||
@@ -222,7 +224,7 @@ impl Headless {
|
||||
self.session.execute("document.save", p).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `app.export {path, format?, …}`: `document.export` (format defaults to the path's extension).
|
||||
/// `app.export {path?, format?, …}`: `document.export` (format defaults to the path's extension).
|
||||
pub fn export(&mut self, p: &Value) -> Result<Value, String> {
|
||||
self.session.execute("document.export", p).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
//! File I/O through MCP: every readable format opens headless.
|
||||
//! File I/O through MCP: every readable format opens headless, and `export` sends one
|
||||
//! `document.export` call whatever the backend.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Backend, Headless, call_tool, tool_definitions};
|
||||
use crate::{Backend, Headless, Remote, call_tool, tool_definitions};
|
||||
|
||||
fn tmp(name: &str) -> String {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-mcp-fileio-{}", std::process::id()));
|
||||
@@ -48,8 +51,74 @@ fn headless_opens_pdf_ai_and_templates() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_file_lists_the_engine_formats() {
|
||||
fn export_tool_lists_engine_formats_and_options() {
|
||||
let tools = tool_definitions();
|
||||
let export = tools.iter().find(|t| t["name"] == "export").unwrap();
|
||||
let props = &export["inputSchema"]["properties"];
|
||||
assert_eq!(props["format"]["enum"], json!(["vectorcraft", "svg", "pdf", "png", "jpg", "webp"]));
|
||||
for k in ["artboard", "range", "options"] {
|
||||
assert!(props.get(k).is_some(), "export takes {k}");
|
||||
}
|
||||
let open = tools.iter().find(|t| t["name"] == "open_file").unwrap();
|
||||
assert!(open["description"].as_str().unwrap().contains(".ait"));
|
||||
}
|
||||
|
||||
/// A fake app that records `engine.execute` calls and answers each with an empty object.
|
||||
fn recording_app() -> (String, std::thread::JoinHandle<Vec<Value>>) {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let h = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let mut out = stream.try_clone().unwrap();
|
||||
let mut seen = vec![];
|
||||
for line in BufReader::new(stream).lines() {
|
||||
let msg: Value = serde_json::from_str(&line.unwrap()).unwrap();
|
||||
writeln!(out, "{}", json!({"id": msg["id"], "ok": true, "result": {}})).unwrap();
|
||||
seen.push(msg);
|
||||
}
|
||||
seen
|
||||
});
|
||||
(addr, h)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_is_the_same_call_in_the_app_and_headless() {
|
||||
let args = json!({"format": "png", "artboard": 1, "options": {"scale": 2, "artboard": 0}});
|
||||
let (addr, app) = recording_app();
|
||||
let mut remote = Remote::connect(&addr).unwrap();
|
||||
assert!(!call_tool(&mut remote, "export", &args).is_error);
|
||||
drop(remote);
|
||||
let seen = app.join().unwrap();
|
||||
assert_eq!(seen[0]["method"], "engine.execute");
|
||||
assert_eq!(seen[0]["params"], json!({"command": "document.export", "params": {"format": "png", "artboard": 1, "scale": 2}}));
|
||||
|
||||
// Headless runs that very call: artboard 1 (40 pt wide) at scale 2.
|
||||
let mut h = two_boards();
|
||||
let r = text(&call_tool(&mut h, "export", &args));
|
||||
let png = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(&png[16..20], 80u32.to_be_bytes(), "IHDR width");
|
||||
let direct = h.call("engine.execute", seen[0]["params"].clone()).unwrap();
|
||||
assert_eq!(direct["dataBase64"], r["dataBase64"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exporting_native_keeps_the_document_path() {
|
||||
let mut h = two_boards();
|
||||
let doc = tmp("keep.vectorcraft");
|
||||
h.call("app.save", json!({"path": doc})).unwrap();
|
||||
let copy = tmp("copy.vectorcraft");
|
||||
let r = text(&call_tool(&mut h, "export", &json!({"path": copy})));
|
||||
assert_eq!(r["format"], "vectorcraft");
|
||||
assert_eq!(h.session.doc().unwrap().path.as_deref(), Some(doc.as_str()));
|
||||
// PDF range through the tool: one page.
|
||||
let pdf = text(&call_tool(&mut h, "export", &json!({"format": "pdf", "range": "2"})));
|
||||
let pdf = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(pdf_pages(&pdf), 1);
|
||||
}
|
||||
|
||||
fn pdf_pages(pdf: &[u8]) -> usize {
|
||||
let mut h = Headless::new();
|
||||
h.call("engine.execute", json!({"command": "document.open", "params": {"name": "p.pdf", "dataBase64": vectorcraft_format::base64_encode(pdf)}}))
|
||||
.unwrap();
|
||||
h.session.doc().unwrap().doc.artboards.len()
|
||||
}
|
||||
+17
-30
@@ -2,7 +2,7 @@
|
||||
//! [`Backend`].
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_engine::cmd::fileio::OPEN_EXTS;
|
||||
use vectorcraft_engine::cmd::fileio::{FORMATS, OPEN_EXTS};
|
||||
|
||||
use crate::backend::Backend;
|
||||
|
||||
@@ -264,16 +264,19 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"export",
|
||||
"Export",
|
||||
"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).",
|
||||
"Export the active document with the engine's document.export (the same bytes in the app and headless): svg (artboard viewBox), pdf (one page per artboard: all, or `artboard` / `range`), png/jpg/webp (one rendered artboard) or vectorcraft (a native copy; the document keeps its path). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.",
|
||||
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)"),
|
||||
"format": {"type": "string", "enum": FORMATS.iter().filter(|f| f.write).map(|f| f.id).collect::<Vec<_>>(), "description": "Default: from the path's extension"},
|
||||
"path": string("Destination file (omit to get dataBase64)"),
|
||||
"scale": num("Raster pixels per point (default 1)"),
|
||||
"artboard": {"type": "integer", "minimum": 0, "description": "0-based artboard (default: the first; PDF default: all)"},
|
||||
"range": string("1-based artboards such as \"1-3, 5\" (PDF: one page each; other formats take one)"),
|
||||
"selection": {"type": "boolean", "description": "Export only the selection, cropped to it"},
|
||||
"outlineText": {"type": "boolean", "description": "SVG: text as outlines (viewable without the fonts)"},
|
||||
"options": {"type": "object", "description": "More format options, passed through (see run_command document.formats), e.g. {\"quality\": 80} for jpg"},
|
||||
}),
|
||||
&["path"],
|
||||
&[],
|
||||
),
|
||||
false,
|
||||
),
|
||||
@@ -733,31 +736,15 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, Str
|
||||
"open_file" => j(b.call("app.open", json!({"path": req_str(a, "path")?}))?),
|
||||
"save_file" => j(b.call("app.save", json!({"path": a.get("path").and_then(Value::as_str)}))?),
|
||||
"export" => {
|
||||
let path = req_str(a, "path")?;
|
||||
let fmt = a
|
||||
.get("format")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_ascii_lowercase)
|
||||
.unwrap_or_else(|| std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default());
|
||||
if !["svg", "png", "pdf", "jpg", "jpeg", "webp", "vectorcraft"].contains(&fmt.as_str()) {
|
||||
return Err(format!("unknown export format `{fmt}` (svg, png, pdf, jpg, webp, vectorcraft)"));
|
||||
// One params object for every backend; the named arguments win over `options`.
|
||||
let mut params = a.get("options").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
for k in ["path", "format", "scale", "artboard", "range", "outlineText"] {
|
||||
if let Some(v) = a.get(k).filter(|v| !v.is_null()) {
|
||||
params.insert(k.into(), v.clone());
|
||||
}
|
||||
}
|
||||
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}))?)
|
||||
let cmd = if a.get("selection").and_then(Value::as_bool) == Some(true) { "document.exportSelection" } else { "document.export" };
|
||||
j(exec(b, cmd, Value::Object(params))?)
|
||||
}
|
||||
"add_text" => j(add_text(b, a)?),
|
||||
"apply_effect" => {
|
||||
|
||||
@@ -48,6 +48,8 @@ pub struct RenderOptions {
|
||||
pub overprint_preview: bool,
|
||||
/// Trim View: clip the artwork to the artboards (nothing on the pasteboard is drawn).
|
||||
pub trim: bool,
|
||||
/// Leave template layers out (exports and thumbnails: templates are guides, not artwork).
|
||||
pub skip_templates: bool,
|
||||
}
|
||||
|
||||
impl Default for RenderOptions {
|
||||
@@ -61,6 +63,7 @@ impl Default for RenderOptions {
|
||||
proof: None,
|
||||
overprint_preview: false,
|
||||
trim: false,
|
||||
skip_templates: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -295,7 +298,8 @@ impl Renderer {
|
||||
if !self.draw_pattern_edit(&mut ctx, &frame) {
|
||||
// While editing an opacity mask its art is seen only through the mask (Illustrator).
|
||||
let mask_layer = doc.mask_edit.map(|m| m.layer);
|
||||
for layer in doc.layers.iter().filter(|l| Some(l.id) != mask_layer) {
|
||||
let skipped = |l: &Node| Some(l.id) == mask_layer || opts.skip_templates && matches!(l.kind, NodeKind::Layer { template: true, .. });
|
||||
for layer in doc.layers.iter().filter(|l| !skipped(l)) {
|
||||
self.draw_arc(&mut ctx, &frame, layer);
|
||||
}
|
||||
}
|
||||
@@ -327,12 +331,13 @@ impl Renderer {
|
||||
Rendered { width: w as u32, height: h as u32, pixels }
|
||||
}
|
||||
|
||||
/// Render one artboard (or any document rect) at `scale` pixels per point, transparent or on white.
|
||||
/// Render one artboard (or any document rect) at `scale` pixels per point, transparent or on white,
|
||||
/// as exported: template layers are left out.
|
||||
pub fn render_region(&mut self, doc: &Document, region: Rect, scale: f64, white: bool) -> Rendered {
|
||||
let w = (region.width() * scale).round().max(1.0) as u32;
|
||||
let h = (region.height() * scale).round().max(1.0) as u32;
|
||||
let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0));
|
||||
let opts = RenderOptions { background: white.then_some([255, 255, 255, 255]), ..Default::default() };
|
||||
let opts = RenderOptions { background: white.then_some([255, 255, 255, 255]), skip_templates: true, ..Default::default() };
|
||||
self.render(doc, w, h, view, &opts)
|
||||
}
|
||||
|
||||
|
||||
@@ -236,4 +236,17 @@ mod tests {
|
||||
drop(w);
|
||||
assert!(export(&mut app, None, Some("/tmp/x.bmp".into()), &Value::Null).is_err(), "BMP is read-only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_for_screens_with_params_runs_the_engine() {
|
||||
let (mut app, _) = app();
|
||||
app.session.execute("file.new", &json!({"width": 60, "height": 40, "artboards": 3})).unwrap();
|
||||
let r = app.run("file.exportForScreens", json!({"range": "2-3", "formats": [{"format": "pdf"}]})).unwrap();
|
||||
let files = r["files"].as_array().unwrap();
|
||||
assert_eq!(files.len(), 2);
|
||||
assert_eq!(files[0]["name"], "Artboard-2.pdf");
|
||||
assert!(app.ui.dialog.is_none());
|
||||
app.run("file.exportForScreens", Value::Null).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("exportForScreens"));
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
("file.recentFiles", "Recent Files", "", "{} → [path…] most recent first"),
|
||||
("file.export.svg", "Export As SVG…", "", "{path?, artboard?, outlineText?} (document.export options)"),
|
||||
("file.export.png", "Export As PNG…", "", "{path?, scale?: 1, artboard?} (document.export options)"),
|
||||
("file.exportForScreens", "Export for Screens…", "Cmd+Alt+E", "{path?, scale?}"),
|
||||
("file.exportForScreens", "Export for Screens…", "Cmd+Alt+E", "{} opens the dialog; with params = document.exportForScreens"),
|
||||
("file.documentSetup", "Document Setup…", "Cmd+Alt+P", "{}"),
|
||||
("file.newDialog", "New…", "Cmd+N", "{} opens the New Document dialog"),
|
||||
("edit.preferences", "Preferences…", "Cmd+K", "{category?} open Preferences (engine: prefs.get / prefs.set / prefs.list)"),
|
||||
@@ -147,7 +147,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
("ui.paramDialog", "Command Dialog", "", "{command, label?, params} open a parameter dialog for any command"),
|
||||
("ui.recolorDialog", "Recolor Artwork…", "", "{} open Recolor Artwork (engine: recolor.colors / recolor.apply)"),
|
||||
("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"),
|
||||
("file.export.pdf", "Save as PDF…", "", "{path?}"),
|
||||
("file.export.pdf", "Save as PDF…", "", "{path?, artboard? | artboards? | range?: \"1-3, 5\"} (document.export options)"),
|
||||
("help.about", "About VectorCraft", "", "{}"),
|
||||
("help.commandPalette", "Search Commands…", "Cmd+Shift+/", "{}"),
|
||||
("app.quit", "Quit VectorCraft", "Cmd+Q", "{}"),
|
||||
@@ -232,7 +232,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
||||
}
|
||||
}
|
||||
"file.export.svg" => io::export(app, Some("svg"), s("path"), p).map(|p| json!({"path": p})),
|
||||
"file.exportForScreens" if p.get("path").is_none() && p.get("scale").is_none() => {
|
||||
"file.exportForScreens" if p.as_object().is_none_or(|o| o.is_empty()) => {
|
||||
let n = app.session.active().map(|d| d.doc.artboards.len()).unwrap_or(0);
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
|
||||
app.ui.dialog = Some(crate::state::Dialog::new(
|
||||
@@ -241,7 +241,8 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
||||
));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"file.export.png" | "file.exportForScreens" => io::export(app, Some("png"), s("path"), p).map(|p| json!({"path": p})),
|
||||
"file.exportForScreens" => app.run("document.exportForScreens", p.clone()),
|
||||
"file.export.png" => io::export(app, Some("png"), s("path"), p).map(|p| json!({"path": p})),
|
||||
"file.documentSetup" => {
|
||||
let units = app.session.active().map(|d| d.doc.units.label()).unwrap_or("Points");
|
||||
app.ui.dialog = Some(crate::state::Dialog::new("documentSetup", json!({"units": units})));
|
||||
|
||||
@@ -18,4 +18,4 @@
|
||||
| `ui.screenshot` | `{path?}` | capture the window (PNG). Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error |
|
||||
| `ui.render` | `{path?, scale?}` | render the artboard headlessly (PNG) |
|
||||
| `ui.resize` / `ui.focus` | | |
|
||||
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`). `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes through the host (a download on the web). `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit |
|
||||
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`). `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes through the host (a download on the web). `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit |
|
||||
+3
-3
@@ -59,7 +59,7 @@ 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 any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, PNG, JPEG, GIF, WebP, TIFF, BMP (an image opens as a document of its pixel size). Templates open as a new untitled document. `run_command document.formats` lists the formats. |
|
||||
| `save_file` | `{path?}` | Saves in the native `.vectorcraft` format. |
|
||||
| `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. |
|
||||
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `pdf`, `png`, `jpg`, `webp` or `vectorcraft` (the list comes from `document.formats`). 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; `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`). |
|
||||
| `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. |
|
||||
@@ -130,6 +130,6 @@ vectorcraft-cli run --cmd file.new --params '{"width":800,"height":600}' \
|
||||
```
|
||||
|
||||
`run` prints one JSON line per step (`open`, `cmd`, `export`) and exits non-zero on the first failure. `--params`
|
||||
applies to the `--cmd` just before it. `run` also accepts the host commands `file.open`, `file.save`, `file.export`
|
||||
and `tool.select`. `run --in`, `convert` and `info` read every format `document.open` reads
|
||||
applies to the `--cmd` just before it. `run` also accepts the host commands `file.open`, `file.save`, `file.export`,
|
||||
`file.exportForScreens` and `tool.select`. `run --in`, `convert` and `info` read every format `document.open` reads
|
||||
(`vectorcraft-cli --help` lists them).
|
||||
Reference in new issue
Block a user