M4.51: Export for Screens downloads on the web, picks folders, zips
document.exportForScreens takes zip: true to pack every file into one store-only .zip (returned as dataBase64 without a folder, written there with one); without a folder the files come back as base64. The engine's Export for Screens moves to fileio/screens.rs, with a small ZIP writer (fileio/zip.rs). The dialog starts in the user's Desktop (else home, on every OS), has a folder picker button (the Services.pick_folder the Links panel uses) and stays open when the export fails. On the web it has no folder: OK reads "Download" (DialogSpec.ok_label) and the files download, several as one zip. file.exportForScreens with params goes through io::export_for_screens. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8ab7237443
commit
902d95173e
|
|
@ -1,10 +1,9 @@
|
|||
//! `document.export`, `document.serialize`, `document.exportSelection`, `document.exportForScreens`.
|
||||
//! `document.export`, `document.serialize`, `document.exportSelection`, `document.exportForOffice`.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
|
||||
|
|
@ -12,10 +11,7 @@ use crate::DocState;
|
|||
|
||||
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,
|
||||
};
|
||||
use super::{ARTBOARD_PARAMS, Format, default_name, encode, encode_all, merge, writable, write_encoded, 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)?;
|
||||
|
|
@ -139,100 +135,6 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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 matches!(format.id, "vectorcraft" | "template" | "dxf") {
|
||||
return Err(bad(C, "Export for Screens writes png, jpg, webp, gif, png8, svg, svgz 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).
|
||||
// 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(),
|
||||
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));
|
||||
// A file per artboard is for viewing: it doesn't carry the whole document to edit.
|
||||
o.entry("preserveEditing").or_insert(json!(false));
|
||||
}
|
||||
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 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 {
|
||||
create_dir(dir)?;
|
||||
}
|
||||
let mut written = HashSet::new();
|
||||
let mut files = vec![];
|
||||
for (b, name) in boards.iter().copied().zip(artboard_file_names(&doc, &boards)) {
|
||||
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 }))
|
||||
}
|
||||
|
||||
/// File → Save for Office Documents: one artboard as a PNG at `ppi`, on white unless
|
||||
/// `transparent`.
|
||||
pub(super) fn export_for_office(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@
|
|||
//! - `load`: `document.open` (native, legacy, SVG/SVGZ, PDF/.ai/.ait, raster images).
|
||||
//! - `encode`: one encoder per writable format, with typed options parsed from the params.
|
||||
//! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…).
|
||||
//! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForScreens`.
|
||||
//! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForOffice`.
|
||||
//! - `screens`: Export for Screens (`document.exportForScreens`); `zip`
|
||||
//! packs its files as one download.
|
||||
//! - `save`: `document.save`, Save As / a Copy / as Template, New from Template, Revert and the
|
||||
//! per-format options (`file.formatOptions`); [`save_with`] is the one save path of every frontend.
|
||||
//! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`.
|
||||
|
|
@ -26,8 +28,10 @@ mod pdfimport;
|
|||
pub mod pngtext;
|
||||
pub mod ppi;
|
||||
mod save;
|
||||
mod screens;
|
||||
mod svg;
|
||||
mod text;
|
||||
mod zip;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -42,7 +46,8 @@ pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_byt
|
|||
pub use native::with_compression_pref;
|
||||
pub use pdfimport::{LoadOptions, page_document};
|
||||
pub use save::{
|
||||
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, templates_folder,
|
||||
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, export_folder, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates,
|
||||
templates_folder,
|
||||
};
|
||||
pub use svg::options_map as svg_options;
|
||||
/// Atomic file writes (a temporary file renamed over the target: a failed write never damages the
|
||||
|
|
@ -96,9 +101,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Export for Screens",
|
||||
["File", "Export"],
|
||||
None,
|
||||
"{folder?, artboards?: [index…] | range?: \"1-3\" (default all), formats?: [{format: png|jpg|webp|gif|png8|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}]}",
|
||||
"{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), formats?: [{format: png|jpg|webp|gif|png8|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
|
||||
screens::export_for_screens
|
||||
),
|
||||
cmd!(query "command.batch", "Batch", [], None, "{label?, commands: [{command, params}]} run several commands as ONE undo step; stops at the first error and rolls back", has_doc, batch::batch),
|
||||
cmd!(
|
||||
|
|
@ -675,6 +680,8 @@ mod tests_jpeg;
|
|||
#[cfg(test)]
|
||||
mod tests_palette;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_screens;
|
||||
#[cfg(test)]
|
||||
mod tests_text;
|
||||
|
||||
|
|
|
|||
|
|
@ -103,8 +103,20 @@ pub fn templates_folder(prefs: &Prefs) -> Option<String> {
|
|||
if !prefs.templates_folder.is_empty() {
|
||||
return Some(prefs.templates_folder.clone());
|
||||
}
|
||||
let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).ok().filter(|h| !h.is_empty())?;
|
||||
Some(std::path::Path::new(&home).join("Documents").join("VectorCraft Templates").to_string_lossy().to_string())
|
||||
Some(std::path::Path::new(&home_folder()?).join("Documents").join("VectorCraft Templates").to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// The user's home folder (`HOME`, else `USERPROFILE` on Windows; none on the web).
|
||||
fn home_folder() -> Option<String> {
|
||||
std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).ok().filter(|h| !h.is_empty())
|
||||
}
|
||||
|
||||
/// Where exports go by default (Export for Screens): the user's Desktop when there is one, else
|
||||
/// their home folder (none on the web, which downloads).
|
||||
pub fn export_folder() -> Option<String> {
|
||||
let home = home_folder()?;
|
||||
let desktop = std::path::Path::new(&home).join("Desktop");
|
||||
Some(if desktop.is_dir() { desktop.to_string_lossy().into_owned() } else { home })
|
||||
}
|
||||
|
||||
/// The folder of a path (`None` for a bare file name).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
//! File → Export → Export for Screens (`document.exportForScreens`): the chosen artboards in every
|
||||
//! format row, into a folder or returned for download (each file, or one ZIP).
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::*;
|
||||
use super::export::without_artboards;
|
||||
use super::{ArtboardPick, Format, artboard_file_names, create_dir, encode, writable, write_file};
|
||||
|
||||
const C: &str = "document.exportForScreens";
|
||||
|
||||
/// 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> {
|
||||
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 matches!(format.id, "vectorcraft" | "template" | "dxf") {
|
||||
return Err(bad(C, "Export for Screens writes png, jpg, webp, gif, png8, svg, svgz 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).
|
||||
// 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(),
|
||||
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));
|
||||
// A file per artboard is for viewing: it doesn't carry the whole document to edit.
|
||||
o.entry("preserveEditing").or_insert(json!(false));
|
||||
}
|
||||
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())
|
||||
}
|
||||
|
||||
/// A name safe as one file name: letters, digits, `-`, `_` and `.` kept, anything else `-` (`None`
|
||||
/// when nothing but dots is left).
|
||||
fn safe_name(s: &str) -> Option<String> {
|
||||
let n: String = s.trim().chars().map(|c| if c.is_alphanumeric() || "-_.".contains(c) { c } else { '-' }).collect();
|
||||
(!n.trim_matches('.').is_empty()).then_some(n)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
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 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").filter(|f| !f.is_empty());
|
||||
let zip = bool_or(p, "zip", false);
|
||||
if let Some(dir) = folder {
|
||||
create_dir(dir)?;
|
||||
}
|
||||
let mut written = HashSet::new();
|
||||
// Written paths, or (name, bytes) to return or zip.
|
||||
let mut paths = vec![];
|
||||
let mut kept: Vec<(String, Vec<u8>)> = vec![];
|
||||
for (b, name) in boards.iter().copied().zip(artboard_file_names(&doc, &boards)) {
|
||||
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) if !zip => {
|
||||
let path = format!("{dir}/{file}");
|
||||
write_file(&path, &bytes)?;
|
||||
paths.push(path);
|
||||
}
|
||||
_ => kept.push((file, bytes)),
|
||||
}
|
||||
}
|
||||
}
|
||||
let names: Vec<&str> = kept.iter().map(|(n, _)| n.as_str()).collect();
|
||||
if zip {
|
||||
let archive = super::zip::store(&kept).map_err(|e| bad(C, e))?;
|
||||
let zip_name = format!("{prefix}{}.zip", safe_name(&super::file_stem(&doc.title)).unwrap_or_else(|| "Untitled".into()));
|
||||
return match folder {
|
||||
Some(dir) => {
|
||||
let path = format!("{dir}/{zip_name}");
|
||||
write_file(&path, &archive)?;
|
||||
Ok(json!({ "path": path, "bytes": archive.len(), "files": names }))
|
||||
}
|
||||
None => {
|
||||
Ok(json!({ "name": zip_name, "dataBase64": vectorcraft_format::base64_encode(&archive), "bytes": archive.len(), "files": names }))
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok(match folder {
|
||||
Some(_) => json!({ "files": paths }),
|
||||
None => {
|
||||
json!({ "files": kept.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect::<Vec<_>>() })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
//! Export for Screens: files returned for download, one by one or as a ZIP.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn b64(v: &Value) -> Vec<u8> {
|
||||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
|
||||
}
|
||||
|
||||
/// A document of `artboards` 40×30 pt artboards side by side with a 20 pt square on the first.
|
||||
fn session(artboards: usize) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 40, "height": 30, "artboards": artboards})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 20})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: Value) -> Value {
|
||||
s.execute("document.exportForScreens", &p).unwrap()
|
||||
}
|
||||
|
||||
/// `(name, bytes)` of each returned file.
|
||||
fn files(r: &Value) -> Vec<(String, Vec<u8>)> {
|
||||
r["files"].as_array().unwrap().iter().map(|f| (f["name"].as_str().unwrap().to_string(), b64(f))).collect()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_zip_is_written_into_the_folder() {
|
||||
let mut s = session(1);
|
||||
let dir = std::env::temp_dir().join(format!("vc-screens-zip-{}", std::process::id()));
|
||||
let folder = dir.to_string_lossy().replace('\\', "/");
|
||||
let r = export(&mut s, json!({"folder": folder, "zip": true, "formats": [{"format": "png"}, {"format": "svg"}]}));
|
||||
let zip = std::fs::read(r["path"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(super::zip::entries(&zip).unwrap().len(), 2);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zip_downloads_round_trip() {
|
||||
let mut s = session(2);
|
||||
let p = json!({"formats": [{"format": "png"}, {"format": "png", "scale": 2}, {"format": "pdf"}]});
|
||||
let plain = files(&export(&mut s, p.clone()));
|
||||
let mut q = p;
|
||||
q["zip"] = json!(true);
|
||||
let r = export(&mut s, q);
|
||||
let title = super::file_stem(&s.doc().unwrap().doc.title);
|
||||
assert_eq!(r["name"], format!("{title}.zip"));
|
||||
let zip = b64(&r);
|
||||
assert_eq!(r["bytes"].as_u64(), Some(zip.len() as u64));
|
||||
let entries = super::zip::entries(&zip).unwrap();
|
||||
let names = |files: &[(String, Vec<u8>)]| files.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(names(&entries), names(&plain), "the same files, sub-folders in their names");
|
||||
for ((name, zipped), (_, returned)) in entries.iter().zip(&plain) {
|
||||
if name.ends_with(".pdf") {
|
||||
// A PDF carries the time it was written.
|
||||
assert_eq!(vectorcraft_pdf::import(zipped).unwrap().artboards.len(), 1, "{name}");
|
||||
} else {
|
||||
assert_eq!(zipped, returned, "{name}");
|
||||
}
|
||||
}
|
||||
assert_eq!(r["files"].as_array().unwrap().len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zip_archives_store_their_files() {
|
||||
let files = [("a.txt", b"hello".to_vec()), ("sub/ünï.png", vec![0u8, 1, 2, 255]), ("empty", vec![])];
|
||||
let zip = super::zip::store(&files).unwrap();
|
||||
let back = super::zip::entries(&zip).unwrap();
|
||||
assert_eq!(back.len(), 3);
|
||||
for ((n, b), (m, c)) in files.iter().zip(&back) {
|
||||
assert_eq!((*n, b), (m.as_str(), c));
|
||||
}
|
||||
assert_eq!(super::zip::entries(&super::zip::store::<&str, Vec<u8>>(&[]).unwrap()).unwrap(), vec![]);
|
||||
let mut broken = zip.clone();
|
||||
let at = broken.windows(5).position(|w| w == b"hello").unwrap();
|
||||
broken[at] = b'j';
|
||||
assert!(super::zip::entries(&broken).is_err(), "the CRC catches a changed byte");
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
//! A store-only (uncompressed) ZIP archive: several exported files as one download. The files are
|
||||
//! already compressed (PNG, JPEG, PDF…), so storing them keeps the archive fast to write and read.
|
||||
|
||||
use flate2::Crc;
|
||||
|
||||
/// The DOS date of every entry: 1980-01-01, the earliest a ZIP date can say (no clock on the web,
|
||||
/// and a fixed date keeps archives of the same files identical).
|
||||
const DOS_DATE: u16 = (1 << 5) | 1;
|
||||
/// General purpose flag: file names are UTF-8.
|
||||
const UTF8_NAMES: u16 = 1 << 11;
|
||||
/// Version needed to extract: 2.0.
|
||||
const VERSION: u16 = 20;
|
||||
|
||||
/// The archive of `files` (`(name, bytes)`; `/` separates folders in a name), stored without
|
||||
/// compression. Fails past the limits of a ZIP without its 64-bit extension (65535 files, 4 GB).
|
||||
pub fn store<N: AsRef<str>, B: AsRef<[u8]>>(files: &[(N, B)]) -> Result<Vec<u8>, String> {
|
||||
let too_big = || "too much to zip: at most 65535 files and 4 GB".to_string();
|
||||
let count = u16::try_from(files.len()).map_err(|_| too_big())?;
|
||||
let mut out = Vec::with_capacity(files.iter().map(|(n, b)| 76 + 2 * n.as_ref().len() + b.as_ref().len()).sum::<usize>() + 22);
|
||||
let mut central = Vec::new();
|
||||
for (name, bytes) in files {
|
||||
let (name, bytes) = (name.as_ref().as_bytes(), bytes.as_ref());
|
||||
let offset = u32::try_from(out.len()).map_err(|_| too_big())?;
|
||||
let size = u32::try_from(bytes.len()).map_err(|_| too_big())?;
|
||||
let name_len = u16::try_from(name.len()).map_err(|_| format!("file name too long to zip: {}", String::from_utf8_lossy(name)))?;
|
||||
let mut crc = Crc::new();
|
||||
crc.update(bytes);
|
||||
// The fields both headers share: flags, method (0: stored), time, date, CRC, sizes, name length.
|
||||
let mut shared = Vec::with_capacity(26);
|
||||
for v in [UTF8_NAMES, 0, 0, DOS_DATE] {
|
||||
shared.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
for v in [crc.sum(), size, size] {
|
||||
shared.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
shared.extend_from_slice(&name_len.to_le_bytes());
|
||||
// Local file header.
|
||||
out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
|
||||
out.extend_from_slice(&VERSION.to_le_bytes());
|
||||
out.extend_from_slice(&shared);
|
||||
out.extend_from_slice(&0u16.to_le_bytes()); // extra field length
|
||||
out.extend_from_slice(name);
|
||||
out.extend_from_slice(bytes);
|
||||
// Central directory record.
|
||||
central.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
|
||||
central.extend_from_slice(&VERSION.to_le_bytes()); // made by
|
||||
central.extend_from_slice(&VERSION.to_le_bytes()); // needed to extract
|
||||
central.extend_from_slice(&shared);
|
||||
// Extra and comment lengths, disk number, internal and external attributes.
|
||||
central.extend_from_slice(&[0; 2 + 2 + 2 + 2 + 4]);
|
||||
central.extend_from_slice(&offset.to_le_bytes());
|
||||
central.extend_from_slice(name);
|
||||
}
|
||||
let start = u32::try_from(out.len()).map_err(|_| too_big())?;
|
||||
let central_len = u32::try_from(central.len()).map_err(|_| too_big())?;
|
||||
out.extend_from_slice(¢ral);
|
||||
// End of central directory.
|
||||
out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
|
||||
out.extend_from_slice(&[0; 4]); // this disk, the directory's disk
|
||||
out.extend_from_slice(&count.to_le_bytes());
|
||||
out.extend_from_slice(&count.to_le_bytes());
|
||||
out.extend_from_slice(¢ral_len.to_le_bytes());
|
||||
out.extend_from_slice(&start.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes()); // comment length
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The entries of a store-only archive written by [`store`] (read through its central directory,
|
||||
/// checking every CRC) → `(name, bytes)`.
|
||||
#[cfg(test)]
|
||||
pub fn entries(zip: &[u8]) -> Result<Vec<(String, Vec<u8>)>, String> {
|
||||
let u16_at = |at: usize| zip.get(at..at + 2).map(|b| u16::from_le_bytes([b[0], b[1]])).ok_or("truncated");
|
||||
let u32_at = |at: usize| zip.get(at..at + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])).ok_or("truncated");
|
||||
let end = zip.len().checked_sub(22).ok_or("no end record")?;
|
||||
if u32_at(end)? != 0x0605_4b50 {
|
||||
return Err("no end record".into());
|
||||
}
|
||||
let count = u16_at(end + 10)? as usize;
|
||||
let mut at = u32_at(end + 16)? as usize;
|
||||
let mut out = vec![];
|
||||
for _ in 0..count {
|
||||
if u32_at(at)? != 0x0201_4b50 || u16_at(at + 10)? != 0 {
|
||||
return Err("not a stored central record".into());
|
||||
}
|
||||
let (crc, size, name_len) = (u32_at(at + 16)?, u32_at(at + 20)? as usize, u16_at(at + 28)? as usize);
|
||||
let extra = u16_at(at + 30)? as usize + u16_at(at + 32)? as usize;
|
||||
let local = u32_at(at + 42)? as usize;
|
||||
let name = String::from_utf8(zip.get(at + 46..at + 46 + name_len).ok_or("truncated")?.to_vec()).map_err(|e| e.to_string())?;
|
||||
if u32_at(local)? != 0x0403_4b50 {
|
||||
return Err(format!("{name}: no local header"));
|
||||
}
|
||||
let data = local + 30 + u16_at(local + 26)? as usize + u16_at(local + 28)? as usize;
|
||||
let bytes = zip.get(data..data + size).ok_or("truncated")?.to_vec();
|
||||
let mut c = Crc::new();
|
||||
c.update(&bytes);
|
||||
if c.sum() != crc {
|
||||
return Err(format!("{name}: CRC mismatch"));
|
||||
}
|
||||
out.push((name, bytes));
|
||||
at += 46 + name_len + extra;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
|
@ -32,7 +32,11 @@ const HOST_COMMANDS: &[(&str, &str, &str)] = &[
|
|||
"{path?, format?, options?, svg?: {…SVG options}} = document.save: .vectorcraft, .ai (a PDF that reopens editable), .pdf, .svg or .svgz (default: the document's own path and format)",
|
||||
),
|
||||
("file.export", "Export…", "{path?, format?, artboard?, range?, scale?, …} = document.export (no path → dataBase64)"),
|
||||
("file.exportForScreens", "Export for Screens…", "{folder?, artboards? | range?, formats?, prefix?} = document.exportForScreens"),
|
||||
(
|
||||
"file.exportForScreens",
|
||||
"Export for Screens…",
|
||||
"{folder?, zip?, artboards? | range?, formats?, prefix?} = document.exportForScreens (no folder → the files, or one zip, as dataBase64)",
|
||||
),
|
||||
("tool.select", "Select Tool", "{tool} e.g. selection, directSelection, pen, rectangle, ellipse, polygon, star, lineSegment"),
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,37 @@
|
|||
//! File → Export → Export for Screens: artboards (thumbnails and checkboxes), scale/format rows,
|
||||
//! destination folder and file-name prefix.
|
||||
//! the destination folder (typed or picked; the web downloads instead) and file-name prefix.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
|
||||
use super::DialogSpec;
|
||||
use crate::VectorcraftApp;
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, io, widgets};
|
||||
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| "Export for Screens".into(), body, confirm, ok: Some("Export Artboard"), ..DialogSpec::FORM };
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
heading: |_| "Export for Screens".into(),
|
||||
body,
|
||||
confirm,
|
||||
ok: Some("Export Artboard"),
|
||||
ok_label: Some(|app| if io::is_web(app) { "Download" } else { "Export Artboard" }),
|
||||
..DialogSpec::FORM
|
||||
};
|
||||
|
||||
/// `Dialog::kind` of this dialog.
|
||||
pub const KIND: &str = "exportForScreens";
|
||||
|
||||
/// Open the dialog: every artboard, PNG at 1x and 2x, into the Desktop (else the home folder).
|
||||
pub fn open(app: &mut VectorcraftApp) {
|
||||
let n = app.session.active().map_or(0, |d| d.doc.artboards.len());
|
||||
app.ui.dialog = Some(Dialog::new(
|
||||
KIND,
|
||||
json!({"boards": vec![true; n], "formats": [{"format": "png", "scale": 1, "suffix": ""}, {"format": "png", "scale": 2, "suffix": "@2x"}], "folder": fileio::export_folder().unwrap_or_default(), "prefix": ""}),
|
||||
));
|
||||
}
|
||||
|
||||
/// Export the checked artboards (the web: no folder, one ZIP for several files); the dialog stays
|
||||
/// open when the export fails.
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let boards: Vec<usize> = d
|
||||
.fields
|
||||
|
|
@ -18,12 +39,21 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
|||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().enumerate().filter(|(_, b)| b.as_bool() == Some(true)).map(|(i, _)| i).collect())
|
||||
.unwrap_or_default();
|
||||
let params = json!({"folder": d.str("folder"), "artboards": boards, "formats": d.fields.get("formats").cloned().unwrap_or(json!([])), "prefix": d.str("prefix")});
|
||||
app.ui.dialog = None;
|
||||
let r = app.run("document.exportForScreens", params);
|
||||
if let Ok(v) = &r {
|
||||
let n = v["files"].as_array().map(|a| a.len()).unwrap_or(0);
|
||||
app.status(format!("Exported {n} file(s) to {}", d.str("folder")));
|
||||
let formats = d.fields.get("formats").cloned().unwrap_or(json!([]));
|
||||
let files = boards.len() * formats.as_array().map_or(0, Vec::len);
|
||||
let mut params = json!({"artboards": boards, "formats": formats, "prefix": d.str("prefix")});
|
||||
if io::is_web(app) {
|
||||
params["zip"] = json!(files > 1);
|
||||
} else {
|
||||
let folder = d.str("folder");
|
||||
if folder.trim().is_empty() {
|
||||
return Err("choose a folder to export to".into());
|
||||
}
|
||||
params["folder"] = json!(folder.trim());
|
||||
}
|
||||
let r = io::export_for_screens(app, params);
|
||||
if r.is_ok() {
|
||||
app.ui.dialog = None;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
|
@ -77,9 +107,22 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
|||
ui.vertical(|ui| {
|
||||
ui.set_width(330.0);
|
||||
ui.label(egui::RichText::new("Export to").color(t.text));
|
||||
let mut folder = d.str("folder");
|
||||
if ui.add(egui::TextEdit::singleline(&mut folder).desired_width(320.0)).changed() {
|
||||
d.fields.insert("folder".into(), json!(folder));
|
||||
if io::is_web(app) {
|
||||
widgets::dim_label(ui, "Your browser downloads the files (several as one .zip).");
|
||||
} else {
|
||||
let pick = app.services.pick_folder.is_some();
|
||||
ui.horizontal(|ui| {
|
||||
let mut folder = d.str("folder");
|
||||
if ui.add(egui::TextEdit::singleline(&mut folder).desired_width(if pick { 290.0 } else { 320.0 })).changed() {
|
||||
d.fields.insert("folder".into(), json!(folder));
|
||||
}
|
||||
if pick
|
||||
&& widgets::icon_button(ui, "folder-open", "Choose a folder", false, 26.0).clicked()
|
||||
&& let Some(f) = app.services.pick_folder.as_mut().and_then(|pick| pick())
|
||||
{
|
||||
d.fields.insert("folder".into(), json!(f));
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.label(egui::RichText::new("Formats").color(t.text));
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ 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 export_for_screens::open as open_export_for_screens;
|
||||
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, open_preset as open_pdf_preset};
|
||||
|
|
@ -97,6 +98,9 @@ pub(crate) struct DialogSpec {
|
|||
pub max_width: Option<f32>,
|
||||
/// The body runs a live preview interaction that Cancel rolls back.
|
||||
pub preview: bool,
|
||||
/// The OK button's label when it depends on the app (Export for Screens' "Download" on the
|
||||
/// web); `ok` when none. Only with an `ok`.
|
||||
pub ok_label: Option<fn(&VectorcraftApp) -> &'static str>,
|
||||
}
|
||||
|
||||
impl DialogSpec {
|
||||
|
|
@ -117,6 +121,7 @@ impl DialogSpec {
|
|||
min_width: 320.0,
|
||||
max_width: None,
|
||||
preview: false,
|
||||
ok_label: None,
|
||||
};
|
||||
|
||||
/// A dialog that draws its own window and confirms through its module.
|
||||
|
|
@ -285,7 +290,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
|||
// all the height left in the window, so the window could never shrink to its content.
|
||||
let row = egui::vec2(ui.available_width(), ui.spacing().interact_size.y);
|
||||
ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if let Some(label) = spec.ok
|
||||
if let Some(label) = spec.ok.map(|ok| spec.ok_label.map_or(ok, |f| f(app)))
|
||||
&& widgets::primary_button(ui, label).clicked()
|
||||
{
|
||||
ok = true;
|
||||
|
|
@ -328,3 +333,5 @@ mod tests_raster_formats;
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests_dxf;
|
||||
#[cfg(test)]
|
||||
mod tests_screens;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
//! Export for Screens, drawn headlessly: desktop folders and web downloads.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
|
||||
use super::tests_export::{frame, kind, set};
|
||||
use super::*;
|
||||
use crate::Services;
|
||||
|
||||
type Log = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// An app with `artboards` 60×40 pt artboards and an ellipse on the first; `web` gives it a
|
||||
/// download service (recorded in the log) instead of a folder picker.
|
||||
fn app(artboards: usize, web: bool) -> (VectorcraftApp, Log) {
|
||||
let log = Log::default();
|
||||
let l = log.clone();
|
||||
let mut services = Services::default();
|
||||
if web {
|
||||
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| l.borrow_mut().push((name.to_string(), bytes.to_vec()))));
|
||||
} else {
|
||||
services.pick_folder = Some(Box::new(|| None));
|
||||
}
|
||||
let mut app = VectorcraftApp::new(Session::new(), services);
|
||||
app.run("file.new", json!({"width": 60, "height": 40, "artboards": artboards})).unwrap();
|
||||
app.run("shape.ellipse", json!({"x": 5, "y": 4, "width": 30, "height": 20})).unwrap();
|
||||
(app, log)
|
||||
}
|
||||
|
||||
fn field(app: &VectorcraftApp, k: &str) -> Value {
|
||||
app.ui.dialog.as_ref().expect("a dialog is open").fields.get(k).cloned().unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
fn open(app: &mut VectorcraftApp) {
|
||||
app.run("file.exportForScreens", json!({})).unwrap();
|
||||
assert_eq!(kind(app), Some(export_for_screens::KIND));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_into_the_folder() {
|
||||
let (mut app, _) = app(2, false);
|
||||
open(&mut app);
|
||||
assert_eq!(field(&app, "folder"), json!(fileio::export_folder().unwrap_or_default()), "the Desktop, else home");
|
||||
assert_eq!((spec(export_for_screens::KIND).ok_label.unwrap())(&app), "Export Artboard");
|
||||
frame(&mut app);
|
||||
let dir = std::env::temp_dir().join(format!("vc-efs-desk-{}", std::process::id()));
|
||||
let folder = dir.to_string_lossy().replace('\\', "/");
|
||||
set(&mut app, "boards", json!([false, true]));
|
||||
set(&mut app, "formats", json!([{"format": "png", "scale": 1, "suffix": ""}, {"format": "svg", "scale": 1, "suffix": ""}]));
|
||||
// No folder keeps the dialog open.
|
||||
set(&mut app, "folder", json!(" "));
|
||||
assert!(confirm(&mut app).is_err());
|
||||
assert_eq!(kind(&app), Some(export_for_screens::KIND));
|
||||
set(&mut app, "folder", json!(folder));
|
||||
let r = confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let files: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect();
|
||||
assert_eq!(files, [format!("{folder}/Artboard-2.png"), format!("{folder}/Artboard-2.svg")]);
|
||||
assert!(std::fs::metadata(dir.join("Artboard-2.png")).unwrap().len() > 50);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_web_downloads_one_zip_or_the_file() {
|
||||
let (mut app, log) = app(2, true);
|
||||
assert_eq!((spec(export_for_screens::KIND).ok_label.unwrap())(&app), "Download");
|
||||
open(&mut app);
|
||||
frame(&mut app);
|
||||
set(&mut app, "formats", json!([{"format": "png", "scale": 1, "suffix": ""}, {"format": "jpg", "scale": 2, "suffix": "@2x"}]));
|
||||
let r = confirm(&mut app).unwrap();
|
||||
assert!(r.get("dataBase64").is_some(), "four files: one zip");
|
||||
{
|
||||
let log = log.borrow();
|
||||
let [(name, zip)] = log.as_slice() else { panic!("one download: {:?}", log.iter().map(|(n, _)| n).collect::<Vec<_>>()) };
|
||||
assert_eq!(name, "Untitled-1.zip");
|
||||
assert_eq!(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(), zip);
|
||||
}
|
||||
assert_eq!(r["files"], json!(["Artboard-1.png", "Artboard-1@2x.jpg", "Artboard-2.png", "Artboard-2@2x.jpg"]));
|
||||
// One file downloads as itself.
|
||||
log.borrow_mut().clear();
|
||||
open(&mut app);
|
||||
set(&mut app, "boards", json!([true, false]));
|
||||
set(&mut app, "formats", json!([{"format": "png", "scale": 1, "suffix": ""}]));
|
||||
confirm(&mut app).unwrap();
|
||||
let log = log.borrow();
|
||||
assert_eq!(log.len(), 1);
|
||||
assert_eq!(log[0].0, "Artboard-1.png");
|
||||
assert_eq!(image::load_from_memory(&log[0].1).unwrap().width(), 60);
|
||||
}
|
||||
|
|
@ -111,7 +111,7 @@ pub(crate) fn write_to(services: &mut Services, path: &str, bytes: &[u8]) -> Res
|
|||
}
|
||||
|
||||
/// The web saves by downloading: no save panel, no folders.
|
||||
fn is_web(app: &VectorcraftApp) -> bool {
|
||||
pub(crate) fn is_web(app: &VectorcraftApp) -> bool {
|
||||
app.services.download.is_some()
|
||||
}
|
||||
|
||||
|
|
@ -507,6 +507,36 @@ pub(crate) fn open_in_app(app: &mut VectorcraftApp, path: &str) -> Result<(), St
|
|||
app.services.open_file.as_mut().ok_or("no app to open files here")?(path)
|
||||
}
|
||||
|
||||
/// File → Export for Screens with `document.exportForScreens` params: the files go into `folder`;
|
||||
/// without one the web downloads them (the ZIP, or each file) and the other frontends get them
|
||||
/// back as base64.
|
||||
pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result<Value, String> {
|
||||
let folder = params.get("folder").and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
let r = app.run("document.exportForScreens", params)?;
|
||||
let count = r["files"].as_array().map_or(0, Vec::len);
|
||||
// Files that came back as bytes: (name, base64).
|
||||
let returned: Vec<(&str, &str)> = match r["dataBase64"].as_str() {
|
||||
Some(zip) => vec![(r["name"].as_str().unwrap_or("Export.zip"), zip)],
|
||||
None => {
|
||||
r["files"].as_array().into_iter().flatten().filter_map(|f| Some((f.get("name")?.as_str()?, f.get("dataBase64")?.as_str()?))).collect()
|
||||
}
|
||||
};
|
||||
if returned.is_empty() {
|
||||
app.status(format!("Exported {count} file(s) to {folder}"));
|
||||
} else if let Some(download) = app.services.download.as_mut() {
|
||||
for (name, data) in &returned {
|
||||
let bytes = vectorcraft_format::base64_decode(data).ok_or("the export returned unreadable data")?;
|
||||
download(name, &bytes);
|
||||
}
|
||||
let what = match returned.as_slice() {
|
||||
[(name, _)] => (*name).to_string(),
|
||||
files => format!("{} files", files.len()),
|
||||
};
|
||||
app.status(format!("Downloaded {what}"));
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Place a file's bytes (no path, so embedded) centred in the view: `file.place`.
|
||||
pub fn place_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
crate::place::run(app, &serde_json::json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).map(|_| ())
|
||||
|
|
|
|||
|
|
@ -102,7 +102,12 @@ 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?, …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.exportForScreens",
|
||||
"Export for Screens…",
|
||||
"Cmd+Alt+E",
|
||||
"{} opens the dialog; with params = document.exportForScreens (no folder on the web: downloads the files or the zip)",
|
||||
),
|
||||
("file.documentSetup", "Document Setup…", "Cmd+Alt+P", "{}"),
|
||||
("file.newDialog", "New…", "Cmd+N", "{} opens the New Document dialog"),
|
||||
("edit.preferences", "Preferences…", "Cmd+K", "{category?} open Preferences (engine: prefs.get / prefs.set / prefs.list)"),
|
||||
|
|
@ -520,15 +525,10 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
|||
}
|
||||
"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());
|
||||
app.ui.dialog = Some(crate::state::Dialog::new(
|
||||
"exportForScreens",
|
||||
json!({"boards": vec![true; n], "formats": [{"format": "png", "scale": 1, "suffix": ""}, {"format": "png", "scale": 2, "suffix": "@2x"}], "folder": format!("{home}/Desktop/VectorCraft Export"), "prefix": ""}),
|
||||
));
|
||||
crate::dialogs::open_export_for_screens(app);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"file.exportForScreens" => app.run("document.exportForScreens", p.clone()),
|
||||
"file.exportForScreens" => io::export_for_screens(app, p.clone()),
|
||||
"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!({}) };
|
||||
|
|
|
|||
Loading…
Reference in New Issue