diff --git a/Cargo.lock b/Cargo.lock index 69c3ca9a..a848b276 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4182,6 +4182,7 @@ dependencies = [ name = "vectorcraft-mcp" version = "0.1.1" dependencies = [ + "image", "log", "proptest", "serde_json", diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 5c85e291..3d03d3e7 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -4,7 +4,8 @@ use serde::Deserialize; use serde::de::DeserializeOwned; use serde_json::Value; -use vectorcraft_doc::Document; +use vectorcraft_doc::{Artboard, Document}; +use vectorcraft_geom::Rect; use vectorcraft_render::AntiAlias; use vectorcraft_render::encode::{RasterExportOptions, RasterFormat}; @@ -167,6 +168,24 @@ pub(crate) fn anti_alias(id: &str) -> std::result::Result { AntiAlias::from_id(id).ok_or_else(|| format!("antiAlias `{id}`: none, art or type")) } +/// The `useArtboards` param of a PDF or raster export (SVG has its own): `true` writes every +/// chosen artboard (default all), `false` the bounds of the visible art instead, absent one +/// artboard (raster) or the chosen pages (PDF). +fn use_artboards(p: &Value) -> Result> { + match p.get("useArtboards") { + None | Some(Value::Null) => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(v) => Err(bad(C, format!("useArtboards must be true or false, not {v}"))), + } +} + +/// A copy of `doc` with one artboard, `rect` (exports of the art's or the selection's bounds). +pub(super) fn single_artboard(doc: &Document, rect: Rect, name: &str) -> Document { + let mut d = doc.clone(); + d.artboards = vec![Artboard { id: 1, name: name.into(), rect, show_center_mark: false, show_cross_hairs: false }]; + d +} + fn boards(r: std::result::Result) -> Result { r.map_err(|e| bad(C, e)) } @@ -213,6 +232,16 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { let f = super::writable(C, Some(format), None)?; let doc = &*doc.without_edit_modes(); let n = doc.artboards.len(); + // SVG reads its own `useArtboards` (an SVG option). + let use_artboards = if f.raster || f.id == "pdf" { use_artboards(p)? } else { None }; + if use_artboards == Some(false) { + let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?; + let mut q = super::export::without_artboards(p); + if let Some(o) = q.as_object_mut() { + o.remove("useArtboards"); + } + return encode_all(&single_artboard(doc, bounds, "Art"), f.id, &q); + } let bytes = match f.id { "vectorcraft" => vectorcraft_format::save_file(doc), "svg" | "svgz" => return super::svg::encode(doc, p, f.id == "svgz").map_err(|e| bad(C, e)), @@ -222,20 +251,33 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { } "png" | "jpg" | "webp" => { let o: RasterOptions = options(f, p)?; - let region = doc.artboards[boards(o.boards.one(n))?].rect; // New Document → Background Contents: White makes the export opaque, unless `background` // says otherwise (JPEG has no alpha: white either way). let page = (doc.setup.background == vectorcraft_doc::Background::White).then_some([255; 3]); let settings = o.settings(page)?; let scale = settings.scale(); - check_format_size(f, region.width() * scale, region.height() * scale)?; - vectorcraft_render::raster_size(region, scale).map_err(|e| bad(C, e))?; let format = match f.id { "png" => RasterFormat::Png, "jpg" => RasterFormat::Jpeg, _ => RasterFormat::WebP, }; - vectorcraft_render::Renderer::new().export_region(doc, region, format, &settings).map_err(EngineError::Other)? + // Use Artboards: one file per chosen artboard (default all); else the one chosen. + let chosen = match use_artboards { + Some(true) => boards(o.boards.resolve(n))?.unwrap_or_else(|| (0..n).collect()), + _ => vec![boards(o.boards.one(n))?], + }; + let mut enc = Encoded::default(); + let mut renderer = vectorcraft_render::Renderer::new(); + for b in chosen { + let region = doc.artboards.get(b).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))?.rect; + check_format_size(f, region.width() * scale, region.height() * scale)?; + vectorcraft_render::raster_size(region, scale).map_err(|e| bad(C, e))?; + enc.files.push((Some(b), renderer.export_region(doc, region, format, &settings).map_err(EngineError::Other)?)); + } + if enc.files.is_empty() { + return Err(bad(C, "the document has no artboard")); + } + return Ok(enc); } _ => 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 index 76d1f61f..7759d1c4 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -8,6 +8,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Node, NodeKind}; use super::super::*; +use super::encode::single_artboard; use super::{ ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, default_name, encode, encode_all, merge, writable, write_encoded, write_file, write_or_return, @@ -44,7 +45,7 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result { /// `p` without its artboard choice, also inside its SVG options (for documents made of one /// synthetic artboard, and for callers that pick the artboard themselves). -fn without_artboards(p: &Value) -> Value { +pub(super) fn without_artboards(p: &Value) -> Value { let strip = |o: &mut serde_json::Map| ARTBOARD_PARAMS.iter().for_each(|k| _ = o.remove(*k)); let mut q = p.clone(); if let Some(o) = q.as_object_mut() { @@ -69,16 +70,12 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result { 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 d = single_artboard(&st.doc, bounds, "Selection"); 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()] })) } diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 3edad4a2..7d89c7f9 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -66,7 +66,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|png|jpg|webp|vectorcraft (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), quality?: 90 (jpg); SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers} (see document.formats), …the PDF options of document.exportPdf} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|png|jpg|webp|vectorcraft (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), quality?: 90 (jpg); SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers} (see document.formats), …the PDF options of document.exportPdf} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", has_doc, export::export ), @@ -168,8 +168,12 @@ const ARTBOARD: FormatOption = FormatOption { default: "0", description: "0-based artboard to export (`artboards: [i]` or `range: \"n\"` naming one artboard work too)", }; -const ARTBOARDS: FormatOption = - FormatOption { name: "artboards", ty: "array", default: "null", description: "0-based artboards, one page each (default: all)" }; +const ARTBOARDS: FormatOption = FormatOption { + name: "artboards", + ty: "array", + default: "null", + description: "0-based artboards (PDF: one page each, default all; SVG, and raster formats with useArtboards: one file each)", +}; const RANGE: FormatOption = FormatOption { name: "range", ty: "string", @@ -178,6 +182,12 @@ const RANGE: FormatOption = FormatOption { }; 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 USE_ARTBOARDS: FormatOption = FormatOption { + name: "useArtboards", + ty: "boolean", + default: "null", + description: "true: every chosen artboard (default all), one file each named -. (PDF: one page each); false: the bounds of the visible art", +}; const PPI: FormatOption = FormatOption { name: "ppi", ty: "number", @@ -238,7 +248,7 @@ pub const FORMATS: &[Format] = &[ read: true, write: true, raster: true, - options: &[ARTBOARD, PPI, SCALE, BACKGROUND, ANTI_ALIAS, INTERLACED], + options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, INTERLACED], }, Format { id: "jpg", @@ -248,7 +258,7 @@ pub const FORMATS: &[Format] = &[ read: true, write: true, raster: true, - options: &[ARTBOARD, PPI, SCALE, BACKGROUND, ANTI_ALIAS, QUALITY], + options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, QUALITY], }, reader("gif", "GIF", &["gif"], "image/gif", true), Format { @@ -259,7 +269,7 @@ pub const FORMATS: &[Format] = &[ read: true, write: true, raster: true, - options: &[ARTBOARD, PPI, SCALE, BACKGROUND, ANTI_ALIAS], + options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS], }, reader("tiff", "TIFF", &["tif", "tiff"], "image/tiff", true), reader("bmp", "BMP", &["bmp"], "image/bmp", true), @@ -451,3 +461,6 @@ mod tests_svgedit; #[cfg(test)] mod tests_raster; + +#[cfg(test)] +mod tests_exportas; diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 0128ff28..a299a872 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -21,6 +21,7 @@ pub(super) const OPTIONS: &[FormatOption] = &[ super::ARTBOARD, super::ARTBOARDS, super::RANGE, + super::USE_ARTBOARDS, FormatOption { name: "preset", ty: "string", default: "\"VectorCraft Default\"", description: "the PDF preset the other options apply over" }, FormatOption { name: "standard", ty: "string", default: "\"none\"", description: "none | pdfA2b (PDF/X is not supported yet)" }, FormatOption { name: "compatibility", ty: "string", default: "\"1.7\"", description: "PDF version: 1.4 | 1.5 | 1.6 | 1.7 | 2.0" }, diff --git a/crates/engine/src/cmd/fileio/tests_exportas.rs b/crates/engine/src/cmd/fileio/tests_exportas.rs new file mode 100644 index 00000000..09bbcf28 --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_exportas.rs @@ -0,0 +1,119 @@ +//! Export As: `useArtboards` writes one file per artboard (all or a range) or, off, the bounds of +//! the visible art. + +use serde_json::{Value, json}; + +use super::*; + +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 b64(v: &Value) -> Vec { + vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap() +} + +fn rgba(bytes: &[u8]) -> image::RgbaImage { + image::load_from_memory(bytes).unwrap().to_rgba8() +} + +fn tmp_dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("vc-exportas-{tag}-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d +} + +#[test] +fn use_artboards_writes_one_file_per_artboard_in_the_range() { + let mut s = session(60.0, 40.0, 5); + let dir = tmp_dir("range"); + let path = dir.join("Logo.png").to_string_lossy().to_string(); + let r = s.execute("document.export", &json!({"path": path, "useArtboards": true, "range": "1-3,5"})).unwrap(); + let files: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect(); + let names: Vec<&str> = files.iter().map(|f| f.rsplit(['/', '\\']).next().unwrap()).collect(); + assert_eq!(names, ["Logo-Artboard-1.png", "Logo-Artboard-2.png", "Logo-Artboard-3.png", "Logo-Artboard-5.png"], "{r}"); + assert_eq!(r["path"], files[0]); + for f in files { + assert_eq!(rgba(&std::fs::read(f).unwrap()).dimensions(), (60, 40)); + } + // Without a range: every artboard; without a path the files come back named after the document. + let r = s.execute("document.export", &json!({"format": "jpg", "useArtboards": true})).unwrap(); + let files = r["files"].as_array().unwrap(); + assert_eq!(files.len(), 5); + assert_eq!(files[0]["name"], "Untitled-1-Artboard-1.jpg"); + assert_eq!(&b64(&files[4])[..2], [0xFF, 0xD8]); + // One artboard is one file, written where asked; a PDF holds the range as pages of one file. + let r = s.execute("document.export", &json!({"format": "webp", "useArtboards": true, "artboards": [3]})).unwrap(); + assert!(r.get("files").is_none() && !b64(&r).is_empty(), "{r}"); + let r = s.execute("document.export", &json!({"format": "pdf", "useArtboards": true, "range": "2-4"})).unwrap(); + assert_eq!(vectorcraft_pdf::import(&b64(&r)).unwrap().artboards.len(), 3); + // Without Use Artboards a raster export holds one artboard. + assert!(s.execute("document.export", &json!({"format": "png", "range": "1-2"})).is_err()); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn per_artboard_names_keep_the_folder_and_extension() { + let mut s = session(20.0, 20.0, 3); + s.execute("artboard.setProps", &json!({"index": 0, "name": "Hero / Wide"})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 1, "name": "hero---wide"})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let enc = encode_all(&doc, "jpg", &json!({"useArtboards": true})).unwrap(); + let names: Vec = enc.named(&doc, "C:\\out\\shot.jpeg").into_iter().map(|(n, _)| n).collect(); + assert_eq!(names, ["C:\\out\\shot-Hero---Wide.jpeg", "C:\\out\\shot-hero---wide-2.jpeg", "C:\\out\\shot-Artboard-3.jpeg"]); +} + +#[test] +fn bad_artboard_choices_error() { + let mut s = session(20.0, 20.0, 3); + for p in [ + json!({"useArtboards": true, "range": ""}), + json!({"useArtboards": true, "range": "4"}), + json!({"useArtboards": true, "artboards": []}), + json!({"useArtboards": "yes"}), + json!({"format": "pdf", "useArtboards": "yes"}), + ] { + assert!(s.execute("document.export", &p).is_err(), "{p}"); + } +} + +#[test] +fn without_artboards_the_export_covers_the_visible_art() { + let mut s = session(200.0, 100.0, 1); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 20, "y": 10, "width": 30, "height": 20})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 90, "y": 40, "width": 40, "height": 35})).unwrap(); + let hidden = s.execute("shape.rectangle", &json!({"x": 150, "y": 0, "width": 40, "height": 99})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [hidden]})).unwrap(); + s.execute("object.hide", &json!({})).unwrap(); + // The union of the two visible rectangles: 20..130 × 10..75. + let png = b64(&s.execute("document.export", &json!({"format": "png", "useArtboards": false})).unwrap()); + let img = rgba(&png); + assert_eq!(img.dimensions(), (110, 65)); + assert_eq!(img.get_pixel(0, 0)[3], 255, "the art starts at the corner"); + assert_eq!( + rgba(&b64(&s.execute("document.export", &json!({"format": "png", "useArtboards": false, "ppi": 144})).unwrap())).dimensions(), + (220, 130) + ); + // A PDF takes the art's bounds too (SVG has its own `useArtboards` option). + let pdf = vectorcraft_pdf::import(&b64(&s.execute("document.export", &json!({"format": "pdf", "useArtboards": false})).unwrap())).unwrap(); + let page = pdf.artboards[0].rect; + assert_eq!((page.width().round(), page.height().round()), (110.0, 65.0)); + let mut empty = session(50.0, 50.0, 1); + let e = empty.execute("document.export", &json!({"format": "png", "useArtboards": false})).unwrap_err().to_string(); + assert!(e.contains("no visible art"), "{e}"); +} + +#[test] +fn formats_list_use_artboards() { + let mut s = Session::new(); + let r = s.execute("document.formats", &json!({})).unwrap(); + let opts = |id: &str| r["formats"].as_array().unwrap().iter().find(|f| f["id"] == id).unwrap()["options"].clone(); + for id in ["svg", "pdf", "png", "jpg", "webp"] { + assert!(opts(id).get("useArtboards").is_some(), "{id} lists useArtboards"); + assert!(opts(id).get("range").is_some(), "{id} lists range"); + } + assert!(opts("vectorcraft").get("useArtboards").is_none()); +} diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml index 7234d53e..dc30eb7d 100644 --- a/crates/mcp/Cargo.toml +++ b/crates/mcp/Cargo.toml @@ -22,3 +22,4 @@ log = { workspace = true } [dev-dependencies] vectorcraft-testkit = { workspace = true } proptest = { workspace = true } +image = { workspace = true } diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index c12adbe3..3f639cd6 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -30,6 +30,8 @@ pub const DEFAULT_ADDR: &str = "127.0.0.1:7979"; #[cfg(test)] mod tests; #[cfg(test)] +mod tests_exportas; +#[cfg(test)] mod tests_fileio; #[cfg(test)] mod tests_freeform; diff --git a/crates/mcp/src/tests_exportas.rs b/crates/mcp/src/tests_exportas.rs new file mode 100644 index 00000000..be1b3639 --- /dev/null +++ b/crates/mcp/src/tests_exportas.rs @@ -0,0 +1,41 @@ +//! Export As through MCP: `options` carries `useArtboards` and the raster options to +//! `document.export`, so a range writes one file per artboard. + +use serde_json::{Value, json}; + +use crate::{Backend, Headless, call_tool}; + +fn text(r: &crate::ToolResult) -> Value { + assert!(!r.is_error, "{r:?}"); + serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap() +} + +fn three_boards() -> Headless { + let mut h = Headless::new(); + h.call("engine.execute", json!({"command": "file.new", "params": {"width": 50, "height": 30, "artboards": 3}})).unwrap(); + h +} + +#[test] +fn export_with_a_range_writes_one_file_per_artboard() { + let mut h = three_boards(); + let dir = std::env::temp_dir().join(format!("vectorcraft-mcp-exportas-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("icon.png").to_string_lossy().to_string(); + let args = json!({"path": path, "range": "1, 3", "options": {"useArtboards": true, "ppi": 144, "background": "white"}}); + let r = text(&call_tool(&mut h, "export", &args)); + let files = r["files"].as_array().unwrap(); + assert_eq!(files.len(), 2, "{r}"); + for (f, board) in files.iter().zip(["Artboard-1", "Artboard-3"]) { + let p = f.as_str().unwrap(); + assert!(p.ends_with(&format!("icon-{board}.png")), "{p}"); + let img = image::load_from_memory(&std::fs::read(p).unwrap()).unwrap().to_rgba8(); + assert_eq!(img.dimensions(), (100, 60), "144 ppi"); + assert!(img.pixels().all(|px| px[3] == 255), "white background"); + } + // Without a path the files come back as bytes. + let r = text(&call_tool(&mut h, "export", &json!({"format": "jpg", "options": {"useArtboards": true}}))); + let names: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Untitled-1-Artboard-1.jpg", "Untitled-1-Artboard-2.jpg", "Untitled-1-Artboard-3.jpg"]); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/render/src/encode/mod.rs b/crates/render/src/encode/mod.rs index bbbb3a53..2a3eef2a 100644 --- a/crates/render/src/encode/mod.rs +++ b/crates/render/src/encode/mod.rs @@ -4,10 +4,10 @@ pub mod png; -use vectorcraft_doc::Document; +use vectorcraft_doc::{Document, Node, NodeKind}; use vectorcraft_geom::Rect; -use crate::{AntiAlias, RenderOptions, Rendered, Renderer}; +use crate::{AntiAlias, RenderOptions, Rendered, Renderer, fx}; /// A raster file format. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -109,5 +109,25 @@ pub(crate) fn webp(img: &Rendered) -> Result, String> { Ok(buf) } +/// Bounds of the art an export draws: visible objects off template layers (guides left out), +/// with their strokes and live effects. `None` when nothing would be drawn. +pub fn art_bounds(doc: &Document) -> Option { + doc.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, drawn_bounds(l))) +} + +fn drawn_bounds(n: &Node) -> Option { + if !n.visible { + return None; + } + match &n.kind { + NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. } => None, + NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => { + children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, drawn_bounds(c))) + } + _ if fx::has_fx(n) => fx::visual_bounds(n), + _ => n.visual_bounds(), + } +} + #[cfg(test)] mod tests; diff --git a/crates/render/src/encode/tests.rs b/crates/render/src/encode/tests.rs index 0b91d3fc..84f930ad 100644 --- a/crates/render/src/encode/tests.rs +++ b/crates/render/src/encode/tests.rs @@ -171,3 +171,21 @@ fn anti_alias_none_is_the_same_on_every_thread_count() { let worst = st.pixels.iter().zip(&mt.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap(); assert!(worst <= 4, "max channel difference {worst}"); } + +#[test] +fn art_bounds_cover_visible_art_only() { + let mut d = circle_doc(); + let l = d.layers[0].id; + let hidden = { + let id = d.alloc_id(); + let mut n = + Node::path(id, shapes::rectangle(Rect::new(100.0, 100.0, 120.0, 120.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + n.visible = false; + n + }; + d.insert(Some(l), 1, hidden).unwrap(); + let b = art_bounds(&d).unwrap(); + assert!((b.x0 - 5.3).abs() < 1e-6 && (b.x1 - 31.1).abs() < 1e-6 && (b.y1 - 26.2).abs() < 1e-6, "{b:?}"); + let empty = Document::new(10.0, 10.0); + assert_eq!(art_bounds(&empty), None); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index b626b52c..2bf3ce89 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -304,7 +304,7 @@ 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", if p.is_object() { p.clone() } else { json!({}) })), "app.export" => match s("path") { - Some(path) => wrap(crate::io::export(app, s("format"), Some(path.to_string()), p).map(|p| json!({"path": p}))), + Some(path) => wrap(crate::io::export(app, s("format"), Some(path.to_string()), p)), // No save dialog for an agent: the bytes come back, as in headless mode. None => wrap(app.run("document.export", p.clone())), }, diff --git a/crates/ui-egui/src/dialogs/export_as.rs b/crates/ui-egui/src/dialogs/export_as.rs new file mode 100644 index 00000000..20695e14 --- /dev/null +++ b/crates/ui-egui/src/dialogs/export_as.rs @@ -0,0 +1,124 @@ +//! File → Export → Export As…: the format and Use Artboards (All or a range: one file per +//! artboard, or one page each in a PDF); without artboards the export covers the visible art. OK +//! picks the file, then the format's options dialog follows (PNG/JPEG/WebP Options, SVG Options, +//! and Save PDF for artboards as pages), else the file is written. + +use std::sync::LazyLock; + +use serde_json::{Value, json}; +use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, Format}; + +use super::{DialogSpec, form, png_options, save_pdf, svg_options}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, io, widgets}; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| "Export As".into(), body, confirm, ok: Some("Export…"), min_width: 380.0, ..DialogSpec::FORM }; + +/// The formats Export As writes (the native format is Save's). +static FORMATS: LazyLock> = LazyLock::new(|| fileio::FORMATS.iter().filter(|f| f.write && f.id != "vectorcraft").collect()); +static LABELS: LazyLock> = LazyLock::new(|| FORMATS.iter().map(|f| f.label).collect()); + +/// Open the dialog on `format` (an id or extension, default PNG); `p` may preset `useArtboards` +/// and `range`. +pub fn open(app: &mut VectorcraftApp, format: Option<&str>, p: &Value) { + let n = app.session.active().map_or(0, |s| s.doc.artboards.len()); + let format = format.and_then(fileio::format).filter(|f| FORMATS.iter().any(|g| g.id == f.id)).map_or("png", |f| f.id); + let range = p.get("range").and_then(Value::as_str); + app.ui.dialog = Some(Dialog::new( + "exportAs", + json!({ + "format": format, + "useArtboards": p.get("useArtboards").and_then(Value::as_bool).unwrap_or(false), + "all": range.is_none(), + "range": range.map_or_else(|| if n > 1 { format!("1-{n}") } else { "1".into() }, str::to_string), + }), + )); +} + +/// The chosen format (PNG when the field names none Export As writes). +fn chosen(d: &Dialog) -> Option<&'static Format> { + let id = d.str("format"); + let find = |id: &str| FORMATS.iter().copied().find(|f| f.id == id); + find(&id).or_else(|| find("png")) +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + let Some(f) = chosen(d) else { return false }; + egui::Grid::new("export-as").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { + ui.label(egui::RichText::new("Format:").color(t.text_dim)); + if let Some(i) = widgets::dropdown(ui, "export-as-format", f.label, &LABELS, 160.0) { + d.fields.insert("format".into(), json!(FORMATS[i].id)); + } + ui.end_row(); + }); + ui.add_space(10.0); + form::check(ui, d, "useArtboards", "Use Artboards"); + let use_artboards = d.bool("useArtboards"); + ui.add_enabled_ui(use_artboards, |ui| { + ui.horizontal(|ui| { + ui.add_space(22.0); + let mut all = d.bool("all"); + if ui.radio_value(&mut all, true, "All").changed() | ui.radio_value(&mut all, false, "Range:").changed() { + d.fields.insert("all".into(), json!(all)); + } + let mut range = d.str("range"); + if ui.add_enabled(!all, egui::TextEdit::singleline(&mut range).desired_width(90.0)).changed() { + d.fields.insert("range".into(), json!(range)); + } + }); + }); + ui.add_space(8.0); + let ext = f.extensions[0]; + let hint = match (use_artboards, f.id) { + (true, "pdf") => "One page per artboard.".to_string(), + (true, _) => format!("One file per artboard: -.{ext}"), + (false, _) => "The bounds of the visible art.".to_string(), + }; + ui.label(egui::RichText::new(hint).color(t.text_dim).size(11.5)); + false +} + +/// Check the artboard range, pick the file, then show the format's options or write the file. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let f = chosen(d).ok_or("no format to export to")?; + let use_artboards = d.bool("useArtboards"); + let all = d.bool("all"); + let range = d.str("range"); + if use_artboards && !all { + // A bad range keeps the dialog open. + let n = app.session.active().map_or(0, |s| s.doc.artboards.len()); + ArtboardPick { range: Some(range.clone()), ..Default::default() }.resolve(n)?; + } + // Nothing to export keeps the dialog open too (before the save dialog, not after it). + if !use_artboards && app.session.active().is_some_and(|s| vectorcraft_render::encode::art_bounds(&s.doc).is_none()) { + return Err("nothing to export: the document has no visible art (or turn on Use Artboards)".into()); + } + app.ui.dialog = None; + let path = io::target_path(app, None, f.extensions[0])?; + let range = if all { "all".to_string() } else { range }; + match f.id { + "svg" | "svgz" => { + svg_options::open(app, svg_options::Mode::Export, Some(&path)); + if let Some(o) = app.ui.dialog.as_mut() { + o.fields.extend([("useArtboards".into(), json!(use_artboards)), ("allArtboards".into(), json!(all)), ("range".into(), json!(range))]); + } + Ok(Value::Null) + } + // Artboards as pages go on in Save PDF; the art's bounds are written straight away. + "pdf" if use_artboards => save_pdf::open(app, &json!({ "path": path, "range": (!all).then_some(range) })), + _ => { + let mut params = json!({ "format": f.id, "path": path, "useArtboards": use_artboards }); + if use_artboards { + params["range"] = json!(range); + } + if f.raster { + png_options::open(app, f, params); + return Ok(Value::Null); + } + io::export(app, Some(f.id), Some(path), ¶ms) + } + } +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 56b38c24..17197412 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -17,6 +17,7 @@ pub mod confirm; mod document_setup; mod effect; pub mod expand; +mod export_as; mod export_for_screens; pub mod eyedropper; pub mod flatten; @@ -51,6 +52,7 @@ use serde_json::{Value, json}; pub use color_picker::open as open_color_picker; pub use document_setup::open as open_document_setup; pub use effect::open as open_effect_dialog; +pub use export_as::open as open_export_as; pub use new_document::{open as open_new_document, preset_card}; pub use png_options::open as open_raster_options; pub use save_pdf::open as open_save_pdf; @@ -183,6 +185,7 @@ registry! { NewDocumentMore: [new_document::MORE] => new_document::MORE_SPEC, Place: [place::KIND] => place::SPEC, RasterOptions: ["pngOptions", "jpgOptions", "webpOptions"] => png_options::SPEC, + ExportAs: ["exportAs"] => export_as::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/dialogs/png_options.rs b/crates/ui-egui/src/dialogs/png_options.rs index 4caf6ce3..51feb18a 100644 --- a/crates/ui-egui/src/dialogs/png_options.rs +++ b/crates/ui-egui/src/dialogs/png_options.rs @@ -54,9 +54,20 @@ pub fn open(app: &mut VectorcraftApp, f: &Format, mut params: Value) { app.ui.dialog = Some(Dialog::new(&kind(f), params)); } -/// The size in points of the region an export covers: the chosen artboard. +/// The size in points of the one region an export covers (`None` when it writes several +/// artboards): the chosen artboard, or the bounds of the visible art. fn export_size(doc: &vectorcraft_doc::Document, p: &Value) -> Option<(f64, f64)> { - let r = doc.artboards.get(ArtboardPick::deserialize(p).ok()?.one(doc.artboards.len()).ok()?)?.rect; + let pick = ArtboardPick::deserialize(p).ok()?; + let n = doc.artboards.len(); + let r = match p["useArtboards"].as_bool() { + Some(false) => vectorcraft_render::encode::art_bounds(doc)?, + Some(true) => match pick.resolve(n).ok()?.as_deref() { + Some([i]) => doc.artboards.get(*i)?.rect, + None if n == 1 => doc.artboards[0].rect, + _ => return None, + }, + None => doc.artboards.get(pick.one(n).ok()?)?.rect, + }; Some((r.width(), r.height())) } @@ -147,5 +158,5 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let (format, path) = (d.str("format"), d.str("path")); app.ui.dialog = None; - io::export(app, Some(&format), Some(path), &form::params(d)).map(|path| json!({"path": path})) + io::export(app, Some(&format), Some(path), &form::params(d)) } diff --git a/crates/ui-egui/src/dialogs/tests_export.rs b/crates/ui-egui/src/dialogs/tests_export.rs index 3ec24d67..b654fbc1 100644 --- a/crates/ui-egui/src/dialogs/tests_export.rs +++ b/crates/ui-egui/src/dialogs/tests_export.rs @@ -1,4 +1,4 @@ -//! The raster options dialogs, drawn headlessly. +//! Export As and the raster options dialogs, drawn headlessly. use std::cell::RefCell; use std::rc::Rc; @@ -48,6 +48,10 @@ fn set(app: &mut VectorcraftApp, field: &str, value: serde_json::Value) { app.ui.dialog.as_mut().expect("a dialog is open").fields.insert(field.into(), value); } +fn names(w: &Written) -> Vec { + w.borrow().iter().map(|(p, _)| p.clone()).collect() +} + #[test] fn png_options_draw_and_export_with_the_chosen_options() { let (mut app, written) = app(2); @@ -95,3 +99,143 @@ fn export_as_png_picks_the_file_then_shows_png_options() { assert_eq!(r["path"], "/x/a.png"); assert_eq!(&written.borrow()[0].1[16..20], 120u32.to_be_bytes(), "60 pt at 144 ppi"); } + +#[test] +fn export_as_with_a_range_then_png_options_writes_one_file_per_artboard() { + let (mut app, written) = app(3); + app.run("file.exportAs", json!({})).unwrap(); + assert_eq!(kind(&app), Some("exportAs")); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("range"), "1-3"); + frame(&mut app); + set(&mut app, "useArtboards", json!(true)); + set(&mut app, "all", json!(false)); + set(&mut app, "range", json!("1, 3")); + frame(&mut app); + confirm(&mut app).unwrap(); + assert_eq!(kind(&app), Some("pngOptions"), "PNG Options follow"); + assert!(written.borrow().is_empty(), "nothing is written before the options are confirmed"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("path"), d.str("range"), d.str("background")), ("/out/Untitled-1.png".into(), "1, 3".into(), "transparent".into())); + assert!(!d.fields.contains_key("__size"), "two artboards: no single size to show"); + frame(&mut app); + set(&mut app, "ppi", json!(144)); + set(&mut app, "interlaced", json!(true)); + set(&mut app, "antiAlias", json!("none")); + frame(&mut app); + let r = confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(r["files"].as_array().unwrap().len(), 2, "{r}"); + assert_eq!(names(&written), ["/out/Untitled-1-Artboard-1.png", "/out/Untitled-1-Artboard-3.png"]); + let png = &written.borrow()[0].1; + assert_eq!(png[28], 1, "interlaced"); + let img = image::load_from_memory(png).unwrap().to_rgba8(); + assert_eq!(img.dimensions(), (120, 80), "144 ppi"); + assert!(img.pixels().all(|p| p[3] == 0 || p[3] == 255), "no anti-aliasing"); +} + +#[test] +fn a_bad_range_keeps_export_as_open() { + let (mut app, written) = app(2); + app.run("file.exportAs", json!({"useArtboards": true, "range": "5"})).unwrap(); + assert!(!app.ui.dialog.as_ref().unwrap().bool("all")); + assert!(confirm(&mut app).is_err()); + assert_eq!(kind(&app), Some("exportAs")); + assert!(written.borrow().is_empty()); +} + +#[test] +fn svg_continues_in_svg_options_and_pdf_art_is_written_directly() { + let (mut app, written) = app(2); + app.run("file.exportAs", json!({"format": "svg", "useArtboards": true})).unwrap(); + confirm(&mut app).unwrap(); + assert_eq!(kind(&app), Some("svgOptions"), "the SVG options follow"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("path"), d.bool("useArtboards"), d.str("range")), ("/out/Untitled-1.svg".into(), true, "all".into())); + frame(&mut app); + confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(names(&written), ["/out/Untitled-1-Artboard-1.svg", "/out/Untitled-1-Artboard-2.svg"]); + // Without artboards: one file of the art's bounds, with no options dialog for a PDF. + app.run("file.exportAs", json!({"format": "pdf"})).unwrap(); + let r = confirm(&mut app).unwrap(); + assert_eq!(r["path"], "/out/Untitled-1.pdf"); + let pdf = vectorcraft_pdf::import(&written.borrow()[2].1).unwrap(); + assert_eq!((pdf.artboards[0].rect.width().round(), pdf.artboards[0].rect.height().round()), (30.0, 20.0), "the ellipse's bounds"); +} + +#[test] +fn jpeg_and_webp_options_draw_and_export() { + let (mut app, written) = app(1); + app.run("file.exportAs", json!({"format": "jpg"})).unwrap(); + confirm(&mut app).unwrap(); + assert_eq!(kind(&app), Some("jpgOptions")); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("background"), d.f64("quality", 0.0)), ("white".into(), 90.0)); + assert_eq!(d.fields["__size"], json!([30.0, 20.0]), "the art's bounds"); + frame(&mut app); + set(&mut app, "background", json!("#204080")); + set(&mut app, "quality", json!(70)); + frame(&mut app); + assert_eq!(confirm(&mut app).unwrap()["path"], "/out/Untitled-1.jpg"); + let img = image::load_from_memory(&written.borrow()[0].1).unwrap().to_rgb8(); + assert_eq!(img.dimensions(), (30, 20)); + let px = img.get_pixel(0, 0); + assert!(px[2] > 100 && px[0] < 60, "the background colour: {px:?}"); + + app.run("file.exportAs", json!({"format": "webp", "useArtboards": true})).unwrap(); + confirm(&mut app).unwrap(); + assert_eq!(kind(&app), Some("webpOptions")); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["__size"], json!([60.0, 40.0]), "the one artboard"); + set(&mut app, "__otherPpi", json!(true)); + set(&mut app, "ppi", json!(36)); + frame(&mut app); + confirm(&mut app).unwrap(); + let (path, bytes) = written.borrow().last().cloned().unwrap(); + assert_eq!(path, "/out/Untitled-1.webp", "one artboard is one file, named as picked"); + assert_eq!(image::load_from_memory(&bytes).unwrap().width(), 30); +} + +#[test] +fn old_ids_and_agents_export_directly_with_a_path() { + let (mut app, written) = app(2); + let r = app.run("file.export.png", json!({"path": "/x/a.png", "artboard": 1, "ppi": 144})).unwrap(); + assert_eq!(r["path"], "/x/a.png"); + let r = app.run("file.exportAs", json!({"path": "/x/b.webp", "useArtboards": true})).unwrap(); + assert_eq!(r["files"], json!(["/x/b-Artboard-1.webp", "/x/b-Artboard-2.webp"])); + assert!(app.ui.dialog.is_none()); + assert_eq!(written.borrow().len(), 3); + assert_eq!(&written.borrow()[0].1[16..20], 120u32.to_be_bytes(), "60 pt at 144 ppi"); + assert!(crate::menus::enabled(&app, "file.exportAs")); +} + +#[test] +fn nothing_to_export_keeps_export_as_open_before_the_save_dialog() { + let (mut app, written) = app(1); + app.services.pick_save = Some(Box::new(|_: &str| panic!("no save dialog without art"))); + app.run("select.all", json!({})).unwrap(); + app.run("edit.clear", json!({})).unwrap(); + app.run("file.exportAs", json!({})).unwrap(); + assert!(confirm(&mut app).unwrap_err().contains("no visible art")); + assert_eq!(kind(&app), Some("exportAs")); + // With Use Artboards the empty artboard is exported. + set(&mut app, "useArtboards", json!(true)); + app.services.pick_save = Some(Box::new(|name: &str| Some(format!("/out/{name}")))); + confirm(&mut app).unwrap(); + assert_eq!(kind(&app), Some("pngOptions")); + assert!(written.borrow().is_empty()); +} + +#[test] +fn pdf_artboards_continue_in_the_save_pdf_dialog() { + let (mut app, written) = app(3); + app.run("file.exportAs", json!({"format": "pdf", "useArtboards": true, "range": "2-3"})).unwrap(); + confirm(&mut app).unwrap(); + assert_eq!(kind(&app), Some("savePdf"), "the PDF options follow"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("path"), d.str("range")), ("/out/Untitled-1.pdf".into(), "2-3".into())); + assert!(written.borrow().is_empty()); + frame(&mut app); + confirm(&mut app).unwrap(); + let pdf = vectorcraft_pdf::import(&written.borrow()[0].1).unwrap(); + assert_eq!(pdf.artboards.len(), 2, "one page per artboard in the range"); +} diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index a50704a5..f02e5d31 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -80,11 +80,13 @@ pub(crate) fn target_path(app: &mut VectorcraftApp, path: Option, ext: & } /// Write every file of an export (one per artboard, linked images) for the destination `path`. -fn write_encoded(app: &mut VectorcraftApp, doc: &vectorcraft_doc::Document, path: &str, enc: &fileio::Encoded) -> Result<(), String> { - for (p, bytes) in enc.named(doc, path) { - write_out(app, &p, bytes)?; +/// → the export's own files (without the linked images). +fn write_encoded(app: &mut VectorcraftApp, doc: &vectorcraft_doc::Document, path: &str, enc: &fileio::Encoded) -> Result, String> { + let named = enc.named(doc, path); + for (p, bytes) in &named { + write_out(app, p, bytes)?; } - Ok(()) + Ok(named.into_iter().take(enc.files.len()).map(|(p, _)| p).collect()) } fn format_param(p: &Value) -> Option<&str> { @@ -142,9 +144,10 @@ pub fn note_recent(app: &mut VectorcraftApp, path: &str) { } /// Export the active document in `format` (default: the path's extension, else PNG) with the -/// `document.export` options in `params` (artboard, range, scale, SVG options…): every file it -/// writes (an SVG per artboard, linked images) goes next to `path`. The document keeps its path. -pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option, params: &Value) -> Result { +/// `document.export` options in `params` (artboard, range, useArtboards, ppi, SVG options…): +/// every file it writes (one per artboard, linked images) goes next to `path`. The document keeps +/// its path. → `{path, warnings, files?}` (`files`: every file when there are several). +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])?; @@ -154,9 +157,20 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option f, }; let enc = fileio::encode_all(&doc, f.id, params).map_err(|e| e.to_string())?; - write_encoded(app, &doc, &path, &enc)?; - app.status(format!("Exported {path}")); - Ok(path) + let files = write_encoded(app, &doc, &path, &enc)?; + let what = match files.as_slice() { + [one] => one.clone(), + _ => format!("{} files", files.len()), + }; + app.status(match enc.warnings.first() { + Some(first) => format!("Exported {what} with {} note(s): {first}", enc.warnings.len()), + None => format!("Exported {what}"), + }); + let mut out = json!({ "path": files.first().unwrap_or(&path), "warnings": enc.warnings }); + if files.len() > 1 { + out["files"] = json!(files); + } + Ok(out) } /// Run an engine command that returns `{dataBase64}` and write the bytes to a picked path diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 54a3054c..26d66d7e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -376,6 +376,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{path?, preset?, range?, …document.exportPdf options} open the Save PDF dialog with these options (fields = the options, sections are objects; OK writes path, else asks)", ), + ( + "file.exportAs", + "Export As…", + "", + "{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -445,7 +451,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option io::export(app, Some("svg"), s("path"), p).map(|p| json!({"path": p})), + "file.export.svg" => io::export(app, Some("svg"), s("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()); @@ -464,7 +470,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option io::export(app, Some("png"), s("path"), p).map(|p| json!({"path": p})), + "file.export.png" => io::export(app, Some("png"), s("path"), p), "file.documentSetup" => crate::dialogs::open_document_setup(app), "edit.preferences" => { crate::prefs_dialog::open(app, s("category").as_deref()); @@ -742,6 +748,11 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::width_point::open(app, p), "ui.colorGuideLimit" => crate::panels::color_guide::set_limit(app, p), "ui.savePdfDialog" => crate::dialogs::open_save_pdf(app, p), + "file.exportAs" if s("path").is_none() => { + crate::dialogs::open_export_as(app, s("format").as_deref(), p); + Ok(Value::Null) + } + "file.exportAs" => io::export(app, s("format").as_deref(), s("path"), p), _ => return None, }; Some(r) @@ -897,6 +908,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { | "file.place" | "file.export.svg" | "file.export.png" + | "file.exportAs" | "file.exportForScreens" | "file.documentSetup" | "view.zoomIn" @@ -976,6 +988,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { "Export", vec![ c("Export for Screens…", "file.exportForScreens"), + c("Export As…", "file.exportAs"), c("Export As SVG…", "file.export.svg"), c("Export As PNG…", "file.export.png"), todos("Save for Web (Legacy)…", "Cmd+Alt+Shift+S"), diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 07224d39..41b16380 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -19,6 +19,7 @@ | `ui.render` | `{path?, scale?}` | render the artboard headlessly (PNG) | | `ui.resize` / `ui.focus` | | | | `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, 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 | +| `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `-.`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards | | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | | SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options | diff --git a/docs/mcp.md b/docs/mcp.md index 7ef5ecd9..050844ee 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -157,8 +157,13 @@ Draw, look, export: {"name":"screenshot","arguments":{"scale":0.5}} {"name":"export","arguments":{"path":"/tmp/art.svg"}} {"name":"export","arguments":{"path":"/tmp/art.png","options":{"ppi":144,"background":"white","antiAlias":"type"}}} +{"name":"export","arguments":{"path":"/tmp/icon.png","range":"1-3","options":{"useArtboards":true}}} ``` +Export As goes through `options` too: for PNG, JPEG and WebP `useArtboards: true` writes one file per artboard (all, +or `range`), named `-.`, and returns `{path, files}` (a PDF keeps them as pages of one file; an +SVG writes one file per artboard of a `range`); `useArtboards: false` exports the bounds of the visible art. + Raster exports take more options in `options`: `ppi` (72, 150, 300…; stored in the file), `background` (`transparent`, `white`, `black` or `"#rrggbb"`), `antiAlias` (`none`, `art`, `type`: text snapped to pixels) and, for PNG, `interlaced`.