M4.33: Export As with Use Artboards (All or a range) and art bounds

File > Export > Export As... (file.exportAs) picks any writable format and Use Artboards: All
or a range writes one file per artboard, <file>-<artboard>.<ext> (a PDF keeps them as pages);
off, the export covers the bounds of the visible art (an artless document is refused before the
save dialog). The format's options dialog follows: PNG/JPEG/WebP Options, SVG Options, or Save
PDF for artboards as pages. document.export takes useArtboards for raster formats (true: one
file per chosen artboard, default all) and PDF (false: the art's bounds), and the app's exports
return every file written and the encoder's warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-05 07:22:17 +07:00
parent 157724279f
commit 950a941003
20 changed files with 606 additions and 36 deletions

1
Cargo.lock generated
View File

@ -4182,6 +4182,7 @@ dependencies = [
name = "vectorcraft-mcp"
version = "0.1.1"
dependencies = [
"image",
"log",
"proptest",
"serde_json",

View File

@ -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, String> {
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<Option<bool>> {
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<T>(r: std::result::Result<T, String>) -> Result<T> {
r.map_err(|e| bad(C, e))
}
@ -213,6 +232,16 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
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<Encoded> {
}
"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))),
};

View File

@ -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<Value> {
/// `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<String, Value>| 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<Value> {
let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template));
let nodes: Vec<Arc<Node>> = 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()] }))
}

View File

@ -66,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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 <file>-<artboard>.<ext> (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;

View File

@ -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" },

View File

@ -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<u8> {
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<String> = 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());
}

View File

@ -22,3 +22,4 @@ log = { workspace = true }
[dev-dependencies]
vectorcraft-testkit = { workspace = true }
proptest = { workspace = true }
image = { workspace = true }

View File

@ -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;

View File

@ -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);
}

View File

@ -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<Vec<u8>, 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<Rect> {
doc.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, drawn_bounds(l)))
}
fn drawn_bounds(n: &Node) -> Option<Rect> {
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;

View File

@ -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);
}

View File

@ -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())),
},

View File

@ -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<Vec<&'static Format>> = LazyLock::new(|| fileio::FORMATS.iter().filter(|f| f.write && f.id != "vectorcraft").collect());
static LABELS: LazyLock<Vec<&'static str>> = 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: <name>-<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<Value, String> {
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), &params)
}
}
}

View File

@ -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).

View File

@ -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<Value, String> {
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))
}

View File

@ -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<String> {
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");
}

View File

@ -80,11 +80,13 @@ pub(crate) fn target_path(app: &mut VectorcraftApp, path: Option<String>, 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<Vec<String>, 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<String>, params: &Value) -> Result<String, String> {
/// `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<String>, params: &Value) -> Result<Value, String> {
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<Strin
_ => 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

View File

@ -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<R
crate::dialogs::svg_options::open(app, crate::dialogs::svg_options::Mode::Export, None);
Ok(Value::Null)
}
"file.export.svg" => 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<R
crate::dialogs::open_raster_options(app, f, params);
Ok(Value::Null)
}),
"file.export.png" => 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<R
"ui.widthPointEdit" => 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<Item>)> {
"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"),

View File

@ -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`), `<path stem>-<artboard>.<ext>`, 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 |

View File

@ -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 `<file>-<artboard>.<ext>`, 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`.