diff --git a/Cargo.lock b/Cargo.lock index 62c7c9d7..473150b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4128,6 +4128,8 @@ dependencies = [ name = "vectorcraft-engine" version = "0.1.1" dependencies = [ + "flate2", + "image", "kurbo 0.13.1", "log", "proptest", diff --git a/Cargo.toml b/Cargo.toml index 53988b86..30a0ec15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,12 +41,13 @@ proptest = "1" # rendering / formats 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" krilla = "0.8" skrifa = "0.47" harfrust = "0.13" linesweeper = "0.4" +flate2 = { version = "1", default-features = false, features = ["rust_backend"] } # ui egui = "0.36" diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index ef5f34a1..6c6c2f26 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -2,9 +2,9 @@ //! //! ```text //! 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 convert IN OUT [--scale 2] [--artboard 0] [--outline-text] +//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] //! vectorcraft-cli info FILE //! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5] //! vectorcraft-cli perf [--paths 50000] @@ -28,21 +28,23 @@ USAGE: (vectorcraft --control 7979), falling back to a headless in-process session. 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. vectorcraft-cli commands Print the command catalogue as JSON. - vectorcraft-cli convert IN OUT [--scale N] [--artboard I] [--outline-text] - Open IN (.vectorcraft, .svg, .pdf/.ai, images) and export OUT by extension (.svg, .pdf, .png, - .jpg, .webp, .vectorcraft). Live effects are kept; --outline-text writes SVG text as paths. + vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] + Open IN (any readable format) and export OUT by extension (.svg, .pdf, .png, .jpg, .webp, + .vectorcraft). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard + unless one of them is given, the other formats the first. Live effects are kept; + --outline-text writes SVG text as paths. vectorcraft-cli info FILE Print a JSON summary: title, colour mode, units, artboards, object counts by kind, fonts. 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. vectorcraft-cli perf [--paths N] @@ -50,6 +52,11 @@ USAGE: 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 { let args: Vec = std::env::args().skip(1).collect(); let r = match args.first().map(String::as_str) { @@ -61,14 +68,14 @@ fn main() -> ExitCode { Some("bench") => bench(&args[1..]), Some("perf") => perf::run(&args[1..]), Some("-h" | "--help" | "help") | None => { - print!("{USAGE}"); + print!("{}", usage()); Ok(()) } Some("-V" | "--version") => { println!("vectorcraft-cli {}", env!("CARGO_PKG_VERSION")); Ok(()) } - Some(other) => Err(format!("unknown subcommand `{other}`\n\n{USAGE}")), + Some(other) => Err(format!("unknown subcommand `{other}`\n\n{}", usage())), }; match r { Ok(()) => ExitCode::SUCCESS, @@ -119,12 +126,13 @@ fn commands() -> Result<(), String> { fn convert(args: &[String]) -> Result<(), String> { let mut files = vec![]; - let (mut scale, mut artboard, mut outline_text) = (1.0f64, 0u64, false); + let (mut scale, mut artboard, mut range, mut outline_text) = (1.0f64, None::, None::, false); let mut it = args.iter(); while let Some(a) = it.next() { match a.as_str() { "--scale" | "-s" => scale = it.next().and_then(|v| v.parse().ok()).ok_or("--scale needs a number")?, - "--artboard" | "-a" => artboard = it.next().and_then(|v| v.parse().ok()).ok_or("--artboard needs an index")?, + "--artboard" | "-a" => artboard = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--artboard needs an index")?), + "--range" | "-r" => range = Some(it.next().cloned().ok_or("--range needs artboards such as 1-3,5")?), "--outline-text" => outline_text = true, f => files.push(f.to_string()), } @@ -135,7 +143,7 @@ fn convert(args: &[String]) -> Result<(), String> { let r = h .call( "engine.execute", - json!({"command": "document.export", "params": {"path": output, "scale": scale, "artboard": artboard, "outlineText": outline_text}}), + json!({"command": "document.export", "params": {"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text}}), ) .map_err(|e| format!("export {output}: {e}"))?; println!("{r}"); diff --git a/apps/vectorcraft-cli/tests/fileio.rs b/apps/vectorcraft-cli/tests/fileio.rs new file mode 100644 index 00000000..e95f36ab --- /dev/null +++ b/apps/vectorcraft-cli/tests/fileio.rs @@ -0,0 +1,82 @@ +//! 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(")>>>; -const OPEN_EXTS: &[&str] = &["vectorcraft", "drawcraft", "svg", "png", "jpg", "jpeg", "gif", "webp"]; const CANVAS_ID: &str = "vectorcraft_canvas"; const LOADING_ID: &str = "vectorcraft_loading"; @@ -99,7 +99,8 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { let inbox = open_inbox.clone(); let ctx = ctx.clone(); 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; }; 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 parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes)); 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 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")?; @@ -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)?; 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", - } -} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 7c0ab7d2..9ae909c4 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -11,6 +11,7 @@ mod control_server; mod native_menu; use vectorcraft_engine::Session; +use vectorcraft_engine::cmd::fileio; use vectorcraft_ui_egui::{Services, VectorcraftApp}; struct App(VectorcraftApp, #[cfg(target_os = "macos")] Option); @@ -98,8 +99,8 @@ fn save_prefs(app: &VectorcraftApp) { fn services() -> Services { Services { pick_open: Some(Box::new(|| { - rfd::FileDialog::new() - .add_filter("All supported", &["vectorcraft", "drawcraft", "svg", "png", "jpg", "jpeg", "gif", "webp"]) + fileio::open_filters() + .fold(rfd::FileDialog::new(), |d, (name, exts)| d.add_filter(name, exts)) .pick_file() .map(|p| p.to_string_lossy().to_string()) })), diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 287920b9..06901e21 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -21,6 +21,8 @@ vectorcraft-trace = { workspace = true } vectorcraft-svg = { workspace = true } vectorcraft-format = { workspace = true } vectorcraft-pdf = { workspace = true } +image = { workspace = true } +flate2 = { workspace = true } kurbo = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs deleted file mode 100644 index db5836ed..00000000 --- a/crates/engine/src/cmd/file.rs +++ /dev/null @@ -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 { - 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 { - 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" Result { - 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 { - 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> { - encode_doc(&s.doc()?.doc, format, scale, artboard) -} - -fn encode_doc(doc: &vectorcraft_doc::Document, format: &str, scale: f64, artboard: usize) -> Result> { - 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> { - 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 { - 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 { - 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 { - 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> = 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 { - 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 { - 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, ¶ms) { - 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 { - 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 = 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 })) -} diff --git a/crates/engine/src/cmd/fileio/batch.rs b/crates/engine/src/cmd/fileio/batch.rs new file mode 100644 index 00000000..108e0ffd --- /dev/null +++ b/crates/engine/src/cmd/fileio/batch.rs @@ -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 { + 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, ¶ms) { + 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 })) +} diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs new file mode 100644 index 00000000..a2ee6947 --- /dev/null +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -0,0 +1,140 @@ +//! 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"; + +/// The params that pick artboards (the fields of [`ArtboardPick`]). +pub const ARTBOARD_PARAMS: [&str; 3] = ["artboard", "artboards", "range"]; + +/// 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, + pub artboards: Option>, + pub range: Option, +} + +impl ArtboardPick { + /// The named artboards (`None`: none named), checked against the document's `count`. + pub fn resolve(&self, count: usize) -> std::result::Result>, 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 { + 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, +} + +#[derive(Default, Deserialize)] +#[serde(default)] +struct RasterOptions { + #[serde(flatten)] + boards: ArtboardPick, + scale: Option, + quality: Option, +} + +fn boards(r: std::result::Result) -> Result { + r.map_err(|e| bad(C, e)) +} + +fn options(f: &Format, p: &Value) -> Result { + if !p.is_object() { + return Ok(T::default()); + } + T::deserialize(p).map_err(|e| bad(C, format!("{} options: {e}", f.label))) +} + +/// Most pixels a raster export renders (1 GiB of RGBA). +const MAX_PIXELS: f64 = 268_435_456.0; + +/// Refuse a raster export the encoder can't write or memory can't hold (instead of writing an +/// empty file or aborting). +fn check_pixels(f: &Format, w: f64, h: f64) -> Result<()> { + // WebP stores sizes in 14 bits, JPEG in 16. + let side = if f.id == "webp" { 16383.0 } else { 65535.0 }; + if w.round() > side || h.round() > side || w * h > MAX_PIXELS { + return Err(bad( + C, + format!( + "{}×{} px is too large for {} (at most {side} px a side, {MAX_PIXELS} px in all): lower the scale", + w.round(), + h.round(), + f.label + ), + )); + } + Ok(()) +} + +/// Encode `doc` as `format` (an id or extension from [`super::FORMATS`]) with that format's +/// options from `p` (see `document.formats`). Raster formats leave template layers out. +pub fn encode(doc: &Document, format: &str, p: &Value) -> Result> { + 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" => { + let o: ArtboardPick = options(f, p)?; + let opts = vectorcraft_pdf::PdfOptions { artboards: boards(o.resolve(n))?, ..Default::default() }; + super::super::rasterfx::export_pdf(doc, &opts).map_err(|e| EngineError::Other(e.to_string()))? + } + "png" | "jpg" | "webp" => { + let o: RasterOptions = options(f, p)?; + let region = doc.artboards[boards(o.boards.one(n))?].rect; + let scale = o.scale.unwrap_or(1.0).clamp(0.01, 64.0); + check_pixels(f, region.width() * scale, region.height() * scale)?; + 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))), + }) +} diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs new file mode 100644 index 00000000..566dd4cf --- /dev/null +++ b/crates/engine/src/cmd/fileio/export.rs @@ -0,0 +1,157 @@ +//! `document.export`, `document.serialize`, `document.exportSelection`, `document.exportForScreens`. + +use std::collections::HashSet; +use std::sync::Arc; + +use serde::Deserialize; +use serde_json::{Value, json}; +use vectorcraft_doc::{Node, NodeKind}; + +use super::super::*; +use super::{ARTBOARD_PARAMS, ArtboardPick, Format, create_dir, encode, writable, write_file, write_or_return}; + +pub(super) fn serialize(s: &mut Session, p: &Value) -> Result { + 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 { + let path = str_param(p, "path"); + let f = writable("document.export", str_param(p, "format"), path)?; + let bytes = encode(&s.doc()?.doc, f.id, p)?; + write_or_return(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_PARAMS { + o.remove(k); + } + } + q +} + +/// File → Export Selection: the selected objects alone, cropped to their visual bounds. Objects on +/// template layers are guides, not artwork, and are left out. +pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result { + 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 is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. })); + // On a template layer or sublayer, at any depth. + let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template)); + let nodes: Vec> = ids.into_iter().filter(|id| !on_template(*id)).filter_map(|id| st.doc.node(id).cloned().map(Arc::new)).collect(); + let bounds = nodes.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)).ok_or_else(|| bad(C, "select something to export"))?; + let mut d = (*st.doc).clone(); + let mut layer = Node::layer(d.alloc_id(), "Selection", vectorcraft_doc::LayerColor::Preset(0)); + 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()] })) +} + +/// One Export for Screens format row: what to write, at which scale, with which file-name suffix. +struct ScreenFormat { + format: &'static Format, + /// The row's own options (scale, quality…) for the encoder. + options: Value, + suffix: String, +} + +fn screen_format(row: &Value) -> Result { + const C: &str = "document.exportForScreens"; + if !row.is_object() { + return Err(bad(C, "each format is an object {format, scale?, suffix?}")); + } + let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?; + if format.id == "vectorcraft" { + return Err(bad(C, "Export for Screens writes png, jpg, webp, svg or pdf")); + } + // Vector formats have no pixel size: scale doesn't apply and adds no @Nx suffix (not even one + // left over from a raster row switched to SVG or PDF). + let scale = if format.raster { f64_or(row, "scale", 1.0) } else { 1.0 }; + let suffix = match str_param(row, "suffix") { + Some(s) if !format.raster && is_scale_suffix(s) => String::new(), + Some(s) => s.to_string(), + None if (scale - 1.0).abs() < 1e-9 => String::new(), + None => format!("@{scale}x"), + }; + let mut options = without_artboards(row); + if let Some(o) = options.as_object_mut() { + o.insert("scale".into(), json!(scale)); + } + Ok(ScreenFormat { format, options, suffix }) +} + +/// A pixel-density suffix such as `@2x` or `@0.5x`. +fn is_scale_suffix(s: &str) -> bool { + s.strip_prefix('@').and_then(|s| s.strip_suffix(['x', 'X'])).is_some_and(|n| n.parse::().is_ok()) +} + +/// File → Export for Screens: every chosen artboard in every format, one file each (a PDF holds +/// its artboard alone). Artboards that share a name get `-2`, `-3`… instead of overwriting. +pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result { + const C: &str = "document.exportForScreens"; + let doc = s.doc()?.doc.clone(); + let n = doc.artboards.len(); + let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(C, e.to_string()))? } else { ArtboardPick::default() }; + let boards = pick.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect()); + let formats: Vec = match p.get("formats").and_then(Value::as_array) { + Some(rows) => rows.iter().map(screen_format).collect::>()?, + None => vec![screen_format(&json!({}))?], + }; + let prefix = str_param(p, "prefix").unwrap_or(""); + let folder = str_param(p, "folder"); + if let Some(dir) = folder { + create_dir(dir)?; + } + // Names are compared without case: `Icon` and `icon` are one file on most desktop file systems. + let mut names = HashSet::new(); + let mut written = HashSet::new(); + let mut files = vec![]; + for b in boards { + let mut base: String = doc.artboards[b].name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect(); + if base.is_empty() { + base = format!("Artboard-{}", b + 1); + } + let mut name = base.clone(); + for i in 2.. { + if names.insert(name.to_lowercase()) { + break; + } + name = format!("{base}-{i}"); + } + for sf in &formats { + let file = format!("{prefix}{name}{}.{}", sf.suffix, sf.format.extensions[0]); + if !written.insert(file.to_lowercase()) { + continue; // the same file from two identical rows + } + let mut options = sf.options.clone(); + options["artboard"] = json!(b); + let bytes = encode(&doc, sf.format.id, &options)?; + match folder { + Some(dir) => { + let path = format!("{dir}/{file}"); + write_file(&path, &bytes)?; + files.push(json!(path)); + } + None => files.push(json!({ "name": file, "dataBase64": vectorcraft_format::base64_encode(&bytes) })), + } + } + } + Ok(json!({ "files": files })) +} diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs new file mode 100644 index 00000000..65a06f26 --- /dev/null +++ b/crates/engine/src/cmd/fileio/load.rs @@ -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, +} + +/// 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 { + 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) -> Result { + 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 { + 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 { + 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 { + 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> { + 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)) +} diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs new file mode 100644 index 00000000..b8505e5c --- /dev/null +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -0,0 +1,344 @@ +//! 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::{ARTBOARD_PARAMS, ArtboardPick, encode}; +pub use load::{Loaded, RasterImage, detect, load, open_bytes, raster_image}; + +use super::*; +use crate::EngineError; + +pub fn specs() -> Vec { + 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, artboards?: [i…], range?: \"1-3, 5\" (1-based; PDF writes one page per artboard, default all; the other formats write one artboard), scale?: 1 (raster), quality?: 90 (jpg), outlineText?: bool (svg)} → {path, format, bytes}; no path → {dataBase64, format, bytes}. Never changes the document's path", + has_doc, + export::export + ), + 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 (template layers left out) → {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…] | range?: \"1-3\" (default all), formats?: [{format: png|jpg|webp|svg|pdf, scale?: 1 (raster only), suffix?: \"@2x\" (raster default: @{scale}x when scale ≠ 1; svg/pdf drop @Nx suffixes)}], prefix?} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]}", + 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 = 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 ARTBOARDS: FormatOption = + FormatOption { name: "artboards", ty: "array", default: "null", description: "0-based artboards, one page each (default: all)" }; +const RANGE: FormatOption = FormatOption { + name: "range", + ty: "string", + default: "null", + description: "1-based artboards such as \"1-3, 5\" (wins over artboards and artboard)", +}; +const SCALE: FormatOption = FormatOption { name: "scale", ty: "number", default: "1", description: "pixels per point (0.01–64)" }; +const QUALITY: FormatOption = FormatOption { name: "quality", ty: "integer", default: "90", description: "JPEG quality 1–100" }; +const OUTLINE_TEXT: FormatOption = + 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: &[ARTBOARD, ARTBOARDS, RANGE], + }, + reader("ai", "PDF-compatible .ai", &["ai"], "application/pdf", false), + reader("ait", "PDF-compatible .ait template", &["ait"], "application/pdf", false), + Format { id: "png", label: "PNG", extensions: &["png"], mime: "image/png", read: true, write: true, raster: true, options: &[ARTBOARD, SCALE] }, + 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 { + 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 { + let ids = |pick: fn(&Format) -> bool| FORMATS.iter().filter(|f| pick(f)).map(|f| f.id).collect::>(); + Ok(json!({ + "formats": FORMATS.iter().map(Format::to_json).collect::>(), + "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> { + 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> { + 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 { + 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; diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs new file mode 100644 index 00000000..8400b2c0 --- /dev/null +++ b/crates/engine/src/cmd/fileio/save.rs @@ -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 { + 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 { + let mut d = (*s.doc()?.doc).clone(); + d.template = true; + write_or_return(str_param(p, "path"), &vectorcraft_format::save_file(&d), json!({})) +} diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs new file mode 100644 index 00000000..ea31f52b --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -0,0 +1,330 @@ +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 { + 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 { + 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"").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::>(); + 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:?}"); + } +} + +/// Pages in a PDF, counted by importing it (one artboard per page). +fn pdf_pages(bytes: &[u8]) -> usize { + vectorcraft_pdf::import(bytes).unwrap().artboards.len() +} + +#[test] +fn pdf_export_honours_artboard_and_range() { + let mut s = session(100.0, 80.0, 3); + let pages = |s: &mut Session, p: Value| pdf_pages(&b64(&s.execute("document.export", &p).unwrap())); + assert_eq!(pages(&mut s, json!({"format": "pdf"})), 3); + assert_eq!(pages(&mut s, json!({"format": "pdf", "artboard": 1})), 1); + assert_eq!(pages(&mut s, json!({"format": "pdf", "artboards": [0, 2]})), 2); + assert_eq!(pages(&mut s, json!({"format": "pdf", "range": "2-3"})), 2); + assert_eq!(pdf_pages(&b64(&s.execute("document.serialize", &json!({"format": "pdf", "artboard": 2})).unwrap())), 1); + assert!(s.execute("document.export", &json!({"format": "pdf", "artboard": 3})).is_err()); + assert!(s.execute("document.export", &json!({"format": "pdf", "range": "1-4"})).is_err()); + // Single-image formats take one artboard. + assert!(s.execute("document.export", &json!({"format": "png", "range": "1-2"})).is_err()); + let png = b64(&s.execute("document.export", &json!({"format": "png", "range": "2", "scale": 0.5})).unwrap()); + assert_eq!(image::load_from_memory(&png).unwrap().width(), 50); + assert!(s.execute("document.export", &json!({"format": "png", "scale": "big"})).is_err(), "typed options reject a bad type"); +} + +#[test] +fn export_without_path_returns_bytes_and_never_retargets() { + let mut s = session(100.0, 100.0, 1); + let dir = tmp_dir("retarget"); + let doc_path = dir.join("doc.vectorcraft").to_string_lossy().to_string(); + s.execute("document.save", &json!({"path": doc_path})).unwrap(); + let copy = dir.join("copy.vectorcraft").to_string_lossy().to_string(); + let r = s.execute("document.export", &json!({"path": copy})).unwrap(); + assert_eq!(r["format"], "vectorcraft"); + assert!(vectorcraft_format::sniff(&std::fs::read(©).unwrap())); + assert_eq!(s.doc().unwrap().path.as_deref(), Some(doc_path.as_str()), "exporting the native format keeps the document's path"); + let r = s.execute("document.export", &json!({"format": "jpg"})).unwrap(); + assert_eq!(&b64(&r)[..2], [0xFF, 0xD8]); + assert!(r.get("path").is_none()); + assert!(s.execute("document.export", &json!({"path": dir.join("x.bmp").to_string_lossy()})).is_err(), "BMP is read-only"); + // A never-saved document hands its native bytes back. + let mut s = session(10.0, 10.0, 1); + let r = s.execute("document.save", &json!({})).unwrap(); + assert!(vectorcraft_format::sniff(&b64(&r))); + assert_eq!(s.doc().unwrap().path, None); + let _ = std::fs::remove_dir_all(dir); +} + +/// A red square on a template layer and a blue one on a normal layer above it. +fn template_doc() -> (Session, u64, u64) { + let mut s = session(100.0, 100.0, 1); + let template = s.doc().unwrap().doc.layers[0].id.0; + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let red = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("layer.new", &json!({})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap(); + let blue = s.execute("shape.rectangle", &json!({"x": 50, "y": 0, "width": 50, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("layer.setProps", &json!({"id": template, "template": true})).unwrap(); + (s, red, blue) +} + +fn has_red(png: &[u8]) -> bool { + image::load_from_memory(png).unwrap().to_rgba8().pixels().any(|p| p[0] > 200 && p[2] < 80 && p[3] > 0) +} + +#[test] +fn raster_exports_leave_template_layers_out() { + let (mut s, red, blue) = template_doc(); + let png = b64(&s.execute("document.export", &json!({"format": "png"})).unwrap()); + assert!(!has_red(&png), "no red pixels from the template layer"); + assert!(image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(75, 50)[2] > 200, "the normal layer is exported"); + s.execute("select.set", &json!({"ids": [red, blue]})).unwrap(); + let r = s.execute("document.exportSelection", &json!({"format": "png"})).unwrap(); + assert!(!has_red(&b64(&r))); + assert!(r["bounds"][0].as_f64().unwrap() > 40.0, "only the blue square (and its stroke) is exported: {}", r["bounds"]); +} + +#[test] +fn export_for_screens_one_page_per_pdf_unique_names() { + let mut s = session(100.0, 80.0, 3); + s.execute("artboard.setProps", &json!({"index": 0, "name": "Icon"})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 1, "name": "Icon"})).unwrap(); + let dir = tmp_dir("screens"); + let folder = dir.to_string_lossy().to_string(); + let r = s + .execute( + "document.exportForScreens", + &json!({"folder": folder, "formats": [{"format": "pdf"}, {"format": "svg", "scale": 2}, {"format": "svg", "scale": 3}, {"format": "png", "scale": 2}]}), + ) + .unwrap(); + let files: Vec = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect(); + let names: Vec<&str> = files.iter().map(|f| f.rsplit('/').next().unwrap()).collect(); + assert_eq!( + names, + ["Icon.pdf", "Icon.svg", "Icon@2x.png", "Icon-2.pdf", "Icon-2.svg", "Icon-2@2x.png", "Artboard-3.pdf", "Artboard-3.svg", "Artboard-3@2x.png"] + ); + for f in files.iter().filter(|f| f.ends_with(".pdf")) { + assert_eq!(pdf_pages(&std::fs::read(f).unwrap()), 1, "{f}"); + } + // No folder: the files come back as bytes. + let r = s.execute("document.exportForScreens", &json!({"range": "3", "formats": [{"format": "jpg", "scale": 0.5}]})).unwrap(); + assert_eq!(r["files"][0]["name"], "Artboard-3@0.5x.jpg"); + assert_eq!(image::load_from_memory(&b64(&r["files"][0])).unwrap().width(), 50); + assert!(s.execute("document.exportForScreens", &json!({"formats": [{"format": "vectorcraft"}]})).is_err()); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn template_sublayers_are_left_out_too() { + let mut s = session(100.0, 100.0, 1); + let sub = s.execute("layer.newSublayer", &json!({})).unwrap()["id"].as_u64().unwrap(); + s.execute("layer.setCurrent", &json!({"id": sub})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let red = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 100})).unwrap()["id"].as_u64().unwrap(); + assert_eq!(s.doc().unwrap().doc.ancestry(vectorcraft_doc::NodeId(red)).unwrap().len(), 3, "the square is on a sublayer"); + assert!(has_red(&b64(&s.execute("document.export", &json!({"format": "png"})).unwrap()))); + s.execute("layer.setProps", &json!({"id": sub, "template": true})).unwrap(); + assert!(!has_red(&b64(&s.execute("document.export", &json!({"format": "png"})).unwrap())), "a template sublayer is left out"); + s.execute("select.set", &json!({"ids": [red]})).unwrap(); + assert!(s.execute("document.exportSelection", &json!({"format": "png"})).is_err(), "nothing but template art is selected"); +} + +#[test] +fn export_for_screens_vector_rows_drop_scale_suffixes_and_names_ignore_case() { + let mut s = session(100.0, 80.0, 3); + s.execute("artboard.setProps", &json!({"index": 0, "name": "Icon"})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 1, "name": "icon"})).unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).artboards[2].name.clear(); + // The dialog's second row starts as PNG @2x; switched to PDF it keeps that suffix text. + let formats = + json!([{"format": "png", "scale": 1, "suffix": ""}, {"format": "pdf", "scale": 2, "suffix": "@2x"}, {"format": "svg", "suffix": "-web"}]); + let r = s.execute("document.exportForScreens", &json!({"formats": formats})).unwrap(); + let names: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect(); + assert_eq!( + names, + [ + "Icon.png", + "Icon.pdf", + "Icon-web.svg", + "icon-2.png", + "icon-2.pdf", + "icon-2-web.svg", + "Artboard-3.png", + "Artboard-3.pdf", + "Artboard-3-web.svg" + ] + ); +} + +#[test] +fn oversized_raster_exports_are_refused() { + let mut s = session(2000.0, 2000.0, 1); + let e = s.execute("document.export", &json!({"format": "webp", "scale": 10})).unwrap_err().to_string(); + assert!(e.contains("too large"), "{e}"); + assert!(s.execute("document.export", &json!({"format": "png", "scale": 40})).is_err(), "80000 px a side"); +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 1df0c1bc..11231ac4 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -13,7 +13,7 @@ mod docmenu; mod draw2; mod edit; mod effectcmd; -mod file; +pub mod fileio; mod fonts; mod gradient; pub(crate) mod graph; @@ -146,7 +146,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(edit::specs()); v.extend(clipboard::specs()); v.extend(recolor::specs()); - v.extend(file::specs()); + v.extend(fileio::specs()); v.extend(create::specs()); v.extend(object::specs()); v.extend(path::specs()); @@ -307,3 +307,38 @@ pub(crate) fn unique_name(base: &str, taken: impl Fn(&str) -> bool) -> String { } (2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string()) } + +/// 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, String> { + let num = |t: &str, open: usize| -> std::result::Result { + 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) +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index eacb1d76..48fc6114 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -25,10 +25,11 @@ impl Default for Headless { /// 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. 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.saveAs", "Save As…", "{path}"), - ("file.export", "Export…", "{format?: svg|png|vectorcraft, path, scale?, artboard?}"), + ("file.export", "Export…", "{path?, format?, artboard?, range?, scale?, …} = document.export (no path → dataBase64)"), + ("file.exportForScreens", "Export for Screens…", "{folder?, artboards? | range?, formats?, prefix?} = document.exportForScreens"), ("tool.select", "Select Tool", "{tool} e.g. selection, directSelection, pen, rectangle, ellipse, polygon, star, lineSegment"), ]; @@ -36,14 +37,6 @@ fn s<'a>(p: &'a Value, k: &str) -> Option<&'a 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 { Some(match k { "down" => PointerKind::Down, @@ -111,6 +104,7 @@ impl Headless { "file.open" => self.open(params), "file.save" | "file.saveAs" => self.save(params), "file.export" => self.export(params), + "file.exportForScreens" => self.session.execute("document.exportForScreens", params).map_err(|e| e.to_string()), "tool.select" => self.select_tool(params), _ => self.session.execute(id, params).map_err(|e| e.to_string()), } @@ -215,72 +209,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 { let path = s(p, "path").ok_or("missing `path`")?; - let bytes = std::fs::read(path).map_err(|e| format!("read {path}: {e}"))?; - 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" Result { 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)")?; - let bytes = vectorcraft_format::save_file(&st.doc); - 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(); + if s(p, "path").is_none() && st.path.is_none() { + return Err("missing `path` (document was never saved)".into()); } - 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 { - let path = s(p, "path").ok_or("missing `path`")?; - 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", ¶ms).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()})) + self.session.execute("document.export", p).map_err(|e| e.to_string()) } } diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index 74e751ab..38c06092 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -29,3 +29,5 @@ pub const DEFAULT_ADDR: &str = "127.0.0.1:7979"; #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_fileio; diff --git a/crates/mcp/src/tests_fileio.rs b/crates/mcp/src/tests_fileio.rs new file mode 100644 index 00000000..8b35f91d --- /dev/null +++ b/crates/mcp/src/tests_fileio.rs @@ -0,0 +1,124 @@ +//! File I/O through MCP: every readable format opens headless, and `export` sends one +//! `document.export` call whatever the backend. + +use std::io::{BufRead, BufReader, Write}; + +use serde_json::{Value, json}; + +use crate::{Backend, Headless, Remote, call_tool, tool_definitions}; + +fn tmp(name: &str) -> String { + let dir = std::env::temp_dir().join(format!("vectorcraft-mcp-fileio-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join(name).to_string_lossy().to_string() +} + +fn text(r: &crate::ToolResult) -> Value { + assert!(!r.is_error, "{r:?}"); + serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap() +} + +/// A headless session with two artboards of different widths (100 and 40 pt) and a rectangle. +fn two_boards() -> Headless { + let mut h = Headless::new(); + h.call("engine.execute", json!({"command": "file.new", "params": {"width": 100, "height": 50, "artboards": 2}})).unwrap(); + h.call("engine.execute", json!({"command": "artboard.setProps", "params": {"index": 1, "width": 40}})).unwrap(); + h.call("engine.execute", json!({"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 20, "height": 20}})).unwrap(); + h +} + +#[test] +fn headless_opens_pdf_ai_and_templates() { + let mut h = two_boards(); + let pdf = h.call("engine.execute", json!({"command": "document.serialize", "params": {"format": "pdf"}})).unwrap(); + let pdf = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap(); + for name in ["x.pdf", "x.ai"] { + let path = tmp(name); + std::fs::write(&path, &pdf).unwrap(); + let r = text(&call_tool(&mut h, "open_file", &json!({"path": path}))); + assert_eq!(r["title"], name); + assert_eq!(h.session.doc().unwrap().doc.artboards.len(), 2, "one artboard per page"); + } + let ait = tmp("x.ait"); + std::fs::write(&ait, &pdf).unwrap(); + let r = text(&call_tool(&mut h, "open_file", &json!({"path": ait}))); + assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}"); + let tpl = tmp("t.vectorcraft"); + h.call("engine.execute", json!({"command": "file.saveAsTemplate", "params": {"path": tpl}})).unwrap(); + let r = text(&call_tool(&mut h, "open_file", &json!({"path": tpl}))); + assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}"); + assert_eq!(h.session.doc().unwrap().path, None, "Save asks for a new name"); +} + +#[test] +fn export_tool_lists_engine_formats_and_options() { + let tools = tool_definitions(); + let export = tools.iter().find(|t| t["name"] == "export").unwrap(); + let props = &export["inputSchema"]["properties"]; + assert_eq!(props["format"]["enum"], json!(["vectorcraft", "svg", "pdf", "png", "jpg", "webp"])); + for k in ["artboard", "range", "options"] { + assert!(props.get(k).is_some(), "export takes {k}"); + } + let open = tools.iter().find(|t| t["name"] == "open_file").unwrap(); + assert!(open["description"].as_str().unwrap().contains(".ait")); +} + +/// A fake app that records `engine.execute` calls and answers each with an empty object. +fn recording_app() -> (String, std::thread::JoinHandle>) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let h = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut out = stream.try_clone().unwrap(); + let mut seen = vec![]; + for line in BufReader::new(stream).lines() { + let msg: Value = serde_json::from_str(&line.unwrap()).unwrap(); + writeln!(out, "{}", json!({"id": msg["id"], "ok": true, "result": {}})).unwrap(); + seen.push(msg); + } + seen + }); + (addr, h) +} + +#[test] +fn export_is_the_same_call_in_the_app_and_headless() { + let args = json!({"format": "png", "artboard": 1, "options": {"scale": 2, "artboard": 0, "range": "1-2"}}); + let (addr, app) = recording_app(); + let mut remote = Remote::connect(&addr).unwrap(); + assert!(!call_tool(&mut remote, "export", &args).is_error); + drop(remote); + let seen = app.join().unwrap(); + assert_eq!(seen[0]["method"], "engine.execute"); + assert_eq!(seen[0]["params"], json!({"command": "document.export", "params": {"format": "png", "artboard": 1, "scale": 2}})); + + // Headless runs that very call: artboard 1 (40 pt wide) at scale 2. + let mut h = two_boards(); + let r = text(&call_tool(&mut h, "export", &args)); + let png = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + assert_eq!(&png[16..20], 80u32.to_be_bytes(), "IHDR width"); + let direct = h.call("engine.execute", seen[0]["params"].clone()).unwrap(); + assert_eq!(direct["dataBase64"], r["dataBase64"]); +} + +#[test] +fn exporting_native_keeps_the_document_path() { + let mut h = two_boards(); + let doc = tmp("keep.vectorcraft"); + h.call("app.save", json!({"path": doc})).unwrap(); + let copy = tmp("copy.vectorcraft"); + let r = text(&call_tool(&mut h, "export", &json!({"path": copy}))); + assert_eq!(r["format"], "vectorcraft"); + assert_eq!(h.session.doc().unwrap().path.as_deref(), Some(doc.as_str())); + // PDF range through the tool: one page. + let pdf = text(&call_tool(&mut h, "export", &json!({"format": "pdf", "range": "2"}))); + let pdf = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap(); + assert_eq!(pdf_pages(&pdf), 1); +} + +fn pdf_pages(pdf: &[u8]) -> usize { + let mut h = Headless::new(); + h.call("engine.execute", json!({"command": "document.open", "params": {"name": "p.pdf", "dataBase64": vectorcraft_format::base64_encode(pdf)}})) + .unwrap(); + h.session.doc().unwrap().doc.artboards.len() +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index c8366db6..5cd9b125 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -2,6 +2,7 @@ //! [`Backend`]. use serde_json::{Map, Value, json}; +use vectorcraft_engine::cmd::fileio::{ARTBOARD_PARAMS, FORMATS, OPEN_EXTS}; use crate::backend::Backend; @@ -246,7 +247,10 @@ pub fn tool_definitions() -> Vec { tool( "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"]), false, ), @@ -260,16 +264,19 @@ pub fn tool_definitions() -> Vec { tool( "export", "Export", - "Export the active document: svg (artboard viewBox), pdf, png/jpg/webp (rendered artboard) or vectorcraft (native). `selection: true` exports only the selected objects, cropped to their bounds. Live effects are kept (geometry baked, SVG filters for shadows/glows/blur).", + "Export the active document with the engine's document.export (the same bytes in the app and headless): svg (artboard viewBox), pdf (one page per artboard: all, or `artboard` / `range`), png/jpg/webp (one rendered artboard) or vectorcraft (a native copy; the document keeps its path). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.", obj( json!({ - "format": {"type": "string", "enum": ["svg", "png", "pdf", "jpg", "webp", "vectorcraft"], "description": "Default: from the path's extension"}, - "path": string("Destination file"), - "scale": num("PNG pixels per point (default 1)"), + "format": {"type": "string", "enum": FORMATS.iter().filter(|f| f.write).map(|f| f.id).collect::>(), "description": "Default: from the path's extension"}, + "path": string("Destination file (omit to get dataBase64)"), + "scale": num("Raster pixels per point (default 1)"), + "artboard": {"type": "integer", "minimum": 0, "description": "0-based artboard (default: the first; PDF default: all)"}, + "range": string("1-based artboards such as \"1-3, 5\" (PDF: one page each; other formats take one)"), "selection": {"type": "boolean", "description": "Export only the selection, cropped to it"}, "outlineText": {"type": "boolean", "description": "SVG: text as outlines (viewable without the fonts)"}, + "options": {"type": "object", "description": "More format options, passed through (see run_command document.formats), e.g. {\"quality\": 80} for jpg"}, }), - &["path"], + &[], ), false, ), @@ -729,31 +736,21 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result j(b.call("app.open", json!({"path": req_str(a, "path")?}))?), "save_file" => j(b.call("app.save", json!({"path": a.get("path").and_then(Value::as_str)}))?), "export" => { - let path = req_str(a, "path")?; - let fmt = a - .get("format") - .and_then(Value::as_str) - .map(str::to_ascii_lowercase) - .unwrap_or_else(|| std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()); - if !["svg", "png", "pdf", "jpg", "jpeg", "webp", "vectorcraft"].contains(&fmt.as_str()) { - return Err(format!("unknown export format `{fmt}` (svg, png, pdf, jpg, webp, vectorcraft)")); + // One params object for every backend; the named arguments win over `options` (a named + // artboard or range replaces every artboard choice in `options`). + let mut params = a.get("options").and_then(Value::as_object).cloned().unwrap_or_default(); + if ["artboard", "range"].iter().any(|k| a.get(*k).is_some_and(|v| !v.is_null())) { + for k in ARTBOARD_PARAMS { + params.remove(k); + } } - let scale = a.get("scale").and_then(Value::as_f64).unwrap_or(1.0); - if a.get("selection").and_then(Value::as_bool) == Some(true) { - return j(exec( - b, - "document.exportSelection", - json!({"path": path, "format": fmt, "scale": scale, "outlineText": a.get("outlineText")}), - )?); + for k in ["path", "format", "scale", "artboard", "range", "outlineText"] { + if let Some(v) = a.get(k).filter(|v| !v.is_null()) { + params.insert(k.into(), v.clone()); + } } - if a.get("outlineText").and_then(Value::as_bool) == Some(true) { - return j(exec(b, "document.export", json!({"path": path, "format": fmt, "scale": scale, "outlineText": true}))?); - } - // The desktop app exports native files through Save (which also sets the document path). - if fmt == "vectorcraft" && b.has_ui() { - return j(b.call("app.save", json!({"path": path}))?); - } - j(b.call("app.export", json!({"format": fmt, "path": path, "scale": scale}))?) + let cmd = if a.get("selection").and_then(Value::as_bool) == Some(true) { "document.exportSelection" } else { "document.export" }; + j(exec(b, cmd, Value::Object(params))?) } "add_text" => j(add_text(b, a)?), "apply_effect" => { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c5f0e4e6..10cfadc6 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -48,6 +48,8 @@ pub struct RenderOptions { pub overprint_preview: bool, /// Trim View: clip the artwork to the artboards (nothing on the pasteboard is drawn). pub trim: bool, + /// Leave template layers out (exports and thumbnails: templates are guides, not artwork). + pub skip_templates: bool, } impl Default for RenderOptions { @@ -61,6 +63,7 @@ impl Default for RenderOptions { proof: None, overprint_preview: false, trim: false, + skip_templates: false, } } } @@ -327,12 +330,13 @@ impl Renderer { Rendered { width: w as u32, height: h as u32, pixels } } - /// Render one artboard (or any document rect) at `scale` pixels per point, transparent or on white. + /// Render one artboard (or any document rect) at `scale` pixels per point, transparent or on white, + /// as exported: template layers are left out. pub fn render_region(&mut self, doc: &Document, region: Rect, scale: f64, white: bool) -> Rendered { let w = (region.width() * scale).round().max(1.0) as u32; let h = (region.height() * scale).round().max(1.0) as u32; let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0)); - let opts = RenderOptions { background: white.then_some([255, 255, 255, 255]), ..Default::default() }; + let opts = RenderOptions { background: white.then_some([255, 255, 255, 255]), skip_templates: true, ..Default::default() }; self.render(doc, w, h, view, &opts) } @@ -402,7 +406,8 @@ impl Renderer { } fn draw_arc(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc) { - if !a.visible || f.opts.hidden.contains(&a.id) { + let skipped_template = f.opts.skip_templates && matches!(a.kind, NodeKind::Layer { template: true, .. }); + if !a.visible || skipped_template || f.opts.hidden.contains(&a.id) { return; } match self.bounds_of(a) { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 15d8fdc3..e4431f94 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -15,9 +15,11 @@ //! - `ui.dialog.set {field, value}` / `ui.dialog.confirm` / `ui.dialog.cancel` //! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?}` //! - `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 //! 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; the document keeps its path. No path → `{dataBase64, format, bytes}` (as headless) use std::sync::mpsc::Sender; @@ -294,13 +296,11 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques } "app.open" => wrap(app.run("file.open", json!({"path": s("path")}))), "app.save" => wrap(app.run("file.save", json!({"path": s("path")}))), - "app.export" => { - 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.export" => match s("path") { + Some(path) => wrap(crate::io::export(app, s("format"), Some(path.to_string()), p).map(|p| json!({"path": p}))), + // No save dialog for an agent: the bytes come back, as in headless mode. + None => wrap(app.run("document.export", p.clone())), + }, "app.quit" => { ctx.send_viewport_cmd(egui::ViewportCommand::Close); ok(Value::Null) diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 12fedecc..8dde2d9f 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -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 vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; +use serde_json::Value; +use vectorcraft_doc::{ImageObject, Node, NodeKind}; +use vectorcraft_engine::cmd::fileio; use vectorcraft_geom::Affine; use crate::VectorcraftApp; -fn ext(name: &str) -> String { - 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 { - 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. +/// Open bytes of any readable format as a new document (templates open untitled). pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option) -> Result<(), String> { - let e = ext(name); - 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" 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 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, &name, &bytes, Some(path.to_string()))?; + open_bytes(app, path, &bytes, Some(path.to_string()))?; note_recent(app, path); Ok(()) } @@ -154,19 +52,25 @@ fn suggested(app: &VectorcraftApp, ext: &str) -> String { 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, ext: &str) -> Result { + 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). pub fn save(app: &mut VectorcraftApp, path: Option, save_as: bool) -> Result { let st = app.session.active().ok_or("no document")?; let existing = if save_as { None } else { st.path.clone() }; - let path = match path.or(existing) { - Some(p) => p, - None if app.services.download.is_some() => suggested(app, "vectorcraft"), - 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); + let path = target_path(app, path.or(existing), vectorcraft_format::EXTENSION)?; + let doc = app.session.active().ok_or("no document")?.doc.clone(); + let bytes = fileio::encode(&doc, "vectorcraft", &Value::Null).map_err(|e| e.to_string())?; write_out(app, &path, &bytes)?; if let Some(st) = app.session.active_mut() { 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)); } -/// Export the document as SVG / PDF / PNG / JPEG / WebP. -pub fn export(app: &mut VectorcraftApp, format: &str, path: Option, scale: f64) -> Result { - let st = app.session.active().ok_or("no document")?; - let doc = st.doc.clone(); - let path = match path { - Some(p) => p, - None if app.services.download.is_some() => suggested(app, format), - 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}`")), - }; +/// Export the active document in `format` (default: the path's extension, else PNG) with the +/// `document.export` options in `params` (artboard, range, scale…). The document keeps its path. +pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option, params: &Value) -> Result { + let f = fileio::writable_format(format, path.as_deref())?; + let doc = app.session.active().ok_or("no document")?.doc.clone(); + let path = target_path(app, path, f.extensions[0])?; + let bytes = fileio::encode(&doc, f.id, params).map_err(|e| e.to_string())?; write_out(app, &path, &bytes)?; app.status(format!("Exported {path}")); Ok(path) @@ -218,19 +103,12 @@ pub fn export(app: &mut VectorcraftApp, format: &str, path: Option, scal /// Run an engine command that returns `{dataBase64}` and write the bytes to a picked path /// (Export Selection, Save as Template). -pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, mut params: serde_json::Value) -> Result { - let path = match params.get("path").and_then(serde_json::Value::as_str) { - 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")? - } - }; +pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, mut params: Value) -> Result { + let path = target_path(app, params.get("path").and_then(Value::as_str).map(str::to_string), ext)?; if let Some(o) = params.as_object_mut() { o.remove("path"); 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)); } } @@ -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). pub fn place_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result<(), String> { - let e = ext(name); - if e == "svg" { - let s = std::str::from_utf8(bytes).map_err(|_| "SVG is not UTF-8".to_string())?; - let src = vectorcraft_svg::import(s).map_err(|e| e.to_string())?; + let f = fileio::detect(name, bytes).ok_or_else(|| format!("can't place `{name}`: not a format VectorCraft reads"))?; + if !f.raster { + if !matches!(f.id, "svg" | "svgz") { + 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 = src.layers.iter().flat_map(|l| l.children().cloned().unwrap_or_default()).map(|n| (*n).clone()).collect(); app.session.clipboard = 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(); return Ok(()); } - let (w, h) = image_size(bytes)?; - let key = format!("img{:016x}", hash(bytes)); + let fileio::RasterImage { key, blob, width: w, height: h } = fileio::raster_image(bytes).map_err(|e| e.to_string())?; let st = app.session.active().ok_or("no document")?; 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 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 name = name.to_string(); app.session .edit("Place", |d, sel| { - d.images.insert(key.clone(), ImageBlob { mime, bytes }); + d.images.insert(key.clone(), blob); let id = d.alloc_id(); let mut n = Node::new(id, NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None })); n.name = Some(name); @@ -276,3 +153,112 @@ pub fn place_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result }) .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)>>>; + + /// 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 { + 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"); + } + + #[test] + fn export_for_screens_with_params_runs_the_engine() { + let (mut app, _) = app(); + app.session.execute("file.new", &json!({"width": 60, "height": 40, "artboards": 3})).unwrap(); + let r = app.run("file.exportForScreens", json!({"range": "2-3", "formats": [{"format": "pdf"}]})).unwrap(); + let files = r["files"].as_array().unwrap(); + assert_eq!(files.len(), 2); + assert_eq!(files[0]["name"], "Artboard-2.pdf"); + assert!(app.ui.dialog.is_none()); + app.run("file.exportForScreens", Value::Null).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("exportForScreens")); + } + + #[test] + fn control_export_without_path_returns_bytes() { + let (mut app, written) = app(); + app.session.execute("file.new", &json!({"width": 30, "height": 20})).unwrap(); + let (req, _) = crate::control::ControlRequest::new("app.export", json!({"format": "png", "scale": 2})); + let crate::control::Outcome::Done(r) = crate::control::handle(&mut app, &egui::Context::default(), &req) else { panic!("not done") }; + assert_eq!(r["ok"], true, "{r}"); + let png = vectorcraft_format::base64_decode(r["result"]["dataBase64"].as_str().unwrap()).unwrap(); + assert_eq!(&png[16..20], 60u32.to_be_bytes(), "30 pt at scale 2"); + assert!(written.borrow().is_empty(), "no file and no save dialog"); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index cf7fc14a..7c61cf88 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -87,9 +87,9 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("file.clearRecent", "Clear Recent Files", "", "{}"), ("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.export.svg", "Export As SVG…", "", "{path?}"), - ("file.export.png", "Export As PNG…", "", "{path?, scale?: 1}"), - ("file.exportForScreens", "Export for Screens…", "Cmd+Alt+E", "{path?, scale?}"), + ("file.export.svg", "Export As SVG…", "", "{path?, artboard?, outlineText?} (document.export options)"), + ("file.export.png", "Export As PNG…", "", "{path?, scale?: 1, artboard?} (document.export options)"), + ("file.exportForScreens", "Export for Screens…", "Cmd+Alt+E", "{} opens the dialog; with params = document.exportForScreens"), ("file.documentSetup", "Document Setup…", "Cmd+Alt+P", "{}"), ("file.newDialog", "New…", "Cmd+N", "{} opens the New Document dialog"), ("edit.preferences", "Preferences…", "Cmd+K", "{category?} open Preferences (engine: prefs.get / prefs.set / prefs.list)"), @@ -156,7 +156,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("ui.paramDialog", "Command Dialog", "", "{command, label?, params} open a parameter dialog for any command"), ("ui.recolorDialog", "Recolor Artwork…", "", "{} open Recolor Artwork (engine: recolor.colors / recolor.apply)"), ("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"), - ("file.export.pdf", "Save as PDF…", "", "{path?}"), + ("file.export.pdf", "Save as PDF…", "", "{path?, artboard? | artboards? | range?: \"1-3, 5\"} (document.export options)"), ("help.about", "About VectorCraft", "", "{}"), ("help.commandPalette", "Search Commands…", "Cmd+Shift+/", "{}"), ("app.quit", "Quit VectorCraft", "Cmd+Q", "{}"), @@ -240,8 +240,8 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Err("cancelled".into()), } } - "file.export.svg" => io::export(app, "svg", s("path"), 1.0).map(|p| json!({"path": p})), - "file.exportForScreens" if p.get("path").is_none() && p.get("scale").is_none() => { + "file.export.svg" => io::export(app, Some("svg"), s("path"), p).map(|p| json!({"path": p})), + "file.exportForScreens" if p.as_object().is_none_or(|o| o.is_empty()) => { let n = app.session.active().map(|d| d.doc.artboards.len()).unwrap_or(0); let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); app.ui.dialog = Some(crate::state::Dialog::new( @@ -250,9 +250,8 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { - io::export(app, "png", s("path"), p.get("scale").and_then(Value::as_f64).unwrap_or(1.0)).map(|p| json!({"path": p})) - } + "file.exportForScreens" => app.run("document.exportForScreens", p.clone()), + "file.export.png" => io::export(app, Some("png"), s("path"), p).map(|p| json!({"path": p})), "file.documentSetup" => { let units = app.session.active().map(|d| d.doc.units.label()).unwrap_or("Points"); app.ui.dialog = Some(crate::state::Dialog::new("documentSetup", json!({"units": units}))); @@ -466,7 +465,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option app.run("effect.apply", json!({"effect": e, "params": params})), 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" => { app.ui.about = true; Ok(Value::Null) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a858ef14..9f7d6c78 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -18,4 +18,4 @@ | `ui.screenshot` | `{path?}` | capture the window (PNG). Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error | | `ui.render` | `{path?, scale?}` | render the artboard headlessly (PNG) | | `ui.resize` / `ui.focus` | | | -| `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{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?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`). `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `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 | diff --git a/docs/mcp.md b/docs/mcp.md index 56c83df2..50b51256 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -57,9 +57,9 @@ 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. | | `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). | -| `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. | -| `export` | `{path, format?, scale?, selection?}` | `svg`, `pdf`, `png`, `jpg`, `webp` or `vectorcraft`. When `format` is omitted, it comes from the path's extension. `selection: true` exports the selected objects cropped to their bounds; `outlineText: true` writes SVG text as paths. Live effects are kept. | +| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `pdf`, `png`, `jpg`, `webp` or `vectorcraft` (the list comes from `document.formats`). When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds; `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, path?, mode?, pathEffect?, size?, font?, color?}` | Point type at (x, y); area type with `width`/`height`; or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`). | | `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. | | `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group. | @@ -130,5 +130,6 @@ vectorcraft-cli run --cmd file.new --params '{"width":800,"height":600}' \ ``` `run` prints one JSON line per step (`open`, `cmd`, `export`) and exits non-zero on the first failure. `--params` -applies to the `--cmd` just before it. `run` also accepts the host commands `file.open`, `file.save`, `file.export` -and `tool.select`. +applies to the `--cmd` just before it. `run` also accepts the host commands `file.open`, `file.save`, `file.export`, +`file.exportForScreens` and `tool.select`. `run --in`, `convert` and `info` read every format `document.open` reads +(`vectorcraft-cli --help` lists them).