From 902d95173e7606b51ed77838707133200798e4d1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 12:36:53 +0700 Subject: [PATCH 1/2] M4.51: Export for Screens downloads on the web, picks folders, zips document.exportForScreens takes zip: true to pack every file into one store-only .zip (returned as dataBase64 without a folder, written there with one); without a folder the files come back as base64. The engine's Export for Screens moves to fileio/screens.rs, with a small ZIP writer (fileio/zip.rs). The dialog starts in the user's Desktop (else home, on every OS), has a folder picker button (the Services.pick_folder the Links panel uses) and stays open when the export fails. On the web it has no folder: OK reads "Download" (DialogSpec.ok_label) and the files download, several as one zip. file.exportForScreens with params goes through io::export_for_screens. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/fileio/export.rs | 102 +------------ crates/engine/src/cmd/fileio/mod.rs | 15 +- crates/engine/src/cmd/fileio/save.rs | 16 ++- crates/engine/src/cmd/fileio/screens.rs | 135 ++++++++++++++++++ crates/engine/src/cmd/fileio/tests_screens.rs | 80 +++++++++++ crates/engine/src/cmd/fileio/zip.rs | 103 +++++++++++++ crates/mcp/src/headless.rs | 6 +- .../ui-egui/src/dialogs/export_for_screens.rs | 69 +++++++-- crates/ui-egui/src/dialogs/mod.rs | 9 +- crates/ui-egui/src/dialogs/tests_screens.rs | 92 ++++++++++++ crates/ui-egui/src/io.rs | 32 ++++- crates/ui-egui/src/menus.rs | 16 +-- 12 files changed, 545 insertions(+), 130 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/screens.rs create mode 100644 crates/engine/src/cmd/fileio/tests_screens.rs create mode 100644 crates/engine/src/cmd/fileio/zip.rs create mode 100644 crates/ui-egui/src/dialogs/tests_screens.rs diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 924fbeff..67a3fe58 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -1,10 +1,9 @@ -//! `document.export`, `document.serialize`, `document.exportSelection`, `document.exportForScreens`. +//! `document.export`, `document.serialize`, `document.exportSelection`, `document.exportForOffice`. use std::borrow::Cow; use std::collections::HashSet; use std::sync::Arc; -use serde::Deserialize; use serde_json::{Value, json}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; @@ -12,10 +11,7 @@ use crate::DocState; use super::super::*; use super::encode::single_artboard; -use super::{ - ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, default_name, encode, encode_all, merge, writable, write_encoded, - write_file, write_or_return, -}; +use super::{ARTBOARD_PARAMS, Format, default_name, encode, encode_all, merge, writable, write_encoded, write_or_return}; pub(super) fn serialize(s: &mut Session, p: &Value) -> Result { let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?; @@ -139,100 +135,6 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result { write_or_return(path, &bytes, json!({ "bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()] })) } -/// One Export for Screens format row: what to write, at which scale, with which file-name suffix. -struct ScreenFormat { - format: &'static Format, - /// The row's own options (scale, quality…) for the encoder. - options: Value, - suffix: String, -} - -fn screen_format(row: &Value) -> Result { - const C: &str = "document.exportForScreens"; - if !row.is_object() { - return Err(bad(C, "each format is an object {format, scale?, suffix?}")); - } - let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?; - if matches!(format.id, "vectorcraft" | "template" | "dxf") { - return Err(bad(C, "Export for Screens writes png, jpg, webp, gif, png8, svg, svgz or pdf")); - } - // Vector formats have no pixel size: scale doesn't apply and adds no @Nx suffix (not even one - // left over from a raster row switched to SVG or PDF). - // A raster row's `ppi` wins over its `scale` (the encoder does the same), so it names the suffix. - let scale = match row.get("ppi").and_then(Value::as_f64) { - _ if !format.raster => 1.0, - Some(ppi) => ppi / 72.0, - None => f64_or(row, "scale", 1.0), - }; - let suffix = match str_param(row, "suffix") { - Some(s) if !format.raster && is_scale_suffix(s) => String::new(), - Some(s) => s.to_string(), - None if (scale - 1.0).abs() < 1e-9 => String::new(), - None => format!("@{scale}x"), - }; - let mut options = without_artboards(row); - if let Some(o) = options.as_object_mut() { - o.insert("scale".into(), json!(scale)); - // A file per artboard is for viewing: it doesn't carry the whole document to edit. - o.entry("preserveEditing").or_insert(json!(false)); - } - Ok(ScreenFormat { format, options, suffix }) -} - -/// A pixel-density suffix such as `@2x` or `@0.5x`. -fn is_scale_suffix(s: &str) -> bool { - s.strip_prefix('@').and_then(|s| s.strip_suffix(['x', 'X'])).is_some_and(|n| n.parse::().is_ok()) -} - -/// File → Export for Screens: every chosen artboard in every format, one file each (a PDF holds -/// its artboard alone). Artboards that share a name get `-2`, `-3`… instead of overwriting. -pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result { - const C: &str = "document.exportForScreens"; - let doc = s.doc()?.doc.clone(); - let n = doc.artboards.len(); - let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(C, e.to_string()))? } else { ArtboardPick::default() }; - let boards = pick.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect()); - let mut formats: Vec = match p.get("formats").and_then(Value::as_array) { - Some(rows) => rows.iter().map(screen_format).collect::>()?, - None => vec![screen_format(&json!({}))?], - }; - // A top-level anti-aliasing mode applies to every row that doesn't name its own. - if let Some(aa) = p.get("antiAlias") { - for sf in &mut formats { - if let Some(o) = sf.options.as_object_mut() { - o.entry("antiAlias").or_insert_with(|| aa.clone()); - } - } - } - let prefix = str_param(p, "prefix").unwrap_or(""); - let folder = str_param(p, "folder"); - if let Some(dir) = folder { - create_dir(dir)?; - } - let mut written = HashSet::new(); - let mut files = vec![]; - for (b, name) in boards.iter().copied().zip(artboard_file_names(&doc, &boards)) { - for sf in &formats { - let file = format!("{prefix}{name}{}.{}", sf.suffix, sf.format.extensions[0]); - if !written.insert(file.to_lowercase()) { - continue; // the same file from two identical rows - } - let mut options = sf.options.clone(); - options["artboard"] = json!(b); - let bytes = encode(&doc, sf.format.id, &options)?; - match folder { - Some(dir) => { - let path = format!("{dir}/{file}"); - write_file(&path, &bytes)?; - files.push(json!(path)); - } - None => files.push(json!({ "name": file, "dataBase64": vectorcraft_format::base64_encode(&bytes) })), - } - } - } - Ok(json!({ "files": files })) -} - /// File → Save for Office Documents: one artboard as a PNG at `ppi`, on white unless /// `transparent`. pub(super) fn export_for_office(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index dd1d24db..d3042a12 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -4,7 +4,9 @@ //! - `load`: `document.open` (native, legacy, SVG/SVGZ, PDF/.ai/.ait, raster images). //! - `encode`: one encoder per writable format, with typed options parsed from the params. //! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…). -//! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForScreens`. +//! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForOffice`. +//! - `screens`: Export for Screens (`document.exportForScreens`); `zip` +//! packs its files as one download. //! - `save`: `document.save`, Save As / a Copy / as Template, New from Template, Revert and the //! per-format options (`file.formatOptions`); [`save_with`] is the one save path of every frontend. //! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`. @@ -26,8 +28,10 @@ mod pdfimport; pub mod pngtext; pub mod ppi; mod save; +mod screens; mod svg; mod text; +mod zip; use serde_json::{Value, json}; @@ -42,7 +46,8 @@ pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_byt pub use native::with_compression_pref; pub use pdfimport::{LoadOptions, page_document}; pub use save::{ - SAVE_FORMATS, SaveJob, SaveMode, SavePlan, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, templates_folder, + SAVE_FORMATS, SaveJob, SaveMode, SavePlan, export_folder, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, + templates_folder, }; pub use svg::options_map as svg_options; /// Atomic file writes (a temporary file renamed over the target: a failed write never damages the @@ -96,9 +101,9 @@ pub fn specs() -> Vec { "Export for Screens", ["File", "Export"], None, - "{folder?, artboards?: [index…] | range?: \"1-3\" (default all), formats?: [{format: png|jpg|webp|gif|png8|svg|svgz|pdf, scale?: 1 (raster only), ppi?: (raster: wins over scale, scale = ppi / 72), suffix?: \"@2x\" (raster default: @{scale}x when scale ≠ 1; vector formats drop @Nx suffixes), …the format's options (antiAlias, background…)}], prefix?, antiAlias?: none|art|type (raster rows without their own)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]}", + "{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), formats?: [{format: png|jpg|webp|gif|png8|svg|svgz|pdf, scale?: 1 (raster only), ppi?: (raster: wins over scale, scale = ppi / 72), suffix?: \"@2x\" (raster default: @{scale}x when scale ≠ 1; vector formats drop @Nx suffixes), …the format's options (antiAlias, background…)}], prefix?, antiAlias?: none|art|type (raster rows without their own)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]}", has_doc, - export::export_for_screens + screens::export_for_screens ), cmd!(query "command.batch", "Batch", [], None, "{label?, commands: [{command, params}]} run several commands as ONE undo step; stops at the first error and rolls back", has_doc, batch::batch), cmd!( @@ -675,6 +680,8 @@ mod tests_jpeg; #[cfg(test)] mod tests_palette; +#[cfg(test)] +mod tests_screens; #[cfg(test)] mod tests_text; diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 7eb186f9..00739995 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -103,8 +103,20 @@ pub fn templates_folder(prefs: &Prefs) -> Option { if !prefs.templates_folder.is_empty() { return Some(prefs.templates_folder.clone()); } - let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).ok().filter(|h| !h.is_empty())?; - Some(std::path::Path::new(&home).join("Documents").join("VectorCraft Templates").to_string_lossy().to_string()) + Some(std::path::Path::new(&home_folder()?).join("Documents").join("VectorCraft Templates").to_string_lossy().to_string()) +} + +/// The user's home folder (`HOME`, else `USERPROFILE` on Windows; none on the web). +fn home_folder() -> Option { + std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).ok().filter(|h| !h.is_empty()) +} + +/// Where exports go by default (Export for Screens): the user's Desktop when there is one, else +/// their home folder (none on the web, which downloads). +pub fn export_folder() -> Option { + let home = home_folder()?; + let desktop = std::path::Path::new(&home).join("Desktop"); + Some(if desktop.is_dir() { desktop.to_string_lossy().into_owned() } else { home }) } /// The folder of a path (`None` for a bare file name). diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs new file mode 100644 index 00000000..c89b2bb4 --- /dev/null +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -0,0 +1,135 @@ +//! File → Export → Export for Screens (`document.exportForScreens`): the chosen artboards in every +//! format row, into a folder or returned for download (each file, or one ZIP). + +use std::collections::HashSet; + +use serde::Deserialize; +use serde_json::{Value, json}; + +use super::super::*; +use super::export::without_artboards; +use super::{ArtboardPick, Format, artboard_file_names, create_dir, encode, writable, write_file}; + +const C: &str = "document.exportForScreens"; + +/// One Export for Screens format row: what to write, at which scale, with which file-name suffix. +struct ScreenFormat { + format: &'static Format, + /// The row's own options (scale, quality…) for the encoder. + options: Value, + suffix: String, +} + +fn screen_format(row: &Value) -> Result { + if !row.is_object() { + return Err(bad(C, "each format is an object {format, scale?, suffix?}")); + } + let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?; + if matches!(format.id, "vectorcraft" | "template" | "dxf") { + return Err(bad(C, "Export for Screens writes png, jpg, webp, gif, png8, svg, svgz or pdf")); + } + // Vector formats have no pixel size: scale doesn't apply and adds no @Nx suffix (not even one + // left over from a raster row switched to SVG or PDF). + // A raster row's `ppi` wins over its `scale` (the encoder does the same), so it names the suffix. + let scale = match row.get("ppi").and_then(Value::as_f64) { + _ if !format.raster => 1.0, + Some(ppi) => ppi / 72.0, + None => f64_or(row, "scale", 1.0), + }; + let suffix = match str_param(row, "suffix") { + Some(s) if !format.raster && is_scale_suffix(s) => String::new(), + Some(s) => s.to_string(), + None if (scale - 1.0).abs() < 1e-9 => String::new(), + None => format!("@{scale}x"), + }; + let mut options = without_artboards(row); + if let Some(o) = options.as_object_mut() { + o.insert("scale".into(), json!(scale)); + // A file per artboard is for viewing: it doesn't carry the whole document to edit. + o.entry("preserveEditing").or_insert(json!(false)); + } + Ok(ScreenFormat { format, options, suffix }) +} + +/// A pixel-density suffix such as `@2x` or `@0.5x`. +fn is_scale_suffix(s: &str) -> bool { + s.strip_prefix('@').and_then(|s| s.strip_suffix(['x', 'X'])).is_some_and(|n| n.parse::().is_ok()) +} + +/// A name safe as one file name: letters, digits, `-`, `_` and `.` kept, anything else `-` (`None` +/// when nothing but dots is left). +fn safe_name(s: &str) -> Option { + let n: String = s.trim().chars().map(|c| if c.is_alphanumeric() || "-_.".contains(c) { c } else { '-' }).collect(); + (!n.trim_matches('.').is_empty()).then_some(n) +} + +/// File → Export for Screens: every chosen artboard in every format, one file each (a PDF holds +/// its artboard alone). Artboards that share a name get `-2`, `-3`… instead of overwriting. +pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result { + 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 = match p.get("formats").and_then(Value::as_array) { + Some(rows) => rows.iter().map(screen_format).collect::>()?, + None => vec![screen_format(&json!({}))?], + }; + // A top-level anti-aliasing mode applies to every row that doesn't name its own. + if let Some(aa) = p.get("antiAlias") { + for sf in &mut formats { + if let Some(o) = sf.options.as_object_mut() { + o.entry("antiAlias").or_insert_with(|| aa.clone()); + } + } + } + let prefix = str_param(p, "prefix").unwrap_or(""); + let folder = str_param(p, "folder").filter(|f| !f.is_empty()); + let zip = bool_or(p, "zip", false); + if let Some(dir) = folder { + create_dir(dir)?; + } + let mut written = HashSet::new(); + // Written paths, or (name, bytes) to return or zip. + let mut paths = vec![]; + let mut kept: Vec<(String, Vec)> = vec![]; + for (b, name) in boards.iter().copied().zip(artboard_file_names(&doc, &boards)) { + for sf in &formats { + let file = format!("{prefix}{name}{}.{}", sf.suffix, sf.format.extensions[0]); + if !written.insert(file.to_lowercase()) { + continue; // the same file from two identical rows + } + let mut options = sf.options.clone(); + options["artboard"] = json!(b); + let bytes = encode(&doc, sf.format.id, &options)?; + match folder { + Some(dir) if !zip => { + let path = format!("{dir}/{file}"); + write_file(&path, &bytes)?; + paths.push(path); + } + _ => kept.push((file, bytes)), + } + } + } + let names: Vec<&str> = kept.iter().map(|(n, _)| n.as_str()).collect(); + if zip { + let archive = super::zip::store(&kept).map_err(|e| bad(C, e))?; + let zip_name = format!("{prefix}{}.zip", safe_name(&super::file_stem(&doc.title)).unwrap_or_else(|| "Untitled".into())); + return match folder { + Some(dir) => { + let path = format!("{dir}/{zip_name}"); + write_file(&path, &archive)?; + Ok(json!({ "path": path, "bytes": archive.len(), "files": names })) + } + None => { + Ok(json!({ "name": zip_name, "dataBase64": vectorcraft_format::base64_encode(&archive), "bytes": archive.len(), "files": names })) + } + }; + } + Ok(match folder { + Some(_) => json!({ "files": paths }), + None => { + json!({ "files": kept.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect::>() }) + } + }) +} diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs new file mode 100644 index 00000000..0728655d --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -0,0 +1,80 @@ +//! Export for Screens: files returned for download, one by one or as a ZIP. + +use serde_json::{Value, json}; + +use super::*; + +fn b64(v: &Value) -> Vec { + vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap() +} + +/// A document of `artboards` 40×30 pt artboards side by side with a 20 pt square on the first. +fn session(artboards: usize) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 40, "height": 30, "artboards": artboards})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 20})).unwrap(); + s +} + +fn export(s: &mut Session, p: Value) -> Value { + s.execute("document.exportForScreens", &p).unwrap() +} + +/// `(name, bytes)` of each returned file. +fn files(r: &Value) -> Vec<(String, Vec)> { + r["files"].as_array().unwrap().iter().map(|f| (f["name"].as_str().unwrap().to_string(), b64(f))).collect() +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_zip_is_written_into_the_folder() { + let mut s = session(1); + let dir = std::env::temp_dir().join(format!("vc-screens-zip-{}", std::process::id())); + let folder = dir.to_string_lossy().replace('\\', "/"); + let r = export(&mut s, json!({"folder": folder, "zip": true, "formats": [{"format": "png"}, {"format": "svg"}]})); + let zip = std::fs::read(r["path"].as_str().unwrap()).unwrap(); + assert_eq!(super::zip::entries(&zip).unwrap().len(), 2); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn zip_downloads_round_trip() { + let mut s = session(2); + let p = json!({"formats": [{"format": "png"}, {"format": "png", "scale": 2}, {"format": "pdf"}]}); + let plain = files(&export(&mut s, p.clone())); + let mut q = p; + q["zip"] = json!(true); + let r = export(&mut s, q); + let title = super::file_stem(&s.doc().unwrap().doc.title); + assert_eq!(r["name"], format!("{title}.zip")); + let zip = b64(&r); + assert_eq!(r["bytes"].as_u64(), Some(zip.len() as u64)); + let entries = super::zip::entries(&zip).unwrap(); + let names = |files: &[(String, Vec)]| files.iter().map(|(n, _)| n.clone()).collect::>(); + assert_eq!(names(&entries), names(&plain), "the same files, sub-folders in their names"); + for ((name, zipped), (_, returned)) in entries.iter().zip(&plain) { + if name.ends_with(".pdf") { + // A PDF carries the time it was written. + assert_eq!(vectorcraft_pdf::import(zipped).unwrap().artboards.len(), 1, "{name}"); + } else { + assert_eq!(zipped, returned, "{name}"); + } + } + assert_eq!(r["files"].as_array().unwrap().len(), 6); +} + +#[test] +fn zip_archives_store_their_files() { + let files = [("a.txt", b"hello".to_vec()), ("sub/ünï.png", vec![0u8, 1, 2, 255]), ("empty", vec![])]; + let zip = super::zip::store(&files).unwrap(); + let back = super::zip::entries(&zip).unwrap(); + assert_eq!(back.len(), 3); + for ((n, b), (m, c)) in files.iter().zip(&back) { + assert_eq!((*n, b), (m.as_str(), c)); + } + assert_eq!(super::zip::entries(&super::zip::store::<&str, Vec>(&[]).unwrap()).unwrap(), vec![]); + let mut broken = zip.clone(); + let at = broken.windows(5).position(|w| w == b"hello").unwrap(); + broken[at] = b'j'; + assert!(super::zip::entries(&broken).is_err(), "the CRC catches a changed byte"); +} diff --git a/crates/engine/src/cmd/fileio/zip.rs b/crates/engine/src/cmd/fileio/zip.rs new file mode 100644 index 00000000..b3a9a397 --- /dev/null +++ b/crates/engine/src/cmd/fileio/zip.rs @@ -0,0 +1,103 @@ +//! A store-only (uncompressed) ZIP archive: several exported files as one download. The files are +//! already compressed (PNG, JPEG, PDF…), so storing them keeps the archive fast to write and read. + +use flate2::Crc; + +/// The DOS date of every entry: 1980-01-01, the earliest a ZIP date can say (no clock on the web, +/// and a fixed date keeps archives of the same files identical). +const DOS_DATE: u16 = (1 << 5) | 1; +/// General purpose flag: file names are UTF-8. +const UTF8_NAMES: u16 = 1 << 11; +/// Version needed to extract: 2.0. +const VERSION: u16 = 20; + +/// The archive of `files` (`(name, bytes)`; `/` separates folders in a name), stored without +/// compression. Fails past the limits of a ZIP without its 64-bit extension (65535 files, 4 GB). +pub fn store, B: AsRef<[u8]>>(files: &[(N, B)]) -> Result, String> { + let too_big = || "too much to zip: at most 65535 files and 4 GB".to_string(); + let count = u16::try_from(files.len()).map_err(|_| too_big())?; + let mut out = Vec::with_capacity(files.iter().map(|(n, b)| 76 + 2 * n.as_ref().len() + b.as_ref().len()).sum::() + 22); + let mut central = Vec::new(); + for (name, bytes) in files { + let (name, bytes) = (name.as_ref().as_bytes(), bytes.as_ref()); + let offset = u32::try_from(out.len()).map_err(|_| too_big())?; + let size = u32::try_from(bytes.len()).map_err(|_| too_big())?; + let name_len = u16::try_from(name.len()).map_err(|_| format!("file name too long to zip: {}", String::from_utf8_lossy(name)))?; + let mut crc = Crc::new(); + crc.update(bytes); + // The fields both headers share: flags, method (0: stored), time, date, CRC, sizes, name length. + let mut shared = Vec::with_capacity(26); + for v in [UTF8_NAMES, 0, 0, DOS_DATE] { + shared.extend_from_slice(&v.to_le_bytes()); + } + for v in [crc.sum(), size, size] { + shared.extend_from_slice(&v.to_le_bytes()); + } + shared.extend_from_slice(&name_len.to_le_bytes()); + // Local file header. + out.extend_from_slice(&0x0403_4b50u32.to_le_bytes()); + out.extend_from_slice(&VERSION.to_le_bytes()); + out.extend_from_slice(&shared); + out.extend_from_slice(&0u16.to_le_bytes()); // extra field length + out.extend_from_slice(name); + out.extend_from_slice(bytes); + // Central directory record. + central.extend_from_slice(&0x0201_4b50u32.to_le_bytes()); + central.extend_from_slice(&VERSION.to_le_bytes()); // made by + central.extend_from_slice(&VERSION.to_le_bytes()); // needed to extract + central.extend_from_slice(&shared); + // Extra and comment lengths, disk number, internal and external attributes. + central.extend_from_slice(&[0; 2 + 2 + 2 + 2 + 4]); + central.extend_from_slice(&offset.to_le_bytes()); + central.extend_from_slice(name); + } + let start = u32::try_from(out.len()).map_err(|_| too_big())?; + let central_len = u32::try_from(central.len()).map_err(|_| too_big())?; + out.extend_from_slice(¢ral); + // End of central directory. + out.extend_from_slice(&0x0605_4b50u32.to_le_bytes()); + out.extend_from_slice(&[0; 4]); // this disk, the directory's disk + out.extend_from_slice(&count.to_le_bytes()); + out.extend_from_slice(&count.to_le_bytes()); + out.extend_from_slice(¢ral_len.to_le_bytes()); + out.extend_from_slice(&start.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); // comment length + Ok(out) +} + +/// The entries of a store-only archive written by [`store`] (read through its central directory, +/// checking every CRC) → `(name, bytes)`. +#[cfg(test)] +pub fn entries(zip: &[u8]) -> Result)>, String> { + let u16_at = |at: usize| zip.get(at..at + 2).map(|b| u16::from_le_bytes([b[0], b[1]])).ok_or("truncated"); + let u32_at = |at: usize| zip.get(at..at + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])).ok_or("truncated"); + let end = zip.len().checked_sub(22).ok_or("no end record")?; + if u32_at(end)? != 0x0605_4b50 { + return Err("no end record".into()); + } + let count = u16_at(end + 10)? as usize; + let mut at = u32_at(end + 16)? as usize; + let mut out = vec![]; + for _ in 0..count { + if u32_at(at)? != 0x0201_4b50 || u16_at(at + 10)? != 0 { + return Err("not a stored central record".into()); + } + let (crc, size, name_len) = (u32_at(at + 16)?, u32_at(at + 20)? as usize, u16_at(at + 28)? as usize); + let extra = u16_at(at + 30)? as usize + u16_at(at + 32)? as usize; + let local = u32_at(at + 42)? as usize; + let name = String::from_utf8(zip.get(at + 46..at + 46 + name_len).ok_or("truncated")?.to_vec()).map_err(|e| e.to_string())?; + if u32_at(local)? != 0x0403_4b50 { + return Err(format!("{name}: no local header")); + } + let data = local + 30 + u16_at(local + 26)? as usize + u16_at(local + 28)? as usize; + let bytes = zip.get(data..data + size).ok_or("truncated")?.to_vec(); + let mut c = Crc::new(); + c.update(&bytes); + if c.sum() != crc { + return Err(format!("{name}: CRC mismatch")); + } + out.push((name, bytes)); + at += 46 + name_len + extra; + } + Ok(out) +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 6de4b57c..f873c637 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -32,7 +32,11 @@ const HOST_COMMANDS: &[(&str, &str, &str)] = &[ "{path?, format?, options?, svg?: {…SVG options}} = document.save: .vectorcraft, .ai (a PDF that reopens editable), .pdf, .svg or .svgz (default: the document's own path and format)", ), ("file.export", "Export…", "{path?, format?, artboard?, range?, scale?, …} = document.export (no path → dataBase64)"), - ("file.exportForScreens", "Export for Screens…", "{folder?, artboards? | range?, formats?, prefix?} = document.exportForScreens"), + ( + "file.exportForScreens", + "Export for Screens…", + "{folder?, zip?, artboards? | range?, formats?, prefix?} = document.exportForScreens (no folder → the files, or one zip, as dataBase64)", + ), ("tool.select", "Select Tool", "{tool} e.g. selection, directSelection, pen, rectangle, ellipse, polygon, star, lineSegment"), ]; diff --git a/crates/ui-egui/src/dialogs/export_for_screens.rs b/crates/ui-egui/src/dialogs/export_for_screens.rs index a670db46..56d72115 100644 --- a/crates/ui-egui/src/dialogs/export_for_screens.rs +++ b/crates/ui-egui/src/dialogs/export_for_screens.rs @@ -1,16 +1,37 @@ //! File → Export → Export for Screens: artboards (thumbnails and checkboxes), scale/format rows, -//! destination folder and file-name prefix. +//! the destination folder (typed or picked; the web downloads instead) and file-name prefix. use serde_json::{Value, json}; +use vectorcraft_engine::cmd::fileio; use super::DialogSpec; -use crate::VectorcraftApp; use crate::state::Dialog; use crate::theme::Tokens; +use crate::{VectorcraftApp, io, widgets}; -pub(super) const SPEC: DialogSpec = - DialogSpec { heading: |_| "Export for Screens".into(), body, confirm, ok: Some("Export Artboard"), ..DialogSpec::FORM }; +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| "Export for Screens".into(), + body, + confirm, + ok: Some("Export Artboard"), + ok_label: Some(|app| if io::is_web(app) { "Download" } else { "Export Artboard" }), + ..DialogSpec::FORM +}; +/// `Dialog::kind` of this dialog. +pub const KIND: &str = "exportForScreens"; + +/// Open the dialog: every artboard, PNG at 1x and 2x, into the Desktop (else the home folder). +pub fn open(app: &mut VectorcraftApp) { + let n = app.session.active().map_or(0, |d| d.doc.artboards.len()); + app.ui.dialog = Some(Dialog::new( + KIND, + json!({"boards": vec![true; n], "formats": [{"format": "png", "scale": 1, "suffix": ""}, {"format": "png", "scale": 2, "suffix": "@2x"}], "folder": fileio::export_folder().unwrap_or_default(), "prefix": ""}), + )); +} + +/// Export the checked artboards (the web: no folder, one ZIP for several files); the dialog stays +/// open when the export fails. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let boards: Vec = d .fields @@ -18,12 +39,21 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { .and_then(Value::as_array) .map(|a| a.iter().enumerate().filter(|(_, b)| b.as_bool() == Some(true)).map(|(i, _)| i).collect()) .unwrap_or_default(); - let params = json!({"folder": d.str("folder"), "artboards": boards, "formats": d.fields.get("formats").cloned().unwrap_or(json!([])), "prefix": d.str("prefix")}); - app.ui.dialog = None; - let r = app.run("document.exportForScreens", params); - if let Ok(v) = &r { - let n = v["files"].as_array().map(|a| a.len()).unwrap_or(0); - app.status(format!("Exported {n} file(s) to {}", d.str("folder"))); + let formats = d.fields.get("formats").cloned().unwrap_or(json!([])); + let files = boards.len() * formats.as_array().map_or(0, Vec::len); + let mut params = json!({"artboards": boards, "formats": formats, "prefix": d.str("prefix")}); + if io::is_web(app) { + params["zip"] = json!(files > 1); + } else { + let folder = d.str("folder"); + if folder.trim().is_empty() { + return Err("choose a folder to export to".into()); + } + params["folder"] = json!(folder.trim()); + } + let r = io::export_for_screens(app, params); + if r.is_ok() { + app.ui.dialog = None; } r } @@ -77,9 +107,22 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { ui.vertical(|ui| { ui.set_width(330.0); ui.label(egui::RichText::new("Export to").color(t.text)); - let mut folder = d.str("folder"); - if ui.add(egui::TextEdit::singleline(&mut folder).desired_width(320.0)).changed() { - d.fields.insert("folder".into(), json!(folder)); + if io::is_web(app) { + widgets::dim_label(ui, "Your browser downloads the files (several as one .zip)."); + } else { + let pick = app.services.pick_folder.is_some(); + ui.horizontal(|ui| { + let mut folder = d.str("folder"); + if ui.add(egui::TextEdit::singleline(&mut folder).desired_width(if pick { 290.0 } else { 320.0 })).changed() { + d.fields.insert("folder".into(), json!(folder)); + } + if pick + && widgets::icon_button(ui, "folder-open", "Choose a folder", false, 26.0).clicked() + && let Some(f) = app.services.pick_folder.as_mut().and_then(|pick| pick()) + { + d.fields.insert("folder".into(), json!(f)); + } + }); } ui.add_space(8.0); ui.label(egui::RichText::new("Formats").color(t.text)); diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 425ce655..caf7720a 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -67,6 +67,7 @@ pub use color_picker::open as open_color_picker; pub use document_setup::open as open_document_setup; pub use effect::open as open_effect_dialog; pub use export_as::open as open_export_as; +pub use export_for_screens::open as open_export_for_screens; pub use new_document::{open as open_new_document, preset_card}; pub use png_options::open as open_raster_options; pub use save_pdf::{open as open_save_pdf, open_preset as open_pdf_preset}; @@ -97,6 +98,9 @@ pub(crate) struct DialogSpec { pub max_width: Option, /// The body runs a live preview interaction that Cancel rolls back. pub preview: bool, + /// The OK button's label when it depends on the app (Export for Screens' "Download" on the + /// web); `ok` when none. Only with an `ok`. + pub ok_label: Option &'static str>, } impl DialogSpec { @@ -117,6 +121,7 @@ impl DialogSpec { min_width: 320.0, max_width: None, preview: false, + ok_label: None, }; /// A dialog that draws its own window and confirms through its module. @@ -285,7 +290,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { // all the height left in the window, so the window could never shrink to its content. let row = egui::vec2(ui.available_width(), ui.spacing().interact_size.y); ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| { - if let Some(label) = spec.ok + if let Some(label) = spec.ok.map(|ok| spec.ok_label.map_or(ok, |f| f(app))) && widgets::primary_button(ui, label).clicked() { ok = true; @@ -328,3 +333,5 @@ mod tests_raster_formats; #[cfg(test)] mod tests_dxf; +#[cfg(test)] +mod tests_screens; diff --git a/crates/ui-egui/src/dialogs/tests_screens.rs b/crates/ui-egui/src/dialogs/tests_screens.rs new file mode 100644 index 00000000..5e04818b --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_screens.rs @@ -0,0 +1,92 @@ +//! Export for Screens, drawn headlessly: desktop folders and web downloads. + +use std::cell::RefCell; +use std::rc::Rc; + +use serde_json::{Value, json}; +use vectorcraft_engine::Session; +use vectorcraft_engine::cmd::fileio; + +use super::tests_export::{frame, kind, set}; +use super::*; +use crate::Services; + +type Log = Rc)>>>; + +/// An app with `artboards` 60×40 pt artboards and an ellipse on the first; `web` gives it a +/// download service (recorded in the log) instead of a folder picker. +fn app(artboards: usize, web: bool) -> (VectorcraftApp, Log) { + let log = Log::default(); + let l = log.clone(); + let mut services = Services::default(); + if web { + services.download = Some(Box::new(move |name: &str, bytes: &[u8]| l.borrow_mut().push((name.to_string(), bytes.to_vec())))); + } else { + services.pick_folder = Some(Box::new(|| None)); + } + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 60, "height": 40, "artboards": artboards})).unwrap(); + app.run("shape.ellipse", json!({"x": 5, "y": 4, "width": 30, "height": 20})).unwrap(); + (app, log) +} + +fn field(app: &VectorcraftApp, k: &str) -> Value { + app.ui.dialog.as_ref().expect("a dialog is open").fields.get(k).cloned().unwrap_or(Value::Null) +} + +fn open(app: &mut VectorcraftApp) { + app.run("file.exportForScreens", json!({})).unwrap(); + assert_eq!(kind(app), Some(export_for_screens::KIND)); +} + +#[test] +fn exports_into_the_folder() { + let (mut app, _) = app(2, false); + open(&mut app); + assert_eq!(field(&app, "folder"), json!(fileio::export_folder().unwrap_or_default()), "the Desktop, else home"); + assert_eq!((spec(export_for_screens::KIND).ok_label.unwrap())(&app), "Export Artboard"); + frame(&mut app); + let dir = std::env::temp_dir().join(format!("vc-efs-desk-{}", std::process::id())); + let folder = dir.to_string_lossy().replace('\\', "/"); + set(&mut app, "boards", json!([false, true])); + set(&mut app, "formats", json!([{"format": "png", "scale": 1, "suffix": ""}, {"format": "svg", "scale": 1, "suffix": ""}])); + // No folder keeps the dialog open. + set(&mut app, "folder", json!(" ")); + assert!(confirm(&mut app).is_err()); + assert_eq!(kind(&app), Some(export_for_screens::KIND)); + set(&mut app, "folder", json!(folder)); + let r = confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let files: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect(); + assert_eq!(files, [format!("{folder}/Artboard-2.png"), format!("{folder}/Artboard-2.svg")]); + assert!(std::fs::metadata(dir.join("Artboard-2.png")).unwrap().len() > 50); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn the_web_downloads_one_zip_or_the_file() { + let (mut app, log) = app(2, true); + assert_eq!((spec(export_for_screens::KIND).ok_label.unwrap())(&app), "Download"); + open(&mut app); + frame(&mut app); + set(&mut app, "formats", json!([{"format": "png", "scale": 1, "suffix": ""}, {"format": "jpg", "scale": 2, "suffix": "@2x"}])); + let r = confirm(&mut app).unwrap(); + assert!(r.get("dataBase64").is_some(), "four files: one zip"); + { + let log = log.borrow(); + let [(name, zip)] = log.as_slice() else { panic!("one download: {:?}", log.iter().map(|(n, _)| n).collect::>()) }; + assert_eq!(name, "Untitled-1.zip"); + assert_eq!(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(), zip); + } + assert_eq!(r["files"], json!(["Artboard-1.png", "Artboard-1@2x.jpg", "Artboard-2.png", "Artboard-2@2x.jpg"])); + // One file downloads as itself. + log.borrow_mut().clear(); + open(&mut app); + set(&mut app, "boards", json!([true, false])); + set(&mut app, "formats", json!([{"format": "png", "scale": 1, "suffix": ""}])); + confirm(&mut app).unwrap(); + let log = log.borrow(); + assert_eq!(log.len(), 1); + assert_eq!(log[0].0, "Artboard-1.png"); + assert_eq!(image::load_from_memory(&log[0].1).unwrap().width(), 60); +} diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 3388aef0..8a85948b 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -111,7 +111,7 @@ pub(crate) fn write_to(services: &mut Services, path: &str, bytes: &[u8]) -> Res } /// The web saves by downloading: no save panel, no folders. -fn is_web(app: &VectorcraftApp) -> bool { +pub(crate) fn is_web(app: &VectorcraftApp) -> bool { app.services.download.is_some() } @@ -507,6 +507,36 @@ pub(crate) fn open_in_app(app: &mut VectorcraftApp, path: &str) -> Result<(), St app.services.open_file.as_mut().ok_or("no app to open files here")?(path) } +/// File → Export for Screens with `document.exportForScreens` params: the files go into `folder`; +/// without one the web downloads them (the ZIP, or each file) and the other frontends get them +/// back as base64. +pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result { + let folder = params.get("folder").and_then(Value::as_str).unwrap_or_default().to_string(); + let r = app.run("document.exportForScreens", params)?; + let count = r["files"].as_array().map_or(0, Vec::len); + // Files that came back as bytes: (name, base64). + let returned: Vec<(&str, &str)> = match r["dataBase64"].as_str() { + Some(zip) => vec![(r["name"].as_str().unwrap_or("Export.zip"), zip)], + None => { + r["files"].as_array().into_iter().flatten().filter_map(|f| Some((f.get("name")?.as_str()?, f.get("dataBase64")?.as_str()?))).collect() + } + }; + if returned.is_empty() { + app.status(format!("Exported {count} file(s) to {folder}")); + } else if let Some(download) = app.services.download.as_mut() { + for (name, data) in &returned { + let bytes = vectorcraft_format::base64_decode(data).ok_or("the export returned unreadable data")?; + download(name, &bytes); + } + let what = match returned.as_slice() { + [(name, _)] => (*name).to_string(), + files => format!("{} files", files.len()), + }; + app.status(format!("Downloaded {what}")); + } + Ok(r) +} + /// Place a file's bytes (no path, so embedded) centred in the view: `file.place`. pub fn place_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result<(), String> { crate::place::run(app, &serde_json::json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).map(|_| ()) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7164d903..528f2ae3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -102,7 +102,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "{} opens SVG Options; with params = document.export {path?, svg?: {…SVG options}, range?…} (a .svgz path writes it gzipped)", ), ("file.export.png", "Export As PNG…", "", "{path?, …document.export options}; no path: pick the file, then PNG Options"), - ("file.exportForScreens", "Export for Screens…", "Cmd+Alt+E", "{} opens the dialog; with params = document.exportForScreens"), + ( + "file.exportForScreens", + "Export for Screens…", + "Cmd+Alt+E", + "{} opens the dialog; with params = document.exportForScreens (no folder on the web: downloads the files or the zip)", + ), ("file.documentSetup", "Document Setup…", "Cmd+Alt+P", "{}"), ("file.newDialog", "New…", "Cmd+N", "{} opens the New Document dialog"), ("edit.preferences", "Preferences…", "Cmd+K", "{category?} open Preferences (engine: prefs.get / prefs.set / prefs.list)"), @@ -520,15 +525,10 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option io::export(app, Some("svg"), s("path"), p), "file.exportForScreens" if p.as_object().is_none_or(|o| o.is_empty()) => { - let n = app.session.active().map(|d| d.doc.artboards.len()).unwrap_or(0); - let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); - app.ui.dialog = Some(crate::state::Dialog::new( - "exportForScreens", - json!({"boards": vec![true; n], "formats": [{"format": "png", "scale": 1, "suffix": ""}, {"format": "png", "scale": 2, "suffix": "@2x"}], "folder": format!("{home}/Desktop/VectorCraft Export"), "prefix": ""}), - )); + crate::dialogs::open_export_for_screens(app); Ok(Value::Null) } - "file.exportForScreens" => app.run("document.exportForScreens", p.clone()), + "file.exportForScreens" => io::export_for_screens(app, p.clone()), "file.export.png" if s("path").is_none() => io::target_path(app, None, "png").and_then(|path| { let f = vectorcraft_engine::cmd::fileio::format("png").ok_or("no PNG encoder")?; let mut params = if p.is_object() { p.clone() } else { json!({}) }; From c96bf40d1816ae14cc493a0156a5c92a3f01d8c0 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 12:37:07 +0700 Subject: [PATCH 2/2] M4.52: Export for Screens options: full document, bleed, sub-folders, sizes, presets, format settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/doc/src/lib.rs | 5 + crates/engine/src/cmd/fileio/mod.rs | 14 +- crates/engine/src/cmd/fileio/screens.rs | 439 +++++++++++--- crates/engine/src/cmd/fileio/tests_screens.rs | 195 +++++- crates/mcp/src/headless.rs | 2 +- .../ui-egui/src/dialogs/export_for_screens.rs | 569 ++++++++++++++---- crates/ui-egui/src/dialogs/png_options.rs | 202 ++++--- crates/ui-egui/src/dialogs/svg_options.rs | 44 +- crates/ui-egui/src/dialogs/tests_screens.rs | 134 ++++- crates/ui-egui/src/io.rs | 13 +- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/widgets.rs | 49 +- docs/control-protocol.md | 1 + docs/mcp.md | 18 + 14 files changed, 1331 insertions(+), 356 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 0d28b3a3..3e7ac165 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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, /// 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, diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index d3042a12..8519de28 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -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 { "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 { 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()) diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index c89b2bb4..eaff1a8a 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -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 { - 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 { + let s = s.trim().to_ascii_lowercase(); + let num = |t: &str| t.trim().parse::().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 { + let positive = |key: &str, make: fn(f64) -> Self| -> Option> { + 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 { + 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 { + 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::().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> { + 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 { 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 { + 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) -> Result { + 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, + zip: bool, + full_document: bool, + include_bleed: bool, + subfolders: bool, + preset: Option, + prefix: Option, +} + +/// 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, + /// Sub-folders already made. + made: HashSet, + /// `(relative name, bytes)` kept to return or zip (empty when written to a folder). + kept: Vec<(String, Vec)>, + /// The paths written. + written: Vec, +} + +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) -> 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 { - 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 = match p.get("formats").and_then(Value::as_array) { - Some(rows) => rows.iter().map(screen_format).collect::>()?, - 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 = 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 = rows.iter().map(|r| Row::parse(s, r, &settings)).collect::>()?; + 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)> = 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 = 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::>() }) + json!({ "files": out.kept.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect::>() }) } }) } + +/// 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 { + Ok(json!({ "settings": s.doc()?.doc.export_settings })) +} diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs index 0728655d..0ff69b39 100644 --- a/crates/engine/src/cmd/fileio/tests_screens.rs +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -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 { @@ -25,22 +27,176 @@ fn files(r: &Value) -> Vec<(String, Vec)> { r["files"].as_array().unwrap().iter().map(|f| (f["name"].as_str().unwrap().to_string(), b64(f))).collect() } +fn names(r: &Value) -> Vec { + 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 = 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()); +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index f873c637..104bbe51 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -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"), ]; diff --git a/crates/ui-egui/src/dialogs/export_for_screens.rs b/crates/ui-egui/src/dialogs/export_for_screens.rs index 56d72115..6db7141b 100644 --- a/crates/ui-egui/src/dialogs/export_for_screens.rs +++ b/crates/ui-egui/src/dialogs/export_for_screens.rs @@ -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); 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 = (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 { - let boards: Vec = 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 { + 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 { + 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 { + 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 = app.session.active().map(|s| s.doc.artboards.iter().map(|a| a.name.clone()).collect()).unwrap_or_default(); - let mut boards: Vec = - 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 = + 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 = 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 = 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 = 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). diff --git a/crates/ui-egui/src/dialogs/png_options.rs b/crates/ui-egui/src/dialogs/png_options.rs index c457b98b..edae47aa 100644 --- a/crates/ui-egui/src/dialogs/png_options.rs +++ b/crates/ui-egui/src/dialogs/png_options.rs @@ -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, ¶ms)); - // 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 { + 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), diff --git a/crates/ui-egui/src/dialogs/svg_options.rs b/crates/ui-egui/src/dialogs/svg_options.rs index 4b207b64..720822dd 100644 --- a/crates/ui-egui/src/dialogs/svg_options.rs +++ b/crates/ui-egui/src/dialogs/svg_options.rs @@ -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 { +pub(super) fn first_use() -> Map { 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, ``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 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. diff --git a/crates/ui-egui/src/dialogs/tests_screens.rs b/crates/ui-egui/src/dialogs/tests_screens.rs index 5e04818b..55a48f7d 100644 --- a/crates/ui-egui/src/dialogs/tests_screens.rs +++ b/crates/ui-egui/src/dialogs/tests_screens.rs @@ -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)>>>; /// 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::>(), [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 = field(&app, "formats").as_array().unwrap().iter().map(|r| r["scale"].clone()).collect(); + assert_eq!(scales, [json!("64w"), json!("3x"), json!("144ppi")]); } diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 8a85948b..fd1808c8 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -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 { + 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 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 { + 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 +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 86200a06..3e270458 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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 | diff --git a/docs/mcp.md b/docs/mcp.md index 854c751f..40a4c6bb 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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":{}}} +```