Merge pull request #73 from storytold/pr/m4-export-as-png

M4: Export As with Use Artboards; PNG/JPEG/WebP resolution, background, anti-aliasing (M4.33, M4.34)
This commit is contained in:
BFlat 2026-10-05 09:28:45 +07:00 committed by GitHub
commit d6ebecee39
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1760 additions and 102 deletions

2
Cargo.lock generated
View File

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

View File

@ -4,7 +4,10 @@
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};
use super::super::*;
use super::Format;
@ -109,12 +112,78 @@ impl Encoded {
}
#[derive(Default, Deserialize)]
#[serde(default)]
#[serde(default, rename_all = "camelCase")]
struct RasterOptions {
#[serde(flatten)]
boards: ArtboardPick,
scale: Option<f64>,
/// Pixels per inch (wins over `scale`).
ppi: Option<f64>,
quality: Option<u8>,
background: Option<Value>,
anti_alias: Option<String>,
interlaced: Option<bool>,
}
impl RasterOptions {
/// The render and encoder settings: `ppi`, else 72 × `scale`, within 0.01–64 pixels per point;
/// `background`, else `page` (the document's background).
fn settings(&self, page: Option<[u8; 3]>) -> Result<RasterExportOptions> {
if let Some(ppi) = self.ppi.filter(|p| !(p.is_finite() && *p > 0.0)) {
return Err(bad(C, format!("ppi must be a positive number, not {ppi}")));
}
let ppi = self.ppi.unwrap_or(72.0 * self.scale.unwrap_or(1.0));
Ok(RasterExportOptions {
ppi: (ppi / 72.0).clamp(0.01, 64.0) * 72.0,
background: match &self.background {
Some(v) => background(v).map_err(|e| bad(C, e))?,
None => page,
},
anti_alias: self.anti_alias.as_deref().map(anti_alias).transpose().map_err(|e| bad(C, e))?.unwrap_or_default(),
interlaced: self.interlaced.unwrap_or(false),
quality: self.quality.unwrap_or(90),
})
}
}
/// A raster background: `transparent` (also `none` or null), `white`, `black` or a colour
/// (`"#rrggbb"`, `[r, g, b]` 0–1, `{c, m, y, k}`, `{gray}`) → opaque RGB, `None` = transparent.
pub(crate) fn background(v: &Value) -> std::result::Result<Option<[u8; 3]>, String> {
match v {
Value::Null => Ok(None),
Value::String(s) if s.eq_ignore_ascii_case("transparent") || s.eq_ignore_ascii_case("none") => Ok(None),
Value::String(s) if s.eq_ignore_ascii_case("white") => Ok(Some([255; 3])),
Value::String(s) if s.eq_ignore_ascii_case("black") => Ok(Some([0; 3])),
other => color_value(other)
.map(|c| {
let [r, g, b, _] = c.to_rgba8(1.0);
Some([r, g, b])
})
.ok_or_else(|| format!("background {other}: transparent, white, black or a colour such as \"#ff8800\"")),
}
}
/// An anti-aliasing mode by id: `none`, `art` or `type`.
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> {
@ -163,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)),
@ -172,19 +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;
let scale = o.scale.unwrap_or(1.0).clamp(0.01, 64.0);
check_format_size(f, region.width() * scale, region.height() * scale)?;
vectorcraft_render::raster_size(region, scale).map_err(|e| bad(C, e))?;
// JPEG has no alpha; New Document → Background Contents: White fills the artboard too.
let white = f.id == "jpg" || doc.setup.background == vectorcraft_doc::Background::White;
let img = vectorcraft_render::Renderer::new().render_region(doc, region, scale, white);
match f.id {
"png" => img.to_png(),
"webp" => img.to_webp(),
_ => img.to_jpeg(o.quality.unwrap_or(90)),
// 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();
let format = match f.id {
"png" => RasterFormat::Png,
"jpg" => RasterFormat::Jpeg,
_ => RasterFormat::WebP,
};
// 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)?));
}
.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()] }))
}
@ -102,7 +99,12 @@ fn screen_format(row: &Value) -> Result<ScreenFormat> {
}
// Vector formats have no pixel size: scale doesn't apply and adds no @Nx suffix (not even one
// left over from a raster row switched to SVG or PDF).
let scale = if format.raster { f64_or(row, "scale", 1.0) } else { 1.0 };
// A raster row's `ppi` wins over its `scale` (the encoder does the same), so it names the suffix.
let scale = match row.get("ppi").and_then(Value::as_f64) {
_ if !format.raster => 1.0,
Some(ppi) => ppi / 72.0,
None => f64_or(row, "scale", 1.0),
};
let suffix = match str_param(row, "suffix") {
Some(s) if !format.raster && is_scale_suffix(s) => String::new(),
Some(s) => s.to_string(),
@ -129,10 +131,18 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
let n = doc.artboards.len();
let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(C, e.to_string()))? } else { ArtboardPick::default() };
let boards = pick.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect());
let formats: Vec<ScreenFormat> = match p.get("formats").and_then(Value::as_array) {
let mut formats: Vec<ScreenFormat> = match p.get("formats").and_then(Value::as_array) {
Some(rows) => rows.iter().map(screen_format).collect::<Result<_>>()?,
None => vec![screen_format(&json!({}))?],
};
// A top-level anti-aliasing mode applies to every row that doesn't name its own.
if let Some(aa) = p.get("antiAlias") {
for sf in &mut formats {
if let Some(o) = sf.options.as_object_mut() {
o.entry("antiAlias").or_insert_with(|| aa.clone());
}
}
}
let prefix = str_param(p, "prefix").unwrap_or("");
let folder = str_param(p, "folder");
if let Some(dir) = folder {

View File

@ -23,6 +23,7 @@ mod svg;
use serde_json::{Value, json};
pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings};
pub(crate) use encode::{anti_alias, background};
pub(crate) use load::source;
pub use load::{Loaded, RasterImage, detect, file_name, load, open_bytes, raster_image};
pub use save::{save_encoding, save_format};
@ -65,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), scale?: 1 (raster), 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
),
@ -92,7 +93,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export for Screens",
["File", "Export"],
None,
"{folder?, artboards?: [index…] | range?: \"1-3\" (default all), formats?: [{format: png|jpg|webp|svg|svgz|pdf, scale?: 1 (raster only), suffix?: \"@2x\" (raster default: @{scale}x when scale ≠ 1; vector formats drop @Nx suffixes)}], prefix?} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]}",
"{folder?, artboards?: [index…] | range?: \"1-3\" (default all), formats?: [{format: png|jpg|webp|svg|svgz|pdf, scale?: 1 (raster only), ppi?: (raster: wins over scale, scale = ppi / 72), suffix?: \"@2x\" (raster default: @{scale}x when scale ≠ 1; vector formats drop @Nx suffixes), …the format's options (antiAlias, background…)}], prefix?, antiAlias?: none|art|type (raster rows without their own)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]}",
has_doc,
export::export_for_screens
),
@ -167,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",
@ -177,6 +182,32 @@ 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",
default: "72",
description: "resolution in pixels per inch (72 = one pixel per point; 150, 300…), stored in the file; wins over scale",
};
const BACKGROUND: FormatOption = FormatOption {
name: "background",
ty: "string",
default: "\"transparent\"",
description: "transparent, white, black or a colour such as \"#ff8800\" (JPEG: white when transparent)",
};
const ANTI_ALIAS: FormatOption = FormatOption {
name: "antiAlias",
ty: "string",
default: "\"art\"",
description: "none (hard pixel edges), art (smooth edges) or type (smooth, text snapped to whole pixels)",
};
const INTERLACED: FormatOption =
FormatOption { name: "interlaced", ty: "boolean", default: "false", description: "Adam7 interlacing (the image builds up while it loads)" };
/// A format `document.open` reads but nothing writes yet.
const fn reader(id: &'static str, label: &'static str, extensions: &'static [&'static str], mime: &'static str, raster: bool) -> Format {
@ -209,7 +240,16 @@ pub const FORMATS: &[Format] = &[
Format { id: "pdf", label: "PDF", extensions: &["pdf"], mime: "application/pdf", read: true, write: true, raster: false, options: pdf::OPTIONS },
reader("ai", "PDF-compatible .ai", &["ai"], "application/pdf", false),
reader("ait", "PDF-compatible .ait template", &["ait"], "application/pdf", false),
Format { id: "png", label: "PNG", extensions: &["png"], mime: "image/png", read: true, write: true, raster: true, options: &[ARTBOARD, SCALE] },
Format {
id: "png",
label: "PNG",
extensions: &["png"],
mime: "image/png",
read: true,
write: true,
raster: true,
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, INTERLACED],
},
Format {
id: "jpg",
label: "JPEG",
@ -218,7 +258,7 @@ pub const FORMATS: &[Format] = &[
read: true,
write: true,
raster: true,
options: &[ARTBOARD, SCALE, QUALITY],
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, QUALITY],
},
reader("gif", "GIF", &["gif"], "image/gif", true),
Format {
@ -229,7 +269,7 @@ pub const FORMATS: &[Format] = &[
read: true,
write: true,
raster: true,
options: &[ARTBOARD, SCALE],
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),
@ -418,3 +458,9 @@ mod tests_pdf;
mod tests_svg;
#[cfg(test)]
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

@ -159,16 +159,7 @@ fn bmp(b: &[u8]) -> Option<(f64, f64)> {
/// `png` with a `pHYs` chunk declaring `ppi` right after the header (any existing one is replaced).
pub fn with_png_resolution(png: &[u8], (x, y): (f64, f64)) -> Vec<u8> {
let Some(head) = be32(png, 8).and_then(|len| slice(png, 0, 20usize.checked_add(len as usize)?)) else { return png.to_vec() };
let ppm = |v: f64| ((v / INCH_M).round() as u32).to_be_bytes();
let mut chunk = Vec::with_capacity(21);
chunk.extend_from_slice(&9u32.to_be_bytes());
chunk.extend_from_slice(b"pHYs");
chunk.extend_from_slice(&ppm(x));
chunk.extend_from_slice(&ppm(y));
chunk.push(1);
let mut crc = flate2::Crc::new();
crc.update(&chunk[4..]);
chunk.extend_from_slice(&crc.sum().to_be_bytes());
let chunk = vectorcraft_render::encode::png::phys_chunk(x, y);
let mut out = Vec::with_capacity(png.len() + chunk.len());
out.extend_from_slice(head);
out.extend_from_slice(&chunk);

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

@ -0,0 +1,149 @@
//! Raster export options: resolution (stored in the file), background, anti-aliasing and
//! interlacing, also on Export for Screens and Rasterize.
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()
}
/// A chunk's data by type (first match) in a PNG file.
fn png_chunk<'a>(file: &'a [u8], ty: &[u8; 4]) -> Option<&'a [u8]> {
let mut i = 8;
while i + 8 <= file.len() {
let len = u32::from_be_bytes(file[i..i + 4].try_into().unwrap()) as usize;
if &file[i + 4..i + 8] == ty {
return Some(&file[i + 8..i + 8 + len]);
}
i += 12 + len;
}
None
}
#[test]
fn png_options_resolution_background_interlace_and_anti_aliasing() {
let mut s = session(40.0, 30.0, 1);
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("shape.ellipse", &json!({"x": 5.3, "y": 4.7, "width": 25.8, "height": 21.5})).unwrap();
let png = |s: &mut Session, p: Value| b64(&s.execute("document.export", &p).unwrap());
let hi = png(&mut s, json!({"format": "png", "ppi": 300}));
let ppm = (300.0f64 / 0.0254).round() as u32;
assert_eq!(&png_chunk(&hi, b"pHYs").unwrap()[..4], ppm.to_be_bytes(), "pHYs = ppi / 0.0254");
assert_eq!(rgba(&png(&mut s, json!({"format": "png", "ppi": 144}))).dimensions(), (80, 60), "144 ppi doubles the size");
// scale alone stores 72 × scale.
let scaled = png(&mut s, json!({"format": "png", "scale": 2}));
assert_eq!(&png_chunk(&scaled, b"pHYs").unwrap()[..4], ((144.0f64 / 0.0254).round() as u32).to_be_bytes());
let white = rgba(&png(&mut s, json!({"format": "png", "background": "white"})));
assert!(white.pixels().all(|p| p[3] == 255), "a white background has no alpha below 255");
assert_eq!(white.get_pixel(0, 0).0, [255, 255, 255, 255]);
assert_eq!(rgba(&png(&mut s, json!({"format": "png", "background": "black"}))).get_pixel(0, 0).0, [0, 0, 0, 255]);
assert_eq!(rgba(&png(&mut s, json!({"format": "png", "background": "#3366cc"}))).get_pixel(0, 0).0, [0x33, 0x66, 0xcc, 255]);
assert_eq!(rgba(&png(&mut s, json!({"format": "png"}))).get_pixel(0, 0)[3], 0, "transparent by default");
assert_eq!(rgba(&png(&mut s, json!({"format": "jpg", "background": "black"}))).get_pixel(0, 0)[0], 0);
let laced = png(&mut s, json!({"format": "png", "interlaced": true}));
assert_eq!(laced[28], 1, "IHDR interlace method: Adam7");
assert_eq!(rgba(&laced), rgba(&png(&mut s, json!({"format": "png"}))), "the interlaced file decodes to the same image");
let hard = rgba(&png(&mut s, json!({"format": "png", "antiAlias": "none"})));
assert!(hard.pixels().all(|p| p[3] == 0 || p[3] == 255), "no anti-aliasing: alpha is 0 or 255");
let soft = rgba(&png(&mut s, json!({"format": "png", "antiAlias": "art"})));
assert!(soft.pixels().any(|p| p[3] != 0 && p[3] != 255));
for bad in [
json!({"antiAlias": "smooth"}),
json!({"background": "plaid"}),
json!({"interlaced": "yes"}),
json!({"ppi": "high"}),
json!({"ppi": 0}),
json!({"ppi": -72}),
] {
let mut p = bad.clone();
p["format"] = json!("png");
assert!(s.execute("document.export", &p).is_err(), "{bad}");
}
}
#[test]
fn screens_and_rasterize_take_anti_aliasing() {
let mut s = session(40.0, 30.0, 1);
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
let id = s.execute("shape.ellipse", &json!({"x": 5.3, "y": 4.7, "width": 25.8, "height": 21.5})).unwrap()["id"].as_u64().unwrap();
let hard = |img: &image::RgbaImage| img.pixels().all(|p| p[3] == 0 || p[3] == 255);
let r = s
.execute(
"document.exportForScreens",
&json!({"antiAlias": "none", "formats": [{"format": "png"}, {"format": "png", "scale": 2, "antiAlias": "art"}]}),
)
.unwrap();
assert!(hard(&rgba(&b64(&r["files"][0]))), "the top-level mode applies to rows without their own");
assert!(!hard(&rgba(&b64(&r["files"][1]))), "a row's own mode wins");
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("object.rasterize", &json!({"ppi": 72, "antiAlias": "none", "background": "transparent"})).unwrap();
let doc = &s.doc().unwrap().doc;
let vectorcraft_doc::NodeKind::Image(im) = &doc.layers[0].children().unwrap()[0].kind else { panic!("rasterized to an image") };
assert!(hard(&rgba(&doc.images[&im.key].bytes)));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("object.rasterize", &json!({"ppi": 72, "background": "black"})).unwrap();
let doc = &s.doc().unwrap().doc;
let vectorcraft_doc::NodeKind::Image(im) = &doc.layers[0].children().unwrap()[0].kind else { panic!("rasterized to an image") };
assert_eq!(rgba(&doc.images[&im.key].bytes).get_pixel(0, 0).0, [0, 0, 0, 255]);
assert!(s.execute("object.rasterize", &json!({"antiAlias": "fuzzy"})).is_err());
}
#[test]
fn formats_list_the_raster_options() {
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 k in ["ppi", "scale", "background", "antiAlias", "interlaced"] {
assert!(opts("png").get(k).is_some(), "png lists {k}");
}
assert_eq!(opts("png")["antiAlias"]["default"], "art");
assert_eq!(opts("png")["ppi"]["default"], 72);
assert!(opts("jpg").get("quality").is_some() && opts("jpg").get("interlaced").is_none());
assert!(opts("webp").get("antiAlias").is_some() && opts("svg").get("antiAlias").is_none());
}
#[test]
fn screens_rows_with_a_resolution_name_their_scale() {
let mut s = session(40.0, 30.0, 1);
let r = s.execute("document.exportForScreens", &json!({"formats": [{"format": "png", "ppi": 144}, {"format": "svg", "ppi": 144}]})).unwrap();
assert_eq!((r["files"][0]["name"].as_str(), r["files"][1]["name"].as_str()), (Some("Artboard-1@2x.png"), Some("Artboard-1.svg")), "{r}");
assert_eq!(rgba(&b64(&r["files"][0])).dimensions(), (80, 60));
}
#[test]
fn a_300_ppi_export_places_back_at_its_physical_size() {
let mut s = session(72.0, 36.0, 1);
let png = b64(&s.execute("document.export", &json!({"format": "png", "ppi": 300, "background": "white"})).unwrap());
assert_eq!(rgba(&png).dimensions(), (300, 150));
assert_eq!(super::ppi::resolution(&png), Some((300.0, 300.0)), "the place reader reads the writer's pHYs");
let r = s.execute("file.place", &json!({"name": "art.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
assert!((r["width"].as_f64().unwrap() - 72.0).abs() < 1e-6 && (r["height"].as_f64().unwrap() - 36.0).abs() < 1e-6, "100%: {r}");
}
#[test]
fn the_document_background_is_the_default_export_background() {
let mut s = session(40.0, 30.0, 1);
s.execute("document.setup", &json!({"backgroundContents": "white"})).unwrap();
let png = |s: &mut Session, p: Value| rgba(&b64(&s.execute("document.export", &p).unwrap()));
assert_eq!(png(&mut s, json!({"format": "png"})).get_pixel(0, 0).0, [255, 255, 255, 255], "a white document exports opaque");
assert_eq!(png(&mut s, json!({"format": "png", "background": "transparent"})).get_pixel(0, 0)[3], 0, "the option wins");
assert_eq!(png(&mut s, json!({"format": "webp", "background": "black"})).get_pixel(0, 0).0, [0, 0, 0, 255]);
}

View File

@ -9,6 +9,7 @@ use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::marks::{MarkStyle, TrimMarks};
use vectorcraft_doc::{Appearance, ImageObject, LiveShape, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, Anchor, PathData, Point, Rect, Vec2};
use vectorcraft_render::encode::{RasterExportOptions, RasterFormat};
use super::edit::{duplicate_in, selected_roots};
use super::*;
@ -89,7 +90,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Rasterize…",
["Object"],
None,
"{ppi?: (document raster effects resolution), background?: \"transparent\"|\"white\", padding?: pt} replace the selection with an embedded PNG image → {id, width, height}",
"{ppi?: (document raster effects resolution), background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art (default)|type (text snapped to pixels), padding?: pt} replace the selection with an embedded PNG image → {id, width, height}",
has_selection,
rasterize
),
@ -317,12 +318,12 @@ pub(crate) fn unique_key(d: &vectorcraft_doc::Document, stem: &str) -> String {
}
}
fn render_png(doc: &vectorcraft_doc::Document, region: Rect, scale: f64, white: bool) -> Result<(Vec<u8>, u32, u32)> {
fn render_png(doc: &vectorcraft_doc::Document, region: Rect, scale: f64, opts: &vectorcraft_render::RenderOptions) -> Result<(Vec<u8>, u32, u32)> {
if region.width() * region.height() * scale * scale > MAX_PIXELS || region.width() * scale > 65535.0 || region.height() * scale > 65535.0 {
return Err(EngineError::Other("image would be too large; lower the resolution".into()));
}
let mut r = vectorcraft_render::Renderer::new();
let img = r.render_region(doc, region, scale, white);
let img = r.render_region_with(doc, region, scale, opts);
Ok((img.to_png().map_err(EngineError::Other)?, img.width, img.height))
}
@ -335,7 +336,12 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "ppi must be between 1 and 2400"));
}
let pad = f64_or(p, "padding", 0.0).max(0.0);
let white = str_param(p, "background") == Some("white");
let raster = RasterExportOptions {
ppi,
background: fileio::background(p.get("background").unwrap_or(&Value::Null)).map_err(|e| bad(C, e))?,
anti_alias: str_param(p, "antiAlias").map(fileio::anti_alias).transpose().map_err(|e| bad(C, e))?.unwrap_or_default(),
..Default::default()
};
let b = st.doc.bounds_of(&roots, true).ok_or_else(|| bad(C, "selection has no bounds"))?.inflate(pad, pad);
let scale = ppi / 72.0;
// Snap to whole pixels.
@ -349,7 +355,7 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
})
.collect();
let tmp = isolated_doc(&st.doc, nodes);
let (png, w, h) = render_png(&tmp, region, scale, white)?;
let (png, w, h) = render_png(&tmp, region, scale, &raster.render_options(RasterFormat::Png))?;
let top = *roots.last().ok_or_else(|| bad(C, "nothing selected"))?;
let id = s.edit("Rasterize", |d, sel| {
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
@ -499,7 +505,7 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
bare.visible = true;
let tmp = isolated_doc(&st.doc, vec![bare]);
let region = Rect::new(r.x0, r.y0, r.x0 + (r.width() * scale).round().max(1.0) / scale, r.y0 + (r.height() * scale).round().max(1.0) / scale);
let (png, w, h) = render_png(&tmp, region, scale, false)?;
let (png, w, h) = render_png(&tmp, region, scale, &RasterExportOptions::default().render_options(RasterFormat::Png))?;
s.edit("Crop Image", |d, _| {
let key = unique_key(d, "crop");
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) });

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

@ -19,6 +19,7 @@ vello_cpu = { workspace = true }
# Filter types (Gaussian blur, drop shadow) are not re-exported by vello_cpu 0.2.
vello_common = { version = "0.2", default-features = false }
image = { workspace = true }
flate2 = { workspace = true }
log = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]

View File

@ -0,0 +1,133 @@
//! Raster export: render a document region with [`RasterExportOptions`] (resolution, background,
//! anti-aliasing) and encode it as PNG ([`png`]: resolution in `pHYs`, Adam7 interlacing), JPEG
//! (resolution in the JFIF header) or lossless WebP.
pub mod png;
use vectorcraft_doc::{Document, Node, NodeKind};
use vectorcraft_geom::Rect;
use crate::{AntiAlias, RenderOptions, Rendered, Renderer, fx};
/// A raster file format.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RasterFormat {
Png,
Jpeg,
WebP,
}
/// How a raster export renders and encodes.
#[derive(Clone, Debug, PartialEq)]
pub struct RasterExportOptions {
/// Resolution in pixels per inch: 72 renders one pixel per point. Stored in the file.
pub ppi: f64,
/// Opaque background colour (RGB); `None` = transparent (JPEG has no alpha: white).
pub background: Option<[u8; 3]>,
pub anti_alias: AntiAlias,
/// PNG: Adam7 interlacing.
pub interlaced: bool,
/// JPEG quality 1–100.
pub quality: u8,
}
impl Default for RasterExportOptions {
fn default() -> Self {
Self { ppi: 72.0, background: None, anti_alias: AntiAlias::Art, interlaced: false, quality: 90 }
}
}
impl RasterExportOptions {
/// Pixels per point.
pub fn scale(&self) -> f64 {
self.ppi / 72.0
}
/// What the renderer draws for `format`: template layers left out, over the background
/// (white for a JPEG without one).
pub fn render_options(&self, format: RasterFormat) -> RenderOptions {
let background = self.background.or((format == RasterFormat::Jpeg).then_some([255; 3]));
RenderOptions {
background: background.map(|[r, g, b]| [r, g, b, 255]),
skip_templates: true,
anti_alias: self.anti_alias,
..Default::default()
}
}
/// Encode a rendered image as `format`.
pub fn encode(&self, img: &Rendered, format: RasterFormat) -> Result<Vec<u8>, String> {
match format {
RasterFormat::Png => {
png::encode(&img.to_straight(), img.width, img.height, &png::PngOptions { ppi: Some(self.ppi), interlaced: self.interlaced })
}
RasterFormat::Jpeg => jpeg(img, self.quality, Some(self.ppi)),
RasterFormat::WebP => webp(img),
}
}
}
impl Renderer {
/// Render `region` of `doc` (an artboard or any rect) as exported and encode it as `format`.
/// Callers check the size first ([`crate::raster_size`]).
pub fn export_region(&mut self, doc: &Document, region: Rect, format: RasterFormat, opts: &RasterExportOptions) -> Result<Vec<u8>, String> {
let img = self.render_region_with(doc, region, opts.scale(), &opts.render_options(format));
opts.encode(&img, format)
}
}
/// JPEG at `quality` 1–100, partly transparent pixels flattened on white, with the resolution
/// `ppi` (if any) in the JFIF header.
pub(crate) fn jpeg(img: &Rendered, quality: u8, ppi: Option<f64>) -> Result<Vec<u8>, String> {
let rgb: Vec<u8> = img
.to_straight()
.as_chunks::<4>()
.0
.iter()
.flat_map(|p| {
let a = p[3] as u32;
let mix = |c: u8| ((c as u32 * a + 255 * (255 - a)) / 255) as u8;
[mix(p[0]), mix(p[1]), mix(p[2])]
})
.collect();
let mut buf = Vec::new();
let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100));
if let Some(ppi) = ppi {
enc.set_pixel_density(image::codecs::jpeg::PixelDensity::dpi(ppi.round().clamp(1.0, u16::MAX as f64) as u16));
}
image::ImageEncoder::write_image(enc, &rgb, img.width, img.height, image::ExtendedColorType::Rgb8)
.map_err(|e| format!("JPEG encoding failed: {e}"))?;
Ok(buf)
}
/// Lossless WebP.
pub(crate) fn webp(img: &Rendered) -> Result<Vec<u8>, String> {
let mut buf = Vec::new();
let enc = image::codecs::webp::WebPEncoder::new_lossless(&mut buf);
image::ImageEncoder::write_image(enc, &img.to_straight(), img.width, img.height, image::ExtendedColorType::Rgba8)
.map_err(|e| format!("WebP encoding failed: {e}"))?;
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

@ -0,0 +1,160 @@
//! A PNG writer for raster exports: 8-bit RGBA (RGB when every pixel is opaque), adaptive row
//! filters, the resolution as a `pHYs` chunk and optional Adam7 interlacing (the image builds up
//! progressively while it loads). Chunks are written here; flate2 compresses the image data.
use std::borrow::Cow;
use std::io::Write as _;
use flate2::Crc;
use flate2::write::ZlibEncoder;
/// What goes into the file besides the pixels.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct PngOptions {
/// Pixels per inch, stored as pixels per metre in `pHYs` (`None`: no `pHYs` chunk).
pub ppi: Option<f64>,
/// Adam7 interlacing.
pub interlaced: bool,
}
const SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
/// Most image data bytes per `IDAT` chunk.
const IDAT_MAX: usize = 1 << 20;
/// Adam7 passes: (x0, y0, dx, dy).
const ADAM7: [(u32, u32, u32, u32); 7] = [(0, 0, 8, 8), (4, 0, 8, 8), (0, 4, 4, 8), (2, 0, 4, 4), (0, 2, 2, 4), (1, 0, 2, 2), (0, 1, 1, 2)];
/// Pixels per metre for a resolution in pixels per inch (the unit `pHYs` stores).
pub fn pixels_per_metre(ppi: f64) -> u32 {
(ppi / 0.0254).round().clamp(1.0, u32::MAX as f64) as u32
}
/// A whole `pHYs` chunk (length, type, data, CRC) declaring `x` × `y` pixels per inch.
pub fn phys_chunk(x: f64, y: f64) -> Vec<u8> {
let [x, y] = [x, y].map(|v| pixels_per_metre(v).to_be_bytes());
let mut out = Vec::with_capacity(21);
// x, y, unit 1 = metre.
chunk(&mut out, b"pHYs", &[x.as_slice(), y.as_slice(), &[1]].concat());
out
}
/// Encode straight-alpha RGBA8 pixels (`width`×`height`, row-major) as a PNG file.
pub fn encode(rgba: &[u8], width: u32, height: u32, o: &PngOptions) -> Result<Vec<u8>, String> {
if width == 0 || height == 0 || width > i32::MAX as u32 || height > i32::MAX as u32 {
return Err(format!("PNG encoding failed: {width} × {height} pixels is not a valid image size"));
}
if rgba.len() as u64 != u64::from(width) * u64::from(height) * 4 {
return Err("PNG encoding failed: pixel buffer doesn't match the image size".into());
}
let px = rgba.as_chunks::<4>().0;
let opaque = px.iter().all(|p| p[3] == 255);
let (channels, color_type) = if opaque { (3, 2) } else { (4, 6) };
let pixels: Cow<[u8]> = if opaque { Cow::Owned(px.iter().flat_map(|p| [p[0], p[1], p[2]]).collect()) } else { Cow::Borrowed(rgba) };
let mut out = Vec::with_capacity(pixels.len() / 2 + 64);
out.extend_from_slice(SIGNATURE);
let mut ihdr = Vec::with_capacity(13);
ihdr.extend_from_slice(&width.to_be_bytes());
ihdr.extend_from_slice(&height.to_be_bytes());
// Bit depth 8, colour type, deflate compression, adaptive filtering, interlace method.
ihdr.extend_from_slice(&[8, color_type, 0, 0, u8::from(o.interlaced)]);
chunk(&mut out, b"IHDR", &ihdr);
if let Some(ppi) = o.ppi {
out.extend_from_slice(&phys_chunk(ppi, ppi));
}
let raw = if o.interlaced {
let mut raw = Vec::with_capacity(pixels.len() + height as usize * 2);
for pass in ADAM7 {
let sub = sub_image(&pixels, width, height, channels, pass);
if let Some((w, h, px)) = sub {
filter_rows(&px, w, h, channels, &mut raw);
}
}
raw
} else {
let mut raw = Vec::with_capacity(pixels.len() + height as usize);
filter_rows(&pixels, width, height, channels, &mut raw);
raw
};
let mut z = ZlibEncoder::new(Vec::with_capacity(raw.len() / 2), flate2::Compression::default());
let data = z.write_all(&raw).and_then(|()| z.finish()).map_err(|e| format!("PNG encoding failed: {e}"))?;
for part in data.chunks(IDAT_MAX) {
chunk(&mut out, b"IDAT", part);
}
chunk(&mut out, b"IEND", &[]);
Ok(out)
}
/// Append one chunk: length, type, data, CRC of type and data.
fn chunk(out: &mut Vec<u8>, ty: &[u8; 4], data: &[u8]) {
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
out.extend_from_slice(ty);
out.extend_from_slice(data);
let mut crc = Crc::new();
crc.update(ty);
crc.update(data);
out.extend_from_slice(&crc.sum().to_be_bytes());
}
/// The pixels of one Adam7 pass as their own image (`None` when the pass is empty).
fn sub_image(px: &[u8], width: u32, height: u32, channels: usize, (x0, y0, dx, dy): (u32, u32, u32, u32)) -> Option<(u32, u32, Vec<u8>)> {
let w = width.saturating_sub(x0).div_ceil(dx);
let h = height.saturating_sub(y0).div_ceil(dy);
if w == 0 || h == 0 {
return None;
}
let mut out = Vec::with_capacity(w as usize * h as usize * channels);
for y in (y0..height).step_by(dy as usize) {
let row = &px[y as usize * width as usize * channels..][..width as usize * channels];
for x in (x0..width).step_by(dx as usize) {
out.extend_from_slice(&row[x as usize * channels..][..channels]);
}
}
Some((w, h, out))
}
/// Filter every row of a `w`×`h` image into `out` (filter type byte + filtered bytes), picking
/// per row the filter with the smallest sum of absolute values (the usual heuristic).
fn filter_rows(px: &[u8], w: u32, h: u32, bpp: usize, out: &mut Vec<u8>) {
let stride = w as usize * bpp;
let zero = vec![0u8; stride];
let mut cand: [Vec<u8>; 5] = std::array::from_fn(|_| vec![0u8; stride]);
for y in 0..h as usize {
let cur = &px[y * stride..][..stride];
let prev = if y == 0 { &zero[..] } else { &px[(y - 1) * stride..][..stride] };
// The first pixel has no left neighbour (a = c = 0).
let (head, tail) = (bpp.min(stride), stride.saturating_sub(bpp));
let [none, sub, up, avg, pae] = &mut cand;
none.copy_from_slice(cur);
sub[..head].copy_from_slice(&cur[..head]);
for i in 0..stride {
up[i] = cur[i].wrapping_sub(prev[i]);
}
for i in 0..head {
avg[i] = cur[i].wrapping_sub(prev[i] / 2);
pae[i] = cur[i].wrapping_sub(paeth(0, prev[i], 0));
}
for j in 0..tail {
let i = j + bpp;
let (a, b, c) = (cur[j], prev[i], prev[j]);
sub[i] = cur[i].wrapping_sub(a);
avg[i] = cur[i].wrapping_sub(((a as u16 + b as u16) / 2) as u8);
pae[i] = cur[i].wrapping_sub(paeth(a, b, c));
}
let cost = |c: &[u8]| c.iter().map(|&v| (v as i8).unsigned_abs() as u64).sum::<u64>();
let best = (0..5).min_by_key(|&f| cost(&cand[f])).unwrap_or(0);
out.push(best as u8);
out.extend_from_slice(&cand[best]);
}
}
fn paeth(a: u8, b: u8, c: u8) -> u8 {
let p = a as i16 + b as i16 - c as i16;
let (pa, pb, pc) = ((p - a as i16).abs(), (p - b as i16).abs(), (p - c as i16).abs());
if pa <= pb && pa <= pc {
a
} else if pb <= pc {
b
} else {
c
}
}

View File

@ -0,0 +1,191 @@
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, CharStyle, Node, NodeKind, TextObject};
use vectorcraft_geom::{Point, shapes};
use super::png::{PngOptions, encode, pixels_per_metre};
use super::*;
/// A chunk's data by type (first match).
fn chunk_data<'a>(file: &'a [u8], ty: &[u8; 4]) -> Option<&'a [u8]> {
let mut i = 8;
while i + 8 <= file.len() {
let len = u32::from_be_bytes(file[i..i + 4].try_into().unwrap()) as usize;
if &file[i + 4..i + 8] == ty {
return Some(&file[i + 8..i + 8 + len]);
}
i += 12 + len;
}
None
}
/// Deterministic straight-alpha noise with some fully transparent and some opaque pixels.
fn noise(w: u32, h: u32, opaque: bool) -> Vec<u8> {
let mut x: u32 = 0x1234_5678;
(0..w * h * 4)
.map(|i| {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
if i % 4 == 3 && opaque { 255 } else { (x >> 24) as u8 }
})
.collect()
}
fn decode(png: &[u8]) -> Vec<u8> {
image::load_from_memory_with_format(png, image::ImageFormat::Png).expect("decodes").to_rgba8().into_raw()
}
#[test]
fn png_round_trips_plain_and_interlaced_at_odd_sizes() {
for (w, h) in [(1, 1), (3, 5), (9, 9), (17, 2), (8, 8), (33, 31)] {
for opaque in [false, true] {
let px = noise(w, h, opaque);
for interlaced in [false, true] {
let file = encode(&px, w, h, &PngOptions { ppi: None, interlaced }).unwrap();
assert_eq!(&file[..8], b"\x89PNG\r\n\x1a\n");
assert_eq!(file[28], u8::from(interlaced), "IHDR interlace method");
assert_eq!(file[25], if opaque { 2 } else { 6 }, "opaque images are written as RGB");
assert_eq!(decode(&file), px, "{w}×{h} opaque={opaque} interlaced={interlaced}");
assert!(chunk_data(&file, b"pHYs").is_none());
}
}
}
}
#[test]
fn phys_holds_pixels_per_metre() {
let file = encode(&noise(2, 2, true), 2, 2, &PngOptions { ppi: Some(300.0), interlaced: false }).unwrap();
let phys = chunk_data(&file, b"pHYs").expect("pHYs");
let ppm = (300.0f64 / 0.0254).round() as u32;
assert_eq!(ppm, pixels_per_metre(300.0));
assert_eq!(phys, [ppm.to_be_bytes().as_slice(), ppm.to_be_bytes().as_slice(), &[1]].concat());
}
#[test]
fn png_refuses_a_buffer_of_the_wrong_size() {
assert!(encode(&[0; 12], 2, 2, &PngOptions::default()).is_err());
assert!(encode(&[], 0, 0, &PngOptions::default()).is_err());
}
/// A 40×30 pt document with a black circle off the pixel grid.
fn circle_doc() -> Document {
let mut d = Document::new(40.0, 30.0);
let id = d.alloc_id();
let n = Node::path(id, shapes::ellipse(Rect::new(5.3, 4.7, 31.1, 26.2)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
fn export(d: &Document, format: RasterFormat, o: &RasterExportOptions) -> image::RgbaImage {
let bytes = Renderer::new().export_region(d, d.artboards[0].rect, format, o).unwrap();
image::load_from_memory(&bytes).unwrap().to_rgba8()
}
#[test]
fn resolution_sets_the_pixel_size_and_is_stored() {
let d = circle_doc();
assert_eq!(export(&d, RasterFormat::Png, &RasterExportOptions::default()).dimensions(), (40, 30));
let o = RasterExportOptions { ppi: 144.0, ..Default::default() };
assert_eq!(export(&d, RasterFormat::Png, &o).dimensions(), (80, 60), "144 ppi doubles the size");
let png = Renderer::new().export_region(&d, d.artboards[0].rect, RasterFormat::Png, &o).unwrap();
assert_eq!(&chunk_data(&png, b"pHYs").unwrap()[..4], pixels_per_metre(144.0).to_be_bytes());
let jpg = Renderer::new().export_region(&d, d.artboards[0].rect, RasterFormat::Jpeg, &o).unwrap();
// JFIF APP0: units 1 (dots per inch), then the x and y density.
let app0 = jpg.windows(5).position(|w| w == b"JFIF\0").unwrap();
assert_eq!(&jpg[app0 + 7..app0 + 12], [1, 0, 144, 0, 144]);
}
#[test]
fn backgrounds_are_opaque_or_transparent() {
let d = circle_doc();
let transparent = export(&d, RasterFormat::Png, &RasterExportOptions::default());
assert_eq!(transparent.get_pixel(0, 0)[3], 0);
for (bg, want) in [([255, 255, 255], [255, 255, 255, 255]), ([0, 0, 0], [0, 0, 0, 255]), ([30, 120, 200], [30, 120, 200, 255])] {
let img = export(&d, RasterFormat::Png, &RasterExportOptions { background: Some(bg), ..Default::default() });
assert!(img.pixels().all(|p| p[3] == 255), "no alpha below 255 on {bg:?}");
assert_eq!(img.get_pixel(0, 0).0, want);
}
let webp = export(&d, RasterFormat::WebP, &RasterExportOptions { background: Some([0, 0, 0]), ..Default::default() });
assert_eq!(webp.get_pixel(0, 0).0, [0, 0, 0, 255]);
}
#[test]
fn interlaced_export_decodes_to_the_same_pixels() {
let d = circle_doc();
let plain = Renderer::new().export_region(&d, d.artboards[0].rect, RasterFormat::Png, &RasterExportOptions::default()).unwrap();
let o = RasterExportOptions { interlaced: true, ..Default::default() };
let laced = Renderer::new().export_region(&d, d.artboards[0].rect, RasterFormat::Png, &o).unwrap();
assert_eq!((plain[28], laced[28]), (0, 1));
assert_eq!(decode(&plain), decode(&laced));
}
#[test]
fn anti_alias_none_paints_whole_pixels() {
let d = circle_doc();
let soft = export(&d, RasterFormat::Png, &RasterExportOptions::default());
assert!(soft.pixels().any(|p| p[3] != 0 && p[3] != 255), "art anti-aliasing has partial edge pixels");
let hard = export(&d, RasterFormat::Png, &RasterExportOptions { anti_alias: AntiAlias::None, ..Default::default() });
assert!(hard.pixels().all(|p| p[3] == 0 || p[3] == 255), "no anti-aliasing: every alpha is 0 or 255");
let covered = |img: &image::RgbaImage| img.pixels().filter(|p| p[3] >= 128).count() as i64;
assert!((covered(&hard) - covered(&soft)).abs() <= 8, "the same shape, only harder edges");
}
#[test]
fn type_anti_aliasing_snaps_text_to_pixels() {
let mut d = Document::new(80.0, 30.0);
let id = d.alloc_id();
let t = TextObject::point(Point::new(4.37, 18.61), "Hill", CharStyle { size: 12.0, ..CharStyle::default() });
let n = Node::new(id, NodeKind::Text(Box::new(t)));
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let art = export(&d, RasterFormat::Png, &RasterExportOptions::default());
let typ = export(&d, RasterFormat::Png, &RasterExportOptions { anti_alias: AntiAlias::Type, ..Default::default() });
assert!(art.pixels().any(|p| p[3] > 0), "the text is drawn");
assert_ne!(art, typ, "type moves to whole pixels");
// Snapping moves glyphs by under a pixel: the ink stays where it was.
let ink = |img: &image::RgbaImage| img.pixels().map(|p| p[3] as u64).sum::<u64>() as f64;
assert!((ink(&typ) / ink(&art) - 1.0).abs() < 0.1);
}
#[test]
fn anti_alias_none_is_the_same_on_every_thread_count() {
// Glows are filtered offscreen when the renderer is multithreaded: their clip must be hard too.
let mut d = Document::new(80.0, 80.0);
let id = d.alloc_id();
let mut n = Node::path(id, shapes::rectangle(Rect::new(20.5, 20.3, 60.4, 59.6)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
let glow = serde_json::json!({"color": "#ffffff", "mode": "normal", "opacity": 100, "blur": 4});
n.appearance.effects = vec![vectorcraft_doc::Effect { id: "stylize.innerGlow".into(), params: glow, visible: true }];
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let opts = RenderOptions { anti_alias: AntiAlias::None, ..Default::default() };
let render = |threads: u16| {
let mut r = Renderer::new();
r.threads = threads;
r.render_region_with(&d, d.artboards[0].rect, 1.0, &opts)
};
let (st, mt) = (render(0), render(3));
for img in [&st, &mt] {
assert!(img.to_straight().chunks_exact(4).all(|p| p[3] == 0 || p[3] == 255), "hard edges");
}
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

@ -306,7 +306,7 @@ impl Renderer {
}
let (w, h) = (w as u16, h as u16);
// Silhouette (the object's painted alpha), offset by the shadow distance.
let mut off = crate::single_threaded_context(w, h);
let mut off = f.offscreen_context(w, h);
let crop = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f };
self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy)));
off.flush();
@ -580,7 +580,7 @@ impl Renderer {
return;
}
let (w, h) = (w as u16, h as u16);
let mut off = crate::single_threaded_context(w, h);
let mut off = f.offscreen_context(w, h);
let shifted = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f };
self.inside_layer(|r| draw(r, &mut off, &shifted, None));
off.flush();

View File

@ -11,7 +11,7 @@ use vectorcraft_geom::{Affine, BezPath, FillRule, Rect};
use vello_cpu::peniko::{self, BlendMode, Compose, Mix};
use vello_cpu::{Pixmap, RenderContext};
use crate::{Frame, Renderer, blend_mode, fill_rule, single_threaded_context};
use crate::{Frame, Renderer, blend_mode, fill_rule};
/// How a group composites onto what is below it.
pub(crate) struct Composite<'a> {
@ -162,7 +162,7 @@ impl Renderer {
/// `draw` rendered into a new context of region `r` (frame `sub`), over `backdrop` if any.
fn offscreen(&mut self, r: Region, sub: &Frame, backdrop: Option<Arc<Pixmap>>, draw: &mut Content) -> Pixmap {
let mut ctx = single_threaded_context(r.w, r.h);
let mut ctx = sub.offscreen_context(r.w, r.h);
if let Some(b) = &backdrop {
draw_pixmap(&mut ctx, b.clone(), (0.0, 0.0));
}

View File

@ -7,6 +7,7 @@
#![forbid(unsafe_code)]
mod brush_fx;
pub mod encode;
mod freeform;
mod fx;
mod group;
@ -48,16 +49,14 @@ pub fn raster_size(region: Rect, scale: f64) -> Result<(u32, u32), String> {
if !(scale.is_finite() && scale > 0.0) {
return Err(format!("invalid scale {scale}"));
}
let w = (region.width() * scale).round().max(1.0);
let h = (region.height() * scale).round().max(1.0);
let max = f64::from(MAX_RASTER_SIDE);
if !(w <= max && h <= max && w * h <= MAX_RASTER_PIXELS as f64) {
let (w, h) = region_pixels(region, scale);
if !(w <= MAX_RASTER_SIDE && h <= MAX_RASTER_SIDE && u64::from(w) * u64::from(h) <= MAX_RASTER_PIXELS) {
return Err(format!(
"the image would be {w:.0} × {h:.0} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {} megapixels: lower the scale or resolution",
"the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {} megapixels: lower the scale or resolution",
MAX_RASTER_PIXELS / 1_000_000
));
}
Ok((w as u32, h as u32))
Ok((w, h))
}
/// Rendering options.
@ -89,6 +88,54 @@ pub struct RenderOptions {
pub mask_view: Option<NodeId>,
/// Screen view: highlight substituted fonts and glyphs as Document Setup asks.
pub highlight_substitutions: bool,
/// Edge smoothing (raster export option).
pub anti_alias: AntiAlias,
}
/// How edges are rasterized (raster export option).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AntiAlias {
/// Hard edges: a pixel is painted when a path, text or image covers at least half of it.
/// Raster effects (blur, shadows, glows) and pattern tiles stay smooth.
None,
/// Smooth edges everywhere.
#[default]
Art,
/// Smooth edges with type snapped to the pixel grid (see `TextLayout::snap_to_pixels`):
/// crisper small text.
Type,
}
impl AntiAlias {
pub const ALL: [Self; 3] = [Self::None, Self::Art, Self::Type];
/// The id used in command params (`none`, `art`, `type`).
pub fn id(self) -> &'static str {
match self {
Self::None => "none",
Self::Art => "art",
Self::Type => "type",
}
}
/// The mode an id names (any case).
pub fn from_id(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|a| a.id().eq_ignore_ascii_case(id))
}
/// The name shown in options dialogs.
pub fn label(self) -> &'static str {
match self {
Self::None => "None",
Self::Art => "Art Optimized",
Self::Type => "Type Optimized",
}
}
/// vello's aliasing threshold: paint a pixel only above half coverage when edges are hard.
fn threshold(self) -> Option<u8> {
(self == Self::None).then_some(127)
}
}
impl Default for RenderOptions {
@ -106,6 +153,7 @@ impl Default for RenderOptions {
tile_edge: vectorcraft_doc::LAYER_COLORS[0].1,
mask_view: None,
highlight_substitutions: false,
anti_alias: AntiAlias::Art,
}
}
}
@ -131,7 +179,8 @@ impl Rendered {
}
out
}
/// Encode as PNG.
/// Encode as PNG without metadata (thumbnails, screenshots, embedded rasters; exports use
/// [`encode`]).
pub fn to_png(&self) -> Result<Vec<u8>, String> {
let mut buf = Vec::new();
let img = image::RgbaImage::from_raw(self.width, self.height, self.to_straight())
@ -141,30 +190,11 @@ impl Rendered {
}
/// Encode as JPEG (flattened on white) at `quality` 1..=100.
pub fn to_jpeg(&self, quality: u8) -> Result<Vec<u8>, String> {
let rgba = self.to_straight();
let rgb: Vec<u8> = rgba
.as_chunks::<4>()
.0
.iter()
.flat_map(|p| {
let a = p[3] as u32;
let mix = |c: u8| ((c as u32 * a + 255 * (255 - a)) / 255) as u8;
[mix(p[0]), mix(p[1]), mix(p[2])]
})
.collect();
let mut buf = Vec::new();
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100));
image::ImageEncoder::write_image(enc, &rgb, self.width, self.height, image::ExtendedColorType::Rgb8)
.map_err(|e| format!("JPEG encoding failed: {e}"))?;
Ok(buf)
encode::jpeg(self, quality, None)
}
/// Encode as lossless WebP.
pub fn to_webp(&self) -> Result<Vec<u8>, String> {
let mut buf = Vec::new();
let enc = image::codecs::webp::WebPEncoder::new_lossless(&mut buf);
image::ImageEncoder::write_image(enc, &self.to_straight(), self.width, self.height, image::ExtendedColorType::Rgba8)
.map_err(|e| format!("WebP encoding failed: {e}"))?;
Ok(buf)
encode::webp(self)
}
/// Straight-alpha RGBA at (x, y).
pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] {
@ -318,6 +348,21 @@ struct Frame<'a> {
ink: Ink,
}
impl Frame<'_> {
/// Text space → device pixels when type is snapped to the pixel grid (Type anti-aliasing).
fn text_snap(&self, t: &TextObject) -> Option<Affine> {
(self.opts.anti_alias == AntiAlias::Type).then(|| self.view * t.xf)
}
/// A render context for drawing this frame's art offscreen (on the calling thread), with its
/// edge smoothing: hard edges must not depend on where the art is drawn.
fn offscreen_context(&self, w: u16, h: u16) -> RenderContext {
let mut ctx = single_threaded_context(w, h);
ctx.set_aliasing_threshold(self.opts.anti_alias.threshold());
ctx
}
}
impl Renderer {
pub fn new() -> Self {
Self {
@ -412,6 +457,8 @@ impl Renderer {
}
_ => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: threads, ..Default::default() }),
};
// `reset` keeps the threshold of the previous render: set it every time.
ctx.set_aliasing_threshold(opts.anti_alias.threshold());
if let Some(bg) = opts.background {
ctx.set_transform(Affine::IDENTITY);
ctx.set_paint(f.ink.fixed(bg));
@ -478,11 +525,15 @@ impl Renderer {
/// Render one artboard (or any document rect) at `scale` pixels per point, transparent or on white,
/// as exported: template layers are left out.
pub fn render_region(&mut self, doc: &Document, region: Rect, scale: f64, white: bool) -> Rendered {
let w = (region.width() * scale).round().max(1.0) as u32;
let h = (region.height() * scale).round().max(1.0) as u32;
let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0));
let opts = RenderOptions { background: white.then_some([255, 255, 255, 255]), skip_templates: true, ..Default::default() };
self.render(doc, w, h, view, &opts)
self.render_region_with(doc, region, scale, &opts)
}
/// Render a document rect at `scale` pixels per point with `opts`.
pub fn render_region_with(&mut self, doc: &Document, region: Rect, scale: f64, opts: &RenderOptions) -> Rendered {
let (w, h) = region_pixels(region, scale);
let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0));
self.render(doc, w, h, view, opts)
}
/// Render a single node (thumbnails, previews) fitted into `size`×`size` pixels.
@ -633,7 +684,7 @@ impl Renderer {
/// The coverage of opacity mask `m` per output pixel (row-major): its art rendered offscreen,
/// as luminance ([`mask_value`]).
fn mask_values(&mut self, f: &Frame, m: &vectorcraft_doc::OpacityMask, w: u16, h: u16) -> Vec<u8> {
let mut mctx = single_threaded_context(w, h);
let mut mctx = f.offscreen_context(w, h);
// Mask art is a picture of its own: it takes no part in a knockout group around the object,
// and its luminance is that of its screen colours.
let outer =
@ -682,7 +733,10 @@ impl Renderer {
&& a.blend == vectorcraft_color::BlendMode::Normal
&& !fx::has_fx(a)
{
let g = self.text_geom_of(a, t);
let g = match f.text_snap(t) {
Some(xf) => Arc::new(text_geom_snapped(t, Some(xf))),
None => self.text_geom_of(a, t),
};
self.draw_text_geom(ctx, f, a, t, &g);
self.stats.drawn += 1;
return;
@ -1054,7 +1108,7 @@ impl Renderer {
}
fn draw_text(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject) {
let g = text_geom(t);
let g = text_geom_snapped(t, f.text_snap(t));
self.draw_text_geom(ctx, f, n, t, &g);
}
@ -1306,8 +1360,16 @@ struct TextGeom {
}
fn text_geom(t: &TextObject) -> TextGeom {
text_geom_snapped(t, None)
}
/// Glyph geometry of `t`; `snap` (text space → device pixels) puts the glyphs on whole pixels.
fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
let db = vectorcraft_text::FontDb::global();
let layout = vectorcraft_text::layout(db, t);
let mut layout = vectorcraft_text::layout(db, t);
if let Some(xf) = snap {
layout.snap_to_pixels(xf);
}
let mut runs = vec![BezPath::new(); t.runs.len()];
let mut all = BezPath::new();
// Per run: is its family missing, and the face its glyphs should come from.
@ -1337,6 +1399,10 @@ fn text_geom(t: &TextObject) -> TextGeom {
/// Document Setup's highlight behind substituted fonts and glyphs (screen only).
const SUBSTITUTED: peniko::Color = peniko::Color::from_rgba8(255, 120, 190, 110);
/// The pixel size of `region` rendered at `scale` pixels per point (at least 1×1).
pub fn region_pixels(region: Rect, scale: f64) -> (u32, u32) {
((region.width() * scale).round().max(1.0) as u32, (region.height() * scale).round().max(1.0) as u32)
}
fn rects_overlap(a: Rect, b: Rect) -> bool {
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1

View File

@ -20,7 +20,7 @@ pub mod thread;
pub use features::OtFeatures;
pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace};
use kurbo::{BezPath, Point, Rect, Vec2};
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
pub use layout::{layout, layout_with};
pub use vectorcraft_doc::TextObject;
@ -144,6 +144,29 @@ impl TextLayout {
pub fn line_of(&self, byte: usize) -> usize {
self.lines.iter().rposition(|l| l.start <= byte).unwrap_or(0)
}
/// Snap type to the pixel grid (type-optimized anti-aliasing): move each glyph so its pen
/// position on the baseline lands on a whole device pixel, given `to_device` (text space →
/// device pixels). Whole baselines and glyph starts keep small type crisp and evenly spaced.
/// Only text that is neither rotated nor skewed nor on a path is snapped; returns whether
/// the layout changed.
pub fn snap_to_pixels(&mut self, to_device: Affine) -> bool {
let [a, b, c, d, _, _] = to_device.as_coeffs();
if self.on_path || b.abs() > 1e-9 || c.abs() > 1e-9 || a.abs() < 1e-12 || d.abs() < 1e-12 {
return false;
}
let mut moved = false;
for g in self.glyphs.iter_mut().filter(|g| g.angle == 0.0) {
let p = to_device * g.origin;
let shift = Vec2::new((p.x.round() - p.x) / a, (p.y.round() - p.y) / d);
if shift == Vec2::ZERO {
continue;
}
g.origin += shift;
g.outline.apply_affine(Affine::translate(shift));
moved = true;
}
moved
}
}
fn dir(angle: f64) -> Vec2 {
@ -332,4 +355,6 @@ mod tests;
#[cfg(test)]
mod tests_scripts;
#[cfg(test)]
mod tests_snap;
#[cfg(test)]
mod tests_typo;

View File

@ -0,0 +1,39 @@
//! Pixel snapping for type-optimized anti-aliasing.
use kurbo::{Affine, Shape};
use vectorcraft_doc::CharStyle;
use super::*;
fn text_at(x: f64, y: f64) -> TextObject {
TextObject::point(Point::new(x, y), "Hill", CharStyle { size: 9.0, ..CharStyle::default() })
}
#[test]
fn glyph_starts_land_on_whole_device_pixels() {
let t = text_at(10.3, 20.6);
let mut l = layout(FontDb::global(), &t);
let before: Vec<Point> = l.glyphs.iter().map(|g| g.origin).collect();
let to_device = Affine::scale(2.0) * t.xf;
assert!(l.snap_to_pixels(to_device));
for (g, was) in l.glyphs.iter().zip(before) {
let p = to_device * g.origin;
assert!((p.x - p.x.round()).abs() < 1e-9 && (p.y - p.y.round()).abs() < 1e-9, "{p:?}");
assert!((g.origin - was).hypot() <= 0.5f64.hypot(0.5) / 2.0 + 1e-9, "moved at most half a pixel");
// The outline moved with its pen position.
assert!(!g.outline.elements().is_empty());
}
let ink = |l: &TextLayout| l.glyphs[0].outline.bounding_box();
let fresh = layout(FontDb::global(), &t);
let d = ink(&l).origin() - ink(&fresh).origin();
assert!((d - (l.glyphs[0].origin - fresh.glyphs[0].origin)).hypot() < 1e-9);
}
#[test]
fn rotated_text_is_left_alone() {
let t = text_at(10.3, 20.6);
let mut l = layout(FontDb::global(), &t);
let before: Vec<Point> = l.glyphs.iter().map(|g| g.origin).collect();
assert!(!l.snap_to_pixels(Affine::rotate(0.3) * t.xf));
assert_eq!(l.glyphs.iter().map(|g| g.origin).collect::<Vec<_>>(), before);
}

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;
@ -29,6 +30,7 @@ mod new_document;
pub mod new_swatch;
mod path_ops;
pub mod place;
mod png_options;
pub mod recolor;
pub mod saturate;
mod save_changes;
@ -50,7 +52,9 @@ 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;
pub use tools::open_tool_dialog;
@ -180,6 +184,8 @@ registry! {
SvgOptions: [svg_options::KIND] => svg_options::SPEC,
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).
@ -278,3 +284,5 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_export;

View File

@ -0,0 +1,162 @@
//! The raster export options: PNG Options (resolution, background, anti-aliasing, interlaced),
//! JPEG Options (quality instead of interlacing; no transparency) and WebP Options. Fields are
//! `document.export` params (format, path, artboard choice…); `__`-prefixed ones are the dialog's.
use serde::Deserialize;
use serde_json::{Value, json};
use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, Format};
use vectorcraft_render::AntiAlias;
use super::{DialogSpec, form};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, io, widgets};
pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, ok: Some("Export"), min_width: 360.0, ..DialogSpec::FORM };
/// Resolution presets (pixels per inch).
const RESOLUTIONS: [f64; 3] = [72.0, 150.0, 300.0];
/// Their labels, then `Other` (any resolution).
const RESOLUTION_LABELS: [&str; 4] = ["Screen (72 ppi)", "Medium (150 ppi)", "High (300 ppi)", "Other"];
/// Background param values.
const BACKGROUNDS: [&str; 3] = ["transparent", "white", "black"];
/// Their labels, then `Other` (a colour).
const BACKGROUND_LABELS: [&str; 4] = ["Transparent", "White", "Black", "Other"];
/// The dialog kind of a raster format (`pngOptions`, `jpgOptions`, `webpOptions`).
fn kind(f: &Format) -> String {
format!("{}Options", f.id)
}
fn format_of(d: &Dialog) -> Option<&'static Format> {
fileio::format(d.kind.strip_suffix("Options")?)
}
/// Open the options of raster format `f` for an export whose params (format, path, artboard
/// choice) are `params`.
pub fn open(app: &mut VectorcraftApp, f: &Format, mut params: Value) {
let size = app.session.active().and_then(|st| export_size(&st.doc, &params));
// The document's background (New Document → Background Contents) is the default; JPEG has no alpha.
let white = f.id == "jpg" || app.session.active().is_some_and(|st| st.doc.setup.background == vectorcraft_doc::Background::White);
if let Some(o) = params.as_object_mut() {
o.insert("ppi".into(), json!(72));
o.insert("background".into(), json!(if white { "white" } else { "transparent" }));
o.insert("antiAlias".into(), json!(AntiAlias::default().id()));
match f.id {
"png" => o.insert("interlaced".into(), json!(false)),
"jpg" => o.insert("quality".into(), json!(90)),
_ => None,
};
if let Some((w, h)) = size {
o.insert("__size".into(), json!([w, h]));
}
}
app.ui.dialog = Some(Dialog::new(&kind(f), params));
}
/// 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 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()))
}
fn heading(d: &Dialog) -> String {
format!("{} Options", format_of(d).map_or("Export", |f| f.label))
}
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let id = format_of(d).map_or("png", |f| f.id);
let label = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(text).color(t.text_dim));
egui::Grid::new("raster-options").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
label(ui, "Resolution:");
let ppi = d.f64("ppi", 72.0);
let preset = RESOLUTIONS.iter().position(|p| *p == ppi).filter(|_| !d.bool("__otherPpi"));
ui.horizontal(|ui| {
if let Some(i) = widgets::dropdown(ui, "ro-ppi", RESOLUTION_LABELS[preset.unwrap_or(3)], &RESOLUTION_LABELS, 150.0) {
if let Some(p) = RESOLUTIONS.get(i) {
d.fields.insert("ppi".into(), json!(p));
}
d.fields.insert("__otherPpi".into(), json!(i == 3));
}
if preset.is_none()
&& let Some(v) = widgets::plain_field(ui, "ro-ppi-other", ppi, " ppi", 0, 80.0)
{
d.fields.insert("ppi".into(), json!(v.round().clamp(1.0, 4608.0)));
}
});
ui.end_row();
label(ui, "Background Color:");
let bg = d.str("background");
let choice = BACKGROUNDS.iter().position(|v| bg.eq_ignore_ascii_case(v)).unwrap_or(3);
// JPEG has no transparency: its list starts at White.
let first = usize::from(id == "jpg");
ui.horizontal(|ui| {
if let Some(i) = widgets::dropdown(ui, "ro-bg", BACKGROUND_LABELS[choice], &BACKGROUND_LABELS[first..], 150.0) {
let value = BACKGROUNDS.get(i + first).copied().unwrap_or("#808080");
d.fields.insert("background".into(), json!(value));
}
if choice == 3 {
let c = vectorcraft_color::Color::from_hex(&bg).map_or([128, 128, 128, 255], |c| c.to_rgba8(1.0));
let mut rgb = [c[0], c[1], c[2]];
if ui.color_edit_button_srgb(&mut rgb).changed() {
d.fields.insert("background".into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])));
}
}
});
ui.end_row();
label(ui, "Anti-aliasing:");
let aa = AntiAlias::from_id(&d.str("antiAlias")).unwrap_or_default();
if let Some(i) = widgets::dropdown(ui, "ro-aa", aa.label(), &AntiAlias::ALL.map(AntiAlias::label), 150.0) {
d.fields.insert("antiAlias".into(), json!(AntiAlias::ALL[i].id()));
}
ui.end_row();
match id {
"png" => {
ui.label("");
form::check(ui, d, "interlaced", "Interlaced");
ui.end_row();
}
"jpg" => {
label(ui, "Quality:");
let mut q = d.f64("quality", 90.0).round() as u8;
if ui.add(egui::Slider::new(&mut q, 1..=100)).changed() {
d.fields.insert("quality".into(), json!(q));
}
ui.end_row();
}
_ => {}
}
if let Some([w, h]) =
d.fields.get("__size").and_then(Value::as_array).map(|a| [0, 1].map(|i| a.get(i).and_then(Value::as_f64).unwrap_or(0.0)))
{
label(ui, "Size:");
let (pw, ph) = vectorcraft_render::region_pixels(vectorcraft_geom::Rect::new(0.0, 0.0, w, h), ppi / 72.0);
ui.label(egui::RichText::new(format!("{pw} × {ph} px")).color(t.text));
ui.end_row();
}
});
false
}
/// Write the file(s) with the chosen options.
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))
}

View File

@ -0,0 +1,241 @@
//! Export As and the raster options dialogs, drawn headlessly.
use std::cell::RefCell;
use std::rc::Rc;
use serde_json::json;
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::fileio;
use super::*;
use crate::Services;
type Written = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
/// An app with `artboards` 60×40 pt artboards whose save dialog answers `/out/<suggested>` and
/// whose writer records what it is given.
fn app(artboards: usize) -> (VectorcraftApp, Written) {
let written = Written::default();
let w = written.clone();
let services = Services {
pick_save: Some(Box::new(|name: &str| Some(format!("/out/{name}")))),
write: Some(Box::new(move |p: &str, b: &[u8]| {
w.borrow_mut().push((p.to_string(), b.to_vec()));
Ok(())
})),
..Default::default()
};
let mut app = VectorcraftApp::new(Session::new(), services);
app.run("file.new", json!({"width": 60, "height": 40, "artboards": artboards})).unwrap();
app.run("paint.setStroke", json!({"none": true})).unwrap();
app.run("shape.ellipse", json!({"x": 5, "y": 4, "width": 30, "height": 20})).unwrap();
(app, written)
}
/// One headless frame of the dialog layer.
fn frame(app: &mut VectorcraftApp) {
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
let mut out = ctx.run_ui(Default::default(), |ui| show(app, ui.ctx()));
out.textures_delta.clear();
}
fn kind(app: &VectorcraftApp) -> Option<&str> {
app.ui.dialog.as_ref().map(|d| d.kind.as_str())
}
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);
png_options::open(&mut app, fileio::format("png").unwrap(), json!({"format": "png", "path": "/out/a.png", "artboard": 1}));
assert_eq!(kind(&app), Some("pngOptions"));
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.f64("ppi", 0.0), d.str("background"), d.str("antiAlias"), d.bool("interlaced")), (72.0, "transparent".into(), "art".into(), false));
assert_eq!(d.fields["__size"], json!([60.0, 40.0]), "the chosen artboard");
frame(&mut app);
// Other resolution and an Other background colour draw their own field and colour button.
set(&mut app, "__otherPpi", json!(true));
set(&mut app, "ppi", json!(200));
set(&mut app, "background", json!("#204080"));
set(&mut app, "antiAlias", json!("type"));
set(&mut app, "interlaced", json!(true));
frame(&mut app);
assert_eq!(confirm(&mut app).unwrap()["path"], "/out/a.png");
assert!(app.ui.dialog.is_none());
let png = written.borrow()[0].1.clone();
assert_eq!(png[28], 1, "interlaced");
let phys = png.windows(4).position(|w| w == b"pHYs").unwrap();
assert_eq!(&png[phys + 4..phys + 8], ((200.0f64 / 0.0254).round() as u32).to_be_bytes());
let img = image::load_from_memory(&png).unwrap().to_rgba8();
assert_eq!(img.dimensions(), (167, 111), "60 × 40 pt at 200 ppi");
assert_eq!(img.get_pixel(0, 0).0, [0x20, 0x40, 0x80, 255]);
// JPEG Options: quality, and no transparent background.
png_options::open(&mut app, fileio::format("jpg").unwrap(), json!({"format": "jpg", "path": "/out/a.jpg"}));
assert_eq!(kind(&app), Some("jpgOptions"));
assert_eq!(app.ui.dialog.as_ref().unwrap().str("background"), "white");
frame(&mut app);
set(&mut app, "quality", json!(40));
confirm(&mut app).unwrap();
assert_eq!(&written.borrow()[1].1[..2], [0xFF, 0xD8]);
}
#[test]
fn export_as_png_picks_the_file_then_shows_png_options() {
let (mut app, written) = app(1);
app.run("file.export.png", json!({})).unwrap();
assert_eq!(kind(&app), Some("pngOptions"));
assert_eq!(app.ui.dialog.as_ref().unwrap().str("path"), "/out/Untitled-1.png");
assert!(written.borrow().is_empty());
app.ui.dialog = None;
let r = app.run("file.export.png", json!({"path": "/x/a.png", "ppi": 144})).unwrap();
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

@ -68,7 +68,7 @@ fn suggested(app: &VectorcraftApp, ext: &str) -> String {
}
/// `path`, else a suggested name (web download) or one picked in a save dialog.
fn target_path(app: &mut VectorcraftApp, path: Option<String>, ext: &str) -> Result<String, String> {
pub(crate) fn target_path(app: &mut VectorcraftApp, path: Option<String>, ext: &str) -> Result<String, String> {
match path {
Some(p) => Ok(p),
None if app.services.download.is_some() => Ok(suggested(app, ext)),
@ -80,11 +80,13 @@ fn target_path(app: &mut VectorcraftApp, path: Option<String>, ext: &str) -> Res
}
/// 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

@ -103,7 +103,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{} opens SVG Options; with params = document.export {path?, svg?: {…SVG options}, range?…} (a .svgz path writes it gzipped)",
),
("file.export.png", "Export As PNG…", "", "{path?, scale?: 1, artboard?} (document.export options)"),
("file.export.png", "Export As PNG…", "", "{path?, …document.export options}; no path: pick the file, then PNG Options"),
("file.exportForScreens", "Export for Screens…", "Cmd+Alt+E", "{} opens the dialog; with params = document.exportForScreens"),
("file.documentSetup", "Document Setup…", "Cmd+Alt+P", "{}"),
("file.newDialog", "New…", "Cmd+N", "{} opens the New Document dialog"),
@ -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());
@ -456,7 +462,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
Ok(Value::Null)
}
"file.exportForScreens" => app.run("document.exportForScreens", p.clone()),
"file.export.png" => io::export(app, Some("png"), s("path"), p).map(|p| json!({"path": p})),
"file.export.png" if s("path").is_none() => io::target_path(app, None, "png").and_then(|path| {
let f = vectorcraft_engine::cmd::fileio::format("png").ok_or("no PNG encoder")?;
let mut params = if p.is_object() { p.clone() } else { json!({}) };
params["format"] = json!("png");
params["path"] = json!(path);
crate::dialogs::open_raster_options(app, f, params);
Ok(Value::Null)
}),
"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());
@ -734,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)
@ -889,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"
@ -968,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"),
@ -1112,7 +1133,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
c("Expand…", "ui.expandDialog"),
c("Expand Appearance", "effect.expandAppearance"),
c("Crop Image", "object.cropImage"),
cp("Rasterize…", "object.rasterize", json!({"ppi": 72, "background": "transparent"})),
cp("Rasterize…", "object.rasterize", json!({"ppi": 72, "background": "transparent", "antiAlias": "art"})),
cp("Create Gradient Mesh…", "object.mesh.create", json!({"rows": 4, "cols": 4, "appearance": "flat", "highlight": 100})),
cp(
"Create Object Mosaic…",

View File

@ -19,10 +19,12 @@
| `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 |
| `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the canvas are placed at the drop point (linked; Shift embeds), elsewhere or with no document open they open. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them |
| `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in the file; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` |
Swatch editors: `engine.execute` with `ui.swatchOptions {name}` opens the `swatchOptions` dialog for a colour swatch
(fields `name`, `spot`, `global`, `mode`: `gray`/`rgb`/`hsb`/`lab`/`cmyk`/`web`, `color`: `"#rrggbb"` or a colour object,

View File

@ -156,8 +156,18 @@ Draw, look, export:
{"name":"draw_path","arguments":{"d":"M72 400 C 150 300 250 500 330 400","stroke":"#e53935","strokeWidth":4,"fill":"none"}}
{"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`.
Long-tail commands:
```json