M4.12: one loader and encoder for every frontend — engine fileio (load/encode/export/save, append-only FORMATS table, OPEN_EXTS, document.formats query, typed per-format options, parse_range); document.open reads native/legacy, SVG/SVGZ, PDF/.ai, .ait (opens untitled like native templates) and PNG/JPEG/GIF/WebP/TIFF/BMP; desktop, web, control channel, headless MCP and the CLI open and encode through it; open dialogs list "All readable files" plus one filter per format

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-04 11:36:51 +07:00
parent d99853ed2a
commit 4d503c0327
25 changed files with 1293 additions and 559 deletions

2
Cargo.lock generated
View File

@ -4128,6 +4128,8 @@ dependencies = [
name = "vectorcraft-engine" name = "vectorcraft-engine"
version = "0.1.1" version = "0.1.1"
dependencies = [ dependencies = [
"flate2",
"image",
"kurbo 0.13.1", "kurbo 0.13.1",
"log", "log",
"proptest", "proptest",

View File

@ -41,12 +41,13 @@ proptest = "1"
# rendering / formats # rendering / formats
vello_cpu = "0.2" vello_cpu = "0.2"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
usvg = "0.48" usvg = "0.48"
krilla = "0.8" krilla = "0.8"
skrifa = "0.47" skrifa = "0.47"
harfrust = "0.13" harfrust = "0.13"
linesweeper = "0.4" linesweeper = "0.4"
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
# ui # ui
egui = "0.36" egui = "0.36"

View File

@ -2,7 +2,7 @@
//! //!
//! ```text //! ```text
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] //! 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 run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg|.png|.pdf|.jpg|.webp|.vectorcraft]... [--scale 2]
//! vectorcraft-cli commands //! vectorcraft-cli commands
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0] [--outline-text] //! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0] [--outline-text]
//! vectorcraft-cli info FILE //! vectorcraft-cli info FILE
@ -28,21 +28,21 @@ USAGE:
(vectorcraft --control 7979), falling back to a headless in-process session. (vectorcraft --control 7979), falling back to a headless in-process session.
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N] vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
Headless batch: open FILE (.vectorcraft/.svg) or start a new document, run commands in Headless batch: open FILE (any readable format) or start a new document, run commands in
order, export (.svg, .png, .pdf, .jpg, .webp, .vectorcraft by extension). Prints one JSON result per step. order, export (.svg, .png, .pdf, .jpg, .webp, .vectorcraft by extension). Prints one JSON result per step.
vectorcraft-cli commands vectorcraft-cli commands
Print the command catalogue as JSON. 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] [--outline-text]
Open IN (.vectorcraft, .svg, .pdf/.ai, images) and export OUT by extension (.svg, .pdf, .png, Open IN (any readable format) and export OUT by extension (.svg, .pdf, .png, .jpg, .webp,
.jpg, .webp, .vectorcraft). Live effects are kept; --outline-text writes SVG text as paths. .vectorcraft). Live effects are kept; --outline-text writes SVG text as paths.
vectorcraft-cli info FILE vectorcraft-cli info FILE
Print a JSON summary: title, colour mode, units, artboards, object counts by kind, fonts. Print a JSON summary: title, colour mode, units, artboards, object counts by kind, fonts.
vectorcraft-cli bench FILE [--size WxH] [--iters N] vectorcraft-cli bench FILE [--size WxH] [--iters N]
Render FILE (.vectorcraft/.svg) fitted to WxH (default 2880x1800) and print ms per frame Render FILE (any readable format) fitted to WxH (default 2880x1800) and print ms per frame
(warm), multithreaded and single-threaded. (warm), multithreaded and single-threaded.
vectorcraft-cli perf [--paths N] vectorcraft-cli perf [--paths N]
@ -50,6 +50,11 @@ USAGE:
synthetic N-path document (default 50000). Exits non-zero if a budget is exceeded. synthetic N-path document (default 50000). Exits non-zero if a budget is exceeded.
"; ";
/// The usage text plus the formats `document.open` reads.
fn usage() -> String {
format!("{USAGE}\nReadable formats: .{}\n", vectorcraft_engine::cmd::fileio::OPEN_EXTS.join(", ."))
}
fn main() -> ExitCode { fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect(); let args: Vec<String> = std::env::args().skip(1).collect();
let r = match args.first().map(String::as_str) { let r = match args.first().map(String::as_str) {
@ -61,14 +66,14 @@ fn main() -> ExitCode {
Some("bench") => bench(&args[1..]), Some("bench") => bench(&args[1..]),
Some("perf") => perf::run(&args[1..]), Some("perf") => perf::run(&args[1..]),
Some("-h" | "--help" | "help") | None => { Some("-h" | "--help" | "help") | None => {
print!("{USAGE}"); print!("{}", usage());
Ok(()) Ok(())
} }
Some("-V" | "--version") => { Some("-V" | "--version") => {
println!("vectorcraft-cli {}", env!("CARGO_PKG_VERSION")); println!("vectorcraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(()) Ok(())
} }
Some(other) => Err(format!("unknown subcommand `{other}`\n\n{USAGE}")), Some(other) => Err(format!("unknown subcommand `{other}`\n\n{}", usage())),
}; };
match r { match r {
Ok(()) => ExitCode::SUCCESS, Ok(()) => ExitCode::SUCCESS,

View File

@ -0,0 +1,75 @@
//! The CLI opens every readable format (PDF, .ai, images) through the engine's `document.open`.
use std::process::Command;
use serde_json::Value;
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");
fn tmp(name: &str) -> String {
let dir = std::env::temp_dir().join(format!("vectorcraft-cli-fileio-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir.join(name).to_string_lossy().to_string()
}
fn ok(args: &[&str]) -> String {
let out = Command::new(BIN).args(args).output().unwrap();
assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
String::from_utf8(out.stdout).unwrap()
}
/// `run` a new `width`×`height` document with a rectangle and export it to `out`.
fn make(out: &str, width: u32, height: u32) {
ok(&[
"run",
"--cmd",
"file.new",
"--params",
&format!(r#"{{"width":{width},"height":{height},"artboards":2}}"#),
"--cmd",
"shape.rectangle",
"--params",
r#"{"x":1,"y":1,"width":2,"height":1}"#,
"--export",
out,
]);
}
fn info(file: &str) -> Value {
serde_json::from_str(&ok(&["info", file])).unwrap()
}
#[test]
fn convert_pdf_to_svg_and_info_on_ai() {
let pdf = tmp("in.pdf");
make(&pdf, 120, 80);
let svg = tmp("out.svg");
ok(&["convert", &pdf, &svg]);
assert!(std::fs::read_to_string(&svg).unwrap().contains("<svg"));
let ai = tmp("in.ai");
std::fs::copy(&pdf, &ai).unwrap();
let v = info(&ai);
assert_eq!(v["artboards"].as_array().unwrap().len(), 2, "{v}");
assert_eq!(v["artboards"][0]["rect"][2], 120.0);
// `run --in` takes it too.
ok(&["run", "--in", &ai, "--cmd", "document.inspect"]);
}
#[test]
fn images_open_at_their_pixel_size() {
let webp = tmp("tiny.webp");
make(&webp, 3, 2);
let v = info(&webp);
assert_eq!(v["artboards"][0]["rect"], serde_json::json!([0.0, 0.0, 3.0, 2.0]), "{v}");
assert_eq!(v["kinds"]["Image"], 1, "{v}");
let png = tmp("tiny.png");
ok(&["convert", &webp, &png, "--scale", "2"]);
assert_eq!(&std::fs::read(&png).unwrap()[16..24], [0, 0, 0, 6, 0, 0, 0, 4], "IHDR 6×4");
}
#[test]
fn help_lists_readable_formats() {
let out = ok(&["--help"]);
assert!(out.contains("Readable formats: .vectorcraft") && out.contains(".ait") && out.contains(".webp"), "{out}");
}

View File

@ -3,12 +3,12 @@
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use vectorcraft_engine::Session; use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::fileio;
use vectorcraft_ui_egui::{Services, VectorcraftApp}; use vectorcraft_ui_egui::{Services, VectorcraftApp};
use wasm_bindgen::JsCast as _; use wasm_bindgen::JsCast as _;
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>; type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
const OPEN_EXTS: &[&str] = &["vectorcraft", "drawcraft", "svg", "png", "jpg", "jpeg", "gif", "webp"];
const CANVAS_ID: &str = "vectorcraft_canvas"; const CANVAS_ID: &str = "vectorcraft_canvas";
const LOADING_ID: &str = "vectorcraft_loading"; const LOADING_ID: &str = "vectorcraft_loading";
@ -99,7 +99,8 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let inbox = open_inbox.clone(); let inbox = open_inbox.clone();
let ctx = ctx.clone(); let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move { wasm_bindgen_futures::spawn_local(async move {
let Some(file) = rfd::AsyncFileDialog::new().add_filter("All supported", OPEN_EXTS).pick_file().await else { let dialog = fileio::open_filters().fold(rfd::AsyncFileDialog::new(), |d, (name, exts)| d.add_filter(name, exts));
let Some(file) = dialog.pick_file().await else {
return; return;
}; };
let bytes = file.read().await; let bytes = file.read().await;
@ -125,7 +126,7 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
let document = window.document().ok_or("no document")?; let document = window.document().ok_or("no document")?;
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes)); let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
let opts = web_sys::BlobPropertyBag::new(); let opts = web_sys::BlobPropertyBag::new();
opts.set_type(mime_for(&name)); opts.set_type(fileio::format_for_name(&name).map_or("application/octet-stream", |f| f.mime));
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?; let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?; let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?; let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
@ -143,14 +144,3 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?; window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?;
Ok(()) Ok(())
} }
fn mime_for(name: &str) -> &'static str {
match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() {
Some("svg") => "image/svg+xml",
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("pdf") => "application/pdf",
Some("vectorcraft" | "drawcraft") => "application/json",
_ => "application/octet-stream",
}
}

View File

@ -11,6 +11,7 @@ mod control_server;
mod native_menu; mod native_menu;
use vectorcraft_engine::Session; use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::fileio;
use vectorcraft_ui_egui::{Services, VectorcraftApp}; use vectorcraft_ui_egui::{Services, VectorcraftApp};
struct App(VectorcraftApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>); struct App(VectorcraftApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
@ -98,8 +99,8 @@ fn save_prefs(app: &VectorcraftApp) {
fn services() -> Services { fn services() -> Services {
Services { Services {
pick_open: Some(Box::new(|| { pick_open: Some(Box::new(|| {
rfd::FileDialog::new() fileio::open_filters()
.add_filter("All supported", &["vectorcraft", "drawcraft", "svg", "png", "jpg", "jpeg", "gif", "webp"]) .fold(rfd::FileDialog::new(), |d, (name, exts)| d.add_filter(name, exts))
.pick_file() .pick_file()
.map(|p| p.to_string_lossy().to_string()) .map(|p| p.to_string_lossy().to_string())
})), })),

View File

@ -21,6 +21,8 @@ vectorcraft-trace = { workspace = true }
vectorcraft-svg = { workspace = true } vectorcraft-svg = { workspace = true }
vectorcraft-format = { workspace = true } vectorcraft-format = { workspace = true }
vectorcraft-pdf = { workspace = true } vectorcraft-pdf = { workspace = true }
image = { workspace = true }
flate2 = { workspace = true }
kurbo = { workspace = true } kurbo = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }

View File

@ -1,282 +0,0 @@
//! Document I/O and transactions: open/save/export by path or bytes (base64), and `command.batch`.
//!
//! These live in the engine so every frontend (UI, CLI, MCP headless) shares them.
use serde_json::{Value, json};
use vectorcraft_doc::Document;
use super::*;
use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"document.open",
"Open Document",
[],
None,
"{path} or {name, dataBase64} → {index}; .vectorcraft, .svg, .pdf, .ai (PDF-compatible)",
always,
open
),
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|jpg|webp|vectorcraft, path, scale?: 1, artboard?: 0, outlineText?: bool (SVG: text as outlines)}",
has_doc,
export
),
cmd!(
"document.exportSelection",
"Export Selection…",
["File"],
None,
"{path?, format?: png|jpg|webp|svg|pdf (default: from the extension, else png), scale?: 1} the selected objects cropped to their bounds → {path, bytes, bounds} (no path → {dataBase64, bounds})",
has_selection,
export_selection
),
cmd!(
"file.saveAsTemplate",
"Save as Template…",
["File"],
None,
"{path?} a native copy that opens as a new untitled document (no path → {dataBase64})",
has_doc,
save_template
),
cmd!(
"document.exportForScreens",
"Export for Screens",
["File", "Export"],
None,
"{folder, artboards?: [index…] (default all), formats?: [{format: png|jpg|webp|svg|pdf, scale?: 1, suffix?: \"@2x\"}], prefix?} → {files: [...]}",
has_doc,
export_for_screens
),
cmd!(query "command.batch", "Batch", [], None, "{label?, commands: [{command, params}]} run several commands as ONE undo step; stops at the first error and rolls back", has_doc, batch),
]
}
fn load(name: &str, bytes: &[u8]) -> Result<Document> {
let lower = name.to_ascii_lowercase();
if vectorcraft_format::is_native_name(&lower) || vectorcraft_format::sniff(bytes) {
return vectorcraft_format::load(bytes).map_err(|e| EngineError::Other(e.to_string()));
}
if lower.ends_with(".svg") || bytes.starts_with(b"<?xml") || bytes.starts_with(b"<svg") {
let s = std::str::from_utf8(bytes).map_err(|_| EngineError::Other("SVG is not UTF-8".into()))?;
let mut d = vectorcraft_svg::import(s).map_err(|e| EngineError::Other(e.to_string()))?;
d.title = std::path::Path::new(name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string());
return Ok(d);
}
if lower.ends_with(".pdf") || lower.ends_with(".ai") || bytes.starts_with(b"%PDF") {
let r =
vectorcraft_pdf::import_with_report(bytes, &vectorcraft_pdf::ImportOptions::default()).map_err(|e| EngineError::Other(e.to_string()))?;
let mut d = r.document;
d.title = std::path::Path::new(name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string());
return Ok(d);
}
Err(EngineError::Other(format!("unsupported file `{name}`")))
}
fn open(s: &mut Session, p: &Value) -> Result<Value> {
let (name, bytes, path) = match (str_param(p, "path"), str_param(p, "dataBase64")) {
(Some(path), _) => (path.to_string(), std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?, Some(path.to_string())),
(None, Some(b64)) => (
str_param(p, "name").unwrap_or("untitled.vectorcraft").to_string(),
vectorcraft_format::base64_decode(b64).ok_or_else(|| bad("document.open", "bad base64"))?,
None,
),
_ => return Err(bad("document.open", "give path or dataBase64")),
};
let mut doc = load(&name, &bytes)?;
let mut native = vectorcraft_format::is_native_name(&name);
if doc.template {
// A template opens as a new untitled document (saving asks for a new name).
doc.template = false;
doc.title = s.next_untitled();
native = false;
}
let i = s.add_document(doc, if native { path } else { None });
Ok(json!({ "index": i }))
}
fn save(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path")
.map(str::to_string)
.or_else(|| s.active().and_then(|d| d.path.clone()))
.ok_or_else(|| bad("document.save", "no path"))?;
let bytes = vectorcraft_format::save_file(&s.doc()?.doc);
std::fs::write(&path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let st = s.doc_mut()?;
st.path = Some(path.clone());
st.mark_saved();
Ok(json!({ "path": path }))
}
pub(crate) fn encode(s: &Session, format: &str, scale: f64, artboard: usize) -> Result<Vec<u8>> {
encode_doc(&s.doc()?.doc, format, scale, artboard)
}
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), outline_text, ..Default::default() })
.into_bytes(),
"pdf" => super::rasterfx::export_pdf(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()))?;
let img = vectorcraft_render::Renderer::new().render_region(doc, r, scale.clamp(0.01, 64.0), format != "png" && format != "webp");
match format {
"png" => img.to_png(),
"webp" => img.to_webp(),
_ => img.to_jpeg(90),
}
}
other => return Err(EngineError::Other(format!("unknown format `{other}`"))),
})
}
fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
let f = str_param(p, "format").unwrap_or("vectorcraft");
let bytes = encode(s, f, f64_or(p, "scale", 1.0), p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize)?;
if f == "svg" {
return Ok(json!({ "text": String::from_utf8_lossy(&bytes) }));
}
Ok(json!({ "dataBase64": vectorcraft_format::base64_encode(&bytes) }))
}
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 = 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 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() }))
}
/// File → Export Selection: the selected objects alone, cropped to their visual bounds.
fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.exportSelection";
let path = str_param(p, "path");
let f = str_param(p, "format")
.map(str::to_string)
.or_else(|| path.and_then(|p| std::path::Path::new(p).extension()).map(|e| e.to_string_lossy().to_ascii_lowercase()))
.unwrap_or_else(|| "png".into());
let ids = super::edit::selected_roots(s)?;
let st = s.doc()?;
let nodes: Vec<std::sync::Arc<vectorcraft_doc::Node>> = ids.iter().filter_map(|id| st.doc.node(*id).cloned().map(std::sync::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 = vectorcraft_doc::Node::layer(d.alloc_id(), "Selection", vectorcraft_doc::LayerColor::Preset(0));
if let Some(ch) = layer.children_mut() {
*ch = nodes;
}
d.layers = vec![std::sync::Arc::new(layer)];
let mut ab = d.artboards.first().cloned().ok_or_else(|| bad(C, "the document has no artboard"))?;
ab.rect = bounds;
ab.name = "Selection".into();
d.artboards = vec![ab];
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) => {
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(json!({ "path": path, "bytes": bytes.len(), "bounds": b }))
}
None => Ok(json!({ "dataBase64": vectorcraft_format::base64_encode(&bytes), "bounds": b })),
}
}
/// File → Save as Template: a native copy flagged so opening it starts a new untitled document.
fn save_template(s: &mut Session, p: &Value) -> Result<Value> {
let mut d = (*s.doc()?.doc).clone();
d.template = true;
let bytes = vectorcraft_format::save_file(&d);
match str_param(p, "path") {
Some(path) => {
std::fs::write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(json!({ "path": path }))
}
None => Ok(json!({ "dataBase64": vectorcraft_format::base64_encode(&bytes) })),
}
}
fn batch(s: &mut Session, p: &Value) -> Result<Value> {
let cmds = p.get("commands").and_then(Value::as_array).ok_or_else(|| bad("command.batch", "missing commands"))?.clone();
let label = str_param(p, "label").unwrap_or("Batch").to_string();
s.begin_interaction(&label)?;
// Session-level state a step may change (paint defaults, drawing mode) rolls back too.
let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone());
let mut results = vec![];
for c in &cmds {
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
if id == "command.batch" {
s.cancel_interaction()?;
return Err(bad("command.batch", "batches cannot nest"));
}
let params = c.get("params").cloned().unwrap_or(json!({}));
match s.execute(id, &params) {
Ok(v) => results.push(v),
Err(e) => {
s.cancel_interaction()?;
(s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard) = saved;
return Err(EngineError::Other(format!("batch step {} (`{id}`) failed: {e}", results.len())));
}
}
}
// Commit as one undo step (the interaction's "preview" is the batch itself).
if let Some(it) = s.doc_mut()?.interaction.as_mut() {
it.preview = Some(("command.batch".into(), p.clone()));
}
s.commit_interaction()?;
Ok(json!({ "results": results }))
}
fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
let folder = str_param(p, "folder").ok_or_else(|| bad("document.exportForScreens", "missing folder"))?.to_string();
let n = s.doc()?.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<(String, f64, String)> = match p.get("formats").and_then(Value::as_array) {
Some(a) => a
.iter()
.map(|f| {
let fmt = str_param(f, "format").unwrap_or("png").to_string();
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") });
(fmt, sc, suffix)
})
.collect(),
None => vec![("png".into(), 1.0, String::new())],
};
let prefix = str_param(p, "prefix").unwrap_or("").to_string();
std::fs::create_dir_all(&folder).map_err(|e| EngineError::Other(format!("{folder}: {e}")))?;
let mut files = vec![];
for b in boards {
let name: String =
s.doc()?.doc.artboards[b].name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
for (fmt, sc, suffix) in &formats {
let bytes = encode(s, fmt, *sc, b)?;
let ext = if fmt == "jpeg" { "jpg" } else { fmt.as_str() };
let path = format!("{folder}/{prefix}{name}{suffix}.{ext}");
std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
files.push(path);
}
}
Ok(json!({ "files": files }))
}

View File

@ -0,0 +1,37 @@
//! `command.batch`: several commands as one undo step.
use serde_json::{Value, json};
use super::super::*;
use crate::EngineError;
pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
let cmds = p.get("commands").and_then(Value::as_array).ok_or_else(|| bad("command.batch", "missing commands"))?.clone();
let label = str_param(p, "label").unwrap_or("Batch").to_string();
s.begin_interaction(&label)?;
// Session-level state a step may change (paint defaults, drawing mode) rolls back too.
let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone());
let mut results = vec![];
for c in &cmds {
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
if id == "command.batch" {
s.cancel_interaction()?;
return Err(bad("command.batch", "batches cannot nest"));
}
let params = c.get("params").cloned().unwrap_or(json!({}));
match s.execute(id, &params) {
Ok(v) => results.push(v),
Err(e) => {
s.cancel_interaction()?;
(s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard) = saved;
return Err(EngineError::Other(format!("batch step {} (`{id}`) failed: {e}", results.len())));
}
}
}
// Commit as one undo step (the interaction's "preview" is the batch itself).
if let Some(it) = s.doc_mut()?.interaction.as_mut() {
it.preview = Some(("command.batch".into(), p.clone()));
}
s.commit_interaction()?;
Ok(json!({ "results": results }))
}

View File

@ -0,0 +1,110 @@
//! One encoder per writable format. Each format's options are parsed, typed, from the export params
//! (unknown keys are ignored, so one params object can carry the options of several formats).
use serde::Deserialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use vectorcraft_doc::Document;
use super::super::*;
use super::Format;
use crate::EngineError;
const C: &str = "document.export";
/// Which artboards an export covers: `range` (`"1-3, 5"`, 1-based) wins over `artboards`
/// (0-based), which wins over `artboard`.
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct ArtboardPick {
pub artboard: Option<usize>,
pub artboards: Option<Vec<usize>>,
pub range: Option<String>,
}
impl ArtboardPick {
/// The named artboards (`None`: none named), checked against the document's `count`.
pub fn resolve(&self, count: usize) -> std::result::Result<Option<Vec<usize>>, String> {
let v = match (&self.range, &self.artboards, self.artboard) {
(Some(r), _, _) => parse_range(r, count)?,
(None, Some(a), _) => a.clone(),
(None, None, Some(a)) => vec![a],
(None, None, None) => return Ok(None),
};
if v.is_empty() {
return Err("no artboards named".into());
}
match v.iter().find(|i| **i >= count) {
Some(i) => Err(format!("no artboard {i} (0-based; the document has {count})")),
None => Ok(Some(v)),
}
}
/// The one artboard a single-image format writes (default: the first).
pub fn one(&self, count: usize) -> std::result::Result<usize, String> {
match self.resolve(count)?.as_deref() {
None if count > 0 => Ok(0),
None => Err("the document has no artboard".into()),
Some([i]) => Ok(*i),
Some(_) => Err("this format holds one artboard: export several as PDF or with document.exportForScreens".into()),
}
}
}
#[derive(Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct SvgOptions {
#[serde(flatten)]
boards: ArtboardPick,
/// Text as glyph outlines (SVG Options → Fonts → Convert to Outlines).
outline_text: Option<bool>,
}
#[derive(Default, Deserialize)]
#[serde(default)]
struct RasterOptions {
#[serde(flatten)]
boards: ArtboardPick,
scale: Option<f64>,
quality: Option<u8>,
}
fn boards<T>(r: std::result::Result<T, String>) -> Result<T> {
r.map_err(|e| bad(C, e))
}
fn options<T: DeserializeOwned + Default>(f: &Format, p: &Value) -> Result<T> {
if !p.is_object() {
return Ok(T::default());
}
T::deserialize(p).map_err(|e| bad(C, format!("{} options: {e}", f.label)))
}
/// Encode `doc` as `format` (an id or extension from [`super::FORMATS`]) with that format's
/// options from `p` (see `document.formats`).
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();
Ok(match f.id {
"vectorcraft" => vectorcraft_format::save_file(doc),
"svg" => {
let o: SvgOptions = options(f, p)?;
let artboard = if n == 0 { None } else { Some(boards(o.boards.one(n))?) };
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()))?,
"png" | "jpg" | "webp" => {
let o: RasterOptions = options(f, p)?;
let region = doc.artboards[boards(o.boards.one(n))?].rect;
let scale = o.scale.unwrap_or(1.0).clamp(0.01, 64.0);
let img = vectorcraft_render::Renderer::new().render_region(doc, region, scale, f.id == "jpg");
match f.id {
"png" => img.to_png(),
"webp" => img.to_webp(),
_ => img.to_jpeg(o.quality.unwrap_or(90)),
}
}
_ => return Err(bad(C, format!("no encoder for {} yet", f.label))),
})
}

View File

@ -0,0 +1,97 @@
//! `document.export`, `document.serialize`, `document.exportSelection`, `document.exportForScreens`.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::Node;
use super::super::*;
use super::{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)?;
let bytes = encode(&s.doc()?.doc, f.id, p)?;
if f.id == "svg" {
return Ok(json!({ "text": String::from_utf8_lossy(&bytes) }));
}
Ok(json!({ "dataBase64": vectorcraft_format::base64_encode(&bytes) }))
}
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 bytes = encode(&s.doc()?.doc, f.id, p)?;
write_or_return(Some(path), &bytes, json!({ "format": f.id }))
}
/// `p` without its artboard choice (for documents made of one synthetic artboard).
fn without_artboards(p: &Value) -> Value {
let mut q = p.clone();
if let Some(o) = q.as_object_mut() {
for k in ["artboard", "artboards", "range"] {
o.remove(k);
}
}
q
}
/// File → Export Selection: the selected objects alone, cropped to their visual bounds.
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 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));
if let Some(ch) = layer.children_mut() {
*ch = nodes;
}
d.layers = vec![Arc::new(layer)];
let mut ab = d.artboards.first().cloned().ok_or_else(|| bad(C, "the document has no artboard"))?;
ab.rect = bounds;
ab.name = "Selection".into();
d.artboards = vec![ab];
let bytes = encode(&d, f.id, &without_artboards(p))?;
write_or_return(path, &bytes, json!({ "bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()] }))
}
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 prefix = str_param(p, "prefix").unwrap_or("");
create_dir(folder)?;
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);
}
}
Ok(json!({ "files": files }))
}

View File

@ -0,0 +1,171 @@
//! `document.open`: every readable format into a new document.
use std::borrow::Cow;
use std::io::{Cursor, Read as _};
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::Affine;
use super::super::*;
use super::{Format, format, format_for_name, read_file};
use crate::EngineError;
/// A file read into a document, with its format and non-fatal import notes.
pub struct Loaded {
pub doc: Document,
pub format: &'static Format,
pub warnings: Vec<String>,
}
/// An image ready to embed: PNG/JPEG/GIF/WebP keep their bytes, other formats are stored as PNG
/// (what browsers, PDF and SVG viewers show).
pub struct RasterImage {
/// Content key for `Document::images` (identical files share one blob).
pub key: String,
pub blob: ImageBlob,
pub width: u32,
pub height: u32,
}
fn err(e: impl std::fmt::Display) -> EngineError {
EngineError::Other(e.to_string())
}
/// The last component of a path or file name.
fn file_name(name: &str) -> String {
std::path::Path::new(name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string())
}
/// The readable format of a file: from its content (magic bytes) when that tells, else from the
/// extension of `name`.
pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> {
let by_name = format_for_name(name).filter(|f| f.read);
if vectorcraft_format::sniff(bytes) {
return format("vectorcraft");
}
if bytes.starts_with(&[0x1f, 0x8b]) {
return format("svgz");
}
if bytes.starts_with(b"%PDF") {
// .ai and .ait files are PDF inside; the extension keeps the template meaning.
return by_name.filter(|f| matches!(f.id, "ai" | "ait")).or_else(|| format("pdf"));
}
if let Some(f) = image::guess_format(bytes).ok().and_then(image_format) {
return Some(f);
}
if clipboard::looks_like_svg(&String::from_utf8_lossy(&bytes[..bytes.len().min(4096)])) {
return format("svg");
}
by_name
}
/// The readable raster format behind an `image` crate format.
fn image_format(f: image::ImageFormat) -> Option<&'static Format> {
f.extensions_str().iter().find_map(|e| format(e)).filter(|f| f.read && f.raster)
}
/// Read a file of any readable format (`name`: its file name or path, for the extension and title).
pub fn load(name: &str, bytes: &[u8]) -> Result<Loaded> {
let format = detect(name, bytes).ok_or_else(|| err(format!("can't open `{name}`: not a format VectorCraft reads (see document.formats)")))?;
let title = file_name(name);
let mut warnings = vec![];
let mut doc = match format.id {
"vectorcraft" => vectorcraft_format::load(bytes).map_err(err)?,
"svg" | "svgz" => {
let text: Cow<[u8]> = if format.id == "svgz" { Cow::Owned(gunzip(bytes)?) } else { Cow::Borrowed(bytes) };
let text = std::str::from_utf8(&text).map_err(|_| err("SVG is not UTF-8"))?;
let (d, w) = vectorcraft_svg::import_with_report(text).map_err(err)?;
warnings = w;
d
}
"pdf" | "ai" | "ait" => {
let r = vectorcraft_pdf::import_with_report(bytes, &vectorcraft_pdf::ImportOptions::default()).map_err(err)?;
warnings = r.warnings;
r.document
}
_ if format.raster => raster_doc(&title, bytes)?,
_ => return Err(err(format!("{} files can't be opened yet", format.label))),
};
// Imports are named after the file; a native document keeps its own title (the tab shows the
// file name once it has a path).
if format.id != "vectorcraft" || doc.title.is_empty() {
doc.title = title;
}
Ok(Loaded { doc, format, warnings })
}
/// Open a file's bytes as the new active document (what `document.open` does) →
/// `{index, title, format, warnings}`. `path` is kept for Save only for a native, non-template file.
pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option<String>) -> Result<Value> {
let Loaded { mut doc, format, warnings } = load(name, bytes)?;
// A template (saved by Save as Template, or an .ait file) opens as a new untitled document.
let template = doc.template || format.id == "ait";
if template {
doc.template = false;
doc.title = s.next_untitled();
}
let keep_path = format.id == "vectorcraft" && !template;
let index = s.add_document(doc, path.filter(|_| keep_path));
let title = s.documents()[index].title();
Ok(json!({ "index": index, "title": title, "format": format.id, "warnings": warnings }))
}
pub(super) fn open(s: &mut Session, p: &Value) -> Result<Value> {
match (str_param(p, "path"), str_param(p, "dataBase64")) {
(Some(path), _) => {
let bytes = read_file(path)?;
open_bytes(s, path, &bytes, Some(path.to_string()))
}
(None, Some(b64)) => {
let bytes = vectorcraft_format::base64_decode(b64).ok_or_else(|| bad("document.open", "bad base64"))?;
open_bytes(s, str_param(p, "name").unwrap_or("Untitled"), &bytes, None)
}
_ => Err(bad("document.open", "give path, or name and dataBase64")),
}
}
/// Decode an image's header (and, for formats stored as PNG, its pixels).
pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
let reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?;
let kind = reader.format().ok_or_else(|| err("not an image VectorCraft reads (see document.formats)"))?;
let f = image_format(kind).ok_or_else(|| err(format!("{kind:?} images can't be opened (see document.formats)")))?;
let (bytes, mime, (width, height)) = if matches!(f.id, "png" | "jpg" | "gif" | "webp") {
(bytes.to_vec(), f.mime, reader.into_dimensions().map_err(err)?)
} else {
let img = reader.decode().map_err(err)?.to_rgba8();
let size = img.dimensions();
let mut png = Vec::new();
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).map_err(err)?;
(png, "image/png", size)
};
if width == 0 || height == 0 {
return Err(err("the image is empty"));
}
Ok(RasterImage { key: format!("img{:016x}", fnv1a(&bytes)), blob: ImageBlob { mime: mime.into(), bytes: Arc::new(bytes) }, width, height })
}
/// An image as a document of its pixel size (1 px = 1 pt), the image named after the file.
fn raster_doc(name: &str, bytes: &[u8]) -> Result<Document> {
let RasterImage { key, blob, width, height } = raster_image(bytes)?;
let mut d = Document::new(width as f64, height as f64);
let layer = d.layers[0].id;
let id = d.alloc_id();
let mut n = Node::new(id, NodeKind::Image(ImageObject { key: key.clone(), width, height, xf: Affine::IDENTITY, link: None }));
n.name = Some(name.to_string());
d.images.insert(key, blob);
d.insert(Some(layer), 0, n).map_err(err)?;
Ok(d)
}
fn gunzip(bytes: &[u8]) -> Result<Vec<u8>> {
let mut out = Vec::new();
flate2::read::GzDecoder::new(bytes).read_to_end(&mut out).map_err(|e| err(format!("not a valid SVGZ file: {e}")))?;
Ok(out)
}
/// FNV-1a: a stable content key for embedded images.
fn fnv1a(b: &[u8]) -> u64 {
b.iter().fold(0xcbf29ce484222325, |h, x| (h ^ *x as u64).wrapping_mul(0x100000001b3))
}

View File

@ -0,0 +1,327 @@
//! Document file I/O for every frontend (desktop, web, CLI, control channel, headless MCP): open,
//! save, export and serialize by path or bytes (base64), the format table, and `command.batch`.
//!
//! - `load`: `document.open` (native, legacy, SVG/SVGZ, PDF/.ai/.ait, raster images).
//! - `encode`: one encoder per writable format, with typed options parsed from the params.
//! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForScreens`.
//! - `save`: `document.save`, `file.saveAsTemplate`.
//!
//! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`],
//! agents query `document.formats`.
mod batch;
mod encode;
mod export;
mod load;
mod save;
use serde_json::{Value, json};
pub use encode::{ArtboardPick, encode};
pub use load::{Loaded, RasterImage, detect, load, open_bytes, raster_image};
use super::*;
use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"document.open",
"Open Document",
[],
None,
"{path} or {name, dataBase64} → {index, title, format, warnings}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). Templates (native templates, .ait) open as a new untitled document",
always,
load::open
),
cmd!(
"document.save",
"Save Document",
[],
None,
"{path?} native .vectorcraft (default: the document's path) → {path}; a never-saved document without path → {dataBase64} (stays modified)",
has_doc,
save::save
),
cmd!(
query "document.serialize",
"Serialize Document",
[],
None,
"{format?: vectorcraft (default)|svg|pdf|png|jpg|webp, …the format's options (see document.formats)} → {text} for svg, else {dataBase64}",
has_doc,
export::serialize
),
cmd!(
"document.export",
"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}",
has_doc,
export::export
),
cmd!(
"document.exportSelection",
"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})",
has_selection,
export::export_selection
),
cmd!(
"file.saveAsTemplate",
"Save as Template…",
["File"],
None,
"{path?} a native copy that opens as a new untitled document (no path → {dataBase64})",
has_doc,
save::save_template
),
cmd!(
"document.exportForScreens",
"Export for Screens",
["File", "Export"],
None,
"{folder, artboards?: [index…] (default all), formats?: [{format: png|jpg|webp|svg|pdf, scale?: 1, suffix?: \"@2x\"}], prefix?} → {files: [...]}",
has_doc,
export::export_for_screens
),
cmd!(query "command.batch", "Batch", [], None, "{label?, commands: [{command, params}]} run several commands as ONE undo step; stops at the first error and rolls back", has_doc, batch::batch),
cmd!(
query "document.formats",
"File Formats",
[],
None,
"{} → {formats: [{id, label, extensions, mime, read, write, raster, options: {name: {type, default, description}}}], readable: [id…], writable: [id…], openExtensions: [ext…]}",
always,
formats
),
]
}
/// One option a format's encoder reads from the export params.
#[derive(Clone, Copy, Debug)]
pub struct FormatOption {
pub name: &'static str,
/// JSON type: `number`, `integer`, `boolean`, `string` or `array`.
pub ty: &'static str,
/// The default as a JSON literal (`"1"`, `"false"`, `"null"`).
pub default: &'static str,
pub description: &'static str,
}
/// One file format: how `document.open` recognises it and what `document.export` writes.
#[derive(Clone, Copy, Debug)]
pub struct Format {
/// The `format` param value (`svg`, `png`, …).
pub id: &'static str,
/// Open/save dialog filter name.
pub label: &'static str,
/// Lower-case extensions without the dot; the first is the one exports use.
pub extensions: &'static [&'static str],
pub mime: &'static str,
pub read: bool,
pub write: bool,
/// Pixels (export `scale` applies) rather than vectors.
pub raster: bool,
/// Options the encoder reads (writable formats).
pub options: &'static [FormatOption],
}
impl Format {
pub fn to_json(&self) -> Value {
let options: serde_json::Map<String, Value> = self
.options
.iter()
.map(|o| {
let default: Value = serde_json::from_str(o.default).unwrap_or(Value::Null);
(o.name.to_string(), json!({"type": o.ty, "default": default, "description": o.description}))
})
.collect();
json!({
"id": self.id,
"label": self.label,
"extensions": self.extensions,
"mime": self.mime,
"read": self.read,
"write": self.write,
"raster": self.raster,
"options": options,
})
}
}
const ARTBOARD: FormatOption = FormatOption {
name: "artboard",
ty: "integer",
default: "0",
description: "0-based artboard to export (`artboards: [i]` or `range: \"n\"` naming one artboard work too)",
};
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 =
FormatOption { name: "outlineText", ty: "boolean", default: "false", description: "text as glyph outlines (viewable without the fonts)" };
/// A format `document.open` reads but nothing writes yet.
const fn reader(id: &'static str, label: &'static str, extensions: &'static [&'static str], mime: &'static str, raster: bool) -> Format {
Format { id, label, extensions, mime, read: true, write: false, raster, options: &[] }
}
/// Every format VectorCraft reads or writes. Append-only: new formats go at the end.
pub const FORMATS: &[Format] = &[
Format {
id: "vectorcraft",
label: "VectorCraft",
extensions: &[vectorcraft_format::EXTENSION, vectorcraft_format::LEGACY_EXTENSION],
mime: "application/json",
read: true,
write: true,
raster: false,
options: &[],
},
Format {
id: "svg",
label: "SVG",
extensions: &["svg"],
mime: "image/svg+xml",
read: true,
write: true,
raster: false,
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: &[] },
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] },
Format {
id: "jpg",
label: "JPEG",
extensions: &["jpg", "jpeg"],
mime: "image/jpeg",
read: true,
write: true,
raster: true,
options: &[ARTBOARD, SCALE, QUALITY],
},
reader("gif", "GIF", &["gif"], "image/gif", true),
Format {
id: "webp",
label: "WebP",
extensions: &["webp"],
mime: "image/webp",
read: true,
write: true,
raster: true,
options: &[ARTBOARD, SCALE],
},
reader("tiff", "TIFF", &["tif", "tiff"], "image/tiff", true),
reader("bmp", "BMP", &["bmp"], "image/bmp", true),
];
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
pub const OPEN_EXTS: &[&str] =
&["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp"];
/// Open-dialog filters: "All readable files" first, then one per readable format.
pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
std::iter::once(("All readable files", OPEN_EXTS)).chain(FORMATS.iter().filter(|f| f.read).map(|f| (f.label, f.extensions)))
}
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`).
pub fn format(id_or_ext: &str) -> Option<&'static Format> {
let k = id_or_ext.trim_start_matches('.').to_ascii_lowercase();
FORMATS.iter().find(|f| f.id == k).or_else(|| FORMATS.iter().find(|f| f.extensions.contains(&k.as_str())))
}
/// The lower-case extension of a file name or path (empty when it has none).
pub fn extension(name: &str) -> String {
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
}
/// The format a file name's extension names.
pub fn format_for_name(name: &str) -> Option<&'static Format> {
Some(extension(name)).filter(|e| !e.is_empty()).and_then(|e| format(&e))
}
/// The format to write: `format` (an id or extension), else the path's extension, else PNG.
pub fn writable_format(format_param: Option<&str>, path: Option<&str>) -> std::result::Result<&'static Format, String> {
let f = match (format_param, path.map(extension).filter(|e| !e.is_empty())) {
(Some(f), _) => format(f).ok_or_else(|| format!("unknown format `{f}` (see document.formats)"))?,
(None, Some(e)) => format(&e).ok_or_else(|| format!("unknown extension `.{e}`: pass `format` (see document.formats)"))?,
(None, None) => format("png").expect("png is in FORMATS"),
};
if f.write { Ok(f) } else { Err(format!("{} files can be opened but not written (see document.formats)", f.label)) }
}
/// [`writable_format`] for command `cmd`.
fn writable(cmd: &str, format_param: Option<&str>, path: Option<&str>) -> Result<&'static Format> {
writable_format(format_param, path).map_err(|e| bad(cmd, e))
}
fn formats(_: &mut Session, _: &Value) -> Result<Value> {
let ids = |pick: fn(&Format) -> bool| FORMATS.iter().filter(|f| pick(f)).map(|f| f.id).collect::<Vec<_>>();
Ok(json!({
"formats": FORMATS.iter().map(Format::to_json).collect::<Vec<_>>(),
"readable": ids(|f| f.read),
"writable": ids(|f| f.write),
"openExtensions": OPEN_EXTS,
}))
}
// ---------- the file system (none on the web, where commands take and return bytes) ----------
#[cfg(not(target_arch = "wasm32"))]
fn read_file(path: &str) -> Result<Vec<u8>> {
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
#[cfg(not(target_arch = "wasm32"))]
fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
std::fs::write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
#[cfg(not(target_arch = "wasm32"))]
fn create_dir(path: &str) -> Result<()> {
std::fs::create_dir_all(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
fn no_fs(path: &str) -> EngineError {
EngineError::Other(format!("{path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64)"))
}
#[cfg(target_arch = "wasm32")]
fn read_file(path: &str) -> Result<Vec<u8>> {
Err(no_fs(path))
}
#[cfg(target_arch = "wasm32")]
fn write_file(path: &str, _: &[u8]) -> Result<()> {
Err(no_fs(path))
}
#[cfg(target_arch = "wasm32")]
fn create_dir(path: &str) -> Result<()> {
Err(no_fs(path))
}
/// Write `bytes` to `path` → `{path, bytes, …extra}`; with no path → `{dataBase64, bytes, …extra}`.
fn write_or_return(path: Option<&str>, bytes: &[u8], extra: Value) -> Result<Value> {
let mut out = match path {
Some(path) => {
write_file(path, bytes)?;
json!({ "path": path, "bytes": bytes.len() })
}
None => json!({ "dataBase64": vectorcraft_format::base64_encode(bytes), "bytes": bytes.len() }),
};
if let (Some(o), Value::Object(e)) = (out.as_object_mut(), extra) {
o.extend(e);
}
Ok(out)
}
#[cfg(test)]
mod tests;

View File

@ -0,0 +1,27 @@
//! Native saves: `document.save` and `file.saveAsTemplate`.
use serde_json::{Value, json};
use super::super::*;
use super::write_or_return;
pub(super) fn save(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").map(str::to_string).or_else(|| s.active().and_then(|d| d.path.clone()));
let bytes = vectorcraft_format::save_file(&s.doc()?.doc);
let Some(path) = path else {
// Nowhere to save to (web, agents): hand the bytes back; the document stays modified.
return write_or_return(None, &bytes, json!({}));
};
super::write_file(&path, &bytes)?;
let st = s.doc_mut()?;
st.path = Some(path.clone());
st.mark_saved();
Ok(json!({ "path": path }))
}
/// File → Save as Template: a native copy flagged so opening it starts a new untitled document.
pub(super) fn save_template(s: &mut Session, p: &Value) -> Result<Value> {
let mut d = (*s.doc()?.doc).clone();
d.template = true;
write_or_return(str_param(p, "path"), &vectorcraft_format::save_file(&d), json!({}))
}

View File

@ -0,0 +1,175 @@
use std::io::{Cursor, Write as _};
use serde_json::{Value, json};
use vectorcraft_doc::NodeKind;
use super::*;
use crate::cmd::parse_range;
fn session(width: f64, height: f64, artboards: usize) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": width, "height": height, "artboards": artboards})).unwrap();
s
}
fn tmp_dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("vc-fileio-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d
}
fn b64(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
}
fn open(s: &mut Session, name: &str, bytes: &[u8]) -> Value {
s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)}))
.unwrap_or_else(|e| panic!("{name}: {e}"))
}
/// A `w`×`h` image of one colour in `format`.
fn image_bytes(w: u32, h: u32, format: image::ImageFormat) -> Vec<u8> {
let rgba = image::RgbaImage::from_pixel(w, h, image::Rgba([200, 30, 40, 255]));
let img = if format == image::ImageFormat::Jpeg {
image::DynamicImage::ImageRgb8(image::DynamicImage::ImageRgba8(rgba).to_rgb8())
} else {
rgba.into()
};
let mut out = Vec::new();
img.write_to(&mut Cursor::new(&mut out), format).unwrap();
out
}
fn image_of(s: &Session) -> vectorcraft_doc::ImageObject {
let doc = &s.doc().unwrap().doc;
match &doc.layers[0].children().unwrap()[0].kind {
NodeKind::Image(im) => im.clone(),
other => panic!("expected an image, got {other:?}"),
}
}
#[test]
fn opens_every_readable_format() {
let mut s = session(200.0, 100.0, 1);
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
let native = b64(&s.execute("document.serialize", &json!({})).unwrap());
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string();
let pdf = b64(&s.execute("document.serialize", &json!({"format": "pdf"})).unwrap());
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
gz.write_all(svg.as_bytes()).unwrap();
let svgz = gz.finish().unwrap();
for (name, bytes, format) in [
("a.vectorcraft", native.clone(), "vectorcraft"),
("a.drawcraft", native, "vectorcraft"),
("a.svg", svg.into_bytes(), "svg"),
("a.svgz", svgz, "svgz"),
("a.pdf", pdf.clone(), "pdf"),
("a.ai", pdf, "ai"),
] {
let r = open(&mut s, name, &bytes);
assert_eq!(r["format"], format, "{name}");
assert_eq!(r["title"], if format == "vectorcraft" { "Untitled-1" } else { name }, "{name}");
assert!(s.doc().unwrap().doc.node_count() >= 2, "{name}: the rectangle came through");
}
for (name, kind) in [
("p.png", image::ImageFormat::Png),
("p.jpg", image::ImageFormat::Jpeg),
("p.gif", image::ImageFormat::Gif),
("p.webp", image::ImageFormat::WebP),
("p.tif", image::ImageFormat::Tiff),
("p.bmp", image::ImageFormat::Bmp),
] {
let r = open(&mut s, name, &image_bytes(5, 4, kind));
assert_eq!(r["format"], format(name.rsplit('.').next().unwrap()).unwrap().id, "{name}");
let im = image_of(&s);
assert_eq!((im.width, im.height), (5, 4), "{name}");
let doc = &s.doc().unwrap().doc;
assert_eq!((doc.artboards[0].rect.width(), doc.artboards[0].rect.height()), (5.0, 4.0));
// Browser-safe formats keep their bytes; TIFF and BMP are stored as PNG.
let mime = &doc.images[&im.key].mime;
assert_eq!(mime, if name.ends_with("tif") || name.ends_with("bmp") { "image/png" } else { format_for_name(name).unwrap().mime });
}
assert!(s.execute("document.open", &json!({"name": "x.txt", "dataBase64": "aGVsbG8="})).is_err());
}
#[test]
fn webp_opens_at_its_pixel_size() {
let mut s = Session::new();
open(&mut s, "tiny.webp", &image_bytes(3, 2, image::ImageFormat::WebP));
let im = image_of(&s);
assert_eq!((im.width, im.height), (3, 2));
let ab = s.doc().unwrap().doc.artboards[0].rect;
assert_eq!((ab.width(), ab.height()), (3.0, 2.0));
let r = raster_image(&image_bytes(3, 2, image::ImageFormat::WebP)).unwrap();
assert_eq!((r.width, r.height, r.blob.mime.as_str()), (3, 2, "image/webp"));
}
#[test]
fn content_beats_a_wrong_extension() {
let png = image_bytes(2, 2, image::ImageFormat::Png);
assert_eq!(detect("photo.jpg", &png).unwrap().id, "png");
assert_eq!(detect("noext", b"<?xml version=\"1.0\"?><svg xmlns=\"http://www.w3.org/2000/svg\"/>").unwrap().id, "svg");
assert_eq!(detect("x.ait", b"%PDF-1.7").unwrap().id, "ait");
assert_eq!(detect("x.bin", b"%PDF-1.7").unwrap().id, "pdf");
assert!(detect("x.bin", b"nothing").is_none());
}
#[test]
fn templates_open_untitled() {
let mut s = session(100.0, 100.0, 1);
let dir = tmp_dir("tpl");
let path = dir.join("t.vectorcraft").to_string_lossy().to_string();
s.execute("file.saveAsTemplate", &json!({"path": path})).unwrap();
let r = s.execute("document.open", &json!({"path": path})).unwrap();
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
assert_eq!(s.doc().unwrap().path, None);
assert!(!s.doc().unwrap().doc.template);
let pdf = b64(&s.execute("document.serialize", &json!({"format": "pdf"})).unwrap());
let ait = dir.join("t.ait").to_string_lossy().to_string();
std::fs::write(&ait, pdf).unwrap();
let r = s.execute("document.open", &json!({"path": ait})).unwrap();
assert_eq!(r["format"], "ait");
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
assert_eq!(s.doc().unwrap().path, None);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn open_exts_cover_every_readable_format() {
for f in FORMATS.iter().filter(|f| f.read) {
for e in f.extensions {
assert!(OPEN_EXTS.contains(e), "OPEN_EXTS lacks .{e} ({})", f.label);
}
}
for e in OPEN_EXTS {
assert!(format(e).is_some_and(|f| f.read), ".{e} in OPEN_EXTS is no readable format");
}
let filters: Vec<_> = open_filters().collect();
assert_eq!(filters[0], ("All readable files", OPEN_EXTS));
assert_eq!(filters.len(), 1 + FORMATS.iter().filter(|f| f.read).count());
}
#[test]
fn formats_query_lists_readers_writers_and_options() {
let mut s = Session::new();
let r = s.execute("document.formats", &json!({})).unwrap();
let ids = |k: &str| r[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect::<Vec<_>>();
assert_eq!(ids("writable"), ["vectorcraft", "svg", "pdf", "png", "jpg", "webp"]);
assert!(ids("readable").contains(&"tiff".to_string()) && ids("readable").contains(&"ait".to_string()));
let png = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap();
assert_eq!(png["options"]["scale"]["default"], 1);
assert_eq!(r["openExtensions"].as_array().unwrap().len(), OPEN_EXTS.len());
}
#[test]
fn ranges_parse_one_based() {
assert_eq!(parse_range("1-3, 5", 5).unwrap(), [0, 1, 2, 4]);
assert_eq!(parse_range("3-, 1", 4).unwrap(), [2, 3, 0]);
assert_eq!(parse_range("-2", 4).unwrap(), [0, 1]);
assert_eq!(parse_range("2,2,1\u{2013}2", 4).unwrap(), [1, 0]);
for bad in ["", "0", "6", "3-1", "a", "-", "1-9"] {
assert!(parse_range(bad, 5).is_err(), "{bad:?}");
}
}

View File

@ -12,7 +12,7 @@ mod docmenu;
mod draw2; mod draw2;
mod edit; mod edit;
mod effectcmd; mod effectcmd;
mod file; pub mod fileio;
mod fonts; mod fonts;
pub(crate) mod graph; pub(crate) mod graph;
pub mod help; pub mod help;
@ -141,7 +141,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(edit::specs()); v.extend(edit::specs());
v.extend(clipboard::specs()); v.extend(clipboard::specs());
v.extend(recolor::specs()); v.extend(recolor::specs());
v.extend(file::specs()); v.extend(fileio::specs());
v.extend(create::specs()); v.extend(create::specs());
v.extend(object::specs()); v.extend(object::specs());
v.extend(path::specs()); v.extend(path::specs());
@ -260,3 +260,38 @@ pub(crate) fn targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
pub(crate) fn ok() -> Result<Value> { pub(crate) fn ok() -> Result<Value> {
Ok(Value::Null) Ok(Value::Null)
} }
/// A 1-based page/artboard range such as `"1-3, 5"` → 0-based indices in the order given (repeats
/// dropped). Each number must lie in `1..=count`; `"3-"` runs to the last one, `"-2"` from the first.
pub(crate) fn parse_range(s: &str, count: usize) -> std::result::Result<Vec<usize>, String> {
let num = |t: &str, open: usize| -> std::result::Result<usize, String> {
let t = t.trim();
if t.is_empty() {
return Ok(open);
}
t.parse().ok().filter(|n| (1..=count).contains(n)).ok_or_else(|| format!("range `{s}`: `{t}` is not a number from 1 to {count}"))
};
let mut out = vec![];
for part in s.split(',').map(str::trim).filter(|p| !p.is_empty()) {
let (a, b) = match part.replace('\u{2013}', "-").split_once('-') {
Some((a, b)) if a.trim().is_empty() && b.trim().is_empty() => return Err(format!("range `{s}`: `{part}` names no number")),
Some((a, b)) => (num(a, 1)?, num(b, count)?),
None => {
let n = num(part, 0)?;
(n, n)
}
};
if a > b {
return Err(format!("range `{s}`: `{part}` runs backwards"));
}
for i in a - 1..b {
if !out.contains(&i) {
out.push(i);
}
}
}
if out.is_empty() {
return Err(format!("range `{s}` is empty"));
}
Ok(out)
}

View File

@ -25,10 +25,10 @@ impl Default for Headless {
/// Commands the desktop app adds on top of the engine; the headless backend implements them too so /// Commands the desktop app adds on top of the engine; the headless backend implements them too so
/// `run_command` behaves the same in both modes. /// `run_command` behaves the same in both modes.
const HOST_COMMANDS: &[(&str, &str, &str)] = &[ const HOST_COMMANDS: &[(&str, &str, &str)] = &[
("file.open", "Open…", "{path} open a .vectorcraft or .svg file as a new document"), ("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.save", "Save", "{path?} save as .vectorcraft (default: the document's path)"),
("file.saveAs", "Save As…", "{path}"), ("file.saveAs", "Save As…", "{path}"),
("file.export", "Export…", "{format?: svg|png|vectorcraft, path, scale?, artboard?}"), ("file.export", "Export…", "{path, format?, artboard?, scale?, …} = document.export"),
("tool.select", "Select Tool", "{tool} e.g. selection, directSelection, pen, rectangle, ellipse, polygon, star, lineSegment"), ("tool.select", "Select Tool", "{tool} e.g. selection, directSelection, pen, rectangle, ellipse, polygon, star, lineSegment"),
]; ];
@ -36,14 +36,6 @@ fn s<'a>(p: &'a Value, k: &str) -> Option<&'a str> {
p.get(k).and_then(Value::as_str) p.get(k).and_then(Value::as_str)
} }
fn ext_of(path: &str) -> String {
std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
}
fn file_name(path: &str) -> String {
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
}
pub(crate) fn pointer_kind(k: &str) -> Option<PointerKind> { pub(crate) fn pointer_kind(k: &str) -> Option<PointerKind> {
Some(match k { Some(match k {
"down" => PointerKind::Down, "down" => PointerKind::Down,
@ -215,72 +207,24 @@ impl Headless {
} }
} }
/// `app.open {path}`: `.vectorcraft` or `.svg` as a new active document. /// `app.open {path}`: any readable file as a new active document (`document.open`).
pub fn open(&mut self, p: &Value) -> Result<Value, String> { pub fn open(&mut self, p: &Value) -> Result<Value, String> {
let path = s(p, "path").ok_or("missing `path`")?; let path = s(p, "path").ok_or("missing `path`")?;
let bytes = std::fs::read(path).map_err(|e| format!("read {path}: {e}"))?; self.session.execute("document.open", &json!({"path": path})).map_err(|e| e.to_string())
let name = file_name(path);
let e = ext_of(path);
let mut warnings = vec![];
let (doc, keep_path) = if vectorcraft_format::is_native_ext(&e) || vectorcraft_format::sniff(&bytes) {
let mut d = vectorcraft_format::load(&bytes).map_err(|e| e.to_string())?;
if d.title.is_empty() {
d.title = name.clone();
}
(d, true)
} else if e == "svg" || bytes.starts_with(b"<?xml") || bytes.starts_with(b"<svg") {
let text = std::str::from_utf8(&bytes).map_err(|_| "SVG is not UTF-8".to_string())?;
let (mut d, w) = vectorcraft_svg::import_with_report(text).map_err(|e| e.to_string())?;
d.title = name.clone();
warnings = w;
(d, false)
} else {
return Err(format!("headless mode can open .vectorcraft and .svg files, not .{e}"));
};
let index = self.session.add_document(doc, keep_path.then(|| path.to_string()));
Ok(json!({"index": index, "title": name, "warnings": warnings}))
} }
/// `app.save {path?}`: native format; remembers the path and clears the dirty flag. /// `app.save {path?}`: native format; remembers the path and clears the dirty flag.
pub fn save(&mut self, p: &Value) -> Result<Value, String> { pub fn save(&mut self, p: &Value) -> Result<Value, String> {
let st = self.session.active().ok_or("no document")?; let st = self.session.active().ok_or("no document")?;
let path = s(p, "path").map(str::to_string).or_else(|| st.path.clone()).ok_or("missing `path` (document was never saved)")?; if s(p, "path").is_none() && st.path.is_none() {
let bytes = vectorcraft_format::save_file(&st.doc); return Err("missing `path` (document was never saved)".into());
std::fs::write(&path, bytes).map_err(|e| format!("write {path}: {e}"))?;
if let Some(st) = self.session.active_mut() {
st.path = Some(path.clone());
st.mark_saved();
} }
Ok(json!({"path": path})) self.session.execute("document.save", p).map_err(|e| e.to_string())
} }
/// `app.export {format?, path, scale?, artboard?}`: svg | png | pdf | jpg | webp | vectorcraft (format defaults to the extension). /// `app.export {path, format?, …}`: `document.export` (format defaults to the path's extension).
pub fn export(&mut self, p: &Value) -> Result<Value, String> { pub fn export(&mut self, p: &Value) -> Result<Value, String> {
let path = s(p, "path").ok_or("missing `path`")?; self.session.execute("document.export", p).map_err(|e| e.to_string())
let fmt = s(p, "format").map(str::to_ascii_lowercase).unwrap_or_else(|| ext_of(path));
let st = self.session.active().ok_or("no document")?;
let doc = st.doc.clone();
let bytes = match fmt.as_str() {
"svg" => {
let ab = p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize;
vectorcraft_svg::export(&doc, &vectorcraft_svg::ExportOptions { artboard: Some(ab), ..Default::default() }).into_bytes()
}
"png" => {
let idx = p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize;
let r = doc.artboards.get(idx).map(|a| a.rect).ok_or("no such artboard")?;
let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.01, 16.0);
self.renderer.render_region(&doc, r, scale, false).to_png()
}
"vectorcraft" => vectorcraft_format::save_file(&doc),
// pdf, jpg, webp…: the engine's exporter (same bytes as the app).
other => {
let params = json!({"path": path, "format": other, "scale": p.get("scale"), "artboard": p.get("artboard")});
let r = self.session.execute("document.export", &params).map_err(|e| e.to_string())?;
return Ok(json!({"path": path, "format": fmt, "bytes": r["bytes"]}));
}
};
std::fs::write(path, &bytes).map_err(|e| format!("write {path}: {e}"))?;
Ok(json!({"path": path, "format": fmt, "bytes": bytes.len()}))
} }
} }

View File

@ -29,3 +29,5 @@ pub const DEFAULT_ADDR: &str = "127.0.0.1:7979";
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
#[cfg(test)]
mod tests_fileio;

View File

@ -0,0 +1,55 @@
//! File I/O through MCP: every readable format opens headless.
use serde_json::{Value, json};
use crate::{Backend, Headless, 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 open_file_lists_the_engine_formats() {
let tools = tool_definitions();
let open = tools.iter().find(|t| t["name"] == "open_file").unwrap();
assert!(open["description"].as_str().unwrap().contains(".ait"));
}

View File

@ -2,6 +2,7 @@
//! [`Backend`]. //! [`Backend`].
use serde_json::{Map, Value, json}; use serde_json::{Map, Value, json};
use vectorcraft_engine::cmd::fileio::OPEN_EXTS;
use crate::backend::Backend; use crate::backend::Backend;
@ -246,7 +247,10 @@ pub fn tool_definitions() -> Vec<Value> {
tool( tool(
"open_file", "open_file",
"Open file", "Open file",
"Open a .vectorcraft or .svg file as a new, active document.", &format!(
"Open a file as a new, active document: .{} (see run_command document.formats). Templates (.ait, native templates) open as a new untitled document.",
OPEN_EXTS.join(", .")
),
obj(json!({"path": string("File path")}), &["path"]), obj(json!({"path": string("File path")}), &["path"]),
false, false,
), ),

View File

@ -15,9 +15,11 @@
//! - `ui.dialog.set {field, value}` / `ui.dialog.confirm` / `ui.dialog.cancel` //! - `ui.dialog.set {field, value}` / `ui.dialog.confirm` / `ui.dialog.cancel`
//! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?}` //! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?}`
//! - `ui.render {path?, scale?}`: render the active artboard headlessly (PNG) //! - `ui.render {path?, scale?}`: render the active artboard headlessly (PNG)
//! - `app.open {path}` / `app.save {path?}` / `app.export {format, path, scale?}` / `app.quit` //! - `app.open {path}` (any readable format) / `app.save {path?}` / `app.quit`
//! (`file.close`, `file.closeAll` and `app.quit` first open a `saveChanges` dialog for each //! (`file.close`, `file.closeAll` and `app.quit` first open a `saveChanges` dialog for each
//! modified document: `ui.dialog.confirm` saves, set `discard: true` then confirm to discard) //! modified document: `ui.dialog.confirm` saves, set `discard: true` then confirm to discard)
//! - `app.export {path?, format?, …document.export options}`: encoded by the engine, written through
//! the host (a download on the web); the document keeps its path
use std::sync::mpsc::Sender; use std::sync::mpsc::Sender;
@ -294,13 +296,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
} }
"app.open" => wrap(app.run("file.open", json!({"path": s("path")}))), "app.open" => wrap(app.run("file.open", json!({"path": s("path")}))),
"app.save" => wrap(app.run("file.save", json!({"path": s("path")}))), "app.save" => wrap(app.run("file.save", json!({"path": s("path")}))),
"app.export" => { "app.export" => wrap(crate::io::export(app, s("format"), s("path").map(str::to_string), p).map(|p| json!({"path": p}))),
let f = s("format").unwrap_or("png");
wrap(
crate::io::export(app, f, s("path").map(str::to_string), p.get("scale").and_then(Value::as_f64).unwrap_or(1.0))
.map(|p| json!({"path": p})),
)
}
"app.quit" => { "app.quit" => {
ctx.send_viewport_cmd(egui::ViewportCommand::Close); ctx.send_viewport_cmd(egui::ViewportCommand::Close);
ok(Value::Null) ok(Value::Null)

View File

@ -1,121 +1,20 @@
//! File I/O through the injected services: open, save, export, place. //! File I/O through the injected services (pick, read, write, download); the engine's `fileio`
//! decodes and encodes every format.
use std::sync::Arc; use serde_json::Value;
use vectorcraft_doc::{ImageObject, Node, NodeKind};
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; use vectorcraft_engine::cmd::fileio;
use vectorcraft_geom::Affine; use vectorcraft_geom::Affine;
use crate::VectorcraftApp; use crate::VectorcraftApp;
fn ext(name: &str) -> String { /// Open bytes of any readable format as a new document (templates open untitled).
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
}
/// Decode an image and make a new document (or place into the active one when `place`).
fn image_doc(name: &str, bytes: &[u8]) -> Result<Document, String> {
let (w, h) = image_size(bytes)?;
let mut d = Document::new(w as f64, h as f64);
d.title = name.to_string();
let key = format!("img{:016x}", hash(bytes));
d.images.insert(key.clone(), ImageBlob { mime: mime_of(name), bytes: Arc::new(bytes.to_vec()) });
let l = d.layers[0].id;
let id = d.alloc_id();
let mut n = Node::new(id, NodeKind::Image(ImageObject { key, width: w, height: h, xf: Affine::IDENTITY, link: None }));
n.name = Some(name.to_string());
d.insert(Some(l), 0, n).map_err(|e| e.to_string())?;
Ok(d)
}
pub fn image_size(bytes: &[u8]) -> Result<(u32, u32), String> {
// PNG / JPEG / GIF / WebP headers — enough to size a placed image without decoding.
if bytes.len() > 24 && &bytes[..8] == b"\x89PNG\r\n\x1a\n" {
let w = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
let h = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
return Ok((w, h));
}
if bytes.len() > 10 && &bytes[..6] == b"GIF89a" || bytes.len() > 10 && &bytes[..6] == b"GIF87a" {
return Ok((u16::from_le_bytes([bytes[6], bytes[7]]) as u32, u16::from_le_bytes([bytes[8], bytes[9]]) as u32));
}
if bytes.len() > 4 && bytes[0] == 0xFF && bytes[1] == 0xD8 {
let mut i = 2;
while i + 9 < bytes.len() {
if bytes[i] != 0xFF {
i += 1;
continue;
}
let marker = bytes[i + 1];
let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize;
if (0xC0..=0xCF).contains(&marker) && marker != 0xC4 && marker != 0xC8 && marker != 0xCC {
let h = u16::from_be_bytes([bytes[i + 5], bytes[i + 6]]) as u32;
let w = u16::from_be_bytes([bytes[i + 7], bytes[i + 8]]) as u32;
return Ok((w, h));
}
i += 2 + len;
}
}
Err("unsupported image format".into())
}
fn mime_of(name: &str) -> String {
match ext(name).as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
_ => "application/octet-stream",
}
.to_string()
}
fn hash(b: &[u8]) -> u64 {
// FNV-1a: stable content key for embedded images.
let mut h: u64 = 0xcbf29ce484222325;
for x in b {
h ^= *x as u64;
h = h.wrapping_mul(0x100000001b3);
}
h
}
/// Open bytes as a new document.
pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>) -> Result<(), String> { pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>) -> Result<(), String> {
let e = ext(name); let r = fileio::open_bytes(&mut app.session, name, bytes, path).map_err(|e| e.to_string())?;
let (doc, keep_path) = if vectorcraft_format::is_native_ext(&e) || vectorcraft_format::sniff(bytes) {
let mut d = vectorcraft_format::load(bytes).map_err(|e| e.to_string())?;
if d.template {
// Templates open as a new untitled document.
d.template = false;
d.title = app.session.next_untitled();
(d, false)
} else {
if d.title.is_empty() {
d.title = name.to_string();
}
(d, true)
}
} else if e == "svg" || bytes.starts_with(b"<?xml") || bytes.starts_with(b"<svg") {
let s = std::str::from_utf8(bytes).map_err(|_| "SVG is not UTF-8".to_string())?;
let (mut d, warnings) = vectorcraft_svg::import_with_report(s).map_err(|e| e.to_string())?;
d.title = name.to_string();
if !warnings.is_empty() {
app.status(format!("Opened with {} warning(s): {}", warnings.len(), warnings.first().cloned().unwrap_or_default()));
}
(d, false)
} else if e == "pdf" || e == "ai" || bytes.starts_with(b"%PDF") {
let vectorcraft_pdf::ImportReport { document: mut d, warnings } =
vectorcraft_pdf::import_with_report(bytes, &vectorcraft_pdf::ImportOptions::default()).map_err(|e| e.to_string())?;
d.title = name.to_string();
if !warnings.is_empty() {
app.status(format!("Opened with {} note(s): {}", warnings.len(), warnings.first().cloned().unwrap_or_default()));
}
(d, false)
} else if ["png", "jpg", "jpeg", "gif", "webp"].contains(&e.as_str()) {
(image_doc(name, bytes)?, false)
} else {
return Err(format!("VectorCraft can't open .{e} files yet"));
};
app.session.add_document(doc, if keep_path { path } else { None });
app.sync_views(); app.sync_views();
if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) {
app.status(format!("Opened with {} note(s): {}", w.len(), w[0].as_str().unwrap_or_default()));
}
Ok(()) Ok(())
} }
@ -132,8 +31,7 @@ pub fn open_dialog(app: &mut VectorcraftApp) -> Result<(), String> {
pub fn open_path(app: &mut VectorcraftApp, path: &str) -> Result<(), String> { pub fn open_path(app: &mut VectorcraftApp, path: &str) -> Result<(), String> {
let read = app.services.read.as_ref().ok_or("no file reader")?; let read = app.services.read.as_ref().ok_or("no file reader")?;
let bytes = read(path)?; let bytes = read(path)?;
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.to_string()); open_bytes(app, path, &bytes, Some(path.to_string()))?;
open_bytes(app, &name, &bytes, Some(path.to_string()))?;
note_recent(app, path); note_recent(app, path);
Ok(()) Ok(())
} }
@ -154,19 +52,25 @@ fn suggested(app: &VectorcraftApp, ext: &str) -> String {
format!("{stem}.{ext}") format!("{stem}.{ext}")
} }
/// `path`, else a suggested name (web download) or one picked in a save dialog.
fn target_path(app: &mut VectorcraftApp, path: Option<String>, ext: &str) -> Result<String, String> {
match path {
Some(p) => Ok(p),
None if app.services.download.is_some() => Ok(suggested(app, ext)),
None => {
let s = suggested(app, ext);
app.services.pick_save.as_mut().and_then(|f| f(&s)).ok_or_else(|| "cancelled".into())
}
}
}
/// File → Save / Save As (native format). /// File → Save / Save As (native format).
pub fn save(app: &mut VectorcraftApp, path: Option<String>, save_as: bool) -> Result<String, String> { pub fn save(app: &mut VectorcraftApp, path: Option<String>, save_as: bool) -> Result<String, String> {
let st = app.session.active().ok_or("no document")?; let st = app.session.active().ok_or("no document")?;
let existing = if save_as { None } else { st.path.clone() }; let existing = if save_as { None } else { st.path.clone() };
let path = match path.or(existing) { let path = target_path(app, path.or(existing), vectorcraft_format::EXTENSION)?;
Some(p) => p, let doc = app.session.active().ok_or("no document")?.doc.clone();
None if app.services.download.is_some() => suggested(app, "vectorcraft"), let bytes = fileio::encode(&doc, "vectorcraft", &Value::Null).map_err(|e| e.to_string())?;
None => {
let s = suggested(app, "vectorcraft");
app.services.pick_save.as_mut().and_then(|f| f(&s)).ok_or("cancelled")?
}
};
let bytes = vectorcraft_format::save_file(&app.session.active().unwrap().doc);
write_out(app, &path, &bytes)?; write_out(app, &path, &bytes)?;
if let Some(st) = app.session.active_mut() { if let Some(st) = app.session.active_mut() {
st.path = Some(path.clone()); st.path = Some(path.clone());
@ -185,32 +89,13 @@ pub fn note_recent(app: &mut VectorcraftApp, path: &str) {
r.truncate((app.session.prefs.recent_files_count as usize).clamp(1, 10)); r.truncate((app.session.prefs.recent_files_count as usize).clamp(1, 10));
} }
/// Export the document as SVG / PDF / PNG / JPEG / WebP. /// Export the active document in `format` (default: the path's extension, else PNG) with the
pub fn export(app: &mut VectorcraftApp, format: &str, path: Option<String>, scale: f64) -> Result<String, String> { /// `document.export` options in `params` (artboard, range, scale…). The document keeps its path.
let st = app.session.active().ok_or("no document")?; pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<String>, params: &Value) -> Result<String, String> {
let doc = st.doc.clone(); let f = fileio::writable_format(format, path.as_deref())?;
let path = match path { let doc = app.session.active().ok_or("no document")?.doc.clone();
Some(p) => p, let path = target_path(app, path, f.extensions[0])?;
None if app.services.download.is_some() => suggested(app, format), let bytes = fileio::encode(&doc, f.id, params).map_err(|e| e.to_string())?;
None => {
let s = suggested(app, format);
app.services.pick_save.as_mut().and_then(|f| f(&s)).ok_or("cancelled")?
}
};
let bytes = match format {
"svg" => vectorcraft_svg::export(&doc, &vectorcraft_svg::ExportOptions { artboard: Some(0), ..Default::default() }).into_bytes(),
"pdf" => vectorcraft_engine::export_pdf(&doc, &vectorcraft_pdf::PdfOptions::default()).map_err(|e| e.to_string())?,
"png" | "jpg" | "jpeg" | "webp" => {
let r = doc.artboards.first().map(|a| a.rect).ok_or("no artboard")?;
let img = app.canvas.renderer.render_region(&doc, r, scale, format == "jpg" || format == "jpeg");
match format {
"png" => img.to_png(),
"webp" => img.to_webp(),
_ => img.to_jpeg(90),
}
}
other => return Err(format!("unknown export format `{other}`")),
};
write_out(app, &path, &bytes)?; write_out(app, &path, &bytes)?;
app.status(format!("Exported {path}")); app.status(format!("Exported {path}"));
Ok(path) Ok(path)
@ -218,19 +103,12 @@ pub fn export(app: &mut VectorcraftApp, format: &str, path: Option<String>, scal
/// Run an engine command that returns `{dataBase64}` and write the bytes to a picked path /// Run an engine command that returns `{dataBase64}` and write the bytes to a picked path
/// (Export Selection, Save as Template). /// (Export Selection, Save as Template).
pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, mut params: serde_json::Value) -> Result<String, String> { pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, mut params: Value) -> Result<String, String> {
let path = match params.get("path").and_then(serde_json::Value::as_str) { let path = target_path(app, params.get("path").and_then(Value::as_str).map(str::to_string), ext)?;
Some(p) => p.to_string(),
None if app.services.download.is_some() => suggested(app, ext),
None => {
let s = suggested(app, ext);
app.services.pick_save.as_mut().and_then(|f| f(&s)).ok_or("cancelled")?
}
};
if let Some(o) = params.as_object_mut() { if let Some(o) = params.as_object_mut() {
o.remove("path"); o.remove("path");
if o.get("format").is_none() && id == "document.exportSelection" { if o.get("format").is_none() && id == "document.exportSelection" {
let e = std::path::Path::new(&path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_else(|| ext.into()); let e = Some(fileio::extension(&path)).filter(|e| !e.is_empty()).unwrap_or_else(|| ext.into());
o.insert("format".into(), serde_json::json!(e)); o.insert("format".into(), serde_json::json!(e));
} }
} }
@ -243,10 +121,12 @@ pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, mut pa
/// File → Place… (embed an image or SVG into the active document). /// File → Place… (embed an image or SVG into the active document).
pub fn place_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result<(), String> { pub fn place_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result<(), String> {
let e = ext(name); let f = fileio::detect(name, bytes).ok_or_else(|| format!("can't place `{name}`: not a format VectorCraft reads"))?;
if e == "svg" { if !f.raster {
let s = std::str::from_utf8(bytes).map_err(|_| "SVG is not UTF-8".to_string())?; if !matches!(f.id, "svg" | "svgz") {
let src = vectorcraft_svg::import(s).map_err(|e| e.to_string())?; return Err(format!("Place doesn't take {} files yet", f.label));
}
let src = fileio::load(name, bytes).map_err(|e| e.to_string())?.doc;
let nodes: Vec<Node> = src.layers.iter().flat_map(|l| l.children().cloned().unwrap_or_default()).map(|n| (*n).clone()).collect(); let nodes: Vec<Node> = src.layers.iter().flat_map(|l| l.children().cloned().unwrap_or_default()).map(|n| (*n).clone()).collect();
app.session.clipboard = nodes; app.session.clipboard = nodes;
// Straight to the engine: `app.run` would let the system clipboard replace these nodes. // Straight to the engine: `app.run` would let the system clipboard replace these nodes.
@ -254,19 +134,16 @@ pub fn place_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result
app.sync_views(); app.sync_views();
return Ok(()); return Ok(());
} }
let (w, h) = image_size(bytes)?; let fileio::RasterImage { key, blob, width: w, height: h } = fileio::raster_image(bytes).map_err(|e| e.to_string())?;
let key = format!("img{:016x}", hash(bytes));
let st = app.session.active().ok_or("no document")?; let st = app.session.active().ok_or("no document")?;
let ab = st.doc.artboards.first().map(|a| a.rect).unwrap_or_default(); let ab = st.doc.artboards.first().map(|a| a.rect).unwrap_or_default();
let s = (ab.width() / w as f64).min(ab.height() / h as f64).min(1.0); let s = (ab.width() / w as f64).min(ab.height() / h as f64).min(1.0);
let xf = Affine::translate((ab.center().x - w as f64 * s / 2.0, ab.center().y - h as f64 * s / 2.0)) * Affine::scale(s); let xf = Affine::translate((ab.center().x - w as f64 * s / 2.0, ab.center().y - h as f64 * s / 2.0)) * Affine::scale(s);
let mime = mime_of(name);
let bytes = Arc::new(bytes.to_vec());
let parent = st.insertion_parent(); let parent = st.insertion_parent();
let name = name.to_string(); let name = name.to_string();
app.session app.session
.edit("Place", |d, sel| { .edit("Place", |d, sel| {
d.images.insert(key.clone(), ImageBlob { mime, bytes }); d.images.insert(key.clone(), blob);
let id = d.alloc_id(); let id = d.alloc_id();
let mut n = Node::new(id, NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None })); let mut n = Node::new(id, NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None }));
n.name = Some(name); n.name = Some(name);
@ -276,3 +153,87 @@ pub fn place_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result
}) })
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use std::rc::Rc;
use serde_json::json;
use vectorcraft_engine::Session;
use super::*;
use crate::Services;
type Written = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
/// An app whose writer records what it is given.
fn app() -> (VectorcraftApp, Written) {
let written = Written::default();
let w = written.clone();
let services = Services {
write: Some(Box::new(move |p: &str, b: &[u8]| {
w.borrow_mut().push((p.to_string(), b.to_vec()));
Ok(())
})),
..Default::default()
};
(VectorcraftApp::new(Session::new(), services), written)
}
fn bytes_of(app: &mut VectorcraftApp, cmd: &str, p: Value) -> Vec<u8> {
let v = app.session.execute(cmd, &p).unwrap();
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
}
fn image_size(app: &VectorcraftApp, id: vectorcraft_doc::NodeId) -> (u32, u32) {
match &app.session.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Image(im) => (im.width, im.height),
other => panic!("{other:?}"),
}
}
#[test]
fn webp_opens_and_places_at_its_pixel_size() {
let (mut app, _) = app();
app.session.execute("file.new", &json!({"width": 3, "height": 2})).unwrap();
let webp = bytes_of(&mut app, "document.serialize", json!({"format": "webp"}));
open_bytes(&mut app, "tiny.webp", &webp, None).unwrap();
assert_eq!(app.session.documents().len(), 2);
assert_eq!(app.views.len(), 2, "views follow the documents");
let first = app.session.doc().unwrap().doc.layers[0].children().unwrap()[0].id;
assert_eq!(image_size(&app, first), (3, 2));
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
place_bytes(&mut app, "tiny.webp", &webp).unwrap();
let placed = app.session.doc().unwrap().selection.objects[0];
assert_eq!(image_size(&app, placed), (3, 2));
assert!(place_bytes(&mut app, "x.txt", b"hello").is_err());
}
#[test]
fn templates_open_untitled() {
let (mut app, _) = app();
app.session.execute("file.new", &json!({"width": 50, "height": 50})).unwrap();
let pdf = bytes_of(&mut app, "document.serialize", json!({"format": "pdf"}));
open_bytes(&mut app, "brochure.ait", &pdf, Some("/tmp/brochure.ait".into())).unwrap();
let st = app.session.active().unwrap();
assert!(st.title().starts_with("Untitled-"), "{}", st.title());
assert_eq!(st.path, None);
}
#[test]
fn export_uses_the_engine_options_and_keeps_the_path() {
let (mut app, written) = app();
app.session.execute("file.new", &json!({"width": 100, "height": 50, "artboards": 2})).unwrap();
app.session.execute("artboard.setProps", &json!({"index": 1, "width": 40})).unwrap();
app.session.doc_mut().unwrap().path = Some("/tmp/doc.vectorcraft".into());
export(&mut app, None, Some("/tmp/b.png".into()), &json!({"artboard": 1, "scale": 2})).unwrap();
export(&mut app, None, Some("/tmp/copy.vectorcraft".into()), &Value::Null).unwrap();
let w = written.borrow();
assert_eq!(&w[0].1[16..20], 80u32.to_be_bytes(), "artboard 1 (40 pt) at scale 2");
assert!(vectorcraft_format::sniff(&w[1].1));
assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/tmp/doc.vectorcraft"));
drop(w);
assert!(export(&mut app, None, Some("/tmp/x.bmp".into()), &Value::Null).is_err(), "BMP is read-only");
}
}

View File

@ -83,8 +83,8 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
("file.clearRecent", "Clear Recent Files", "", "{}"), ("file.clearRecent", "Clear Recent Files", "", "{}"),
("type.findFont", "Find Font…", "", "{} open the Find Font dialog (engine: text.fonts / text.replaceFont / select.font)"), ("type.findFont", "Find Font…", "", "{} open the Find Font dialog (engine: text.fonts / text.replaceFont / select.font)"),
("file.recentFiles", "Recent Files", "", "{} → [path…] most recent first"), ("file.recentFiles", "Recent Files", "", "{} → [path…] most recent first"),
("file.export.svg", "Export As SVG…", "", "{path?}"), ("file.export.svg", "Export As SVG…", "", "{path?, artboard?, outlineText?} (document.export options)"),
("file.export.png", "Export As PNG…", "", "{path?, scale?: 1}"), ("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", "{path?, scale?}"),
("file.documentSetup", "Document Setup…", "Cmd+Alt+P", "{}"), ("file.documentSetup", "Document Setup…", "Cmd+Alt+P", "{}"),
("file.newDialog", "New…", "Cmd+N", "{} opens the New Document dialog"), ("file.newDialog", "New…", "Cmd+N", "{} opens the New Document dialog"),
@ -231,7 +231,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
None => Err("cancelled".into()), None => Err("cancelled".into()),
} }
} }
"file.export.svg" => io::export(app, "svg", s("path"), 1.0).map(|p| json!({"path": p})), "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.get("path").is_none() && p.get("scale").is_none() => {
let n = app.session.active().map(|d| d.doc.artboards.len()).unwrap_or(0); let n = app.session.active().map(|d| d.doc.artboards.len()).unwrap_or(0);
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
@ -241,9 +241,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
)); ));
Ok(Value::Null) Ok(Value::Null)
} }
"file.export.png" | "file.exportForScreens" => { "file.export.png" | "file.exportForScreens" => io::export(app, Some("png"), s("path"), p).map(|p| json!({"path": p})),
io::export(app, "png", s("path"), p.get("scale").and_then(Value::as_f64).unwrap_or(1.0)).map(|p| json!({"path": p}))
}
"file.documentSetup" => { "file.documentSetup" => {
let units = app.session.active().map(|d| d.doc.units.label()).unwrap_or("Points"); 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}))); app.ui.dialog = Some(crate::state::Dialog::new("documentSetup", json!({"units": units})));
@ -457,7 +455,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
Some((e, params)) => app.run("effect.apply", json!({"effect": e, "params": params})), Some((e, params)) => app.run("effect.apply", json!({"effect": e, "params": params})),
None => Err("no effect applied yet".into()), None => Err("no effect applied yet".into()),
}, },
"file.export.pdf" => io::export(app, "pdf", s("path"), 1.0).map(|p| json!({"path": p})), "file.export.pdf" => io::export(app, Some("pdf"), s("path"), p).map(|p| json!({"path": p})),
"help.about" => { "help.about" => {
app.ui.about = true; app.ui.about = true;
Ok(Value::Null) Ok(Value::Null)

View File

@ -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.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.render` | `{path?, scale?}` | render the artboard headlessly (PNG) |
| `ui.resize` / `ui.focus` | | | | `ui.resize` / `ui.focus` | | |
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{format, path, scale?}` | `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?, 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 |

View File

@ -57,7 +57,7 @@ or a full `paint.setFill` params object (`{"gradient": …}`, `{"swatch": "name"
| `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. | | `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. |
| `open_panel` | `{panel}` | Remote only. | | `open_panel` | `{panel}` | Remote only. |
| `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). | | `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. | | `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. | | `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?, 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`). | | `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`). |
@ -131,4 +131,5 @@ 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` `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` applies to the `--cmd` just before it. `run` also accepts the host commands `file.open`, `file.save`, `file.export`
and `tool.select`. and `tool.select`. `run --in`, `convert` and `info` read every format `document.open` reads
(`vectorcraft-cli --help` lists them).