Files
vectorcraft/crates/engine/src/cmd/fileio/export.rs
T

158 lines
6.9 KiB
Rust

//! `document.export`, `document.serialize`, `document.exportSelection`, `document.exportForScreens`.
use std::collections::HashSet;
use std::sync::Arc;
use serde::Deserialize;
use serde_json::{Value, json};
use vectorcraft_doc::{Node, NodeKind};
use super::super::*;
use super::{ARTBOARD_PARAMS, ArtboardPick, Format, create_dir, encode, writable, write_file, write_or_return};
pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?;
let bytes = encode(&s.doc()?.doc, f.id, p)?;
if f.id == "svg" {
return Ok(json!({ "text": String::from_utf8_lossy(&bytes) }));
}
Ok(json!({ "dataBase64": vectorcraft_format::base64_encode(&bytes) }))
}
pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path");
let f = writable("document.export", str_param(p, "format"), path)?;
let bytes = encode(&s.doc()?.doc, f.id, p)?;
write_or_return(path, &bytes, json!({ "format": f.id }))
}
/// `p` without its artboard choice (for documents made of one synthetic artboard).
fn without_artboards(p: &Value) -> Value {
let mut q = p.clone();
if let Some(o) = q.as_object_mut() {
for k in ARTBOARD_PARAMS {
o.remove(k);
}
}
q
}
/// File → Export Selection: the selected objects alone, cropped to their visual bounds. Objects on
/// template layers are guides, not artwork, and are left out.
pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.exportSelection";
let path = str_param(p, "path");
let f = writable(C, str_param(p, "format"), path)?;
let ids = edit::selected_roots(s)?;
let st = s.doc()?;
let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. }));
// On a template layer or sublayer, at any depth.
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 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()] }))
}
/// One Export for Screens format row: what to write, at which scale, with which file-name suffix.
struct ScreenFormat {
format: &'static Format,
/// The row's own options (scale, quality…) for the encoder.
options: Value,
suffix: String,
}
fn screen_format(row: &Value) -> Result<ScreenFormat> {
const C: &str = "document.exportForScreens";
if !row.is_object() {
return Err(bad(C, "each format is an object {format, scale?, suffix?}"));
}
let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?;
if format.id == "vectorcraft" {
return Err(bad(C, "Export for Screens writes png, jpg, webp, svg or pdf"));
}
// 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 };
let suffix = match str_param(row, "suffix") {
Some(s) if !format.raster && is_scale_suffix(s) => String::new(),
Some(s) => s.to_string(),
None if (scale - 1.0).abs() < 1e-9 => String::new(),
None => format!("@{scale}x"),
};
let mut options = without_artboards(row);
if let Some(o) = options.as_object_mut() {
o.insert("scale".into(), json!(scale));
}
Ok(ScreenFormat { format, options, suffix })
}
/// A pixel-density suffix such as `@2x` or `@0.5x`.
fn is_scale_suffix(s: &str) -> bool {
s.strip_prefix('@').and_then(|s| s.strip_suffix(['x', 'X'])).is_some_and(|n| n.parse::<f64>().is_ok())
}
/// File → Export for Screens: every chosen artboard in every format, one file each (a PDF holds
/// its artboard alone). Artboards that share a name get `-2`, `-3`… instead of overwriting.
pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.exportForScreens";
let doc = s.doc()?.doc.clone();
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) {
Some(rows) => rows.iter().map(screen_format).collect::<Result<_>>()?,
None => vec![screen_format(&json!({}))?],
};
let prefix = str_param(p, "prefix").unwrap_or("");
let folder = str_param(p, "folder");
if let Some(dir) = folder {
create_dir(dir)?;
}
// Names are compared without case: `Icon` and `icon` are one file on most desktop file systems.
let mut names = HashSet::new();
let mut written = HashSet::new();
let mut files = vec![];
for b in boards {
let mut base: String = doc.artboards[b].name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
if base.is_empty() {
base = format!("Artboard-{}", b + 1);
}
let mut name = base.clone();
for i in 2.. {
if names.insert(name.to_lowercase()) {
break;
}
name = format!("{base}-{i}");
}
for sf in &formats {
let file = format!("{prefix}{name}{}.{}", sf.suffix, sf.format.extensions[0]);
if !written.insert(file.to_lowercase()) {
continue; // the same file from two identical rows
}
let mut options = sf.options.clone();
options["artboard"] = json!(b);
let bytes = encode(&doc, sf.format.id, &options)?;
match folder {
Some(dir) => {
let path = format!("{dir}/{file}");
write_file(&path, &bytes)?;
files.push(json!(path));
}
None => files.push(json!({ "name": file, "dataBase64": vectorcraft_format::base64_encode(&bytes) })),
}
}
}
Ok(json!({ "files": files }))
}