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:
parent
157724279f
commit
950a941003
|
|
@ -4182,6 +4182,7 @@ dependencies = [
|
|||
name = "vectorcraft-mcp"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"proptest",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -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))),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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()] }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -22,3 +22,4 @@ log = { workspace = true }
|
|||
[dev-dependencies]
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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), ¶ms)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
Loading…
Reference in New Issue