mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 16:33:22 -06:00
document.export, document.serialize and document.exportSelection write a PDF-compatible .ai (format: "ai"), and document.formats lists ai as writable, but none of their params texts named it. The params text of document.exportSelection named only png, jpg, webp, svg, svgz and pdf, although the command writes all 20 writable formats. The description of the MCP export tool and its row in docs/mcp.md named every format of the tool's format enum except ai, and the range text and the row said that only PDF writes several artboards. The three params texts now list every format their command writes, and document.export's range and useArtboards texts name .ai beside PDF. The MCP export tool's description names ai, and its artboard and range texts say that .ai, like PDF, writes one page per artboard. The range text and the row also say that SVG, SVGZ and EPS write one file per chosen artboard, and that rasters, DXF, EMF and WMF do so with options.useArtboards: true. Docs: docs/mcp.md (the export tool).
213 lines
9.4 KiB
Rust
213 lines
9.4 KiB
Rust
//! 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, Remote, call_tool, tool_definitions};
|
|
|
|
fn tmp(name: &str) -> String {
|
|
let dir = std::env::temp_dir().join(format!("vectorcraft-mcp-fileio-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
dir.join(name).to_string_lossy().to_string()
|
|
}
|
|
|
|
fn text(r: &crate::ToolResult) -> Value {
|
|
assert!(!r.is_error, "{r:?}");
|
|
serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap()
|
|
}
|
|
|
|
/// A headless session with two artboards of different widths (100 and 40 pt) and a rectangle.
|
|
fn two_boards() -> Headless {
|
|
let mut h = Headless::new();
|
|
h.call("engine.execute", json!({"command": "file.new", "params": {"width": 100, "height": 50, "artboards": 2}})).unwrap();
|
|
h.call("engine.execute", json!({"command": "artboard.setProps", "params": {"index": 1, "width": 40}})).unwrap();
|
|
h.call("engine.execute", json!({"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 20, "height": 20}})).unwrap();
|
|
h
|
|
}
|
|
|
|
#[test]
|
|
fn headless_opens_pdf_ai_and_templates() {
|
|
let mut h = two_boards();
|
|
let pdf = h.call("engine.execute", json!({"command": "document.serialize", "params": {"format": "pdf"}})).unwrap();
|
|
let pdf = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap();
|
|
for name in ["x.pdf", "x.ai"] {
|
|
let path = tmp(name);
|
|
std::fs::write(&path, &pdf).unwrap();
|
|
let r = text(&call_tool(&mut h, "open_file", &json!({"path": path})));
|
|
assert_eq!(r["title"], name);
|
|
assert_eq!(h.session.doc().unwrap().doc.artboards.len(), 2, "one artboard per page");
|
|
}
|
|
let ait = tmp("x.ait");
|
|
std::fs::write(&ait, &pdf).unwrap();
|
|
let r = text(&call_tool(&mut h, "open_file", &json!({"path": ait})));
|
|
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
|
|
let tpl = tmp("t.vectorcraft");
|
|
h.call("engine.execute", json!({"command": "file.saveAsTemplate", "params": {"path": tpl}})).unwrap();
|
|
let r = text(&call_tool(&mut h, "open_file", &json!({"path": tpl})));
|
|
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
|
|
assert_eq!(h.session.doc().unwrap().path, None, "Save asks for a new name");
|
|
}
|
|
|
|
#[test]
|
|
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",
|
|
"svgz",
|
|
"pdf",
|
|
"ai",
|
|
"png",
|
|
"jpg",
|
|
"gif",
|
|
"webp",
|
|
"tiff",
|
|
"bmp",
|
|
"template",
|
|
"png8",
|
|
"txt",
|
|
"dxf",
|
|
"eps",
|
|
"emf",
|
|
"wmf",
|
|
"tga",
|
|
"psd"
|
|
])
|
|
);
|
|
for k in ["artboard", "range", "options"] {
|
|
assert!(props.get(k).is_some(), "export takes {k}");
|
|
}
|
|
// The description names every format of the enum.
|
|
let words: Vec<&str> = export["description"].as_str().unwrap().split(|c: char| !c.is_ascii_alphanumeric()).collect();
|
|
for f in props["format"]["enum"].as_array().unwrap() {
|
|
assert!(words.contains(&f.as_str().unwrap()), "{f}");
|
|
}
|
|
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, "range": "1-2"}});
|
|
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);
|
|
// An .ai export holds every artboard by default and one artboard with a range, and the document keeps its path.
|
|
for (args, pages) in [(json!({"format": "ai"}), 2), (json!({"format": "ai", "range": "2"}), 1)] {
|
|
let ai = text(&call_tool(&mut h, "export", &args));
|
|
assert_eq!(ai["format"], "ai");
|
|
assert_eq!(pdf_pages(&vectorcraft_format::base64_decode(ai["dataBase64"].as_str().unwrap()).unwrap()), pages, "{args}");
|
|
}
|
|
assert_eq!(h.session.doc().unwrap().path.as_deref(), Some(doc.as_str()));
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
/// Issue #421 over MCP: RGB greys switched to CMYK with `grays: "black"` print on the black plate
|
|
/// only, in the document and in the exported PDF.
|
|
#[test]
|
|
fn document_color_mode_puts_rgb_greys_on_the_black_plate() {
|
|
let svg = tmp("greys.svg");
|
|
std::fs::write(
|
|
&svg,
|
|
r##"<svg xmlns="http://www.w3.org/2000/svg" width="200pt" height="50pt" viewBox="0 0 200 50">
|
|
<rect x="0" y="0" width="50" height="50" fill="#000000"/><rect x="50" y="0" width="50" height="50" fill="#333333"/>
|
|
<rect x="100" y="0" width="50" height="50" fill="#808080"/><rect x="150" y="0" width="50" height="50" fill="#e6e6e6"/>
|
|
</svg>"##,
|
|
)
|
|
.unwrap();
|
|
let want = [1.0, 0.8, 0.498, 0.098];
|
|
let k_only = |h: &Headless| -> Vec<f32> {
|
|
let mut ks = vec![];
|
|
for l in &h.session.doc().unwrap().doc.layers {
|
|
l.walk(&mut |n| {
|
|
if n.children().is_none()
|
|
&& let Some(c) = n.appearance.fill_paint().color()
|
|
{
|
|
let v = serde_json::to_value(c).unwrap();
|
|
assert!(v["model"] == "cmyk" && v["c"] == 0.0 && v["m"] == 0.0 && v["y"] == 0.0, "not K only: {v}");
|
|
ks.push(v["k"].as_f64().unwrap() as f32);
|
|
}
|
|
});
|
|
}
|
|
ks
|
|
};
|
|
let close = |ks: &[f32]| ks.len() == want.len() && ks.iter().zip(want).all(|(k, w)| (k - w).abs() < 0.005);
|
|
let mut h = Headless::new();
|
|
text(&call_tool(&mut h, "open_file", &json!({"path": svg})));
|
|
let r = text(&call_tool(&mut h, "run_command", &json!({"command": "file.documentColorMode", "params": {"mode": "cmyk", "grays": "black"}})));
|
|
assert_eq!(r["changed"], 4);
|
|
let ks = k_only(&h);
|
|
assert!(close(&ks), "{ks:?}");
|
|
// The PDF carries the same inks (DeviceCMYK opens as CMYK).
|
|
let pdf = text(&call_tool(&mut h, "export", &json!({"format": "pdf"})));
|
|
let mut back = Headless::new();
|
|
back.call("engine.execute", json!({"command": "document.open", "params": {"name": "greys.pdf", "dataBase64": pdf["dataBase64"]}})).unwrap();
|
|
let ks = k_only(&back);
|
|
assert!(close(&ks), "{ks:?}");
|
|
// document.open takes the same option.
|
|
let mut o = Headless::new();
|
|
let r = call_tool(&mut o, "run_command", &json!({"command": "document.open", "params": {"path": svg, "colorMode": "cmyk", "grays": "black"}}));
|
|
text(&r);
|
|
let ks = k_only(&o);
|
|
assert!(close(&ks), "{ks:?}");
|
|
let bad = call_tool(&mut o, "run_command", &json!({"command": "file.documentColorMode", "params": {"mode": "rgb", "grays": "rich"}}));
|
|
assert!(bad.is_error, "{bad:?}");
|
|
}
|