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/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..8519de28 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`, `document.exportSettings`); `zip` +//! packs its files as one download. //! - `save`: `document.save`, Save As / a Copy / as Template, New from Template, Revert and the //! per-format options (`file.formatOptions`); [`save_with`] is the one save path of every frontend. //! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`. @@ -26,8 +28,10 @@ mod pdfimport; pub mod pngtext; pub mod ppi; mod save; +mod screens; mod svg; mod text; +mod zip; use serde_json::{Value, json}; @@ -42,8 +46,10 @@ pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_byt pub use native::with_compression_pref; pub use pdfimport::{LoadOptions, page_document}; pub use save::{ - SAVE_FORMATS, SaveJob, SaveMode, SavePlan, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, templates_folder, + SAVE_FORMATS, SaveJob, SaveMode, SavePlan, export_folder, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, + templates_folder, }; +pub use screens::{PRESETS as SCREEN_PRESETS, ScreenSize, preset_rows as screen_preset_rows}; pub use svg::options_map as svg_options; /// Atomic file writes (a temporary file renamed over the target: a failed write never damages the /// file it replaces), for the apps' own writers too. @@ -96,9 +102,9 @@ pub fn specs() -> Vec { "Export for Screens", ["File", "Export"], None, - "{folder?, artboards?: [index…] | range?: \"1-3\" (default all), formats?: [{format: png|jpg|webp|gif|png8|svg|svgz|pdf, scale?: 1 (raster only), ppi?: (raster: wins over scale, scale = ppi / 72), suffix?: \"@2x\" (raster default: @{scale}x when scale ≠ 1; vector formats drop @Nx suffixes), …the format's options (antiAlias, background…)}], prefix?, antiAlias?: none|art|type (raster rows without their own)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]}", + "{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|svg|svgz|pdf, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|svg|pdf: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png). The params are remembered in the document (document.exportSettings; not an undo step)", has_doc, - export::export_for_screens + screens::export_for_screens ), cmd!(query "command.batch", "Batch", [], None, "{label?, commands: [{command, params}]} run several commands as ONE undo step; stops at the first error and rolls back", has_doc, batch::batch), cmd!( @@ -128,6 +134,15 @@ pub fn specs() -> Vec { has_doc, export::export_for_office ), + cmd!( + query "document.exportSettings", + "Export for Screens Settings", + [], + None, + "{} → {settings}: the document.exportForScreens params the document last exported with ({} when never; saved with the document, the dialog reopens on them)", + has_doc, + screens::export_settings + ), ] .into_iter() .chain(save::specs()) @@ -675,6 +690,8 @@ mod tests_jpeg; #[cfg(test)] mod tests_palette; +#[cfg(test)] +mod tests_screens; #[cfg(test)] mod tests_text; 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..eaff1a8a --- /dev/null +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -0,0 +1,406 @@ +//! File → Export → Export for Screens (`document.exportForScreens`): the chosen artboards (or the +//! whole document) in every format row at each row's size, into a folder and its sub-folders, or +//! returned for download (each file, or one ZIP); the presets the dialog offers; and the settings +//! the document remembers (`document.exportSettings`). + +use std::collections::HashSet; +use std::sync::Arc; + +use serde::Deserialize; +use serde_json::{Map, Value, json}; +use vectorcraft_doc::Document; +use vectorcraft_geom::Rect; + +use super::super::*; +use super::encode::single_artboard; +use super::export::without_artboards; +use super::{ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, encode, writable, write_file}; + +const C: &str = "document.exportForScreens"; + +/// How big a raster row's files are. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ScreenSize { + /// Pixels per point (`2x`). + Scale(f64), + /// Pixels per inch, stored in the file (`144ppi`: twice 72, so `2x`). + Ppi(f64), + /// The width in pixels; the height follows the artboard's proportions (`100w`). + Width(f64), + /// The height in pixels (`100h`). + Height(f64), +} + +impl ScreenSize { + /// `2x` (or `2`), `100w`, `100h` or `72ppi`, in any case; positive numbers only. + pub fn parse(s: &str) -> Option { + 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) + } + + /// 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))), + } + } + + /// 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()), + } + } +} + +/// A file-name suffix that names a pixel size (`@2x`, `@0.5x`, `@100w`, `@100h`): vector rows +/// leave it out. +fn is_size_suffix(s: &str) -> bool { + s.strip_prefix('@').is_some_and(|t| t.ends_with(|c: char| c.is_ascii_alphabetic()) && ScreenSize::parse(t).is_some()) +} + +/// The presets of the dialog's format rows: (id, label). +pub const PRESETS: [(&str, &str); 2] = [("mobile", "Mobile 1x/2x/3x"), ("density", "Density buckets (0.75x–4x)")]; + +/// Screen density buckets: (sub-folder, scale). +const DENSITY: [(&str, f64); 6] = [("ldpi", 0.75), ("mdpi", 1.0), ("hdpi", 1.5), ("xhdpi", 2.0), ("xxhdpi", 3.0), ("xxxhdpi", 4.0)]; + +/// The format rows of preset `id` (see [`PRESETS`]): PNG at 1x, 2x and 3x (`@2x`, `@3x`), or PNG +/// at every density bucket, each in its bucket's sub-folder under the same name. +pub fn preset_rows(id: &str) -> Option> { + 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) +} + +/// 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 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 r in &mut rows { + if let Some(m) = r.options.as_object_mut() { + m.entry("antiAlias").or_insert_with(|| aa.clone()); + } + } + } + let subfolders = o.subfolders || o.preset.is_some(); + // A prefix names files, not folders. + let prefix: String = o.prefix.as_deref().unwrap_or("").chars().map(|c| if matches!(c, '/' | '\\' | ':') { '-' } else { c }).collect(); + let folder = o.folder.as_deref().filter(|f| !f.is_empty()); + if let Some(dir) = folder { + create_dir(dir)?; + } + let doc = s.doc()?.doc.clone(); + // Include Bleed: each artboard grown by the document's bleed. + let doc = if o.include_bleed && doc.setup.has_bleed() { + let mut d = (*doc).clone(); + for a in &mut d.artboards { + a.rect = doc.setup.bleed_rect(a.rect); + } + Arc::new(d) + } else { + doc + }; + let name_of = |r: &Row, stem: &str| { + let file = format!("{prefix}{stem}{}.{}", r.suffix, r.format.extensions[0]); + if subfolders { format!("{}/{file}", r.folder) } else { file } + }; + let mut out = Output::default(); + if o.full_document { + let stem = safe_name(&super::file_stem(&doc.title)).unwrap_or_else(|| "Untitled".into()); + // The bounds of all visible art, as the one artboard of the files that aren't PDFs. + let mut whole: Option = None; + for r in &rows { + let name = name_of(r, &stem); + if !out.claim(&name) { + continue; + } + let bytes = if r.format.id == "pdf" { + // One page per artboard. + let mut options = r.options.clone(); + options["range"] = json!("all"); + encode(&doc, "pdf", &options)? + } else { + if whole.is_none() { + let bounds = + vectorcraft_render::encode::art_bounds(&doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?; + whole = Some(single_artboard(&doc, bounds, "Document")); + } + let Some(d) = whole.as_ref() else { continue }; + let region = d.artboards.first().map_or(Rect::ZERO, |a| a.rect); + encode(d, r.format.id, &r.options_for(region, Some(0))?)? + }; + out.put(folder, o.zip, name, bytes)?; + } + } else { + let n = doc.artboards.len(); + let boards = o.boards.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect()); + if boards.is_empty() { + return Err(bad(C, "the document has no artboard")); + } + for (b, stem) in boards.iter().copied().zip(artboard_file_names(&doc, &boards)) { + let region = doc.artboards.get(b).map_or(Rect::ZERO, |a| a.rect); + for r in &rows { + let name = name_of(r, &stem); + if !out.claim(&name) { + continue; // the same file from two identical rows + } + let bytes = encode(&doc, r.format.id, &r.options_for(region, Some(b))?)?; + out.put(folder, o.zip, name, bytes)?; + } + } + } + remember(s, p); + let names: Vec<&str> = out.kept.iter().map(|(n, _)| n.as_str()).collect(); + if o.zip { + let zip = super::zip::store(&out.kept).map_err(|e| bad(C, e))?; + let zip_name = format!("{prefix}{}.zip", safe_name(&super::file_stem(&doc.title)).unwrap_or_else(|| "Untitled".into())); + return match folder { + Some(dir) => { + let path = format!("{dir}/{zip_name}"); + write_file(&path, &zip)?; + Ok(json!({ "path": path, "bytes": zip.len(), "files": names })) + } + None => Ok(json!({ "name": zip_name, "dataBase64": vectorcraft_format::base64_encode(&zip), "bytes": zip.len(), "files": names })), + }; + } + Ok(match folder { + Some(_) => json!({ "files": out.written }), + None => { + json!({ "files": out.kept.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect::>() }) + } + }) +} + +/// 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 new file mode 100644 index 00000000..0ff69b39 --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -0,0 +1,269 @@ +//! Export for Screens: sizes (factor, width, height, resolution), presets and sub-folders, Full +//! Document, Include Bleed, per-format settings, ZIP downloads and the settings the document keeps. + +use serde_json::{Value, json}; + +use super::screens::ScreenSize; +use super::*; + +fn b64(v: &Value) -> Vec { + 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() +} + +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(std::path::MAIN_SEPARATOR, "/"); + let r = export(&mut s, json!({"folder": folder, "zip": true, "formats": [{"format": "png"}, {"format": "svg"}]})); + let zip = std::fs::read(r["path"].as_str().unwrap()).unwrap(); + assert_eq!(super::zip::entries(&zip).unwrap().len(), 2); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn full_document_gives_one_file_per_format() { + let mut s = session(3); + let r = export( + &mut s, + json!({"fullDocument": true, "range": "2", "prefix": "x-", "formats": [{"format": "pdf"}, {"format": "png", "scale": 2}, {"format": "svg"}]}), + ); + let f = files(&r); + let title = super::file_stem(&s.doc().unwrap().doc.title); + assert_eq!(names(&r), [format!("x-{title}.pdf"), format!("x-{title}@2x.png"), format!("x-{title}.svg")]); + assert_eq!(vectorcraft_pdf::import(&f[0].1).unwrap().artboards.len(), 3, "one page per artboard, whatever the range"); + // The bounds of the art: the square and its 1 pt stroke. + assert_eq!(size(&f[1].1), (42, 42)); + // Nothing visible: nothing to export but the PDF. + let mut empty = session(1); + empty.execute("edit.undo", &json!({})).unwrap(); + assert!(empty.execute("document.exportForScreens", &json!({"fullDocument": true, "formats": [{"format": "png"}]})).is_err()); + assert_eq!(files(&export(&mut empty, json!({"fullDocument": true, "formats": [{"format": "pdf"}]}))).len(), 1); +} + +#[test] +fn include_bleed_grows_each_artboard() { + let mut s = session(1); + s.execute("document.setup", &json!({"bleed": [2, 4, 6, 8]})).unwrap(); + let r = export(&mut s, json!({"includeBleed": true, "formats": [{"format": "png"}, {"format": "png", "scale": "108w", "suffix": "-w"}]})); + let f = files(&r); + assert_eq!(size(&f[0].1), (54, 36), "40 + 6 + 8 by 30 + 2 + 4"); + assert_eq!(size(&f[1].1), (108, 72), "the width counts the bleed"); + let r = export(&mut s, json!({"formats": [{"format": "png"}]})); + assert_eq!(size(&files(&r)[0].1), (40, 30), "without it, the artboard alone"); +} + +#[test] +fn png_8_rows_write_indexed_colour() { + let mut s = session(1); + let r = export(&mut s, json!({"formats": [{"format": "png8"}, {"format": "png"}]})); + let f = files(&r); + // The IHDR colour type: 3 (indexed) for PNG 8, 6 (RGBA) for PNG; both are .png, the PNG 8 row + // written first. + assert_eq!(names(&r), ["Artboard-1.png"], "the same file name: the first row wins"); + assert_eq!(f[0].1[25], 3); + let r = export(&mut s, json!({"formats": [{"format": "png8", "suffix": "-8"}, {"format": "png"}]})); + let f = files(&r); + assert_eq!((f[0].1[25], f[1].1[25]), (3, 6)); +} + +#[test] +fn format_settings_apply_to_their_rows_and_rows_win() { + let mut s = session(1); + let px = |png: &[u8]| image::load_from_memory(png).unwrap().to_rgba8().get_pixel(1, 1).0; + let r = export( + &mut s, + json!({"settings": {"png": {"background": "black", "scale": 9, "artboard": 7}, "jpg": {"quality": 5}}, "formats": [{"format": "png"}, {"format": "png", "suffix": "-w", "background": "white"}, {"format": "jpg"}, {"format": "jpg", "quality": 100, "suffix": "-100"}]}), + ); + let f = files(&r); + assert_eq!(px(&f[0].1), [0, 0, 0, 255], "the PNG settings' background"); + assert_eq!(size(&f[0].1), (40, 30), "settings don't pick sizes or artboards"); + assert_eq!(px(&f[1].1), [255, 255, 255, 255], "the row's own wins"); + assert!(f[2].1.len() < f[3].1.len(), "quality 5 is smaller than quality 100"); +} + +#[test] +fn zip_downloads_round_trip() { + let mut s = session(2); + let p = json!({"subfolders": true, "formats": [{"format": "png"}, {"format": "png", "scale": 2}, {"format": "pdf"}]}); + let plain = files(&export(&mut s, p.clone())); + let mut q = p; + q["zip"] = json!(true); + let r = export(&mut s, q); + let title = super::file_stem(&s.doc().unwrap().doc.title); + assert_eq!(r["name"], format!("{title}.zip")); + let zip = b64(&r); + assert_eq!(r["bytes"].as_u64(), Some(zip.len() as u64)); + let entries = super::zip::entries(&zip).unwrap(); + let names = |files: &[(String, Vec)]| 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"); +} + +#[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/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..104bbe51 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? | 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 a670db46..6db7141b 100644 --- a/crates/ui-egui/src/dialogs/export_for_screens.rs +++ b/crates/ui-egui/src/dialogs/export_for_screens.rs @@ -1,141 +1,493 @@ -//! File → Export → Export for Screens: artboards (thumbnails and checkboxes), scale/format rows, -//! destination folder and file-name prefix. +//! File → Export → Export for Screens: the Artboards tab (All, a range or Full Document, Include +//! Bleed, thumbnails with checkboxes) and the Assets tab, the destination (folder picker, Open +//! Location after Export, Create Sub-folders; the web downloads instead), the format rows (scale +//! as `2x`, `100w`, `100h` or `72ppi`, suffix, format), the presets, Format Settings (the gear) +//! and the prefix. It opens on the settings the document last exported with. +//! +//! Fields (`ui.dialog.set`): `tab` (artboards|assets), `select` (all|range|full), `range`, +//! `boards` ([bool] per artboard), `includeBleed`, `folder`, `openLocation`, `subfolders`, +//! `prefix`, `preset` (""|mobile|density: where the rows came from), `formats` (rows of +//! `document.exportForScreens`, `scale` as text), `settings` ({png|png8|jpg|webp|gif|svg|pdf: {…}}); +//! `__settings` names the format whose Format Settings show ("" when closed). -use serde_json::{Value, json}; +use serde::Deserialize; +use serde_json::{Map, Value, json}; +use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, SCREEN_PRESETS, ScreenSize, pdf}; -use super::DialogSpec; -use crate::VectorcraftApp; +use super::{DialogSpec, form, png_options, svg_options}; use crate::state::Dialog; use crate::theme::Tokens; +use crate::{VectorcraftApp, io, widgets}; -pub(super) const SPEC: DialogSpec = - DialogSpec { heading: |_| "Export for Screens".into(), body, confirm, ok: Some("Export Artboard"), ..DialogSpec::FORM }; +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| "Export for Screens".into(), + body, + confirm, + ok: Some("Export Artboard"), + ok_label: Some(|app| if io::is_web(app) { "Download" } else { "Export Artboard" }), + min_width: 660.0, + ..DialogSpec::FORM +}; -fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { - 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 params = json!({"folder": d.str("folder"), "artboards": boards, "formats": d.fields.get("formats").cloned().unwrap_or(json!([])), "prefix": d.str("prefix")}); - app.ui.dialog = None; - let r = app.run("document.exportForScreens", params); - if let Ok(v) = &r { - let n = v["files"].as_array().map(|a| a.len()).unwrap_or(0); - app.status(format!("Exported {n} file(s) to {}", d.str("folder"))); +/// `Dialog::kind` of this dialog. +pub const KIND: &str = "exportForScreens"; + +/// The format choices of a row: (label, format, JPEG quality). +const FORMATS: [(&str, &str, Option); 9] = [ + ("PNG", "png", None), + ("PNG 8", "png8", None), + ("JPG 100", "jpg", Some(100)), + ("JPG 80", "jpg", Some(80)), + ("JPG 50", "jpg", Some(50)), + ("WebP", "webp", None), + ("GIF", "gif", None), + ("SVG", "svg", None), + ("PDF", "pdf", None), +]; +const FORMAT_LABELS: [&str; 9] = ["PNG", "PNG 8", "JPG 100", "JPG 80", "JPG 50", "WebP", "GIF", "SVG", "PDF"]; + +/// The formats Format Settings has options for: (format, label). +const SETTINGS: [(&str, &str); 7] = + [("png", "PNG"), ("png8", "PNG 8"), ("jpg", "JPG"), ("webp", "WebP"), ("gif", "GIF"), ("svg", "SVG"), ("pdf", "PDF")]; +const SETTINGS_LABELS: [&str; 7] = ["PNG", "PNG 8", "JPG", "WebP", "GIF", "SVG", "PDF"]; + +/// The sizes a row's scale list offers (any can be typed). +const SIZES: [&str; 12] = ["0.5x", "0.75x", "1x", "1.5x", "2x", "3x", "4x", "512w", "512h", "72ppi", "150ppi", "300ppi"]; + +/// Open the dialog on the settings the active document last exported with (else one PNG row at +/// 1x, into the Desktop). +pub fn open(app: &mut VectorcraftApp) { + let (n, saved) = app.session.active().map(|st| (st.doc.artboards.len(), st.doc.export_settings.clone())).unwrap_or_default(); + let get = |k: &str| saved.get(k).filter(|v| !v.is_null()); + let flag = |k: &str, default: bool| get(k).and_then(Value::as_bool).unwrap_or(default); + // The artboards: Full Document, a range (or a list of them), else all. + let chosen = ArtboardPick::deserialize(&Value::Object(saved.clone())).ok().and_then(|p| p.resolve(n).ok().flatten()); + let select = match (flag("fullDocument", false), &chosen) { + (true, _) => "full", + (false, Some(c)) if c.len() < n => "range", + _ => "all", + }; + let boards: Vec = (0..n).map(|i| chosen.as_ref().is_none_or(|c| c.contains(&i))).collect(); + // The rows: the saved ones (or the saved preset's), sizes shown as text. + let preset = get("preset").and_then(Value::as_str).filter(|p| fileio::screen_preset_rows(p).is_some()); + let rows = match (get("formats").and_then(Value::as_array), preset) { + (Some(rows), _) if rows.iter().any(Value::is_object) => rows.iter().filter(|r| r.is_object()).map(editable_row).collect(), + (_, Some(p)) => fileio::screen_preset_rows(p).unwrap_or_default(), + _ => vec![json!({"format": "png", "scale": "1x", "suffix": ""})], + }; + // Every format's settings: its defaults, then the saved ones. + let mut settings = Map::new(); + for (id, _) in SETTINGS { + let mut s = defaults(app, id); + if let Some(o) = get("settings").and_then(|v| v.get(id)).and_then(Value::as_object) { + s.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))); + } + settings.insert(id.into(), Value::Object(s)); + } + let folder = get("folder").and_then(Value::as_str).filter(|f| !f.is_empty()).map(str::to_string).or_else(fileio::export_folder); + let fields = json!({ + "tab": "artboards", + "select": select, + "range": range_text(&boards), + "boards": boards, + "includeBleed": flag("includeBleed", false), + "folder": folder.unwrap_or_default(), + "openLocation": flag("openLocation", true), + "subfolders": flag("subfolders", preset.is_some()), + "prefix": get("prefix").and_then(Value::as_str).unwrap_or_default(), + "preset": preset.unwrap_or_default(), + "formats": rows, + "settings": settings, + "__settings": "", + "__presets": pdf::presets(&app.session), + }); + app.ui.dialog = Some(Dialog::new(KIND, fields)); +} + +/// The options format `id` starts with in Format Settings: the raster options dialogs' defaults +/// (without quality and image maps: the rows choose quality, a map is another file), the SVG +/// Options' first-use ones (without artboards and images: a linked image is another file), or the +/// default PDF preset. +fn defaults(app: &VectorcraftApp, id: &str) -> Map { + 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) { + p.insert("zip".into(), json!(files > 1)); + } else { + let folder = d.str("folder"); + if folder.trim().is_empty() { + return Err("choose a folder to export to".into()); + } + p.insert("folder".into(), json!(folder.trim())); + p.insert("openLocation".into(), json!(d.bool("openLocation"))); + } + Ok(Value::Object(p)) +} + +/// Export; the dialog stays open when the export fails. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + 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)); - let mut folder = d.str("folder"); - if ui.add(egui::TextEdit::singleline(&mut folder).desired_width(320.0)).changed() { - d.fields.insert("folder".into(), json!(folder)); + ui.set_width(350.0); + let shown = d.str("__settings"); + if SETTINGS.iter().any(|(id, _)| *id == shown) { + format_settings(ui, d, &shown); + } else { + destination(app, ui, d); + ui.add_space(10.0); + formats(ui, d); } - ui.add_space(8.0); - ui.label(egui::RichText::new("Formats").color(t.text)); - let mut formats: Vec = d.fields.get("formats").and_then(Value::as_array).cloned().unwrap_or_default(); - let mut remove = None; - egui::Grid::new("efs-formats").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { - ui.label(egui::RichText::new("Scale").color(t.text_dim)); - ui.label(egui::RichText::new("Suffix").color(t.text_dim)); - ui.label(egui::RichText::new("Format").color(t.text_dim)); - ui.label(""); - ui.end_row(); - for (i, f) in formats.iter_mut().enumerate() { - let mut sc = f["scale"].as_f64().unwrap_or(1.0); - if ui.add(egui::DragValue::new(&mut sc).range(0.1..=10.0).speed(0.5).suffix("x")).changed() { - f["scale"] = json!(sc); - } - let mut suffix = f["suffix"].as_str().unwrap_or("").to_string(); - if ui.add(egui::TextEdit::singleline(&mut suffix).desired_width(60.0)).changed() { - f["suffix"] = json!(suffix); - } - let cur = f["format"].as_str().unwrap_or("png").to_string(); - egui::ComboBox::from_id_salt(("efs-fmt", i)).selected_text(cur.to_uppercase()).width(70.0).show_ui(ui, |ui| { - for fm in ["png", "jpg", "webp", "gif", "png8", "svg", "pdf"] { - if ui.selectable_label(cur == fm, fm.to_uppercase()).clicked() { - f["format"] = json!(fm); - } - } - }); - if ui.small_button("×").clicked() { - remove = Some(i); - } - ui.end_row(); - } - }); - if let Some(i) = remove { - formats.remove(i); - } - if ui.button("+ Add Scale").clicked() { - let next = formats.len() as f64 + 1.0; - formats.push(json!({"format": "png", "scale": next, "suffix": format!("@{next}x")})); - } - d.fields.insert("formats".into(), Value::Array(formats)); - ui.add_space(6.0); - ui.horizontal(|ui| { - ui.label(egui::RichText::new("Prefix").color(t.text_dim)); - let mut prefix = d.str("prefix"); - if ui.add(egui::TextEdit::singleline(&mut prefix).desired_width(120.0)).changed() { - d.fields.insert("prefix".into(), json!(prefix)); - } - }); }); }); - d.fields.insert("boards".into(), json!(boards)); false } +fn set_select(d: &mut Dialog, select: &str) { + d.fields.insert("select".into(), json!(select)); +} + +/// Select (All, Range, Full Document), Include Bleed, and the artboards' thumbnails and checkboxes. +fn artboards(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + let n = app.session.active().map_or(0, |st| st.doc.artboards.len()); + let mut boards: Vec = + 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 (r, _) = ui.allocate_exact_size(egui::vec2(46.0, 46.0), egui::Sense::hover()); + match artboard_thumb(app, ui.ctx(), i) { + Some(tex) => { + let sz = tex.size_vec2(); + let s = (46.0 / sz.x.max(sz.y)).min(1.0); + let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)); + ui.painter().image(tex.id(), egui::Rect::from_center_size(r.center(), sz * s), uv, egui::Color32::WHITE); + } + None => { + ui.painter().rect_filled(r, 2.0, egui::Color32::WHITE); + } + } + if let Some(b) = boards.get_mut(i) { + ui.checkbox(b, name); + } + }); + } + }); + }); + // Checking artboards picks them as the range (all of them: All); a typed range keeps its text. + if boards != checked { + set_select(d, if boards.iter().all(|b| *b) { "all" } else { "range" }); + d.fields.insert("range".into(), json!(range_text(&boards))); + } + if typed || boards != checked { + d.fields.insert("boards".into(), json!(boards)); + } +} + +/// Export to: the folder (typed or picked), Open Location after Export and Create Sub-folders; the +/// web downloads instead. +fn destination(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + widgets::dim_label(ui, "Export to:"); + if io::is_web(app) { + widgets::dim_label(ui, "Your browser downloads the files (several as one .zip)."); + } else { + let pick = app.services.pick_folder.is_some(); + ui.horizontal(|ui| { + form::text(ui, d, "folder", if pick { 300.0 } else { 334.0 }); + if pick + && widgets::icon_button(ui, "folder-open", "Choose a folder", false, 26.0).clicked() + && let Some(f) = app.services.pick_folder.as_mut().and_then(|pick| pick()) + { + d.fields.insert("folder".into(), json!(f)); + } + }); + form::check(ui, d, "openLocation", "Open Location after Export"); + } + form::check(ui, d, "subfolders", "Create Sub-folders"); +} + +/// The preset, the Format Settings gear, the format rows (scale, suffix, format), Add Scale and the +/// prefix. +fn formats(ui: &mut egui::Ui, d: &mut Dialog) { + let mut rows: Vec = d.fields.get("formats").and_then(Value::as_array).cloned().unwrap_or_default(); + let mut edited = false; + ui.horizontal(|ui| { + widgets::dim_label(ui, "Formats:"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if widgets::icon_button(ui, "settings", "Format Settings", false, 24.0).clicked() { + let first = rows.first().and_then(|r| r["format"].as_str()).filter(|f| SETTINGS.iter().any(|(id, _)| id == f)); + d.fields.insert("__settings".into(), json!(first.unwrap_or("png"))); + } + let preset = d.str("preset"); + let labels: Vec<&str> = std::iter::once("Custom").chain(SCREEN_PRESETS.iter().map(|(_, l)| *l)).collect(); + let at = SCREEN_PRESETS.iter().position(|(id, _)| *id == preset).map_or(0, |i| i + 1); + if let Some(i) = widgets::dropdown(ui, "efs-preset", labels[at], &labels, 180.0) { + let id = i.checked_sub(1).and_then(|i| SCREEN_PRESETS.get(i)).map_or("", |(id, _)| *id); + if let Some(preset_rows) = fileio::screen_preset_rows(id) { + rows = preset_rows; + d.fields.insert("subfolders".into(), json!(true)); + d.fields.insert("formats".into(), json!(rows)); + } + d.fields.insert("preset".into(), json!(id)); + } + }); + }); + let mut remove = None; + let one = rows.len() == 1; + egui::Grid::new("efs-formats").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { + for head in ["Scale", "Suffix", "Format", ""] { + widgets::dim_label(ui, head); + } + ui.end_row(); + for (i, row) in rows.iter_mut().enumerate() { + let raster = row["format"].as_str().and_then(fileio::format).is_none_or(|f| f.raster); + let scale = row["scale"].as_str().unwrap_or("1x").to_string(); + ui.add_enabled_ui(raster, |ui| { + if let Some(s) = widgets::text_presets(ui, ("efs-scale", i), &scale, &SIZES, 96.0) { + // The suffix follows the size while it is the automatic one. + let auto = |text: &str| ScreenSize::parse(text).map(ScreenSize::suffix); + if row["suffix"].as_str().is_none_or(|x| Some(x.to_string()) == auto(&scale)) + && let Some(suffix) = auto(&s) + { + row["suffix"] = json!(suffix); + } + row["scale"] = json!(s); + edited = true; + } + }); + if let Some(s) = widgets::text_field(ui, ("efs-suffix", i), row["suffix"].as_str(), 70.0, 1) { + row["suffix"] = json!(s); + edited = true; + } + let (format, quality) = (row["format"].as_str().unwrap_or("png"), row["quality"].as_u64()); + let current = FORMATS.iter().position(|(_, f, q)| *f == format && q.is_none_or(|q| Some(u64::from(q)) == quality)); + let label = current.map_or_else(|| format.to_uppercase(), |i| FORMAT_LABELS[i].to_string()); + if let Some((_, f, q)) = widgets::dropdown(ui, ("efs-fmt", i), &label, &FORMAT_LABELS, 96.0).and_then(|i| FORMATS.get(i)) { + row["format"] = json!(f); + if let Some(o) = row.as_object_mut() { + match q { + Some(q) => _ = o.insert("quality".into(), json!(q)), + None => _ = o.remove("quality"), + } + } + edited = true; + } + ui.add_enabled_ui(!one, |ui| { + if widgets::icon_button(ui, "x", "Remove", false, 22.0).clicked() { + remove = Some(i); + } + }); + ui.end_row(); + } + }); + if let Some(i) = remove { + rows.remove(i); + edited = true; + } + if widgets::secondary_button(ui, "+ Add Scale").clicked() { + // One more than the largest factor so far. + let largest = rows + .iter() + .filter_map(|r| match r["scale"].as_str().and_then(ScreenSize::parse) { + Some(ScreenSize::Scale(f)) => Some(f), + _ => None, + }) + .fold(0.0, f64::max); + let size = ScreenSize::Scale(largest.floor() + 1.0); + rows.push(json!({"format": "png", "scale": size.label(), "suffix": size.suffix()})); + edited = true; + } + // Rows changed by hand are no longer a preset's. + if edited { + d.fields.insert("formats".into(), json!(rows)); + d.fields.insert("preset".into(), json!("")); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + widgets::dim_label(ui, "Prefix:"); + form::text(ui, d, "prefix", 140.0); + }); +} + +/// Format Settings: the options of format `id` that apply to every row of it, then Done. +fn format_settings(ui: &mut egui::Ui, d: &mut Dialog, id: &str) { + let t = Tokens::get(ui.ctx()); + widgets::subheader(ui, "Format Settings"); + ui.add_space(6.0); + let at = SETTINGS.iter().position(|(f, _)| *f == id).unwrap_or(0); + if let Some((f, _)) = widgets::dropdown(ui, "efs-settings-format", SETTINGS_LABELS[at], &SETTINGS_LABELS, 150.0).and_then(|i| SETTINGS.get(i)) { + d.fields.insert("__settings".into(), json!(f)); + } + ui.add_space(8.0); + // The format's options as a dialog of their own, for the options dialogs' rows. + let fields = d.fields.get("settings").and_then(|all| all.get(id)).and_then(Value::as_object).cloned().unwrap_or_default(); + let mut s = Dialog { kind: KIND.into(), fields }; + match id { + "svg" => svg_options::option_fields(ui, &mut s, true), + "pdf" => { + let labels: Vec<&str> = + d.fields.get("__presets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("PDF Preset:").color(t.text_dim)); + if let Some(p) = widgets::dropdown(ui, "efs-pdf-preset", &s.str("preset"), &labels, 220.0).and_then(|i| labels.get(i)) { + s.fields.insert("preset".into(), json!(p)); + } + }); + } + _ => { + egui::Grid::new("efs-settings").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| png_options::option_rows(ui, &mut s, id, true)); + } + } + if let Some(all) = d.fields.get_mut("settings").and_then(Value::as_object_mut) { + all.insert(id.into(), Value::Object(s.fields)); + } + ui.add_space(10.0); + if widgets::secondary_button(ui, "Done").clicked() { + d.fields.insert("__settings".into(), json!("")); + } +} + /// A small cached rendering of artboard `i` (keyed by document revision). fn artboard_thumb(app: &mut VectorcraftApp, ctx: &egui::Context, i: usize) -> Option { let st = app.session.active()?; 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/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 new file mode 100644 index 00000000..55a48f7d --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_screens.rs @@ -0,0 +1,192 @@ +//! Export for Screens, drawn headlessly: the fields it opens on (defaults, then the document's last +//! settings), the artboard choice, Format Settings, desktop folders and web downloads. + +use std::cell::RefCell; +use std::rc::Rc; + +use serde_json::{Value, json}; +use vectorcraft_engine::Session; +use vectorcraft_engine::cmd::fileio; + +use super::tests_export::{frame, kind, set}; +use super::*; +use crate::Services; + +type Log = Rc)>>>; + +/// An app with `artboards` 60×40 pt artboards and an ellipse on the first; `web` gives it a +/// download service (recorded in the log) instead of a file manager (whose reveals are recorded). +fn app(artboards: usize, web: bool) -> (VectorcraftApp, Log) { + let log = Log::default(); + let l = log.clone(); + let mut services = Services::default(); + if web { + services.download = Some(Box::new(move |name: &str, bytes: &[u8]| l.borrow_mut().push((name.to_string(), bytes.to_vec())))); + } else { + services.reveal = Some(Box::new(move |path: &str| { + l.borrow_mut().push((path.to_string(), vec![])); + Ok(()) + })); + services.pick_folder = Some(Box::new(|| None)); + } + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 60, "height": 40, "artboards": artboards})).unwrap(); + app.run("shape.ellipse", json!({"x": 5, "y": 4, "width": 30, "height": 20})).unwrap(); + (app, log) +} + +fn field(app: &VectorcraftApp, k: &str) -> Value { + app.ui.dialog.as_ref().expect("a dialog is open").fields.get(k).cloned().unwrap_or(Value::Null) +} + +fn open(app: &mut VectorcraftApp) { + app.run("file.exportForScreens", json!({})).unwrap(); + assert_eq!(kind(app), Some(export_for_screens::KIND)); +} + +fn temp_folder(tag: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("vc-efs-{tag}-{}", std::process::id())) +} + +#[test] +fn opens_on_defaults_and_draws_every_view() { + let (mut app, _) = app(3, false); + open(&mut app); + assert_eq!((field(&app, "select"), field(&app, "range"), field(&app, "boards")), (json!("all"), json!("1-3"), json!([true, true, true]))); + assert_eq!(field(&app, "formats"), json!([{"format": "png", "scale": "1x", "suffix": ""}])); + assert_eq!(field(&app, "folder"), json!(fileio::export_folder().unwrap_or_default()), "the Desktop, else home"); + assert_eq!((field(&app, "openLocation"), field(&app, "subfolders"), field(&app, "preset")), (json!(true), json!(false), json!(""))); + let settings = field(&app, "settings"); + for f in ["png", "png8", "jpg", "webp", "gif", "svg", "pdf"] { + assert!(settings[f].is_object(), "{f} has settings"); + } + assert!(settings["jpg"].get("quality").is_none() && settings["jpg"].get("imageMap").is_none(), "rows choose quality; no maps"); + assert!(settings["svg"].get("images").is_none(), "no linked images"); + assert_eq!(settings["pdf"]["preset"], json!(fileio::pdf::DEFAULT_PRESET)); + frame(&mut app); + // Assets, Full Document, each format's Format Settings: all draw. + set(&mut app, "tab", json!("assets")); + frame(&mut app); + set(&mut app, "tab", json!("artboards")); + set(&mut app, "select", json!("full")); + for f in ["png", "png8", "jpg", "webp", "gif", "svg", "pdf"] { + set(&mut app, "__settings", json!(f)); + frame(&mut app); + assert_eq!(field(&app, "__settings"), json!(f)); + } + assert_eq!(kind(&app), Some(export_for_screens::KIND)); + assert_eq!((spec(export_for_screens::KIND).ok_label.unwrap())(&app), "Export Artboard"); +} + +#[test] +fn checked_artboards_read_as_a_range() { + let (mut app, _) = app(5, false); + open(&mut app); + set(&mut app, "boards", json!([true, false, true, true, false])); + frame(&mut app); + // Nothing clicked: the fields stay as set. + assert_eq!(field(&app, "boards"), json!([true, false, true, true, false])); + for (boards, text) in [ + (vec![true, true, true], "1-3"), + (vec![false, true, false, true], "2, 4"), + (vec![true, false, true, true, false, true], "1, 3-4, 6"), + (vec![false], ""), + ] { + assert_eq!(export_for_screens::range_text(&boards), text); + } +} + +#[test] +fn exports_into_the_folder_and_shows_it() { + let (mut app, log) = app(2, false); + let dir = temp_folder("desk"); + let folder = dir.to_string_lossy().replace('\\', "/"); + open(&mut app); + set(&mut app, "folder", json!(folder)); + set(&mut app, "subfolders", json!(true)); + set( + &mut app, + "formats", + json!([{"format": "png", "scale": "1x", "suffix": ""}, {"format": "png", "scale": "120w", "suffix": "@120w"}, {"format": "svg", "scale": "1x", "suffix": ""}]), + ); + // A bad range keeps the dialog open; so does no folder. + set(&mut app, "select", json!("range")); + set(&mut app, "range", json!("7")); + assert!(confirm(&mut app).is_err()); + assert_eq!(kind(&app), Some(export_for_screens::KIND)); + set(&mut app, "range", json!("2")); + set(&mut app, "folder", json!(" ")); + assert!(confirm(&mut app).is_err()); + set(&mut app, "folder", json!(folder)); + let r = confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let files: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect(); + assert_eq!(files, [format!("{folder}/1x/Artboard-2.png"), format!("{folder}/120w/Artboard-2@120w.png"), format!("{folder}/SVG/Artboard-2.svg")]); + let png = image::load_from_memory(&std::fs::read(dir.join("120w").join("Artboard-2@120w.png")).unwrap()).unwrap(); + assert_eq!((png.width(), png.height()), (120, 80)); + assert_eq!(log.borrow().iter().map(|(p, _)| p.as_str()).collect::>(), [files[0]], "Open Location shows the first file"); + // It reopens on those settings. + open(&mut app); + assert_eq!((field(&app, "select"), field(&app, "range"), field(&app, "boards")), (json!("range"), json!("2"), json!([false, true]))); + assert_eq!(field(&app, "formats")[1]["scale"], json!("120w")); + assert_eq!((field(&app, "subfolders"), field(&app, "folder")), (json!(true), json!(folder))); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn the_web_downloads_one_zip_or_the_file() { + let (mut app, log) = app(2, true); + assert_eq!((spec(export_for_screens::KIND).ok_label.unwrap())(&app), "Download"); + open(&mut app); + frame(&mut app); + set( + &mut app, + "formats", + json!([{"format": "png", "scale": "1x", "suffix": ""}, {"format": "jpg", "quality": 80, "scale": "2x", "suffix": "@2x"}]), + ); + set(&mut app, "settings", json!({"png": {"background": "black"}})); + let r = confirm(&mut app).unwrap(); + assert!(r.get("dataBase64").is_some(), "four files: one zip"); + { + let log = log.borrow(); + let [(name, zip)] = log.as_slice() else { panic!("one download: {:?}", log.iter().map(|(n, _)| n).collect::>()) }; + assert_eq!(name, "Untitled-1.zip"); + assert_eq!(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(), zip); + } + assert_eq!(r["files"], json!(["Artboard-1.png", "Artboard-1@2x.jpg", "Artboard-2.png", "Artboard-2@2x.jpg"])); + // One file downloads as itself, with the format's settings. + log.borrow_mut().clear(); + open(&mut app); + assert_eq!(field(&app, "settings")["png"]["background"], json!("black"), "remembered"); + set(&mut app, "select", json!("range")); + set(&mut app, "range", json!("1")); + set(&mut app, "formats", json!([{"format": "png", "scale": "1x", "suffix": ""}])); + confirm(&mut app).unwrap(); + let log = log.borrow(); + assert_eq!(log.len(), 1); + assert_eq!(log[0].0, "Artboard-1.png"); + assert_eq!(image::load_from_memory(&log[0].1).unwrap().to_rgba8().get_pixel(1, 1).0, [0, 0, 0, 255], "the PNG settings' background"); +} + +#[test] +fn a_preset_export_reopens_on_its_rows() { + let (mut app, _) = app(1, true); + app.run("file.exportForScreens", json!({"preset": "density", "fullDocument": true})).unwrap(); + open(&mut app); + assert_eq!(field(&app, "preset"), json!("density")); + assert_eq!(field(&app, "select"), json!("full")); + assert_eq!(field(&app, "subfolders"), json!(true)); + let rows = field(&app, "formats"); + assert_eq!(rows.as_array().unwrap().len(), 6); + assert_eq!((rows[0]["scale"].clone(), rows[0]["folder"].clone()), (json!("0.75x"), json!("ldpi"))); + frame(&mut app); + // Agents' numeric sizes show as text. + app.run( + "file.exportForScreens", + json!({"formats": [{"format": "png", "width": 64}, {"format": "png", "scale": 3}, {"format": "png", "ppi": 144}]}), + ) + .unwrap(); + open(&mut app); + let scales: Vec = 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 3388aef0..fd1808c8 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,45 @@ pub(crate) fn open_in_app(app: &mut VectorcraftApp, path: &str) -> Result<(), St app.services.open_file.as_mut().ok_or("no app to open files here")?(path) } +/// File → Export for Screens with `document.exportForScreens` params: the files go into `folder`; +/// without one the web downloads them (the ZIP, or each file) and the other frontends get them +/// back as base64. With `openLocation`, the file manager shows the first file written. +pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result { + let open_location = params.get("openLocation").and_then(Value::as_bool).unwrap_or(false); + let folder = params.get("folder").and_then(Value::as_str).unwrap_or_default().to_string(); + let r = app.run("document.exportForScreens", params)?; + let count = r["files"].as_array().map_or(0, Vec::len); + // Files that came back as bytes: (name, base64). + let returned: Vec<(&str, &str)> = match r["dataBase64"].as_str() { + Some(zip) => vec![(r["name"].as_str().unwrap_or("Export.zip"), zip)], + None => { + r["files"].as_array().into_iter().flatten().filter_map(|f| Some((f.get("name")?.as_str()?, f.get("dataBase64")?.as_str()?))).collect() + } + }; + if returned.is_empty() { + let first = r["path"].as_str().or_else(|| r["files"][0].as_str()); + let mut status = format!("Exported {count} file(s) to {folder}"); + if open_location + && let Some(first) = first + && let Err(e) = reveal_path(app, first) + { + status = format!("{status} ({e})"); + } + app.status(status); + } else if let Some(download) = app.services.download.as_mut() { + for (name, data) in &returned { + let bytes = vectorcraft_format::base64_decode(data).ok_or("the export returned unreadable data")?; + download(name, &bytes); + } + let what = match returned.as_slice() { + [(name, _)] => (*name).to_string(), + files => format!("{} files", files.len()), + }; + app.status(format!("Downloaded {what}")); + } + Ok(r) +} + /// Place a file's bytes (no path, so embedded) centred in the view: `file.place`. pub fn place_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result<(), String> { crate::place::run(app, &serde_json::json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).map(|_| ()) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7164d903..7ef9ede3 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 (on the document's last settings); with params = document.exportForScreens (no folder on the web: downloads the files or the zip; openLocation: shows the first file in the file manager)", + ), ("file.documentSetup", "Document Setup…", "Cmd+Alt+P", "{}"), ("file.newDialog", "New…", "Cmd+N", "{} opens the New Document dialog"), ("edit.preferences", "Preferences…", "Cmd+K", "{category?} open Preferences (engine: prefs.get / prefs.set / prefs.list)"), @@ -520,15 +525,10 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option 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!({}) }; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index b41ab9f4..3faaa7e3 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -723,17 +723,7 @@ fn spin_generic( out = Some(v.max(min)); } if !presets.is_empty() { - let (dr, dresp) = ui.allocate_exact_size(vec2(20.0, h), Sense::click()); - ui.painter().rect_filled(dr, CornerRadius { nw: 0, sw: 0, ne: 2, se: 2 }, t.input); - ui.painter().rect_stroke(dr, CornerRadius { nw: 0, sw: 0, ne: 2, se: 2 }, Stroke::new(1.0, t.input_border), StrokeKind::Inside); - let c = if !enabled { - t.text_disabled - } else if dresp.hovered() { - t.text_strong - } else { - t.icon - }; - icons::paint(ui, "chevron-down", Rect::from_center_size(dr.center(), Vec2::splat(12.0)), c); + let dresp = chevron_cell(ui, h); egui::Popup::menu(&dresp).show(|ui| { ui.set_min_width(width - 10.0); for p in presets { @@ -1263,3 +1253,40 @@ pub fn region_texture( let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); ctx.load_texture(name, color, egui::TextureOptions::LINEAR) } + +/// The chevron cell on the right of a field that opens its preset list (rounded on the right). +fn chevron_cell(ui: &mut Ui, h: f32) -> Response { + let t = Tokens::get(ui.ctx()); + let (dr, dresp) = ui.allocate_exact_size(vec2(20.0, h), Sense::click()); + ui.painter().rect_filled(dr, CornerRadius { nw: 0, sw: 0, ne: 2, se: 2 }, t.input); + ui.painter().rect_stroke(dr, CornerRadius { nw: 0, sw: 0, ne: 2, se: 2 }, Stroke::new(1.0, t.input_border), StrokeKind::Inside); + let c = if !ui.is_enabled() { + t.text_disabled + } else if dresp.hovered() { + t.text_strong + } else { + t.icon + }; + icons::paint(ui, "chevron-down", Rect::from_center_size(dr.center(), Vec2::splat(12.0)), c); + dresp +} + +/// A [`text_field`] with a preset list on its right (Export for Screens' sizes: `2x`, `100w`…). +/// Returns the new text when a change is committed (Enter or focus loss) or a preset is picked. +pub fn text_presets(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: &str, presets: &[&str], width: f32) -> Option { + 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":{}}} +```