Merge pull request #100 from storytold/pr/m4-export-screens

M4.51–M4.52: Export for Screens downloads, zip and options
This commit is contained in:
BFlat 2026-10-05 13:24:40 +07:00 committed by GitHub
commit eacc83e05b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1738 additions and 348 deletions

View File

@ -505,6 +505,10 @@ pub struct Document {
/// kept them in `unknown`, see [`Document::migrate_color_profiles`]).
#[serde(default, skip_serializing_if = "ColorProfiles::is_empty")]
pub color_profiles: ColorProfiles,
/// File → Export for Screens: the settings it last exported with (`document.exportForScreens`
/// params), so the dialog reopens on them.
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub export_settings: serde_json::Map<String, serde_json::Value>,
/// Top-level keys this version doesn't know (written by a newer one), kept so saving doesn't
/// lose them. Separate from [`Document::unknown`], which holds foreign data by design.
#[serde(flatten)]
@ -577,6 +581,7 @@ impl Document {
raster_effects: RasterEffectsSettings::default(),
last_view: None,
color_profiles: ColorProfiles::default(),
export_settings: Default::default(),
extra: Default::default(),
slices: vec![],
slices_clip_to_artboard: true,

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`, `document.exportSettings`); `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,8 +46,10 @@ 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 screens::{PRESETS as SCREEN_PRESETS, ScreenSize, preset_rows as screen_preset_rows};
pub use svg::options_map as svg_options;
/// Atomic file writes (a temporary file renamed over the target: a failed write never damages the
/// file it replaces), for the apps' own writers too.
@ -96,9 +102,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), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|svg|svgz|pdf, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|svg|pdf: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} 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}]} (names include sub-folders: 2x/Icon@2x.png). The params are remembered in the document (document.exportSettings; not an undo step)",
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!(
@ -128,6 +134,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
export::export_for_office
),
cmd!(
query "document.exportSettings",
"Export for Screens Settings",
[],
None,
"{} → {settings}: the document.exportForScreens params the document last exported with ({} when never; saved with the document, the dialog reopens on them)",
has_doc,
screens::export_settings
),
]
.into_iter()
.chain(save::specs())
@ -675,6 +690,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,406 @@
//! File → Export → Export for Screens (`document.exportForScreens`): the chosen artboards (or the
//! whole document) in every format row at each row's size, into a folder and its sub-folders, or
//! returned for download (each file, or one ZIP); the presets the dialog offers; and the settings
//! the document remembers (`document.exportSettings`).
use std::collections::HashSet;
use std::sync::Arc;
use serde::Deserialize;
use serde_json::{Map, Value, json};
use vectorcraft_doc::Document;
use vectorcraft_geom::Rect;
use super::super::*;
use super::encode::single_artboard;
use super::export::without_artboards;
use super::{ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, encode, writable, write_file};
const C: &str = "document.exportForScreens";
/// How big a raster row's files are.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ScreenSize {
/// Pixels per point (`2x`).
Scale(f64),
/// Pixels per inch, stored in the file (`144ppi`: twice 72, so `2x`).
Ppi(f64),
/// The width in pixels; the height follows the artboard's proportions (`100w`).
Width(f64),
/// The height in pixels (`100h`).
Height(f64),
}
impl ScreenSize {
/// `2x` (or `2`), `100w`, `100h` or `72ppi`, in any case; positive numbers only.
pub fn parse(s: &str) -> Option<Self> {
let s = s.trim().to_ascii_lowercase();
let num = |t: &str| t.trim().parse::<f64>().ok().filter(|v| v.is_finite() && *v > 0.0);
if let Some(n) = s.strip_suffix("ppi") {
return num(n).map(Self::Ppi);
}
if let Some(n) = s.strip_suffix('w') {
return num(n).map(Self::Width);
}
if let Some(n) = s.strip_suffix('h') {
return num(n).map(Self::Height);
}
num(s.strip_suffix('x').unwrap_or(&s)).map(Self::Scale)
}
/// The size a format row asks for: `width`, else `height`, else `ppi`, else `scale` (a number,
/// or text such as `"2x"`, `"100w"`, `"72ppi"`); 1x when it names none.
fn of_row(row: &Value) -> Result<Self> {
let positive = |key: &str, make: fn(f64) -> Self| -> Option<Result<Self>> {
let v = row.get(key).filter(|v| !v.is_null())?;
Some(
v.as_f64().filter(|n| n.is_finite() && *n > 0.0).map(make).ok_or_else(|| bad(C, format!("{key} must be a positive number, not {v}"))),
)
};
if let Some(r) = positive("width", Self::Width).or_else(|| positive("height", Self::Height)).or_else(|| positive("ppi", Self::Ppi)) {
return r;
}
match row.get("scale") {
None | Some(Value::Null) => Ok(Self::Scale(1.0)),
Some(Value::String(s)) => Self::parse(s)
.ok_or_else(|| bad(C, format!("scale `{s}`: a factor such as 2x, a width (100w), a height (100h) or a resolution (72ppi)"))),
Some(_) => positive("scale", Self::Scale).unwrap_or(Ok(Self::Scale(1.0))),
}
}
/// The scale factor, for the sizes that have one of their own.
fn factor(self) -> Option<f64> {
match self {
Self::Scale(s) => Some(s),
Self::Ppi(p) => Some(p / 72.0),
Self::Width(_) | Self::Height(_) => None,
}
}
/// Pixels per point for `region`.
fn scale_for(self, region: Rect) -> Result<f64> {
let per = |px: f64, pt: f64| (pt > 0.0).then(|| px / pt).ok_or_else(|| bad(C, "an empty artboard has no width or height to scale to"));
match self {
Self::Width(w) => per(w, region.width()),
Self::Height(h) => per(h, region.height()),
_ => Ok(self.factor().unwrap_or(1.0)),
}
}
/// The sub-folder files of this size go into: `1x`, `2x`, `100w`, `100h` (numbers to 3
/// decimals: 80ppi is `1.111x`).
pub fn label(self) -> String {
let n = |v: f64| (v * 1000.0).round() / 1000.0;
match self {
Self::Width(w) => format!("{}w", n(w)),
Self::Height(h) => format!("{}h", n(h)),
_ => format!("{}x", n(self.factor().unwrap_or(1.0))),
}
}
/// The file-name suffix by default: none at 1x, else `@2x`, `@100w`, `@100h` (a resolution
/// names its scale: 144ppi is `@2x`).
pub fn suffix(self) -> String {
match self.factor() {
Some(s) if (s - 1.0).abs() < 1e-9 => String::new(),
_ => format!("@{}", self.label()),
}
}
}
/// A file-name suffix that names a pixel size (`@2x`, `@0.5x`, `@100w`, `@100h`): vector rows
/// leave it out.
fn is_size_suffix(s: &str) -> bool {
s.strip_prefix('@').is_some_and(|t| t.ends_with(|c: char| c.is_ascii_alphabetic()) && ScreenSize::parse(t).is_some())
}
/// The presets of the dialog's format rows: (id, label).
pub const PRESETS: [(&str, &str); 2] = [("mobile", "Mobile 1x/2x/3x"), ("density", "Density buckets (0.75x–4x)")];
/// Screen density buckets: (sub-folder, scale).
const DENSITY: [(&str, f64); 6] = [("ldpi", 0.75), ("mdpi", 1.0), ("hdpi", 1.5), ("xhdpi", 2.0), ("xxhdpi", 3.0), ("xxxhdpi", 4.0)];
/// The format rows of preset `id` (see [`PRESETS`]): PNG at 1x, 2x and 3x (`@2x`, `@3x`), or PNG
/// at every density bucket, each in its bucket's sub-folder under the same name.
pub fn preset_rows(id: &str) -> Option<Vec<Value>> {
let png = |s: f64| ScreenSize::Scale(s);
match id {
"mobile" => Some([1.0, 2.0, 3.0].iter().map(|s| json!({"format": "png", "scale": png(*s).label(), "suffix": png(*s).suffix()})).collect()),
"density" => {
Some(DENSITY.iter().map(|(folder, s)| json!({"format": "png", "scale": png(*s).label(), "suffix": "", "folder": folder})).collect())
}
_ => None,
}
}
/// A name safe as one file or folder name: letters, digits, `-`, `_` and `.` kept, anything else
/// `-` (`None` when nothing but dots is left: `.` and `..` name other folders).
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)
}
/// Keys of a format row that aren't encoder options.
const ROW_KEYS: [&str; 7] = ["format", "scale", "ppi", "width", "height", "suffix", "folder"];
/// One format row: what to write, at which size, with which suffix, into which sub-folder.
struct Row {
format: &'static Format,
size: ScreenSize,
suffix: String,
/// The sub-folder with Create Sub-folders: the row's `folder`, else its size (raster) or format.
folder: String,
/// The encoder options: the format's settings, then the row's own.
options: Value,
}
impl Row {
fn parse(s: &Session, row: &Value, settings: &Value) -> Result<Self> {
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" | "txt" | "dxf") {
return Err(bad(C, "Export for Screens writes png, png8, jpg, webp, gif, svg, svgz or pdf"));
}
// Vector formats have no pixel size: it doesn't apply and adds no @2x suffix (not even one
// left over from a raster row switched to SVG or PDF).
let size = if format.raster { ScreenSize::of_row(row)? } else { ScreenSize::Scale(1.0) };
let suffix = match str_param(row, "suffix") {
Some(s) if !format.raster && is_size_suffix(s) => String::new(),
Some(s) => s.to_string(),
None => size.suffix(),
};
let folder = match str_param(row, "folder").and_then(safe_name) {
Some(f) => f,
None if format.raster => size.label(),
None => format.id.to_uppercase(),
};
// The format's settings, without anything that picks artboards or sizes (rows do that).
let mut options = Map::new();
if let Some(o) = settings.get(format.id).and_then(Value::as_object) {
options.extend(
o.iter().filter(|(k, _)| !ROW_KEYS.contains(&k.as_str()) && k.as_str() != "useArtboards").map(|(k, v)| (k.clone(), v.clone())),
);
}
if let Some(o) = without_artboards(row).as_object() {
options.extend(o.iter().filter(|(k, _)| !ROW_KEYS.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())));
}
for k in ARTBOARD_PARAMS {
options.remove(k);
}
// A file per artboard is for viewing: it doesn't carry the whole document to edit.
options.entry("preserveEditing").or_insert(json!(false));
let options = Value::Object(options);
// A PDF preset (built-in or saved) becomes its settings.
let options = if format.id == "pdf" { super::pdf::expand_preset(s, C, &options)?.into_owned() } else { options };
Ok(Self { format, size, suffix, folder, options })
}
/// The encoder options for `region`, `artboard` of the document written.
fn options_for(&self, region: Rect, artboard: Option<usize>) -> Result<Value> {
let mut o = self.options.clone();
if let Some(b) = artboard {
o["artboard"] = json!(b);
}
if self.format.raster {
match self.size {
// The resolution is stored in the file too.
ScreenSize::Ppi(ppi) => o["ppi"] = json!(ppi),
size => {
o["scale"] = json!(size.scale_for(region)?);
if let Some(m) = o.as_object_mut() {
m.remove("ppi");
}
}
}
}
Ok(o)
}
}
/// What `document.exportForScreens` reads besides the formats.
#[derive(Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct ScreenParams {
#[serde(flatten)]
boards: ArtboardPick,
folder: Option<String>,
zip: bool,
full_document: bool,
include_bleed: bool,
subfolders: bool,
preset: Option<String>,
prefix: Option<String>,
}
/// The file names (and bytes) an export wrote, or the files it returns.
#[derive(Default)]
struct Output {
/// Lower-case relative names already used (two identical rows write one file).
taken: HashSet<String>,
/// Sub-folders already made.
made: HashSet<String>,
/// `(relative name, bytes)` kept to return or zip (empty when written to a folder).
kept: Vec<(String, Vec<u8>)>,
/// The paths written.
written: Vec<String>,
}
impl Output {
/// Is `name` new (not written by an identical row already)?
fn claim(&mut self, name: &str) -> bool {
self.taken.insert(name.to_lowercase())
}
/// Write `bytes` as `name` (relative) under `folder`, or keep them.
fn put(&mut self, folder: Option<&str>, keep: bool, name: String, bytes: Vec<u8>) -> Result<()> {
match folder {
Some(dir) if !keep => {
if let Some((sub, _)) = name.rsplit_once('/')
&& self.made.insert(sub.to_lowercase())
{
create_dir(&format!("{dir}/{sub}"))?;
}
let path = format!("{dir}/{name}");
write_file(&path, &bytes)?;
self.written.push(path);
}
_ => self.kept.push((name, bytes)),
}
Ok(())
}
}
/// File → Export for Screens: every chosen artboard in every format row, one file each (a PDF holds
/// its artboard alone), or with `fullDocument` one file per row (a PDF of every artboard, else the
/// bounds of all art). Artboards that share a name get `-2`, `-3`… instead of overwriting. The
/// params are remembered in the document (`document.exportSettings`).
pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
let o: ScreenParams = if p.is_object() { ScreenParams::deserialize(p).map_err(|e| bad(C, e.to_string()))? } else { ScreenParams::default() };
let settings = p.get("settings").cloned().unwrap_or(Value::Null);
let rows: Vec<Value> = match (o.preset.as_deref(), p.get("formats")) {
(Some(_), Some(f)) if !f.is_null() => return Err(bad(C, "give formats or a preset, not both")),
(Some(id), _) => preset_rows(id).ok_or_else(|| bad(C, format!("preset `{id}`: mobile or density")))?,
(None, Some(Value::Array(rows))) => rows.clone(),
(None, None | Some(Value::Null)) => vec![json!({})],
(None, Some(v)) => return Err(bad(C, format!("formats must be an array of rows, not {v}"))),
};
let mut rows: Vec<Row> = rows.iter().map(|r| Row::parse(s, r, &settings)).collect::<Result<_>>()?;
if rows.is_empty() {
return Err(bad(C, "no formats to export"));
}
// A top-level anti-aliasing mode applies to every row that doesn't name its own.
if let Some(aa) = p.get("antiAlias") {
for r in &mut rows {
if let Some(m) = r.options.as_object_mut() {
m.entry("antiAlias").or_insert_with(|| aa.clone());
}
}
}
let subfolders = o.subfolders || o.preset.is_some();
// A prefix names files, not folders.
let prefix: String = o.prefix.as_deref().unwrap_or("").chars().map(|c| if matches!(c, '/' | '\\' | ':') { '-' } else { c }).collect();
let folder = o.folder.as_deref().filter(|f| !f.is_empty());
if let Some(dir) = folder {
create_dir(dir)?;
}
let doc = s.doc()?.doc.clone();
// Include Bleed: each artboard grown by the document's bleed.
let doc = if o.include_bleed && doc.setup.has_bleed() {
let mut d = (*doc).clone();
for a in &mut d.artboards {
a.rect = doc.setup.bleed_rect(a.rect);
}
Arc::new(d)
} else {
doc
};
let name_of = |r: &Row, stem: &str| {
let file = format!("{prefix}{stem}{}.{}", r.suffix, r.format.extensions[0]);
if subfolders { format!("{}/{file}", r.folder) } else { file }
};
let mut out = Output::default();
if o.full_document {
let stem = safe_name(&super::file_stem(&doc.title)).unwrap_or_else(|| "Untitled".into());
// The bounds of all visible art, as the one artboard of the files that aren't PDFs.
let mut whole: Option<Document> = None;
for r in &rows {
let name = name_of(r, &stem);
if !out.claim(&name) {
continue;
}
let bytes = if r.format.id == "pdf" {
// One page per artboard.
let mut options = r.options.clone();
options["range"] = json!("all");
encode(&doc, "pdf", &options)?
} else {
if whole.is_none() {
let bounds =
vectorcraft_render::encode::art_bounds(&doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
whole = Some(single_artboard(&doc, bounds, "Document"));
}
let Some(d) = whole.as_ref() else { continue };
let region = d.artboards.first().map_or(Rect::ZERO, |a| a.rect);
encode(d, r.format.id, &r.options_for(region, Some(0))?)?
};
out.put(folder, o.zip, name, bytes)?;
}
} else {
let n = doc.artboards.len();
let boards = o.boards.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect());
if boards.is_empty() {
return Err(bad(C, "the document has no artboard"));
}
for (b, stem) in boards.iter().copied().zip(artboard_file_names(&doc, &boards)) {
let region = doc.artboards.get(b).map_or(Rect::ZERO, |a| a.rect);
for r in &rows {
let name = name_of(r, &stem);
if !out.claim(&name) {
continue; // the same file from two identical rows
}
let bytes = encode(&doc, r.format.id, &r.options_for(region, Some(b))?)?;
out.put(folder, o.zip, name, bytes)?;
}
}
}
remember(s, p);
let names: Vec<&str> = out.kept.iter().map(|(n, _)| n.as_str()).collect();
if o.zip {
let zip = super::zip::store(&out.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, &zip)?;
Ok(json!({ "path": path, "bytes": zip.len(), "files": names }))
}
None => Ok(json!({ "name": zip_name, "dataBase64": vectorcraft_format::base64_encode(&zip), "bytes": zip.len(), "files": names })),
};
}
Ok(match folder {
Some(_) => json!({ "files": out.written }),
None => {
json!({ "files": out.kept.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect::<Vec<_>>() })
}
})
}
/// Keep the params of a successful export in the document (without `zip`, a delivery detail), so
/// the dialog reopens on them. Not an undo step; the document has changed only if they differ.
fn remember(s: &mut Session, p: &Value) {
let Some(o) = p.as_object() else { return };
let mut settings = o.clone();
settings.remove("zip");
let Ok(st) = s.doc_mut() else { return };
if st.doc.export_settings != settings {
Arc::make_mut(&mut st.doc).export_settings = settings;
st.revision += 1;
}
}
/// `document.exportSettings`: the Export for Screens settings the document last exported with.
pub(super) fn export_settings(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "settings": s.doc()?.doc.export_settings }))
}

View File

@ -0,0 +1,269 @@
//! Export for Screens: sizes (factor, width, height, resolution), presets and sub-folders, Full
//! Document, Include Bleed, per-format settings, ZIP downloads and the settings the document keeps.
use serde_json::{Value, json};
use super::screens::ScreenSize;
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()
}
fn names(r: &Value) -> Vec<String> {
files(r).into_iter().map(|(n, _)| n).collect()
}
fn size(png: &[u8]) -> (u32, u32) {
image::load_from_memory(png).unwrap().to_rgba8().dimensions()
}
#[test]
fn sizes_parse_as_factor_width_height_or_resolution() {
assert_eq!(ScreenSize::parse("2x"), Some(ScreenSize::Scale(2.0)));
assert_eq!(ScreenSize::parse(" 0.5 "), Some(ScreenSize::Scale(0.5)));
assert_eq!(ScreenSize::parse("100W"), Some(ScreenSize::Width(100.0)));
assert_eq!(ScreenSize::parse("64h"), Some(ScreenSize::Height(64.0)));
assert_eq!(ScreenSize::parse("144ppi"), Some(ScreenSize::Ppi(144.0)));
for bad in ["", "x", "-2x", "0w", "NaNx", "infh", "2y"] {
assert_eq!(ScreenSize::parse(bad), None, "{bad}");
}
assert_eq!((ScreenSize::Scale(1.0).suffix(), ScreenSize::Scale(1.5).suffix()), (String::new(), "@1.5x".into()));
assert_eq!(
(ScreenSize::Width(100.0).suffix(), ScreenSize::Ppi(144.0).suffix(), ScreenSize::Ppi(144.0).label()),
("@100w".into(), "@2x".into(), "2x".into())
);
}
#[test]
fn a_width_or_height_row_gives_that_many_pixels() {
let mut s = session(1);
let r = export(
&mut s,
json!({"formats": [{"format": "png", "scale": "100w"}, {"format": "png", "height": 60}, {"format": "jpg", "scale": "80ppi"}]}),
);
assert_eq!(names(&r), ["Artboard-1@100w.png", "Artboard-1@60h.png", "Artboard-1@1.111x.jpg"]);
let f = files(&r);
assert_eq!(size(&f[0].1), (100, 75));
assert_eq!(size(&f[1].1), (80, 60));
assert_eq!(size(&f[2].1), (44, 33));
// Width or height win over scale, and a bad size is refused.
let r = export(&mut s, json!({"formats": [{"format": "png", "scale": 3, "width": 20}]}));
assert_eq!(size(&files(&r)[0].1), (20, 15));
for bad in [json!({"scale": "big"}), json!({"width": -5}), json!({"height": "tall"})] {
assert!(s.execute("document.exportForScreens", &json!({"formats": [bad]})).is_err(), "{bad}");
}
// Vector rows ignore sizes and their suffixes.
let r = export(&mut s, json!({"formats": [{"format": "svg", "scale": "100w", "suffix": "@100w"}]}));
assert_eq!(names(&r), ["Artboard-1.svg"]);
}
#[test]
fn density_buckets_go_into_sub_folders() {
let mut s = session(2);
let r = export(&mut s, json!({"preset": "density", "range": "1"}));
assert_eq!(
names(&r),
[
"ldpi/Artboard-1.png",
"mdpi/Artboard-1.png",
"hdpi/Artboard-1.png",
"xhdpi/Artboard-1.png",
"xxhdpi/Artboard-1.png",
"xxxhdpi/Artboard-1.png"
]
);
let widths: Vec<u32> = files(&r).iter().map(|(_, b)| size(b).0).collect();
assert_eq!(widths, [30, 40, 60, 80, 120, 160]);
let r = export(&mut s, json!({"preset": "mobile", "artboards": [1]}));
assert_eq!(names(&r), ["1x/Artboard-2.png", "2x/Artboard-2@2x.png", "3x/Artboard-2@3x.png"]);
assert!(s.execute("document.exportForScreens", &json!({"preset": "mobile", "formats": [{}]})).is_err(), "a preset or formats");
assert!(s.execute("document.exportForScreens", &json!({"preset": "tablet"})).is_err());
// Sub-folders by size for raster rows, by format for vector rows, or the row's own (made safe).
let r = export(
&mut s,
json!({"range": "2", "subfolders": true, "formats": [{"format": "png", "scale": 2}, {"format": "svg"}, {"format": "pdf", "folder": "../print"}]}),
);
assert_eq!(names(&r), ["2x/Artboard-2@2x.png", "SVG/Artboard-2.svg", "..-print/Artboard-2.pdf"]);
// A prefix names files, not folders.
let r = export(&mut s, json!({"range": "2", "prefix": "../a\\b:", "formats": [{"format": "svg"}]}));
assert_eq!(names(&r), ["..-a-b-Artboard-2.svg"]);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn sub_folders_are_made_in_the_export_folder() {
let mut s = session(1);
let dir = std::env::temp_dir().join(format!("vc-screens-sub-{}", std::process::id()));
let folder = dir.to_string_lossy().replace('\\', "/");
let r = export(&mut s, json!({"folder": folder, "preset": "mobile"}));
let written: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect();
assert_eq!(written, [format!("{folder}/1x/Artboard-1.png"), format!("{folder}/2x/Artboard-1@2x.png"), format!("{folder}/3x/Artboard-1@3x.png")]);
assert_eq!(size(&std::fs::read(dir.join("3x").join("Artboard-1@3x.png")).unwrap()), (120, 90));
let _ = std::fs::remove_dir_all(dir);
}
#[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(std::path::MAIN_SEPARATOR, "/");
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 full_document_gives_one_file_per_format() {
let mut s = session(3);
let r = export(
&mut s,
json!({"fullDocument": true, "range": "2", "prefix": "x-", "formats": [{"format": "pdf"}, {"format": "png", "scale": 2}, {"format": "svg"}]}),
);
let f = files(&r);
let title = super::file_stem(&s.doc().unwrap().doc.title);
assert_eq!(names(&r), [format!("x-{title}.pdf"), format!("x-{title}@2x.png"), format!("x-{title}.svg")]);
assert_eq!(vectorcraft_pdf::import(&f[0].1).unwrap().artboards.len(), 3, "one page per artboard, whatever the range");
// The bounds of the art: the square and its 1 pt stroke.
assert_eq!(size(&f[1].1), (42, 42));
// Nothing visible: nothing to export but the PDF.
let mut empty = session(1);
empty.execute("edit.undo", &json!({})).unwrap();
assert!(empty.execute("document.exportForScreens", &json!({"fullDocument": true, "formats": [{"format": "png"}]})).is_err());
assert_eq!(files(&export(&mut empty, json!({"fullDocument": true, "formats": [{"format": "pdf"}]}))).len(), 1);
}
#[test]
fn include_bleed_grows_each_artboard() {
let mut s = session(1);
s.execute("document.setup", &json!({"bleed": [2, 4, 6, 8]})).unwrap();
let r = export(&mut s, json!({"includeBleed": true, "formats": [{"format": "png"}, {"format": "png", "scale": "108w", "suffix": "-w"}]}));
let f = files(&r);
assert_eq!(size(&f[0].1), (54, 36), "40 + 6 + 8 by 30 + 2 + 4");
assert_eq!(size(&f[1].1), (108, 72), "the width counts the bleed");
let r = export(&mut s, json!({"formats": [{"format": "png"}]}));
assert_eq!(size(&files(&r)[0].1), (40, 30), "without it, the artboard alone");
}
#[test]
fn png_8_rows_write_indexed_colour() {
let mut s = session(1);
let r = export(&mut s, json!({"formats": [{"format": "png8"}, {"format": "png"}]}));
let f = files(&r);
// The IHDR colour type: 3 (indexed) for PNG 8, 6 (RGBA) for PNG; both are .png, the PNG 8 row
// written first.
assert_eq!(names(&r), ["Artboard-1.png"], "the same file name: the first row wins");
assert_eq!(f[0].1[25], 3);
let r = export(&mut s, json!({"formats": [{"format": "png8", "suffix": "-8"}, {"format": "png"}]}));
let f = files(&r);
assert_eq!((f[0].1[25], f[1].1[25]), (3, 6));
}
#[test]
fn format_settings_apply_to_their_rows_and_rows_win() {
let mut s = session(1);
let px = |png: &[u8]| image::load_from_memory(png).unwrap().to_rgba8().get_pixel(1, 1).0;
let r = export(
&mut s,
json!({"settings": {"png": {"background": "black", "scale": 9, "artboard": 7}, "jpg": {"quality": 5}}, "formats": [{"format": "png"}, {"format": "png", "suffix": "-w", "background": "white"}, {"format": "jpg"}, {"format": "jpg", "quality": 100, "suffix": "-100"}]}),
);
let f = files(&r);
assert_eq!(px(&f[0].1), [0, 0, 0, 255], "the PNG settings' background");
assert_eq!(size(&f[0].1), (40, 30), "settings don't pick sizes or artboards");
assert_eq!(px(&f[1].1), [255, 255, 255, 255], "the row's own wins");
assert!(f[2].1.len() < f[3].1.len(), "quality 5 is smaller than quality 100");
}
#[test]
fn zip_downloads_round_trip() {
let mut s = session(2);
let p = json!({"subfolders": true, "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");
}
#[test]
fn the_document_remembers_its_export_settings() {
let mut s = session(2);
assert_eq!(s.execute("document.exportSettings", &json!({})).unwrap()["settings"], json!({}));
s.doc_mut().unwrap().mark_saved();
let p = json!({"range": "2", "subfolders": true, "prefix": "p-", "zip": true, "openLocation": true, "formats": [{"format": "jpg", "quality": 80, "scale": "2x"}]});
export(&mut s, p);
let settings = s.execute("document.exportSettings", &json!({})).unwrap()["settings"].clone();
assert_eq!(
settings,
json!({"range": "2", "subfolders": true, "prefix": "p-", "openLocation": true, "formats": [{"format": "jpg", "quality": 80, "scale": "2x"}]})
);
assert!(s.doc().unwrap().is_dirty(), "settings are document data");
s.doc_mut().unwrap().mark_saved();
export(
&mut s,
json!({"range": "2", "subfolders": true, "prefix": "p-", "openLocation": true, "formats": [{"format": "jpg", "quality": 80, "scale": "2x"}]}),
);
assert!(!s.doc().unwrap().is_dirty(), "the same settings change nothing");
let undo_depth = s.doc().unwrap().history.undo.len();
export(&mut s, json!({"range": "1"}));
assert_eq!(s.doc().unwrap().history.undo.len(), undo_depth, "not an undo step");
// A failed export keeps the last settings.
assert!(s.execute("document.exportForScreens", &json!({"range": "9"})).is_err());
assert_eq!(s.execute("document.exportSettings", &json!({})).unwrap()["settings"], json!({"range": "1"}));
// Saved with the document (native round trip); older files have none.
let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
assert_eq!(doc.export_settings, s.doc().unwrap().doc.export_settings);
let plain = String::from_utf8(vectorcraft_format::save(&vectorcraft_doc::Document::new(5.0, 5.0), false)).unwrap();
assert!(!plain.contains("export_settings") && !plain.contains("exportSettings"), "not written when empty");
assert!(vectorcraft_format::load(plain.as_bytes()).unwrap().export_settings.is_empty());
}

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? | fullDocument?, includeBleed?, subfolders?, preset?, formats?, settings?, 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,141 +1,493 @@
//! File → Export → Export for Screens: artboards (thumbnails and checkboxes), scale/format rows,
//! destination folder and file-name prefix.
//! File → Export → Export for Screens: the Artboards tab (All, a range or Full Document, Include
//! Bleed, thumbnails with checkboxes) and the Assets tab, the destination (folder picker, Open
//! Location after Export, Create Sub-folders; the web downloads instead), the format rows (scale
//! as `2x`, `100w`, `100h` or `72ppi`, suffix, format), the presets, Format Settings (the gear)
//! and the prefix. It opens on the settings the document last exported with.
//!
//! Fields (`ui.dialog.set`): `tab` (artboards|assets), `select` (all|range|full), `range`,
//! `boards` ([bool] per artboard), `includeBleed`, `folder`, `openLocation`, `subfolders`,
//! `prefix`, `preset` (""|mobile|density: where the rows came from), `formats` (rows of
//! `document.exportForScreens`, `scale` as text), `settings` ({png|png8|jpg|webp|gif|svg|pdf: {…}});
//! `__settings` names the format whose Format Settings show ("" when closed).
use serde_json::{Value, json};
use serde::Deserialize;
use serde_json::{Map, Value, json};
use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, SCREEN_PRESETS, ScreenSize, pdf};
use super::DialogSpec;
use crate::VectorcraftApp;
use super::{DialogSpec, form, png_options, svg_options};
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" }),
min_width: 660.0,
..DialogSpec::FORM
};
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let boards: Vec<usize> = d
.fields
.get("boards")
.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")));
/// `Dialog::kind` of this dialog.
pub const KIND: &str = "exportForScreens";
/// The format choices of a row: (label, format, JPEG quality).
const FORMATS: [(&str, &str, Option<u8>); 9] = [
("PNG", "png", None),
("PNG 8", "png8", None),
("JPG 100", "jpg", Some(100)),
("JPG 80", "jpg", Some(80)),
("JPG 50", "jpg", Some(50)),
("WebP", "webp", None),
("GIF", "gif", None),
("SVG", "svg", None),
("PDF", "pdf", None),
];
const FORMAT_LABELS: [&str; 9] = ["PNG", "PNG 8", "JPG 100", "JPG 80", "JPG 50", "WebP", "GIF", "SVG", "PDF"];
/// The formats Format Settings has options for: (format, label).
const SETTINGS: [(&str, &str); 7] =
[("png", "PNG"), ("png8", "PNG 8"), ("jpg", "JPG"), ("webp", "WebP"), ("gif", "GIF"), ("svg", "SVG"), ("pdf", "PDF")];
const SETTINGS_LABELS: [&str; 7] = ["PNG", "PNG 8", "JPG", "WebP", "GIF", "SVG", "PDF"];
/// The sizes a row's scale list offers (any can be typed).
const SIZES: [&str; 12] = ["0.5x", "0.75x", "1x", "1.5x", "2x", "3x", "4x", "512w", "512h", "72ppi", "150ppi", "300ppi"];
/// Open the dialog on the settings the active document last exported with (else one PNG row at
/// 1x, into the Desktop).
pub fn open(app: &mut VectorcraftApp) {
let (n, saved) = app.session.active().map(|st| (st.doc.artboards.len(), st.doc.export_settings.clone())).unwrap_or_default();
let get = |k: &str| saved.get(k).filter(|v| !v.is_null());
let flag = |k: &str, default: bool| get(k).and_then(Value::as_bool).unwrap_or(default);
// The artboards: Full Document, a range (or a list of them), else all.
let chosen = ArtboardPick::deserialize(&Value::Object(saved.clone())).ok().and_then(|p| p.resolve(n).ok().flatten());
let select = match (flag("fullDocument", false), &chosen) {
(true, _) => "full",
(false, Some(c)) if c.len() < n => "range",
_ => "all",
};
let boards: Vec<bool> = (0..n).map(|i| chosen.as_ref().is_none_or(|c| c.contains(&i))).collect();
// The rows: the saved ones (or the saved preset's), sizes shown as text.
let preset = get("preset").and_then(Value::as_str).filter(|p| fileio::screen_preset_rows(p).is_some());
let rows = match (get("formats").and_then(Value::as_array), preset) {
(Some(rows), _) if rows.iter().any(Value::is_object) => rows.iter().filter(|r| r.is_object()).map(editable_row).collect(),
(_, Some(p)) => fileio::screen_preset_rows(p).unwrap_or_default(),
_ => vec![json!({"format": "png", "scale": "1x", "suffix": ""})],
};
// Every format's settings: its defaults, then the saved ones.
let mut settings = Map::new();
for (id, _) in SETTINGS {
let mut s = defaults(app, id);
if let Some(o) = get("settings").and_then(|v| v.get(id)).and_then(Value::as_object) {
s.extend(o.iter().map(|(k, v)| (k.clone(), v.clone())));
}
settings.insert(id.into(), Value::Object(s));
}
let folder = get("folder").and_then(Value::as_str).filter(|f| !f.is_empty()).map(str::to_string).or_else(fileio::export_folder);
let fields = json!({
"tab": "artboards",
"select": select,
"range": range_text(&boards),
"boards": boards,
"includeBleed": flag("includeBleed", false),
"folder": folder.unwrap_or_default(),
"openLocation": flag("openLocation", true),
"subfolders": flag("subfolders", preset.is_some()),
"prefix": get("prefix").and_then(Value::as_str).unwrap_or_default(),
"preset": preset.unwrap_or_default(),
"formats": rows,
"settings": settings,
"__settings": "",
"__presets": pdf::presets(&app.session),
});
app.ui.dialog = Some(Dialog::new(KIND, fields));
}
/// The options format `id` starts with in Format Settings: the raster options dialogs' defaults
/// (without quality and image maps: the rows choose quality, a map is another file), the SVG
/// Options' first-use ones (without artboards and images: a linked image is another file), or the
/// default PDF preset.
fn defaults(app: &VectorcraftApp, id: &str) -> Map<String, Value> {
let mut o = match (id, fileio::format(id)) {
("svg", _) => svg_options::first_use(),
("pdf", _) => Map::from_iter([("preset".to_string(), json!(pdf::DEFAULT_PRESET))]),
(_, Some(f)) => png_options::defaults(app, f),
_ => Map::new(),
};
for k in ["quality", "imageMap", "useArtboards", "images"] {
o.remove(k);
}
o
}
/// A row with its size as the text the Scale field shows (`2x`, `100w`, `100h`, `72ppi`).
fn editable_row(row: &Value) -> Value {
let num = |k: &str| row.get(k).and_then(Value::as_f64);
let text = match (num("width"), num("height"), num("ppi"), row.get("scale")) {
(Some(w), ..) => format!("{w}w"),
(_, Some(h), ..) => format!("{h}h"),
(_, _, Some(p), _) => format!("{p}ppi"),
(.., Some(Value::String(s))) => s.clone(),
(.., Some(v)) => format!("{}x", v.as_f64().unwrap_or(1.0)),
_ => "1x".into(),
};
let mut r = row.clone();
if let Some(o) = r.as_object_mut() {
for k in ["width", "height", "ppi"] {
o.remove(k);
}
o.insert("scale".into(), json!(text));
}
r
}
/// The checked artboards as a 1-based range (`1-3, 5`).
pub(super) fn range_text(boards: &[bool]) -> String {
let mut parts = vec![];
let mut start = None;
// A false past the end closes the last run.
for (i, on) in boards.iter().copied().chain([false]).enumerate() {
match (on, start) {
(true, None) => start = Some(i),
(false, Some(s)) => {
parts.push(if i - 1 > s { format!("{}-{i}", s + 1) } else { i.to_string() });
start = None;
}
_ => {}
}
}
parts.join(", ")
}
/// The `document.exportForScreens` params the dialog describes (the web: no folder, one ZIP for
/// several files).
fn params(app: &VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let n = app.session.active().map_or(0, |st| st.doc.artboards.len());
let rows = d.fields.get("formats").and_then(Value::as_array).cloned().unwrap_or_default();
if rows.is_empty() {
return Err("add a format to export".into());
}
let mut p = Map::new();
let files = match d.str("select").as_str() {
"full" => {
p.insert("fullDocument".into(), json!(true));
rows.len()
}
"range" => {
let range = d.str("range");
// A bad range keeps the dialog open.
let chosen = ArtboardPick { range: Some(range.clone()), ..Default::default() }.resolve(n)?.unwrap_or_default();
p.insert("range".into(), json!(range));
chosen.len() * rows.len()
}
_ => n * rows.len(),
};
for k in ["includeBleed", "subfolders"] {
p.insert(k.into(), json!(d.bool(k)));
}
p.insert("prefix".into(), json!(d.str("prefix")));
p.insert("formats".into(), Value::Array(rows));
p.insert("settings".into(), d.fields.get("settings").cloned().unwrap_or(json!({})));
if io::is_web(app) {
p.insert("zip".into(), json!(files > 1));
} else {
let folder = d.str("folder");
if folder.trim().is_empty() {
return Err("choose a folder to export to".into());
}
p.insert("folder".into(), json!(folder.trim()));
p.insert("openLocation".into(), json!(d.bool("openLocation")));
}
Ok(Value::Object(p))
}
/// Export; the dialog stays open when the export fails.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let p = params(app, d)?;
let r = io::export_for_screens(app, p);
if r.is_ok() {
app.ui.dialog = None;
}
r
}
/// Export for Screens: artboard picker (thumbnails + checkboxes), format/scale rows, destination.
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let names: Vec<String> = app.session.active().map(|s| s.doc.artboards.iter().map(|a| a.name.clone()).collect()).unwrap_or_default();
let mut boards: Vec<bool> =
d.fields.get("boards").and_then(Value::as_array).map(|a| a.iter().map(|b| b.as_bool().unwrap_or(false)).collect()).unwrap_or_default();
boards.resize(names.len(), true);
let tab = usize::from(d.str("tab") == "assets");
if let Some(i) = widgets::tab_bar(ui, &["Artboards", "Assets"], tab) {
d.fields.insert("tab".into(), json!(if i == 1 { "assets" } else { "artboards" }));
}
ui.add_space(8.0);
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(260.0);
ui.label(egui::RichText::new("Artboards").color(t.text));
ui.horizontal(|ui| {
if ui.small_button("Select All").clicked() {
boards.iter_mut().for_each(|b| *b = true);
}
if ui.small_button("Clear").clicked() {
boards.iter_mut().for_each(|b| *b = false);
}
});
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
for (i, name) in names.iter().enumerate() {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(egui::vec2(46.0, 46.0), egui::Sense::hover());
match artboard_thumb(app, ui.ctx(), i) {
Some(tex) => {
let sz = tex.size_vec2();
let s = (46.0 / sz.x.max(sz.y)).min(1.0);
let ir = egui::Rect::from_center_size(r.center(), sz * s);
ui.painter().image(
tex.id(),
ir,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
);
}
None => {
ui.painter().rect_filled(r, 2.0, egui::Color32::WHITE);
}
}
ui.checkbox(&mut boards[i], name);
});
}
});
ui.set_width(270.0);
if tab == 1 {
ui.add_space(24.0);
widgets::dim_label(ui, "No assets to export yet: artwork collected for export shows here.");
} else {
artboards(app, ui, d);
}
});
let (sep, _) = ui.allocate_exact_size(egui::vec2(9.0, 360.0), egui::Sense::hover());
let (sep, _) = ui.allocate_exact_size(egui::vec2(17.0, 380.0), egui::Sense::hover());
ui.painter().line_segment([sep.center_top(), sep.center_bottom()], egui::Stroke::new(1.0, t.divider));
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));
ui.set_width(350.0);
let shown = d.str("__settings");
if SETTINGS.iter().any(|(id, _)| *id == shown) {
format_settings(ui, d, &shown);
} else {
destination(app, ui, d);
ui.add_space(10.0);
formats(ui, d);
}
ui.add_space(8.0);
ui.label(egui::RichText::new("Formats").color(t.text));
let mut formats: Vec<Value> = d.fields.get("formats").and_then(Value::as_array).cloned().unwrap_or_default();
let mut remove = None;
egui::Grid::new("efs-formats").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
ui.label(egui::RichText::new("Scale").color(t.text_dim));
ui.label(egui::RichText::new("Suffix").color(t.text_dim));
ui.label(egui::RichText::new("Format").color(t.text_dim));
ui.label("");
ui.end_row();
for (i, f) in formats.iter_mut().enumerate() {
let mut sc = f["scale"].as_f64().unwrap_or(1.0);
if ui.add(egui::DragValue::new(&mut sc).range(0.1..=10.0).speed(0.5).suffix("x")).changed() {
f["scale"] = json!(sc);
}
let mut suffix = f["suffix"].as_str().unwrap_or("").to_string();
if ui.add(egui::TextEdit::singleline(&mut suffix).desired_width(60.0)).changed() {
f["suffix"] = json!(suffix);
}
let cur = f["format"].as_str().unwrap_or("png").to_string();
egui::ComboBox::from_id_salt(("efs-fmt", i)).selected_text(cur.to_uppercase()).width(70.0).show_ui(ui, |ui| {
for fm in ["png", "jpg", "webp", "gif", "png8", "svg", "pdf"] {
if ui.selectable_label(cur == fm, fm.to_uppercase()).clicked() {
f["format"] = json!(fm);
}
}
});
if ui.small_button("×").clicked() {
remove = Some(i);
}
ui.end_row();
}
});
if let Some(i) = remove {
formats.remove(i);
}
if ui.button("+ Add Scale").clicked() {
let next = formats.len() as f64 + 1.0;
formats.push(json!({"format": "png", "scale": next, "suffix": format!("@{next}x")}));
}
d.fields.insert("formats".into(), Value::Array(formats));
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Prefix").color(t.text_dim));
let mut prefix = d.str("prefix");
if ui.add(egui::TextEdit::singleline(&mut prefix).desired_width(120.0)).changed() {
d.fields.insert("prefix".into(), json!(prefix));
}
});
});
});
d.fields.insert("boards".into(), json!(boards));
false
}
fn set_select(d: &mut Dialog, select: &str) {
d.fields.insert("select".into(), json!(select));
}
/// Select (All, Range, Full Document), Include Bleed, and the artboards' thumbnails and checkboxes.
fn artboards(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
let n = app.session.active().map_or(0, |st| st.doc.artboards.len());
let mut boards: Vec<bool> =
d.fields.get("boards").and_then(Value::as_array).map(|a| a.iter().map(|b| b.as_bool().unwrap_or(false)).collect()).unwrap_or_default();
boards.resize(n, true);
let before = boards.clone();
let select = d.str("select");
widgets::dim_label(ui, "Select:");
ui.horizontal(|ui| {
if widgets::radio(ui, "All", select == "all", true) {
set_select(d, "all");
boards.iter_mut().for_each(|b| *b = true);
}
if widgets::radio(ui, "Range:", select == "range", true) {
set_select(d, "range");
}
ui.add_enabled_ui(select == "range", |ui| {
if form::text(ui, d, "range", 90.0)
&& let Ok(Some(chosen)) = (ArtboardPick { range: Some(d.str("range")), ..Default::default() }).resolve(n)
{
boards = (0..n).map(|i| chosen.contains(&i)).collect();
}
});
});
if widgets::radio(ui, "Full Document", select == "full", true) {
set_select(d, "full");
}
ui.add_space(4.0);
form::check(ui, d, "includeBleed", "Include Bleed");
ui.add_space(6.0);
let typed = boards != before;
let checked = boards.clone();
ui.add_enabled_ui(d.str("select") != "full", |ui| {
ui.horizontal(|ui| {
if ui.small_button("Select All").clicked() {
boards.iter_mut().for_each(|b| *b = true);
}
if ui.small_button("Clear").clicked() {
boards.iter_mut().for_each(|b| *b = false);
}
});
let names: Vec<String> = app.session.active().map(|s| s.doc.artboards.iter().map(|a| a.name.clone()).collect()).unwrap_or_default();
egui::ScrollArea::vertical().max_height(250.0).show(ui, |ui| {
for (i, name) in names.iter().enumerate() {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(egui::vec2(46.0, 46.0), egui::Sense::hover());
match artboard_thumb(app, ui.ctx(), i) {
Some(tex) => {
let sz = tex.size_vec2();
let s = (46.0 / sz.x.max(sz.y)).min(1.0);
let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0));
ui.painter().image(tex.id(), egui::Rect::from_center_size(r.center(), sz * s), uv, egui::Color32::WHITE);
}
None => {
ui.painter().rect_filled(r, 2.0, egui::Color32::WHITE);
}
}
if let Some(b) = boards.get_mut(i) {
ui.checkbox(b, name);
}
});
}
});
});
// Checking artboards picks them as the range (all of them: All); a typed range keeps its text.
if boards != checked {
set_select(d, if boards.iter().all(|b| *b) { "all" } else { "range" });
d.fields.insert("range".into(), json!(range_text(&boards)));
}
if typed || boards != checked {
d.fields.insert("boards".into(), json!(boards));
}
}
/// Export to: the folder (typed or picked), Open Location after Export and Create Sub-folders; the
/// web downloads instead.
fn destination(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
widgets::dim_label(ui, "Export to:");
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| {
form::text(ui, d, "folder", if pick { 300.0 } else { 334.0 });
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));
}
});
form::check(ui, d, "openLocation", "Open Location after Export");
}
form::check(ui, d, "subfolders", "Create Sub-folders");
}
/// The preset, the Format Settings gear, the format rows (scale, suffix, format), Add Scale and the
/// prefix.
fn formats(ui: &mut egui::Ui, d: &mut Dialog) {
let mut rows: Vec<Value> = d.fields.get("formats").and_then(Value::as_array).cloned().unwrap_or_default();
let mut edited = false;
ui.horizontal(|ui| {
widgets::dim_label(ui, "Formats:");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::icon_button(ui, "settings", "Format Settings", false, 24.0).clicked() {
let first = rows.first().and_then(|r| r["format"].as_str()).filter(|f| SETTINGS.iter().any(|(id, _)| id == f));
d.fields.insert("__settings".into(), json!(first.unwrap_or("png")));
}
let preset = d.str("preset");
let labels: Vec<&str> = std::iter::once("Custom").chain(SCREEN_PRESETS.iter().map(|(_, l)| *l)).collect();
let at = SCREEN_PRESETS.iter().position(|(id, _)| *id == preset).map_or(0, |i| i + 1);
if let Some(i) = widgets::dropdown(ui, "efs-preset", labels[at], &labels, 180.0) {
let id = i.checked_sub(1).and_then(|i| SCREEN_PRESETS.get(i)).map_or("", |(id, _)| *id);
if let Some(preset_rows) = fileio::screen_preset_rows(id) {
rows = preset_rows;
d.fields.insert("subfolders".into(), json!(true));
d.fields.insert("formats".into(), json!(rows));
}
d.fields.insert("preset".into(), json!(id));
}
});
});
let mut remove = None;
let one = rows.len() == 1;
egui::Grid::new("efs-formats").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
for head in ["Scale", "Suffix", "Format", ""] {
widgets::dim_label(ui, head);
}
ui.end_row();
for (i, row) in rows.iter_mut().enumerate() {
let raster = row["format"].as_str().and_then(fileio::format).is_none_or(|f| f.raster);
let scale = row["scale"].as_str().unwrap_or("1x").to_string();
ui.add_enabled_ui(raster, |ui| {
if let Some(s) = widgets::text_presets(ui, ("efs-scale", i), &scale, &SIZES, 96.0) {
// The suffix follows the size while it is the automatic one.
let auto = |text: &str| ScreenSize::parse(text).map(ScreenSize::suffix);
if row["suffix"].as_str().is_none_or(|x| Some(x.to_string()) == auto(&scale))
&& let Some(suffix) = auto(&s)
{
row["suffix"] = json!(suffix);
}
row["scale"] = json!(s);
edited = true;
}
});
if let Some(s) = widgets::text_field(ui, ("efs-suffix", i), row["suffix"].as_str(), 70.0, 1) {
row["suffix"] = json!(s);
edited = true;
}
let (format, quality) = (row["format"].as_str().unwrap_or("png"), row["quality"].as_u64());
let current = FORMATS.iter().position(|(_, f, q)| *f == format && q.is_none_or(|q| Some(u64::from(q)) == quality));
let label = current.map_or_else(|| format.to_uppercase(), |i| FORMAT_LABELS[i].to_string());
if let Some((_, f, q)) = widgets::dropdown(ui, ("efs-fmt", i), &label, &FORMAT_LABELS, 96.0).and_then(|i| FORMATS.get(i)) {
row["format"] = json!(f);
if let Some(o) = row.as_object_mut() {
match q {
Some(q) => _ = o.insert("quality".into(), json!(q)),
None => _ = o.remove("quality"),
}
}
edited = true;
}
ui.add_enabled_ui(!one, |ui| {
if widgets::icon_button(ui, "x", "Remove", false, 22.0).clicked() {
remove = Some(i);
}
});
ui.end_row();
}
});
if let Some(i) = remove {
rows.remove(i);
edited = true;
}
if widgets::secondary_button(ui, "+ Add Scale").clicked() {
// One more than the largest factor so far.
let largest = rows
.iter()
.filter_map(|r| match r["scale"].as_str().and_then(ScreenSize::parse) {
Some(ScreenSize::Scale(f)) => Some(f),
_ => None,
})
.fold(0.0, f64::max);
let size = ScreenSize::Scale(largest.floor() + 1.0);
rows.push(json!({"format": "png", "scale": size.label(), "suffix": size.suffix()}));
edited = true;
}
// Rows changed by hand are no longer a preset's.
if edited {
d.fields.insert("formats".into(), json!(rows));
d.fields.insert("preset".into(), json!(""));
}
ui.add_space(6.0);
ui.horizontal(|ui| {
widgets::dim_label(ui, "Prefix:");
form::text(ui, d, "prefix", 140.0);
});
}
/// Format Settings: the options of format `id` that apply to every row of it, then Done.
fn format_settings(ui: &mut egui::Ui, d: &mut Dialog, id: &str) {
let t = Tokens::get(ui.ctx());
widgets::subheader(ui, "Format Settings");
ui.add_space(6.0);
let at = SETTINGS.iter().position(|(f, _)| *f == id).unwrap_or(0);
if let Some((f, _)) = widgets::dropdown(ui, "efs-settings-format", SETTINGS_LABELS[at], &SETTINGS_LABELS, 150.0).and_then(|i| SETTINGS.get(i)) {
d.fields.insert("__settings".into(), json!(f));
}
ui.add_space(8.0);
// The format's options as a dialog of their own, for the options dialogs' rows.
let fields = d.fields.get("settings").and_then(|all| all.get(id)).and_then(Value::as_object).cloned().unwrap_or_default();
let mut s = Dialog { kind: KIND.into(), fields };
match id {
"svg" => svg_options::option_fields(ui, &mut s, true),
"pdf" => {
let labels: Vec<&str> =
d.fields.get("__presets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
ui.horizontal(|ui| {
ui.label(egui::RichText::new("PDF Preset:").color(t.text_dim));
if let Some(p) = widgets::dropdown(ui, "efs-pdf-preset", &s.str("preset"), &labels, 220.0).and_then(|i| labels.get(i)) {
s.fields.insert("preset".into(), json!(p));
}
});
}
_ => {
egui::Grid::new("efs-settings").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| png_options::option_rows(ui, &mut s, id, true));
}
}
if let Some(all) = d.fields.get_mut("settings").and_then(Value::as_object_mut) {
all.insert(id.into(), Value::Object(s.fields));
}
ui.add_space(10.0);
if widgets::secondary_button(ui, "Done").clicked() {
d.fields.insert("__settings".into(), json!(""));
}
}
/// A small cached rendering of artboard `i` (keyed by document revision).
fn artboard_thumb(app: &mut VectorcraftApp, ctx: &egui::Context, i: usize) -> Option<egui::TextureHandle> {
let st = app.session.active()?;

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

@ -5,7 +5,7 @@
//! choice…); `__`-prefixed ones are the dialog's.
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::{Map, Value, json};
use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, Format};
use vectorcraft_render::AntiAlias;
use vectorcraft_render::encode::jpeg::{self, ColorModel, Method};
@ -51,44 +51,9 @@ fn format_of(d: &Dialog) -> Option<&'static Format> {
/// choice) are `params`.
pub fn open(app: &mut VectorcraftApp, f: &Format, mut params: Value) {
let size = app.session.active().and_then(|st| export_size(&st.doc, &params));
// The document's background (New Document → Background Contents) is the default; JPEG has no alpha.
let white = f.id == "jpg" || app.session.active().is_some_and(|st| st.doc.setup.background == vectorcraft_doc::Background::White);
if let Some(o) = params.as_object_mut() {
o.insert("ppi".into(), json!(72));
o.insert("background".into(), json!(if white { "white" } else { "transparent" }));
o.insert("antiAlias".into(), json!(AntiAlias::default().id()));
match f.id {
"png" => {
o.insert("interlaced".into(), json!(false));
}
"jpg" => {
// A CMYK document exports CMYK by default.
let cmyk = app.session.active().is_some_and(|st| st.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk);
let model = if cmyk { ColorModel::Cmyk } else { ColorModel::Rgb };
let defaults = jpeg::JpegOptions::default();
o.extend([
("quality".into(), json!(90)),
("colorModel".into(), json!(model.id())),
("method".into(), json!(defaults.method.id())),
("scans".into(), json!(defaults.scans)),
("embedIcc".into(), json!(defaults.embed_icc)),
("imageMap".into(), json!(IMAGE_MAPS[0])),
]);
}
"gif" | "png8" => {
let p = PaletteOptions::default();
o.extend([
("reduction".into(), json!(p.reduction.id())),
("colors".into(), json!(p.colors)),
("dither".into(), json!(p.dither.id())),
("ditherAmount".into(), json!(p.dither_amount)),
("transparency".into(), json!(p.transparency)),
("matte".into(), json!(MATTES[1])),
("interlaced".into(), json!(false)),
]);
}
_ => {}
}
o.extend(defaults(app, f));
if let Some((w, h)) = size {
o.insert("__size".into(), json!([w, h]));
}
@ -96,6 +61,48 @@ pub fn open(app: &mut VectorcraftApp, f: &Format, mut params: Value) {
app.ui.dialog = Some(Dialog::new(&kind(f), params));
}
/// The options raster format `f` starts with (after the resolution): the document's background
/// (New Document → Background Contents; JPEG has no alpha), anti-aliasing and the format's own.
pub(super) fn defaults(app: &VectorcraftApp, f: &Format) -> Map<String, Value> {
let white = f.id == "jpg" || app.session.active().is_some_and(|st| st.doc.setup.background == vectorcraft_doc::Background::White);
let mut o = Map::new();
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" => {
// A CMYK document exports CMYK by default.
let cmyk = app.session.active().is_some_and(|st| st.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk);
let model = if cmyk { ColorModel::Cmyk } else { ColorModel::Rgb };
let defaults = jpeg::JpegOptions::default();
o.extend([
("quality".into(), json!(90)),
("colorModel".into(), json!(model.id())),
("method".into(), json!(defaults.method.id())),
("scans".into(), json!(defaults.scans)),
("embedIcc".into(), json!(defaults.embed_icc)),
("imageMap".into(), json!(IMAGE_MAPS[0])),
]);
}
"gif" | "png8" => {
let p = PaletteOptions::default();
o.extend([
("reduction".into(), json!(p.reduction.id())),
("colors".into(), json!(p.colors)),
("dither".into(), json!(p.dither.id())),
("ditherAmount".into(), json!(p.dither_amount)),
("transparency".into(), json!(p.transparency)),
("matte".into(), json!(MATTES[1])),
("interlaced".into(), json!(false)),
]);
}
_ => {}
}
o
}
/// 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)> {
@ -140,50 +147,13 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
});
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 {
color_button(ui, d, "background");
}
});
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" => jpeg_rows(ui, d, &label),
"gif" | "png8" => palette_rows(ui, d, &label),
"webp" => {
ui.label("");
label(ui, "Lossless (lossy WebP isn't available yet)");
ui.end_row();
}
_ => {}
}
option_rows(ui, d, id, false);
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);
let (pw, ph) = vectorcraft_render::region_pixels(vectorcraft_geom::Rect::new(0.0, 0.0, w, h), d.f64("ppi", 72.0) / 72.0);
ui.label(egui::RichText::new(format!("{pw} × {ph} px")).color(t.text));
ui.end_row();
}
@ -191,13 +161,76 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
false
}
/// The JPEG Options rows: colour model, quality (0–10, stored ×10), method and scans, profile and
/// image map.
fn jpeg_rows(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui, &str) -> egui::Response) {
/// The grid rows of raster format `id`'s options after the resolution: background, anti-aliasing
/// and the format's own. `screens` (Export for Screens' Format Settings) leaves out what its rows
/// choose (JPEG quality) and what writes other files (image maps).
pub(super) fn option_rows(ui: &mut egui::Ui, d: &mut Dialog, id: &str, screens: bool) {
let t = Tokens::get(ui.ctx());
let label = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(text).color(t.text_dim));
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 {
color_button(ui, d, "background");
}
});
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" => jpeg_rows(ui, d, &label, screens),
"gif" | "png8" => palette_rows(ui, d, &label),
"webp" => {
ui.label("");
label(ui, "Lossless (lossy WebP isn't available yet)");
ui.end_row();
}
_ => {}
}
}
/// The JPEG Options rows: colour model, quality, method and scans, profile and image map (not
/// quality and image map for Export for Screens).
fn jpeg_rows(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui, &str) -> egui::Response, screens: bool) {
label(ui, "Color Model:");
choice(ui, d, "colorModel", &ColorModel::ALL.map(ColorModel::id), &ColorModel::ALL.map(ColorModel::label));
ui.end_row();
if !screens {
quality_row(ui, d, label);
}
method_row(ui, d, label);
if !screens {
label(ui, "Image Map:");
choice(ui, d, "imageMap", &IMAGE_MAPS, &IMAGE_MAP_LABELS);
ui.end_row();
}
ui.label("");
form::check(ui, d, "embedIcc", "Embed ICC Profile");
ui.end_row();
}
/// JPEG quality on the dialog's 0–10 scale (stored ×10).
fn quality_row(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui, &str) -> egui::Response) {
label(ui, "Quality:");
let mut q = (d.f64("quality", 90.0) / 10.0).round().clamp(0.0, 10.0) as u8;
ui.horizontal(|ui| {
@ -207,7 +240,10 @@ fn jpeg_rows(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui, &s
label(ui, QUALITY_BANDS[q as usize]);
});
ui.end_row();
}
/// The JPEG method, and its scans when progressive.
fn method_row(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui, &str) -> egui::Response) {
label(ui, "Method:");
ui.horizontal(|ui| {
choice(ui, d, "method", &Method::ALL.map(Method::id), &Method::ALL.map(Method::label));
@ -220,14 +256,6 @@ fn jpeg_rows(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui, &s
}
});
ui.end_row();
label(ui, "Image Map:");
choice(ui, d, "imageMap", &IMAGE_MAPS, &IMAGE_MAP_LABELS);
ui.end_row();
ui.label("");
form::check(ui, d, "embedIcc", "Embed ICC Profile");
ui.end_row();
}
/// The PNG-8 and GIF Options rows: the palette (reduction, colours, dither and amount),

View File

@ -61,7 +61,7 @@ const UI_KEYS: [&str; 5] = ["mode", "path", "showCode", "allArtboards", "range"]
/// The options a first SVG Options dialog starts from: the engine's defaults with Internal CSS
/// and Responsive on (as the reference app's Export As starts). Hidden layers are left to the
/// command (Save keeps them, Export leaves them out).
fn first_use() -> Map<String, Value> {
pub(super) fn first_use() -> Map<String, Value> {
let mut m = serde_json::to_value(vectorcraft_svg::ExportOptions::default()).ok().and_then(|v| v.as_object().cloned()).unwrap_or_default();
m.remove("hiddenLayers");
m.insert("styling".into(), json!("css"));
@ -158,12 +158,35 @@ fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, enabled: boo
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
option_fields(ui, d, false);
if Mode::of(d) == Mode::Export {
artboards(app, ui, d);
} else {
check(ui, d, "preserveEditing", "Preserve Editing Capabilities", true);
}
ui.add_space(10.0);
let show = d.bool("showCode");
if widgets::secondary_button(ui, if show { "Hide Code" } else { "Show Code" }).clicked() {
d.fields.insert("showCode".into(), json!(!show));
}
if show {
ui.add_space(6.0);
code_view(app, ui, d);
}
false
}
/// The SVG options: styling, fonts, images, object ids, decimals, minify, responsive, `<tspan>`s
/// and metadata. `screens` (Export for Screens' Format Settings) leaves out Images: a linked
/// image is another file.
pub(super) fn option_fields(ui: &mut egui::Ui, d: &mut Dialog, screens: bool) {
let t = Tokens::get(ui.ctx());
let mode = Mode::of(d);
egui::Grid::new("svg-options").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
choice(ui, d, "styling", "Styling:", &STYLING);
choice(ui, d, "outlineText", "Font:", &FONTS);
choice(ui, d, "images", "Images:", &IMAGES);
if !screens {
choice(ui, d, "images", "Images:", &IMAGES);
}
choice(ui, d, "objectIds", "Object IDs:", &OBJECT_IDS);
ui.label(egui::RichText::new("Decimal:").color(t.text_dim));
let decimals = d.f64("decimals", 3.0);
@ -181,21 +204,6 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
});
check(ui, d, "fewerTspans", "Fewer <tspan> Elements", !d.bool("outlineText"));
check(ui, d, "metadata", "Include Metadata", true);
if mode == Mode::Export {
artboards(app, ui, d);
} else {
check(ui, d, "preserveEditing", "Preserve Editing Capabilities", true);
}
ui.add_space(10.0);
let show = d.bool("showCode");
if widgets::secondary_button(ui, if show { "Hide Code" } else { "Show Code" }).clicked() {
d.fields.insert("showCode".into(), json!(!show));
}
if show {
ui.add_space(6.0);
code_view(app, ui, d);
}
false
}
/// Use Artboards: all of them or a range; off = the bounds of all art.

View File

@ -0,0 +1,192 @@
//! Export for Screens, drawn headlessly: the fields it opens on (defaults, then the document's last
//! settings), the artboard choice, Format Settings, 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 file manager (whose reveals are recorded).
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.reveal = Some(Box::new(move |path: &str| {
l.borrow_mut().push((path.to_string(), vec![]));
Ok(())
}));
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));
}
fn temp_folder(tag: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("vc-efs-{tag}-{}", std::process::id()))
}
#[test]
fn opens_on_defaults_and_draws_every_view() {
let (mut app, _) = app(3, false);
open(&mut app);
assert_eq!((field(&app, "select"), field(&app, "range"), field(&app, "boards")), (json!("all"), json!("1-3"), json!([true, true, true])));
assert_eq!(field(&app, "formats"), json!([{"format": "png", "scale": "1x", "suffix": ""}]));
assert_eq!(field(&app, "folder"), json!(fileio::export_folder().unwrap_or_default()), "the Desktop, else home");
assert_eq!((field(&app, "openLocation"), field(&app, "subfolders"), field(&app, "preset")), (json!(true), json!(false), json!("")));
let settings = field(&app, "settings");
for f in ["png", "png8", "jpg", "webp", "gif", "svg", "pdf"] {
assert!(settings[f].is_object(), "{f} has settings");
}
assert!(settings["jpg"].get("quality").is_none() && settings["jpg"].get("imageMap").is_none(), "rows choose quality; no maps");
assert!(settings["svg"].get("images").is_none(), "no linked images");
assert_eq!(settings["pdf"]["preset"], json!(fileio::pdf::DEFAULT_PRESET));
frame(&mut app);
// Assets, Full Document, each format's Format Settings: all draw.
set(&mut app, "tab", json!("assets"));
frame(&mut app);
set(&mut app, "tab", json!("artboards"));
set(&mut app, "select", json!("full"));
for f in ["png", "png8", "jpg", "webp", "gif", "svg", "pdf"] {
set(&mut app, "__settings", json!(f));
frame(&mut app);
assert_eq!(field(&app, "__settings"), json!(f));
}
assert_eq!(kind(&app), Some(export_for_screens::KIND));
assert_eq!((spec(export_for_screens::KIND).ok_label.unwrap())(&app), "Export Artboard");
}
#[test]
fn checked_artboards_read_as_a_range() {
let (mut app, _) = app(5, false);
open(&mut app);
set(&mut app, "boards", json!([true, false, true, true, false]));
frame(&mut app);
// Nothing clicked: the fields stay as set.
assert_eq!(field(&app, "boards"), json!([true, false, true, true, false]));
for (boards, text) in [
(vec![true, true, true], "1-3"),
(vec![false, true, false, true], "2, 4"),
(vec![true, false, true, true, false, true], "1, 3-4, 6"),
(vec![false], ""),
] {
assert_eq!(export_for_screens::range_text(&boards), text);
}
}
#[test]
fn exports_into_the_folder_and_shows_it() {
let (mut app, log) = app(2, false);
let dir = temp_folder("desk");
let folder = dir.to_string_lossy().replace('\\', "/");
open(&mut app);
set(&mut app, "folder", json!(folder));
set(&mut app, "subfolders", json!(true));
set(
&mut app,
"formats",
json!([{"format": "png", "scale": "1x", "suffix": ""}, {"format": "png", "scale": "120w", "suffix": "@120w"}, {"format": "svg", "scale": "1x", "suffix": ""}]),
);
// A bad range keeps the dialog open; so does no folder.
set(&mut app, "select", json!("range"));
set(&mut app, "range", json!("7"));
assert!(confirm(&mut app).is_err());
assert_eq!(kind(&app), Some(export_for_screens::KIND));
set(&mut app, "range", json!("2"));
set(&mut app, "folder", json!(" "));
assert!(confirm(&mut app).is_err());
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}/1x/Artboard-2.png"), format!("{folder}/120w/Artboard-2@120w.png"), format!("{folder}/SVG/Artboard-2.svg")]);
let png = image::load_from_memory(&std::fs::read(dir.join("120w").join("Artboard-2@120w.png")).unwrap()).unwrap();
assert_eq!((png.width(), png.height()), (120, 80));
assert_eq!(log.borrow().iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>(), [files[0]], "Open Location shows the first file");
// It reopens on those settings.
open(&mut app);
assert_eq!((field(&app, "select"), field(&app, "range"), field(&app, "boards")), (json!("range"), json!("2"), json!([false, true])));
assert_eq!(field(&app, "formats")[1]["scale"], json!("120w"));
assert_eq!((field(&app, "subfolders"), field(&app, "folder")), (json!(true), json!(folder)));
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": "1x", "suffix": ""}, {"format": "jpg", "quality": 80, "scale": "2x", "suffix": "@2x"}]),
);
set(&mut app, "settings", json!({"png": {"background": "black"}}));
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, with the format's settings.
log.borrow_mut().clear();
open(&mut app);
assert_eq!(field(&app, "settings")["png"]["background"], json!("black"), "remembered");
set(&mut app, "select", json!("range"));
set(&mut app, "range", json!("1"));
set(&mut app, "formats", json!([{"format": "png", "scale": "1x", "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().to_rgba8().get_pixel(1, 1).0, [0, 0, 0, 255], "the PNG settings' background");
}
#[test]
fn a_preset_export_reopens_on_its_rows() {
let (mut app, _) = app(1, true);
app.run("file.exportForScreens", json!({"preset": "density", "fullDocument": true})).unwrap();
open(&mut app);
assert_eq!(field(&app, "preset"), json!("density"));
assert_eq!(field(&app, "select"), json!("full"));
assert_eq!(field(&app, "subfolders"), json!(true));
let rows = field(&app, "formats");
assert_eq!(rows.as_array().unwrap().len(), 6);
assert_eq!((rows[0]["scale"].clone(), rows[0]["folder"].clone()), (json!("0.75x"), json!("ldpi")));
frame(&mut app);
// Agents' numeric sizes show as text.
app.run(
"file.exportForScreens",
json!({"formats": [{"format": "png", "width": 64}, {"format": "png", "scale": 3}, {"format": "png", "ppi": 144}]}),
)
.unwrap();
open(&mut app);
let scales: Vec<Value> = field(&app, "formats").as_array().unwrap().iter().map(|r| r["scale"].clone()).collect();
assert_eq!(scales, [json!("64w"), json!("3x"), json!("144ppi")]);
}

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,45 @@ 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. With `openLocation`, the file manager shows the first file written.
pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result<Value, String> {
let open_location = params.get("openLocation").and_then(Value::as_bool).unwrap_or(false);
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() {
let first = r["path"].as_str().or_else(|| r["files"][0].as_str());
let mut status = format!("Exported {count} file(s) to {folder}");
if open_location
&& let Some(first) = first
&& let Err(e) = reveal_path(app, first)
{
status = format!("{status} ({e})");
}
app.status(status);
} 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 (on the document's last settings); with params = document.exportForScreens (no folder on the web: downloads the files or the zip; openLocation: shows the first file in the file manager)",
),
("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!({}) };

View File

@ -723,17 +723,7 @@ fn spin_generic(
out = Some(v.max(min));
}
if !presets.is_empty() {
let (dr, dresp) = ui.allocate_exact_size(vec2(20.0, h), Sense::click());
ui.painter().rect_filled(dr, CornerRadius { nw: 0, sw: 0, ne: 2, se: 2 }, t.input);
ui.painter().rect_stroke(dr, CornerRadius { nw: 0, sw: 0, ne: 2, se: 2 }, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
let c = if !enabled {
t.text_disabled
} else if dresp.hovered() {
t.text_strong
} else {
t.icon
};
icons::paint(ui, "chevron-down", Rect::from_center_size(dr.center(), Vec2::splat(12.0)), c);
let dresp = chevron_cell(ui, h);
egui::Popup::menu(&dresp).show(|ui| {
ui.set_min_width(width - 10.0);
for p in presets {
@ -1263,3 +1253,40 @@ pub fn region_texture(
let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
ctx.load_texture(name, color, egui::TextureOptions::LINEAR)
}
/// The chevron cell on the right of a field that opens its preset list (rounded on the right).
fn chevron_cell(ui: &mut Ui, h: f32) -> Response {
let t = Tokens::get(ui.ctx());
let (dr, dresp) = ui.allocate_exact_size(vec2(20.0, h), Sense::click());
ui.painter().rect_filled(dr, CornerRadius { nw: 0, sw: 0, ne: 2, se: 2 }, t.input);
ui.painter().rect_stroke(dr, CornerRadius { nw: 0, sw: 0, ne: 2, se: 2 }, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
let c = if !ui.is_enabled() {
t.text_disabled
} else if dresp.hovered() {
t.text_strong
} else {
t.icon
};
icons::paint(ui, "chevron-down", Rect::from_center_size(dr.center(), Vec2::splat(12.0)), c);
dresp
}
/// A [`text_field`] with a preset list on its right (Export for Screens' sizes: `2x`, `100w`…).
/// Returns the new text when a change is committed (Enter or focus loss) or a preset is picked.
pub fn text_presets(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: &str, presets: &[&str], width: f32) -> Option<String> {
let mut out = None;
ui.scope(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
out = text_field(ui, &id, Some(value), width - 20.0, 1);
let resp = chevron_cell(ui, 26.0);
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(width - 10.0);
for p in presets {
if ui.selectable_label(*p == value, *p).clicked() {
out = Some((*p).to_string());
}
}
});
});
out
}

View File

@ -31,6 +31,7 @@
| `app.export` Text and WebP | `{path?, format: txt, encoding?: utf8 \| utf16, lineEndings?: lf \| crlf, selectionOnly?}` / `{format: webp, lossless?, quality?}` | Text writes the stories in stacking order (back to front; a thread once; hidden objects and template layers left out), each line ended by `lineEndings`; `utf16` is little-endian with a byte order mark. Export As → Text opens `txtOptions` (fields `path`, `encoding`, `lineEndings`, `selectionOnly`; OK writes the file). WebP is always lossless: `lossless: false` adds a warning |
| `engine.execute` `document.exportForOffice` | `{}` / `{path?, ppi?: 72 \| 150 \| 300, transparent?, artboard?}` | File → Save for Office Documents…: without params opens `saveForOffice` (fields `ppi`, `transparent`, `artboard`; OK picks the file); with params writes the artboard as a PNG (`path`, else a picked file) → `{path}`. Headless it returns `{dataBase64, width, height}` |
| Background Save / Export | `app.save`, `app.export {path}`, `file.save`, `file.saveAs`, `file.saveCopy` | with the `backgroundSave` / `backgroundExport` preferences on (the default) the desktop app writes the file from a snapshot on a worker thread: the call returns `{path, background: true, status}` at once, `ui.inspect` lists the jobs still running in `background` (labels such as `"Saving a.vectorcraft"`), and the document counts as saved (as of the snapshot) once its file is written. Quit and Close wait for them. With the preferences off (`prefs.set`), saves return once written |
| `engine.execute` `file.exportForScreens` | `{}` / `{…document.exportForScreens params}` | without params opens `exportForScreens` on the settings the document last exported with (`document.exportSettings`). Fields: `tab` (`artboards` \| `assets`), `select` (`all` \| `range` \| `full`), `range` ("1-3, 5"), `boards` (a bool per artboard; checking them sets `select` and `range`), `includeBleed`, `folder` (desktop; default the Desktop, else home), `openLocation`, `subfolders`, `prefix`, `preset` (`""` \| `mobile` \| `density`), `formats` (rows `{format, scale: "2x" \| "100w" \| "100h" \| "72ppi", suffix, quality?, folder?}`), `settings` (`{png \| png8 \| jpg \| webp \| gif \| svg \| pdf: {…options}}`, the gear's Format Settings; `__settings` names the format shown). `ui.dialog.confirm` exports (a bad range or no folder keeps it open); on the web OK reads "Download" and the files download (several as one `.zip`). With params it runs `document.exportForScreens`: no `folder` returns the files (or with `zip: true` one store-only zip) as `dataBase64`; with `openLocation` the first file is shown in the file manager |
| `ui.dialog.*` on `importPdf` | `{field: "page" \| "allPages" \| "range" \| "cropTo" \| "password", value}` | opening (`app.open`, `file.open`) a PDF or `.ai` with several pages or a password, or placing one (`file.place` without `page`), opens the Import PDF dialog (`mode`: `open` or `place`; `pageCount`). While `__locked` is true, set `password` and `ui.dialog.confirm` to unlock it (a one-page file then opens or is placed). Then set `allPages: false` and `range` ("2-3, 5") to open some pages, or `page` to place one, and `cropTo` (`bounding`, `art`, `crop`, `trim`, `bleed`, `media`); `ui.dialog.confirm` opens or places them (placing keeps the other `file.place` params, such as `link`, `template` and `at`, in `__place`). `document.open {pages, cropTo, password}` and `file.place {page, crop, password}` do the same without the dialog |

View File

@ -1175,3 +1175,21 @@ slice ids and the ids of objects with an object slice.
{"name":"run_command","arguments":{"command":"object.slice.create","params":{"x":0,"y":0,"width":200,"height":80}}}
{"name":"run_command","arguments":{"command":"object.slice.move","params":{"dx":10,"dy":0}}}
```
## Export for Screens
`document.exportForScreens` writes every chosen artboard (`range`, `artboards`; default all) in every format row
into `folder`, or returns the files as `dataBase64` without one; `zip: true` packs them into one store-only `.zip`.
A row is `{format, scale?}` where `scale` is a factor (`2`, `"2x"`), a pixel width (`"100w"` or `width: 100`), a
height (`"100h"`) or a resolution (`"72ppi"`); raster rows name their files `@2x`, `@100w`… unless `suffix` says
otherwise. `fullDocument` writes one file per row instead (a PDF of every artboard, other formats the bounds of all
art), `includeBleed` grows each artboard by the document's bleed, `subfolders` puts each row's files in a sub-folder
(its size or format, or its own `folder`), `preset: "mobile"` or `"density"` (Android-style ldpi…xxxhdpi
sub-folders) replaces the rows, and `settings: {png: {…}, jpg: {…}, svg: {…}, pdf: {preset}}` gives every row of a
format its options. The document remembers the last params: `document.exportSettings`.
```json
{"name":"run_command","arguments":{"command":"document.exportForScreens","params":{"preset":"density","zip":true}}}
{"name":"run_command","arguments":{"command":"document.exportForScreens","params":{"range":"1-2","formats":[{"format":"png","scale":"512w"},{"format":"jpg","quality":80,"scale":"2x"},{"format":"svg"}],"settings":{"png":{"background":"white"}}}}}
{"name":"run_command","arguments":{"command":"document.exportSettings","params":{}}}
```