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:
bflatastic 2026-10-05 12:36:53 +07:00
parent 8ab7237443
commit 902d95173e
12 changed files with 545 additions and 130 deletions

View File

@ -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> {

View File

@ -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;

View File

@ -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).

View File

@ -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<_>>() })
}
})
}

View File

@ -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");
}

View File

@ -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(&central);
// 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(&central_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)
}

View File

@ -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"),
];

View File

@ -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));

View File

@ -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;

View File

@ -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);
}

View File

@ -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(|_| ())

View File

@ -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!({}) };