mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 04:37:29 -06:00
document.export {format: "psd"} writes layered bitmaps (8 bits per channel),
written from the format's public specification: RGB, CMYK (ink amounts) or
grayscale, the resolution and an embedded profile, PackBits channels, and the
merged image (the flat render). layers (default true) draws each top-level
layer alone as a pixel layer, cropped to what it paints, with its name,
opacity and blend mode; a background colour becomes a Background layer.
maxEditability turns layers and sublayers into groups and every object into a
layer named as the Layers panel names it (text by its text); clipping, masked,
knockout or appearance-carrying layers stay one layer. hiddenLayers writes
hidden layers hidden instead of leaving them out; layers: false writes a flat
image on white. At most 30000 pixels a side.
Export As offers PSD with the psdOptions dialog (the PNG Options rows, then
Color Model, Flat Image / Write Layers with Maximum Editability and Include
Hidden Layers, Embed ICC Profile). Tests read the files back with the psd
crate (MIT OR Apache-2.0, a dev-dependency).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
148 lines
6.0 KiB
Rust
148 lines
6.0 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",
|
|
"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}");
|
|
}
|
|
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);
|
|
}
|
|
|
|
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()
|
|
}
|