M4.52: Export for Screens options: full document, bleed, sub-folders, sizes, presets, format settings

Artboards: All, a range or Full Document (one file per format: a PDF of
every artboard, other formats the bounds of all art), Include Bleed
(artboards grown by the document's bleed). Rows size files as a factor,
100w, 100h or 72ppi (suffixes @2x, @100w, @100h), and choose PNG, PNG 8,
JPG 100/80/50, WebP, GIF, SVG or PDF. Create Sub-folders (by size, by
format, or a row's own folder) and the presets Mobile 1x/2x/3x and
Density buckets (ldpi…xxxhdpi sub-folders). The gear opens Format
Settings: per-format options for every row of that format (the raster
and SVG options dialogs' rows, the PDF preset). Open Location after
Export shows the first file. The dialog has Artboards and Assets tabs.

The params of the last export are saved in the document
(Document.export_settings, not an undo step), read with
document.exportSettings, and the dialog reopens on them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 13:18:10 +07:00
co-authored by Claude Opus 5.5
parent 902d95173e
commit c96bf40d18
14 changed files with 1331 additions and 356 deletions
+5
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,
+12 -2
View File
@@ -5,7 +5,7 @@
//! - `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` / `exportForOffice`.
//! - `screens`: Export for Screens (`document.exportForScreens`); `zip`
//! - `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.
@@ -49,6 +49,7 @@ pub use save::{
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.
@@ -101,7 +102,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export for Screens",
["File", "Export"],
None,
"{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), formats?: [{format: png|jpg|webp|gif|png8|svg|svgz|pdf, scale?: 1 (raster only), ppi?: (raster: wins over scale, scale = ppi / 72), suffix?: \"@2x\" (raster default: @{scale}x when scale ≠ 1; vector formats drop @Nx suffixes), …the format's options (antiAlias, background…)}], prefix?, antiAlias?: none|art|type (raster rows without their own)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]}",
"{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,
screens::export_for_screens
),
@@ -133,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())
+355 -84
View File
@@ -1,135 +1,406 @@
//! File → Export → Export for Screens (`document.exportForScreens`): the chosen artboards in every
//! format row, into a folder or returned for download (each file, or one ZIP).
//! 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::{Value, json};
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::{ArtboardPick, Format, artboard_file_names, create_dir, encode, writable, write_file};
use super::{ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, encode, writable, write_file};
const C: &str = "document.exportForScreens";
/// One Export for Screens format row: what to write, at which scale, with which file-name suffix.
struct ScreenFormat {
format: &'static Format,
/// The row's own options (scale, quality…) for the encoder.
options: Value,
suffix: String,
/// 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),
}
fn screen_format(row: &Value) -> Result<ScreenFormat> {
if !row.is_object() {
return Err(bad(C, "each format is an object {format, scale?, suffix?}"));
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)
}
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"));
/// 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))),
}
}
// 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));
/// 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()),
}
}
Ok(ScreenFormat { format, options, suffix })
}
/// A pixel-density suffix such as `@2x` or `@0.5x`.
fn is_scale_suffix(s: &str) -> bool {
s.strip_prefix('@').and_then(|s| s.strip_suffix(['x', 'X'])).is_some_and(|n| n.parse::<f64>().is_ok())
/// A 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())
}
/// A name safe as one file name: letters, digits, `-`, `_` and `.` kept, anything else `-` (`None`
/// when nothing but dots is left).
/// 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)
}
/// 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.
/// 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 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!({}))?],
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 sf in &mut formats {
if let Some(o) = sf.options.as_object_mut() {
o.entry("antiAlias").or_insert_with(|| aa.clone());
for r in &mut rows {
if let Some(m) = r.options.as_object_mut() {
m.entry("antiAlias").or_insert_with(|| aa.clone());
}
}
}
let prefix = str_param(p, "prefix").unwrap_or("");
let folder = str_param(p, "folder").filter(|f| !f.is_empty());
let zip = bool_or(p, "zip", false);
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 mut written = HashSet::new();
// Written paths, or (name, bytes) to return or zip.
let mut paths = vec![];
let mut kept: Vec<(String, Vec<u8>)> = vec![];
for (b, name) in boards.iter().copied().zip(artboard_file_names(&doc, &boards)) {
for sf in &formats {
let file = format!("{prefix}{name}{}.{}", sf.suffix, sf.format.extensions[0]);
if !written.insert(file.to_lowercase()) {
continue; // the same file from two identical rows
let 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 mut options = sf.options.clone();
options["artboard"] = json!(b);
let bytes = encode(&doc, sf.format.id, &options)?;
match folder {
Some(dir) if !zip => {
let path = format!("{dir}/{file}");
write_file(&path, &bytes)?;
paths.push(path);
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"));
}
_ => kept.push((file, bytes)),
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)?;
}
}
}
let names: Vec<&str> = kept.iter().map(|(n, _)| n.as_str()).collect();
if zip {
let archive = super::zip::store(&kept).map_err(|e| bad(C, e))?;
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, &archive)?;
Ok(json!({ "path": path, "bytes": archive.len(), "files": names }))
}
None => {
Ok(json!({ "name": zip_name, "dataBase64": vectorcraft_format::base64_encode(&archive), "bytes": archive.len(), "files": names }))
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": paths }),
Some(_) => json!({ "files": out.written }),
None => {
json!({ "files": kept.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect::<Vec<_>>() })
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 }))
}
+192 -3
View File
@@ -1,7 +1,9 @@
//! Export for Screens: files returned for download, one by one or as a ZIP.
//! 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> {
@@ -25,22 +27,176 @@ 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('\\', "/");
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!({"formats": [{"format": "png"}, {"format": "png", "scale": 2}, {"format": "pdf"}]});
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);
@@ -78,3 +234,36 @@ fn zip_archives_store_their_files() {
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());
}
+1 -1
View File
@@ -35,7 +35,7 @@ const HOST_COMMANDS: &[(&str, &str, &str)] = &[
(
"file.exportForScreens",
"Export for Screens…",
"{folder?, zip?, artboards? | range?, formats?, prefix?} = document.exportForScreens (no folder → the files, or one zip, as dataBase64)",
"{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"),
];
+439 -130
View File
@@ -1,10 +1,20 @@
//! File → Export → Export for Screens: artboards (thumbnails and checkboxes), scale/format rows,
//! the destination folder (typed or picked; the web downloads instead) 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 vectorcraft_engine::cmd::fileio;
use serde::Deserialize;
use serde_json::{Map, Value, json};
use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, SCREEN_PRESETS, ScreenSize, pdf};
use super::DialogSpec;
use super::{DialogSpec, form, png_options, svg_options};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, io, widgets};
@@ -15,168 +25,467 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
confirm,
ok: Some("Export Artboard"),
ok_label: Some(|app| if io::is_web(app) { "Download" } else { "Export Artboard" }),
min_width: 660.0,
..DialogSpec::FORM
};
/// `Dialog::kind` of this dialog.
pub const KIND: &str = "exportForScreens";
/// Open the dialog: every artboard, PNG at 1x and 2x, into the Desktop (else the home folder).
/// 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 = app.session.active().map_or(0, |d| d.doc.artboards.len());
app.ui.dialog = Some(Dialog::new(
KIND,
json!({"boards": vec![true; n], "formats": [{"format": "png", "scale": 1, "suffix": ""}, {"format": "png", "scale": 2, "suffix": "@2x"}], "folder": fileio::export_folder().unwrap_or_default(), "prefix": ""}),
));
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));
}
/// Export the checked artboards (the web: no folder, one ZIP for several files); the dialog stays
/// open when the export fails.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let boards: Vec<usize> = d
.fields
.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 formats = d.fields.get("formats").cloned().unwrap_or(json!([]));
let files = boards.len() * formats.as_array().map_or(0, Vec::len);
let mut params = json!({"artboards": boards, "formats": formats, "prefix": d.str("prefix")});
/// 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) {
params["zip"] = json!(files > 1);
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());
}
params["folder"] = json!(folder.trim());
p.insert("folder".into(), json!(folder.trim()));
p.insert("openLocation".into(), json!(d.bool("openLocation")));
}
let r = io::export_for_screens(app, params);
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));
if io::is_web(app) {
widgets::dim_label(ui, "Your browser downloads the files (several as one .zip).");
ui.set_width(350.0);
let shown = d.str("__settings");
if SETTINGS.iter().any(|(id, _)| *id == shown) {
format_settings(ui, d, &shown);
} else {
let pick = app.services.pick_folder.is_some();
destination(app, ui, d);
ui.add_space(10.0);
formats(ui, d);
}
});
});
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 mut folder = d.str("folder");
if ui.add(egui::TextEdit::singleline(&mut folder).desired_width(if pick { 290.0 } else { 320.0 })).changed() {
d.fields.insert("folder".into(), json!(folder));
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 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));
if let Some(b) = boards.get_mut(i) {
ui.checkbox(b, name);
}
});
}
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
// 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).
+115 -87
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),
+26 -18
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.
+117 -17
View File
@@ -1,4 +1,5 @@
//! Export for Screens, drawn headlessly: desktop folders and web downloads.
//! 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;
@@ -14,7 +15,7 @@ use crate::Services;
type Log = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
/// An app with `artboards` 60×40 pt artboards and an ellipse on the first; `web` gives it a
/// download service (recorded in the log) instead of a folder picker.
/// 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();
@@ -22,6 +23,10 @@ fn app(artboards: usize, web: bool) -> (VectorcraftApp, Log) {
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);
@@ -39,27 +44,92 @@ fn open(app: &mut VectorcraftApp) {
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 exports_into_the_folder() {
let (mut app, _) = app(2, false);
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!((spec(export_for_screens::KIND).ok_label.unwrap())(&app), "Export Artboard");
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);
let dir = std::env::temp_dir().join(format!("vc-efs-desk-{}", std::process::id()));
// 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('\\', "/");
set(&mut app, "boards", json!([false, true]));
set(&mut app, "formats", json!([{"format": "png", "scale": 1, "suffix": ""}, {"format": "svg", "scale": 1, "suffix": ""}]));
// No folder keeps the dialog open.
set(&mut app, "folder", json!(" "));
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}/Artboard-2.png"), format!("{folder}/Artboard-2.svg")]);
assert!(std::fs::metadata(dir.join("Artboard-2.png")).unwrap().len() > 50);
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);
}
@@ -69,7 +139,12 @@ fn the_web_downloads_one_zip_or_the_file() {
assert_eq!((spec(export_for_screens::KIND).ok_label.unwrap())(&app), "Download");
open(&mut app);
frame(&mut app);
set(&mut app, "formats", json!([{"format": "png", "scale": 1, "suffix": ""}, {"format": "jpg", "scale": 2, "suffix": "@2x"}]));
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");
{
@@ -79,14 +154,39 @@ fn the_web_downloads_one_zip_or_the_file() {
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.
// One file downloads as itself, with the format's settings.
log.borrow_mut().clear();
open(&mut app);
set(&mut app, "boards", json!([true, false]));
set(&mut app, "formats", json!([{"format": "png", "scale": 1, "suffix": ""}]));
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().width(), 60);
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")]);
}
+11 -2
View File
@@ -509,8 +509,9 @@ pub(crate) fn open_in_app(app: &mut VectorcraftApp, path: &str) -> Result<(), St
/// 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.
/// 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);
@@ -522,7 +523,15 @@ pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result<Val
}
};
if returned.is_empty() {
app.status(format!("Exported {count} file(s) to {folder}"));
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")?;
+1 -1
View File
@@ -106,7 +106,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"file.exportForScreens",
"Export for Screens…",
"Cmd+Alt+E",
"{} opens the dialog; with params = document.exportForScreens (no folder on the web: downloads the files or the zip)",
"{} 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"),
+38 -11
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
}
+1
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 |
+18
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":{}}}
```