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:
commit
d6ebecee39
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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))),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
|
|
@ -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) });
|
||||
|
|
|
|||
|
|
@ -22,3 +22,4 @@ log = { workspace = true }
|
|||
[dev-dependencies]
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ pub const DEFAULT_ADDR: &str = "127.0.0.1:7979";
|
|||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_exportas;
|
||||
#[cfg(test)]
|
||||
mod tests_fileio;
|
||||
#[cfg(test)]
|
||||
mod tests_freeform;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
//! Export As through MCP: `options` carries `useArtboards` and the raster options to
|
||||
//! `document.export`, so a range writes one file per artboard.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Backend, Headless, call_tool};
|
||||
|
||||
fn text(r: &crate::ToolResult) -> Value {
|
||||
assert!(!r.is_error, "{r:?}");
|
||||
serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn three_boards() -> Headless {
|
||||
let mut h = Headless::new();
|
||||
h.call("engine.execute", json!({"command": "file.new", "params": {"width": 50, "height": 30, "artboards": 3}})).unwrap();
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_with_a_range_writes_one_file_per_artboard() {
|
||||
let mut h = three_boards();
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-mcp-exportas-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("icon.png").to_string_lossy().to_string();
|
||||
let args = json!({"path": path, "range": "1, 3", "options": {"useArtboards": true, "ppi": 144, "background": "white"}});
|
||||
let r = text(&call_tool(&mut h, "export", &args));
|
||||
let files = r["files"].as_array().unwrap();
|
||||
assert_eq!(files.len(), 2, "{r}");
|
||||
for (f, board) in files.iter().zip(["Artboard-1", "Artboard-3"]) {
|
||||
let p = f.as_str().unwrap();
|
||||
assert!(p.ends_with(&format!("icon-{board}.png")), "{p}");
|
||||
let img = image::load_from_memory(&std::fs::read(p).unwrap()).unwrap().to_rgba8();
|
||||
assert_eq!(img.dimensions(), (100, 60), "144 ppi");
|
||||
assert!(img.pixels().all(|px| px[3] == 255), "white background");
|
||||
}
|
||||
// Without a path the files come back as bytes.
|
||||
let r = text(&call_tool(&mut h, "export", &json!({"format": "jpg", "options": {"useArtboards": true}})));
|
||||
let names: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["Untitled-1-Artboard-1.jpg", "Untitled-1-Artboard-2.jpg", "Untitled-1-Artboard-3.jpg"]);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -304,7 +304,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
|
|||
"app.open" => wrap(app.run("file.open", json!({"path": s("path")}))),
|
||||
"app.save" => wrap(app.run("file.save", if p.is_object() { p.clone() } else { json!({}) })),
|
||||
"app.export" => match s("path") {
|
||||
Some(path) => wrap(crate::io::export(app, s("format"), Some(path.to_string()), p).map(|p| json!({"path": p}))),
|
||||
Some(path) => wrap(crate::io::export(app, s("format"), Some(path.to_string()), p)),
|
||||
// No save dialog for an agent: the bytes come back, as in headless mode.
|
||||
None => wrap(app.run("document.export", p.clone())),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
//! File → Export → Export As…: the format and Use Artboards (All or a range: one file per
|
||||
//! artboard, or one page each in a PDF); without artboards the export covers the visible art. OK
|
||||
//! picks the file, then the format's options dialog follows (PNG/JPEG/WebP Options, SVG Options,
|
||||
//! and Save PDF for artboards as pages), else the file is written.
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, Format};
|
||||
|
||||
use super::{DialogSpec, form, png_options, save_pdf, svg_options};
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, io, widgets};
|
||||
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| "Export As".into(), body, confirm, ok: Some("Export…"), min_width: 380.0, ..DialogSpec::FORM };
|
||||
|
||||
/// The formats Export As writes (the native format is Save's).
|
||||
static FORMATS: LazyLock<Vec<&'static Format>> = LazyLock::new(|| fileio::FORMATS.iter().filter(|f| f.write && f.id != "vectorcraft").collect());
|
||||
static LABELS: LazyLock<Vec<&'static str>> = LazyLock::new(|| FORMATS.iter().map(|f| f.label).collect());
|
||||
|
||||
/// Open the dialog on `format` (an id or extension, default PNG); `p` may preset `useArtboards`
|
||||
/// and `range`.
|
||||
pub fn open(app: &mut VectorcraftApp, format: Option<&str>, p: &Value) {
|
||||
let n = app.session.active().map_or(0, |s| s.doc.artboards.len());
|
||||
let format = format.and_then(fileio::format).filter(|f| FORMATS.iter().any(|g| g.id == f.id)).map_or("png", |f| f.id);
|
||||
let range = p.get("range").and_then(Value::as_str);
|
||||
app.ui.dialog = Some(Dialog::new(
|
||||
"exportAs",
|
||||
json!({
|
||||
"format": format,
|
||||
"useArtboards": p.get("useArtboards").and_then(Value::as_bool).unwrap_or(false),
|
||||
"all": range.is_none(),
|
||||
"range": range.map_or_else(|| if n > 1 { format!("1-{n}") } else { "1".into() }, str::to_string),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
/// The chosen format (PNG when the field names none Export As writes).
|
||||
fn chosen(d: &Dialog) -> Option<&'static Format> {
|
||||
let id = d.str("format");
|
||||
let find = |id: &str| FORMATS.iter().copied().find(|f| f.id == id);
|
||||
find(&id).or_else(|| find("png"))
|
||||
}
|
||||
|
||||
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some(f) = chosen(d) else { return false };
|
||||
egui::Grid::new("export-as").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
|
||||
ui.label(egui::RichText::new("Format:").color(t.text_dim));
|
||||
if let Some(i) = widgets::dropdown(ui, "export-as-format", f.label, &LABELS, 160.0) {
|
||||
d.fields.insert("format".into(), json!(FORMATS[i].id));
|
||||
}
|
||||
ui.end_row();
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
form::check(ui, d, "useArtboards", "Use Artboards");
|
||||
let use_artboards = d.bool("useArtboards");
|
||||
ui.add_enabled_ui(use_artboards, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(22.0);
|
||||
let mut all = d.bool("all");
|
||||
if ui.radio_value(&mut all, true, "All").changed() | ui.radio_value(&mut all, false, "Range:").changed() {
|
||||
d.fields.insert("all".into(), json!(all));
|
||||
}
|
||||
let mut range = d.str("range");
|
||||
if ui.add_enabled(!all, egui::TextEdit::singleline(&mut range).desired_width(90.0)).changed() {
|
||||
d.fields.insert("range".into(), json!(range));
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
let ext = f.extensions[0];
|
||||
let hint = match (use_artboards, f.id) {
|
||||
(true, "pdf") => "One page per artboard.".to_string(),
|
||||
(true, _) => format!("One file per artboard: <name>-<artboard>.{ext}"),
|
||||
(false, _) => "The bounds of the visible art.".to_string(),
|
||||
};
|
||||
ui.label(egui::RichText::new(hint).color(t.text_dim).size(11.5));
|
||||
false
|
||||
}
|
||||
|
||||
/// Check the artboard range, pick the file, then show the format's options or write the file.
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let f = chosen(d).ok_or("no format to export to")?;
|
||||
let use_artboards = d.bool("useArtboards");
|
||||
let all = d.bool("all");
|
||||
let range = d.str("range");
|
||||
if use_artboards && !all {
|
||||
// A bad range keeps the dialog open.
|
||||
let n = app.session.active().map_or(0, |s| s.doc.artboards.len());
|
||||
ArtboardPick { range: Some(range.clone()), ..Default::default() }.resolve(n)?;
|
||||
}
|
||||
// Nothing to export keeps the dialog open too (before the save dialog, not after it).
|
||||
if !use_artboards && app.session.active().is_some_and(|s| vectorcraft_render::encode::art_bounds(&s.doc).is_none()) {
|
||||
return Err("nothing to export: the document has no visible art (or turn on Use Artboards)".into());
|
||||
}
|
||||
app.ui.dialog = None;
|
||||
let path = io::target_path(app, None, f.extensions[0])?;
|
||||
let range = if all { "all".to_string() } else { range };
|
||||
match f.id {
|
||||
"svg" | "svgz" => {
|
||||
svg_options::open(app, svg_options::Mode::Export, Some(&path));
|
||||
if let Some(o) = app.ui.dialog.as_mut() {
|
||||
o.fields.extend([("useArtboards".into(), json!(use_artboards)), ("allArtboards".into(), json!(all)), ("range".into(), json!(range))]);
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
// Artboards as pages go on in Save PDF; the art's bounds are written straight away.
|
||||
"pdf" if use_artboards => save_pdf::open(app, &json!({ "path": path, "range": (!all).then_some(range) })),
|
||||
_ => {
|
||||
let mut params = json!({ "format": f.id, "path": path, "useArtboards": use_artboards });
|
||||
if use_artboards {
|
||||
params["range"] = json!(range);
|
||||
}
|
||||
if f.raster {
|
||||
png_options::open(app, f, params);
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
io::export(app, Some(f.id), Some(path), ¶ms)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ pub mod confirm;
|
|||
mod document_setup;
|
||||
mod effect;
|
||||
pub mod expand;
|
||||
mod export_as;
|
||||
mod export_for_screens;
|
||||
pub mod eyedropper;
|
||||
pub mod flatten;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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, ¶ms));
|
||||
// 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))
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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…",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
10
docs/mcp.md
10
docs/mcp.md
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue