diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 0d28b3a39..3e7ac165f 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -505,6 +505,10 @@ pub struct Document { /// kept them in `unknown`, see [`Document::migrate_color_profiles`]). #[serde(default, skip_serializing_if = "ColorProfiles::is_empty")] pub color_profiles: ColorProfiles, + /// File → Export for Screens: the settings it last exported with (`document.exportForScreens` + /// params), so the dialog reopens on them. + #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] + pub export_settings: serde_json::Map, /// Top-level keys this version doesn't know (written by a newer one), kept so saving doesn't /// lose them. Separate from [`Document::unknown`], which holds foreign data by design. #[serde(flatten)] @@ -577,6 +581,7 @@ impl Document { raster_effects: RasterEffectsSettings::default(), last_view: None, color_profiles: ColorProfiles::default(), + export_settings: Default::default(), extra: Default::default(), slices: vec![], slices_clip_to_artboard: true, diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index d3042a125..8519de281 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -5,7 +5,7 @@ //! - `encode`: one encoder per writable format, with typed options parsed from the params. //! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…). //! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForOffice`. -//! - `screens`: Export for Screens (`document.exportForScreens`); `zip` +//! - `screens`: Export for Screens (`document.exportForScreens`, `document.exportSettings`); `zip` //! packs its files as one download. //! - `save`: `document.save`, Save As / a Copy / as Template, New from Template, Revert and the //! per-format options (`file.formatOptions`); [`save_with`] is the one save path of every frontend. @@ -49,6 +49,7 @@ pub use save::{ SAVE_FORMATS, SaveJob, SaveMode, SavePlan, export_folder, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, templates_folder, }; +pub use screens::{PRESETS as SCREEN_PRESETS, ScreenSize, preset_rows as screen_preset_rows}; pub use svg::options_map as svg_options; /// Atomic file writes (a temporary file renamed over the target: a failed write never damages the /// file it replaces), for the apps' own writers too. @@ -101,7 +102,7 @@ pub fn specs() -> Vec { "Export for Screens", ["File", "Export"], None, - "{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), formats?: [{format: png|jpg|webp|gif|png8|svg|svgz|pdf, scale?: 1 (raster only), ppi?: (raster: wins over scale, scale = ppi / 72), suffix?: \"@2x\" (raster default: @{scale}x when scale ≠ 1; vector formats drop @Nx suffixes), …the format's options (antiAlias, background…)}], prefix?, antiAlias?: none|art|type (raster rows without their own)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]}", + "{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|svg|svgz|pdf, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|svg|pdf: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png). The params are remembered in the document (document.exportSettings; not an undo step)", has_doc, screens::export_for_screens ), @@ -133,6 +134,15 @@ pub fn specs() -> Vec { has_doc, export::export_for_office ), + cmd!( + query "document.exportSettings", + "Export for Screens Settings", + [], + None, + "{} → {settings}: the document.exportForScreens params the document last exported with ({} when never; saved with the document, the dialog reopens on them)", + has_doc, + screens::export_settings + ), ] .into_iter() .chain(save::specs()) diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index c89b2bb4c..eaff1a8a8 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -1,135 +1,406 @@ -//! File → Export → Export for Screens (`document.exportForScreens`): the chosen artboards in every -//! format row, into a folder or returned for download (each file, or one ZIP). +//! File → Export → Export for Screens (`document.exportForScreens`): the chosen artboards (or the +//! whole document) in every format row at each row's size, into a folder and its sub-folders, or +//! returned for download (each file, or one ZIP); the presets the dialog offers; and the settings +//! the document remembers (`document.exportSettings`). use std::collections::HashSet; +use std::sync::Arc; use serde::Deserialize; -use serde_json::{Value, json}; +use serde_json::{Map, Value, json}; +use vectorcraft_doc::Document; +use vectorcraft_geom::Rect; use super::super::*; +use super::encode::single_artboard; use super::export::without_artboards; -use super::{ArtboardPick, Format, artboard_file_names, create_dir, encode, writable, write_file}; +use super::{ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, encode, writable, write_file}; const C: &str = "document.exportForScreens"; -/// One Export for Screens format row: what to write, at which scale, with which file-name suffix. -struct ScreenFormat { - format: &'static Format, - /// The row's own options (scale, quality…) for the encoder. - options: Value, - suffix: String, +/// How big a raster row's files are. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ScreenSize { + /// Pixels per point (`2x`). + Scale(f64), + /// Pixels per inch, stored in the file (`144ppi`: twice 72, so `2x`). + Ppi(f64), + /// The width in pixels; the height follows the artboard's proportions (`100w`). + Width(f64), + /// The height in pixels (`100h`). + Height(f64), } -fn screen_format(row: &Value) -> Result { - if !row.is_object() { - return Err(bad(C, "each format is an object {format, scale?, suffix?}")); +impl ScreenSize { + /// `2x` (or `2`), `100w`, `100h` or `72ppi`, in any case; positive numbers only. + pub fn parse(s: &str) -> Option { + let s = s.trim().to_ascii_lowercase(); + let num = |t: &str| t.trim().parse::().ok().filter(|v| v.is_finite() && *v > 0.0); + if let Some(n) = s.strip_suffix("ppi") { + return num(n).map(Self::Ppi); + } + if let Some(n) = s.strip_suffix('w') { + return num(n).map(Self::Width); + } + if let Some(n) = s.strip_suffix('h') { + return num(n).map(Self::Height); + } + num(s.strip_suffix('x').unwrap_or(&s)).map(Self::Scale) } - let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?; - if matches!(format.id, "vectorcraft" | "template" | "dxf") { - return Err(bad(C, "Export for Screens writes png, jpg, webp, gif, png8, svg, svgz or pdf")); + + /// The size a format row asks for: `width`, else `height`, else `ppi`, else `scale` (a number, + /// or text such as `"2x"`, `"100w"`, `"72ppi"`); 1x when it names none. + fn of_row(row: &Value) -> Result { + let positive = |key: &str, make: fn(f64) -> Self| -> Option> { + let v = row.get(key).filter(|v| !v.is_null())?; + Some( + v.as_f64().filter(|n| n.is_finite() && *n > 0.0).map(make).ok_or_else(|| bad(C, format!("{key} must be a positive number, not {v}"))), + ) + }; + if let Some(r) = positive("width", Self::Width).or_else(|| positive("height", Self::Height)).or_else(|| positive("ppi", Self::Ppi)) { + return r; + } + match row.get("scale") { + None | Some(Value::Null) => Ok(Self::Scale(1.0)), + Some(Value::String(s)) => Self::parse(s) + .ok_or_else(|| bad(C, format!("scale `{s}`: a factor such as 2x, a width (100w), a height (100h) or a resolution (72ppi)"))), + Some(_) => positive("scale", Self::Scale).unwrap_or(Ok(Self::Scale(1.0))), + } } - // Vector formats have no pixel size: scale doesn't apply and adds no @Nx suffix (not even one - // left over from a raster row switched to SVG or PDF). - // A raster row's `ppi` wins over its `scale` (the encoder does the same), so it names the suffix. - let scale = match row.get("ppi").and_then(Value::as_f64) { - _ if !format.raster => 1.0, - Some(ppi) => ppi / 72.0, - None => f64_or(row, "scale", 1.0), - }; - let suffix = match str_param(row, "suffix") { - Some(s) if !format.raster && is_scale_suffix(s) => String::new(), - Some(s) => s.to_string(), - None if (scale - 1.0).abs() < 1e-9 => String::new(), - None => format!("@{scale}x"), - }; - let mut options = without_artboards(row); - if let Some(o) = options.as_object_mut() { - o.insert("scale".into(), json!(scale)); - // A file per artboard is for viewing: it doesn't carry the whole document to edit. - o.entry("preserveEditing").or_insert(json!(false)); + + /// The scale factor, for the sizes that have one of their own. + fn factor(self) -> Option { + match self { + Self::Scale(s) => Some(s), + Self::Ppi(p) => Some(p / 72.0), + Self::Width(_) | Self::Height(_) => None, + } + } + + /// Pixels per point for `region`. + fn scale_for(self, region: Rect) -> Result { + let per = |px: f64, pt: f64| (pt > 0.0).then(|| px / pt).ok_or_else(|| bad(C, "an empty artboard has no width or height to scale to")); + match self { + Self::Width(w) => per(w, region.width()), + Self::Height(h) => per(h, region.height()), + _ => Ok(self.factor().unwrap_or(1.0)), + } + } + + /// The sub-folder files of this size go into: `1x`, `2x`, `100w`, `100h` (numbers to 3 + /// decimals: 80ppi is `1.111x`). + pub fn label(self) -> String { + let n = |v: f64| (v * 1000.0).round() / 1000.0; + match self { + Self::Width(w) => format!("{}w", n(w)), + Self::Height(h) => format!("{}h", n(h)), + _ => format!("{}x", n(self.factor().unwrap_or(1.0))), + } + } + + /// The file-name suffix by default: none at 1x, else `@2x`, `@100w`, `@100h` (a resolution + /// names its scale: 144ppi is `@2x`). + pub fn suffix(self) -> String { + match self.factor() { + Some(s) if (s - 1.0).abs() < 1e-9 => String::new(), + _ => format!("@{}", self.label()), + } } - Ok(ScreenFormat { format, options, suffix }) } -/// A pixel-density suffix such as `@2x` or `@0.5x`. -fn is_scale_suffix(s: &str) -> bool { - s.strip_prefix('@').and_then(|s| s.strip_suffix(['x', 'X'])).is_some_and(|n| n.parse::().is_ok()) +/// A file-name suffix that names a pixel size (`@2x`, `@0.5x`, `@100w`, `@100h`): vector rows +/// leave it out. +fn is_size_suffix(s: &str) -> bool { + s.strip_prefix('@').is_some_and(|t| t.ends_with(|c: char| c.is_ascii_alphabetic()) && ScreenSize::parse(t).is_some()) } -/// A name safe as one file name: letters, digits, `-`, `_` and `.` kept, anything else `-` (`None` -/// when nothing but dots is left). +/// The presets of the dialog's format rows: (id, label). +pub const PRESETS: [(&str, &str); 2] = [("mobile", "Mobile 1x/2x/3x"), ("density", "Density buckets (0.75x–4x)")]; + +/// Screen density buckets: (sub-folder, scale). +const DENSITY: [(&str, f64); 6] = [("ldpi", 0.75), ("mdpi", 1.0), ("hdpi", 1.5), ("xhdpi", 2.0), ("xxhdpi", 3.0), ("xxxhdpi", 4.0)]; + +/// The format rows of preset `id` (see [`PRESETS`]): PNG at 1x, 2x and 3x (`@2x`, `@3x`), or PNG +/// at every density bucket, each in its bucket's sub-folder under the same name. +pub fn preset_rows(id: &str) -> Option> { + let png = |s: f64| ScreenSize::Scale(s); + match id { + "mobile" => Some([1.0, 2.0, 3.0].iter().map(|s| json!({"format": "png", "scale": png(*s).label(), "suffix": png(*s).suffix()})).collect()), + "density" => { + Some(DENSITY.iter().map(|(folder, s)| json!({"format": "png", "scale": png(*s).label(), "suffix": "", "folder": folder})).collect()) + } + _ => None, + } +} + +/// A name safe as one file or folder name: letters, digits, `-`, `_` and `.` kept, anything else +/// `-` (`None` when nothing but dots is left: `.` and `..` name other folders). fn safe_name(s: &str) -> Option { let n: String = s.trim().chars().map(|c| if c.is_alphanumeric() || "-_.".contains(c) { c } else { '-' }).collect(); (!n.trim_matches('.').is_empty()).then_some(n) } -/// File → Export for Screens: every chosen artboard in every format, one file each (a PDF holds -/// its artboard alone). Artboards that share a name get `-2`, `-3`… instead of overwriting. +/// Keys of a format row that aren't encoder options. +const ROW_KEYS: [&str; 7] = ["format", "scale", "ppi", "width", "height", "suffix", "folder"]; + +/// One format row: what to write, at which size, with which suffix, into which sub-folder. +struct Row { + format: &'static Format, + size: ScreenSize, + suffix: String, + /// The sub-folder with Create Sub-folders: the row's `folder`, else its size (raster) or format. + folder: String, + /// The encoder options: the format's settings, then the row's own. + options: Value, +} + +impl Row { + fn parse(s: &Session, row: &Value, settings: &Value) -> Result { + if !row.is_object() { + return Err(bad(C, "each format is an object {format, scale?, suffix?}")); + } + let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?; + if matches!(format.id, "vectorcraft" | "template" | "txt" | "dxf") { + return Err(bad(C, "Export for Screens writes png, png8, jpg, webp, gif, svg, svgz or pdf")); + } + // Vector formats have no pixel size: it doesn't apply and adds no @2x suffix (not even one + // left over from a raster row switched to SVG or PDF). + let size = if format.raster { ScreenSize::of_row(row)? } else { ScreenSize::Scale(1.0) }; + let suffix = match str_param(row, "suffix") { + Some(s) if !format.raster && is_size_suffix(s) => String::new(), + Some(s) => s.to_string(), + None => size.suffix(), + }; + let folder = match str_param(row, "folder").and_then(safe_name) { + Some(f) => f, + None if format.raster => size.label(), + None => format.id.to_uppercase(), + }; + // The format's settings, without anything that picks artboards or sizes (rows do that). + let mut options = Map::new(); + if let Some(o) = settings.get(format.id).and_then(Value::as_object) { + options.extend( + o.iter().filter(|(k, _)| !ROW_KEYS.contains(&k.as_str()) && k.as_str() != "useArtboards").map(|(k, v)| (k.clone(), v.clone())), + ); + } + if let Some(o) = without_artboards(row).as_object() { + options.extend(o.iter().filter(|(k, _)| !ROW_KEYS.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone()))); + } + for k in ARTBOARD_PARAMS { + options.remove(k); + } + // A file per artboard is for viewing: it doesn't carry the whole document to edit. + options.entry("preserveEditing").or_insert(json!(false)); + let options = Value::Object(options); + // A PDF preset (built-in or saved) becomes its settings. + let options = if format.id == "pdf" { super::pdf::expand_preset(s, C, &options)?.into_owned() } else { options }; + Ok(Self { format, size, suffix, folder, options }) + } + + /// The encoder options for `region`, `artboard` of the document written. + fn options_for(&self, region: Rect, artboard: Option) -> Result { + let mut o = self.options.clone(); + if let Some(b) = artboard { + o["artboard"] = json!(b); + } + if self.format.raster { + match self.size { + // The resolution is stored in the file too. + ScreenSize::Ppi(ppi) => o["ppi"] = json!(ppi), + size => { + o["scale"] = json!(size.scale_for(region)?); + if let Some(m) = o.as_object_mut() { + m.remove("ppi"); + } + } + } + } + Ok(o) + } +} + +/// What `document.exportForScreens` reads besides the formats. +#[derive(Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct ScreenParams { + #[serde(flatten)] + boards: ArtboardPick, + folder: Option, + zip: bool, + full_document: bool, + include_bleed: bool, + subfolders: bool, + preset: Option, + prefix: Option, +} + +/// The file names (and bytes) an export wrote, or the files it returns. +#[derive(Default)] +struct Output { + /// Lower-case relative names already used (two identical rows write one file). + taken: HashSet, + /// Sub-folders already made. + made: HashSet, + /// `(relative name, bytes)` kept to return or zip (empty when written to a folder). + kept: Vec<(String, Vec)>, + /// The paths written. + written: Vec, +} + +impl Output { + /// Is `name` new (not written by an identical row already)? + fn claim(&mut self, name: &str) -> bool { + self.taken.insert(name.to_lowercase()) + } + + /// Write `bytes` as `name` (relative) under `folder`, or keep them. + fn put(&mut self, folder: Option<&str>, keep: bool, name: String, bytes: Vec) -> Result<()> { + match folder { + Some(dir) if !keep => { + if let Some((sub, _)) = name.rsplit_once('/') + && self.made.insert(sub.to_lowercase()) + { + create_dir(&format!("{dir}/{sub}"))?; + } + let path = format!("{dir}/{name}"); + write_file(&path, &bytes)?; + self.written.push(path); + } + _ => self.kept.push((name, bytes)), + } + Ok(()) + } +} + +/// File → Export for Screens: every chosen artboard in every format row, one file each (a PDF holds +/// its artboard alone), or with `fullDocument` one file per row (a PDF of every artboard, else the +/// bounds of all art). Artboards that share a name get `-2`, `-3`… instead of overwriting. The +/// params are remembered in the document (`document.exportSettings`). pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result { - let doc = s.doc()?.doc.clone(); - let n = doc.artboards.len(); - let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(C, e.to_string()))? } else { ArtboardPick::default() }; - let boards = pick.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect()); - let mut formats: Vec = match p.get("formats").and_then(Value::as_array) { - Some(rows) => rows.iter().map(screen_format).collect::>()?, - None => vec![screen_format(&json!({}))?], + let o: ScreenParams = if p.is_object() { ScreenParams::deserialize(p).map_err(|e| bad(C, e.to_string()))? } else { ScreenParams::default() }; + let settings = p.get("settings").cloned().unwrap_or(Value::Null); + let rows: Vec = match (o.preset.as_deref(), p.get("formats")) { + (Some(_), Some(f)) if !f.is_null() => return Err(bad(C, "give formats or a preset, not both")), + (Some(id), _) => preset_rows(id).ok_or_else(|| bad(C, format!("preset `{id}`: mobile or density")))?, + (None, Some(Value::Array(rows))) => rows.clone(), + (None, None | Some(Value::Null)) => vec![json!({})], + (None, Some(v)) => return Err(bad(C, format!("formats must be an array of rows, not {v}"))), }; + let mut rows: Vec = rows.iter().map(|r| Row::parse(s, r, &settings)).collect::>()?; + if rows.is_empty() { + return Err(bad(C, "no formats to export")); + } // A top-level anti-aliasing mode applies to every row that doesn't name its own. if let Some(aa) = p.get("antiAlias") { - for sf in &mut formats { - if let Some(o) = sf.options.as_object_mut() { - o.entry("antiAlias").or_insert_with(|| aa.clone()); + for r in &mut rows { + if let Some(m) = r.options.as_object_mut() { + m.entry("antiAlias").or_insert_with(|| aa.clone()); } } } - let prefix = str_param(p, "prefix").unwrap_or(""); - let folder = str_param(p, "folder").filter(|f| !f.is_empty()); - let zip = bool_or(p, "zip", false); + let subfolders = o.subfolders || o.preset.is_some(); + // A prefix names files, not folders. + let prefix: String = o.prefix.as_deref().unwrap_or("").chars().map(|c| if matches!(c, '/' | '\\' | ':') { '-' } else { c }).collect(); + let folder = o.folder.as_deref().filter(|f| !f.is_empty()); if let Some(dir) = folder { create_dir(dir)?; } - let mut written = HashSet::new(); - // Written paths, or (name, bytes) to return or zip. - let mut paths = vec![]; - let mut kept: Vec<(String, Vec)> = vec![]; - for (b, name) in boards.iter().copied().zip(artboard_file_names(&doc, &boards)) { - for sf in &formats { - let file = format!("{prefix}{name}{}.{}", sf.suffix, sf.format.extensions[0]); - if !written.insert(file.to_lowercase()) { - continue; // the same file from two identical rows + let doc = s.doc()?.doc.clone(); + // Include Bleed: each artboard grown by the document's bleed. + let doc = if o.include_bleed && doc.setup.has_bleed() { + let mut d = (*doc).clone(); + for a in &mut d.artboards { + a.rect = doc.setup.bleed_rect(a.rect); + } + Arc::new(d) + } else { + doc + }; + let name_of = |r: &Row, stem: &str| { + let file = format!("{prefix}{stem}{}.{}", r.suffix, r.format.extensions[0]); + if subfolders { format!("{}/{file}", r.folder) } else { file } + }; + let mut out = Output::default(); + if o.full_document { + let stem = safe_name(&super::file_stem(&doc.title)).unwrap_or_else(|| "Untitled".into()); + // The bounds of all visible art, as the one artboard of the files that aren't PDFs. + let mut whole: Option = None; + for r in &rows { + let name = name_of(r, &stem); + if !out.claim(&name) { + continue; } - let mut options = sf.options.clone(); - options["artboard"] = json!(b); - let bytes = encode(&doc, sf.format.id, &options)?; - match folder { - Some(dir) if !zip => { - let path = format!("{dir}/{file}"); - write_file(&path, &bytes)?; - paths.push(path); + let bytes = if r.format.id == "pdf" { + // One page per artboard. + let mut options = r.options.clone(); + options["range"] = json!("all"); + encode(&doc, "pdf", &options)? + } else { + if whole.is_none() { + let bounds = + vectorcraft_render::encode::art_bounds(&doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?; + whole = Some(single_artboard(&doc, bounds, "Document")); } - _ => kept.push((file, bytes)), + let Some(d) = whole.as_ref() else { continue }; + let region = d.artboards.first().map_or(Rect::ZERO, |a| a.rect); + encode(d, r.format.id, &r.options_for(region, Some(0))?)? + }; + out.put(folder, o.zip, name, bytes)?; + } + } else { + let n = doc.artboards.len(); + let boards = o.boards.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect()); + if boards.is_empty() { + return Err(bad(C, "the document has no artboard")); + } + for (b, stem) in boards.iter().copied().zip(artboard_file_names(&doc, &boards)) { + let region = doc.artboards.get(b).map_or(Rect::ZERO, |a| a.rect); + for r in &rows { + let name = name_of(r, &stem); + if !out.claim(&name) { + continue; // the same file from two identical rows + } + let bytes = encode(&doc, r.format.id, &r.options_for(region, Some(b))?)?; + out.put(folder, o.zip, name, bytes)?; } } } - let names: Vec<&str> = kept.iter().map(|(n, _)| n.as_str()).collect(); - if zip { - let archive = super::zip::store(&kept).map_err(|e| bad(C, e))?; + remember(s, p); + let names: Vec<&str> = out.kept.iter().map(|(n, _)| n.as_str()).collect(); + if o.zip { + let zip = super::zip::store(&out.kept).map_err(|e| bad(C, e))?; let zip_name = format!("{prefix}{}.zip", safe_name(&super::file_stem(&doc.title)).unwrap_or_else(|| "Untitled".into())); return match folder { Some(dir) => { let path = format!("{dir}/{zip_name}"); - write_file(&path, &archive)?; - Ok(json!({ "path": path, "bytes": archive.len(), "files": names })) - } - None => { - Ok(json!({ "name": zip_name, "dataBase64": vectorcraft_format::base64_encode(&archive), "bytes": archive.len(), "files": names })) + write_file(&path, &zip)?; + Ok(json!({ "path": path, "bytes": zip.len(), "files": names })) } + None => Ok(json!({ "name": zip_name, "dataBase64": vectorcraft_format::base64_encode(&zip), "bytes": zip.len(), "files": names })), }; } Ok(match folder { - Some(_) => json!({ "files": paths }), + Some(_) => json!({ "files": out.written }), None => { - json!({ "files": kept.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect::>() }) + json!({ "files": out.kept.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect::>() }) } }) } + +/// Keep the params of a successful export in the document (without `zip`, a delivery detail), so +/// the dialog reopens on them. Not an undo step; the document has changed only if they differ. +fn remember(s: &mut Session, p: &Value) { + let Some(o) = p.as_object() else { return }; + let mut settings = o.clone(); + settings.remove("zip"); + let Ok(st) = s.doc_mut() else { return }; + if st.doc.export_settings != settings { + Arc::make_mut(&mut st.doc).export_settings = settings; + st.revision += 1; + } +} + +/// `document.exportSettings`: the Export for Screens settings the document last exported with. +pub(super) fn export_settings(s: &mut Session, _: &Value) -> Result { + Ok(json!({ "settings": s.doc()?.doc.export_settings })) +} diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs index 0728655d9..0ff69b391 100644 --- a/crates/engine/src/cmd/fileio/tests_screens.rs +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -1,7 +1,9 @@ -//! Export for Screens: files returned for download, one by one or as a ZIP. +//! Export for Screens: sizes (factor, width, height, resolution), presets and sub-folders, Full +//! Document, Include Bleed, per-format settings, ZIP downloads and the settings the document keeps. use serde_json::{Value, json}; +use super::screens::ScreenSize; use super::*; fn b64(v: &Value) -> Vec { @@ -25,22 +27,176 @@ fn files(r: &Value) -> Vec<(String, Vec)> { r["files"].as_array().unwrap().iter().map(|f| (f["name"].as_str().unwrap().to_string(), b64(f))).collect() } +fn names(r: &Value) -> Vec { + files(r).into_iter().map(|(n, _)| n).collect() +} + +fn size(png: &[u8]) -> (u32, u32) { + image::load_from_memory(png).unwrap().to_rgba8().dimensions() +} + +#[test] +fn sizes_parse_as_factor_width_height_or_resolution() { + assert_eq!(ScreenSize::parse("2x"), Some(ScreenSize::Scale(2.0))); + assert_eq!(ScreenSize::parse(" 0.5 "), Some(ScreenSize::Scale(0.5))); + assert_eq!(ScreenSize::parse("100W"), Some(ScreenSize::Width(100.0))); + assert_eq!(ScreenSize::parse("64h"), Some(ScreenSize::Height(64.0))); + assert_eq!(ScreenSize::parse("144ppi"), Some(ScreenSize::Ppi(144.0))); + for bad in ["", "x", "-2x", "0w", "NaNx", "infh", "2y"] { + assert_eq!(ScreenSize::parse(bad), None, "{bad}"); + } + assert_eq!((ScreenSize::Scale(1.0).suffix(), ScreenSize::Scale(1.5).suffix()), (String::new(), "@1.5x".into())); + assert_eq!( + (ScreenSize::Width(100.0).suffix(), ScreenSize::Ppi(144.0).suffix(), ScreenSize::Ppi(144.0).label()), + ("@100w".into(), "@2x".into(), "2x".into()) + ); +} + +#[test] +fn a_width_or_height_row_gives_that_many_pixels() { + let mut s = session(1); + let r = export( + &mut s, + json!({"formats": [{"format": "png", "scale": "100w"}, {"format": "png", "height": 60}, {"format": "jpg", "scale": "80ppi"}]}), + ); + assert_eq!(names(&r), ["Artboard-1@100w.png", "Artboard-1@60h.png", "Artboard-1@1.111x.jpg"]); + let f = files(&r); + assert_eq!(size(&f[0].1), (100, 75)); + assert_eq!(size(&f[1].1), (80, 60)); + assert_eq!(size(&f[2].1), (44, 33)); + // Width or height win over scale, and a bad size is refused. + let r = export(&mut s, json!({"formats": [{"format": "png", "scale": 3, "width": 20}]})); + assert_eq!(size(&files(&r)[0].1), (20, 15)); + for bad in [json!({"scale": "big"}), json!({"width": -5}), json!({"height": "tall"})] { + assert!(s.execute("document.exportForScreens", &json!({"formats": [bad]})).is_err(), "{bad}"); + } + // Vector rows ignore sizes and their suffixes. + let r = export(&mut s, json!({"formats": [{"format": "svg", "scale": "100w", "suffix": "@100w"}]})); + assert_eq!(names(&r), ["Artboard-1.svg"]); +} + +#[test] +fn density_buckets_go_into_sub_folders() { + let mut s = session(2); + let r = export(&mut s, json!({"preset": "density", "range": "1"})); + assert_eq!( + names(&r), + [ + "ldpi/Artboard-1.png", + "mdpi/Artboard-1.png", + "hdpi/Artboard-1.png", + "xhdpi/Artboard-1.png", + "xxhdpi/Artboard-1.png", + "xxxhdpi/Artboard-1.png" + ] + ); + let widths: Vec = files(&r).iter().map(|(_, b)| size(b).0).collect(); + assert_eq!(widths, [30, 40, 60, 80, 120, 160]); + let r = export(&mut s, json!({"preset": "mobile", "artboards": [1]})); + assert_eq!(names(&r), ["1x/Artboard-2.png", "2x/Artboard-2@2x.png", "3x/Artboard-2@3x.png"]); + assert!(s.execute("document.exportForScreens", &json!({"preset": "mobile", "formats": [{}]})).is_err(), "a preset or formats"); + assert!(s.execute("document.exportForScreens", &json!({"preset": "tablet"})).is_err()); + // Sub-folders by size for raster rows, by format for vector rows, or the row's own (made safe). + let r = export( + &mut s, + json!({"range": "2", "subfolders": true, "formats": [{"format": "png", "scale": 2}, {"format": "svg"}, {"format": "pdf", "folder": "../print"}]}), + ); + assert_eq!(names(&r), ["2x/Artboard-2@2x.png", "SVG/Artboard-2.svg", "..-print/Artboard-2.pdf"]); + // A prefix names files, not folders. + let r = export(&mut s, json!({"range": "2", "prefix": "../a\\b:", "formats": [{"format": "svg"}]})); + assert_eq!(names(&r), ["..-a-b-Artboard-2.svg"]); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn sub_folders_are_made_in_the_export_folder() { + let mut s = session(1); + let dir = std::env::temp_dir().join(format!("vc-screens-sub-{}", std::process::id())); + let folder = dir.to_string_lossy().replace('\\', "/"); + let r = export(&mut s, json!({"folder": folder, "preset": "mobile"})); + let written: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect(); + assert_eq!(written, [format!("{folder}/1x/Artboard-1.png"), format!("{folder}/2x/Artboard-1@2x.png"), format!("{folder}/3x/Artboard-1@3x.png")]); + assert_eq!(size(&std::fs::read(dir.join("3x").join("Artboard-1@3x.png")).unwrap()), (120, 90)); + let _ = std::fs::remove_dir_all(dir); +} + #[cfg(not(target_arch = "wasm32"))] #[test] fn a_zip_is_written_into_the_folder() { let mut s = session(1); let dir = std::env::temp_dir().join(format!("vc-screens-zip-{}", std::process::id())); - let folder = dir.to_string_lossy().replace('\\', "/"); + let folder = dir.to_string_lossy().replace(std::path::MAIN_SEPARATOR, "/"); let r = export(&mut s, json!({"folder": folder, "zip": true, "formats": [{"format": "png"}, {"format": "svg"}]})); let zip = std::fs::read(r["path"].as_str().unwrap()).unwrap(); assert_eq!(super::zip::entries(&zip).unwrap().len(), 2); let _ = std::fs::remove_dir_all(dir); } +#[test] +fn full_document_gives_one_file_per_format() { + let mut s = session(3); + let r = export( + &mut s, + json!({"fullDocument": true, "range": "2", "prefix": "x-", "formats": [{"format": "pdf"}, {"format": "png", "scale": 2}, {"format": "svg"}]}), + ); + let f = files(&r); + let title = super::file_stem(&s.doc().unwrap().doc.title); + assert_eq!(names(&r), [format!("x-{title}.pdf"), format!("x-{title}@2x.png"), format!("x-{title}.svg")]); + assert_eq!(vectorcraft_pdf::import(&f[0].1).unwrap().artboards.len(), 3, "one page per artboard, whatever the range"); + // The bounds of the art: the square and its 1 pt stroke. + assert_eq!(size(&f[1].1), (42, 42)); + // Nothing visible: nothing to export but the PDF. + let mut empty = session(1); + empty.execute("edit.undo", &json!({})).unwrap(); + assert!(empty.execute("document.exportForScreens", &json!({"fullDocument": true, "formats": [{"format": "png"}]})).is_err()); + assert_eq!(files(&export(&mut empty, json!({"fullDocument": true, "formats": [{"format": "pdf"}]}))).len(), 1); +} + +#[test] +fn include_bleed_grows_each_artboard() { + let mut s = session(1); + s.execute("document.setup", &json!({"bleed": [2, 4, 6, 8]})).unwrap(); + let r = export(&mut s, json!({"includeBleed": true, "formats": [{"format": "png"}, {"format": "png", "scale": "108w", "suffix": "-w"}]})); + let f = files(&r); + assert_eq!(size(&f[0].1), (54, 36), "40 + 6 + 8 by 30 + 2 + 4"); + assert_eq!(size(&f[1].1), (108, 72), "the width counts the bleed"); + let r = export(&mut s, json!({"formats": [{"format": "png"}]})); + assert_eq!(size(&files(&r)[0].1), (40, 30), "without it, the artboard alone"); +} + +#[test] +fn png_8_rows_write_indexed_colour() { + let mut s = session(1); + let r = export(&mut s, json!({"formats": [{"format": "png8"}, {"format": "png"}]})); + let f = files(&r); + // The IHDR colour type: 3 (indexed) for PNG 8, 6 (RGBA) for PNG; both are .png, the PNG 8 row + // written first. + assert_eq!(names(&r), ["Artboard-1.png"], "the same file name: the first row wins"); + assert_eq!(f[0].1[25], 3); + let r = export(&mut s, json!({"formats": [{"format": "png8", "suffix": "-8"}, {"format": "png"}]})); + let f = files(&r); + assert_eq!((f[0].1[25], f[1].1[25]), (3, 6)); +} + +#[test] +fn format_settings_apply_to_their_rows_and_rows_win() { + let mut s = session(1); + let px = |png: &[u8]| image::load_from_memory(png).unwrap().to_rgba8().get_pixel(1, 1).0; + let r = export( + &mut s, + json!({"settings": {"png": {"background": "black", "scale": 9, "artboard": 7}, "jpg": {"quality": 5}}, "formats": [{"format": "png"}, {"format": "png", "suffix": "-w", "background": "white"}, {"format": "jpg"}, {"format": "jpg", "quality": 100, "suffix": "-100"}]}), + ); + let f = files(&r); + assert_eq!(px(&f[0].1), [0, 0, 0, 255], "the PNG settings' background"); + assert_eq!(size(&f[0].1), (40, 30), "settings don't pick sizes or artboards"); + assert_eq!(px(&f[1].1), [255, 255, 255, 255], "the row's own wins"); + assert!(f[2].1.len() < f[3].1.len(), "quality 5 is smaller than quality 100"); +} + #[test] fn zip_downloads_round_trip() { let mut s = session(2); - let p = json!({"formats": [{"format": "png"}, {"format": "png", "scale": 2}, {"format": "pdf"}]}); + let p = json!({"subfolders": true, "formats": [{"format": "png"}, {"format": "png", "scale": 2}, {"format": "pdf"}]}); let plain = files(&export(&mut s, p.clone())); let mut q = p; q["zip"] = json!(true); @@ -78,3 +234,36 @@ fn zip_archives_store_their_files() { broken[at] = b'j'; assert!(super::zip::entries(&broken).is_err(), "the CRC catches a changed byte"); } + +#[test] +fn the_document_remembers_its_export_settings() { + let mut s = session(2); + assert_eq!(s.execute("document.exportSettings", &json!({})).unwrap()["settings"], json!({})); + s.doc_mut().unwrap().mark_saved(); + let p = json!({"range": "2", "subfolders": true, "prefix": "p-", "zip": true, "openLocation": true, "formats": [{"format": "jpg", "quality": 80, "scale": "2x"}]}); + export(&mut s, p); + let settings = s.execute("document.exportSettings", &json!({})).unwrap()["settings"].clone(); + assert_eq!( + settings, + json!({"range": "2", "subfolders": true, "prefix": "p-", "openLocation": true, "formats": [{"format": "jpg", "quality": 80, "scale": "2x"}]}) + ); + assert!(s.doc().unwrap().is_dirty(), "settings are document data"); + s.doc_mut().unwrap().mark_saved(); + export( + &mut s, + json!({"range": "2", "subfolders": true, "prefix": "p-", "openLocation": true, "formats": [{"format": "jpg", "quality": 80, "scale": "2x"}]}), + ); + assert!(!s.doc().unwrap().is_dirty(), "the same settings change nothing"); + let undo_depth = s.doc().unwrap().history.undo.len(); + export(&mut s, json!({"range": "1"})); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_depth, "not an undo step"); + // A failed export keeps the last settings. + assert!(s.execute("document.exportForScreens", &json!({"range": "9"})).is_err()); + assert_eq!(s.execute("document.exportSettings", &json!({})).unwrap()["settings"], json!({"range": "1"})); + // Saved with the document (native round trip); older files have none. + let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!(doc.export_settings, s.doc().unwrap().doc.export_settings); + let plain = String::from_utf8(vectorcraft_format::save(&vectorcraft_doc::Document::new(5.0, 5.0), false)).unwrap(); + assert!(!plain.contains("export_settings") && !plain.contains("exportSettings"), "not written when empty"); + assert!(vectorcraft_format::load(plain.as_bytes()).unwrap().export_settings.is_empty()); +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index f873c6377..104bbe51a 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -35,7 +35,7 @@ const HOST_COMMANDS: &[(&str, &str, &str)] = &[ ( "file.exportForScreens", "Export for Screens…", - "{folder?, zip?, artboards? | range?, formats?, prefix?} = document.exportForScreens (no folder → the files, or one zip, as dataBase64)", + "{folder?, zip?, artboards? | range? | fullDocument?, includeBleed?, subfolders?, preset?, formats?, settings?, prefix?} = document.exportForScreens (no folder → the files, or one zip, as dataBase64)", ), ("tool.select", "Select Tool", "{tool} e.g. selection, directSelection, pen, rectangle, ellipse, polygon, star, lineSegment"), ]; diff --git a/crates/ui-egui/src/dialogs/export_for_screens.rs b/crates/ui-egui/src/dialogs/export_for_screens.rs index 56d721156..6db7141ba 100644 --- a/crates/ui-egui/src/dialogs/export_for_screens.rs +++ b/crates/ui-egui/src/dialogs/export_for_screens.rs @@ -1,10 +1,20 @@ -//! File → Export → Export for Screens: artboards (thumbnails and checkboxes), scale/format rows, -//! the destination folder (typed or picked; the web downloads instead) and file-name prefix. +//! File → Export → Export for Screens: the Artboards tab (All, a range or Full Document, Include +//! Bleed, thumbnails with checkboxes) and the Assets tab, the destination (folder picker, Open +//! Location after Export, Create Sub-folders; the web downloads instead), the format rows (scale +//! as `2x`, `100w`, `100h` or `72ppi`, suffix, format), the presets, Format Settings (the gear) +//! and the prefix. It opens on the settings the document last exported with. +//! +//! Fields (`ui.dialog.set`): `tab` (artboards|assets), `select` (all|range|full), `range`, +//! `boards` ([bool] per artboard), `includeBleed`, `folder`, `openLocation`, `subfolders`, +//! `prefix`, `preset` (""|mobile|density: where the rows came from), `formats` (rows of +//! `document.exportForScreens`, `scale` as text), `settings` ({png|png8|jpg|webp|gif|svg|pdf: {…}}); +//! `__settings` names the format whose Format Settings show ("" when closed). -use serde_json::{Value, json}; -use vectorcraft_engine::cmd::fileio; +use serde::Deserialize; +use serde_json::{Map, Value, json}; +use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, SCREEN_PRESETS, ScreenSize, pdf}; -use super::DialogSpec; +use super::{DialogSpec, form, png_options, svg_options}; use crate::state::Dialog; use crate::theme::Tokens; use crate::{VectorcraftApp, io, widgets}; @@ -15,168 +25,467 @@ pub(super) const SPEC: DialogSpec = DialogSpec { confirm, ok: Some("Export Artboard"), ok_label: Some(|app| if io::is_web(app) { "Download" } else { "Export Artboard" }), + min_width: 660.0, ..DialogSpec::FORM }; /// `Dialog::kind` of this dialog. pub const KIND: &str = "exportForScreens"; -/// Open the dialog: every artboard, PNG at 1x and 2x, into the Desktop (else the home folder). +/// The format choices of a row: (label, format, JPEG quality). +const FORMATS: [(&str, &str, Option); 9] = [ + ("PNG", "png", None), + ("PNG 8", "png8", None), + ("JPG 100", "jpg", Some(100)), + ("JPG 80", "jpg", Some(80)), + ("JPG 50", "jpg", Some(50)), + ("WebP", "webp", None), + ("GIF", "gif", None), + ("SVG", "svg", None), + ("PDF", "pdf", None), +]; +const FORMAT_LABELS: [&str; 9] = ["PNG", "PNG 8", "JPG 100", "JPG 80", "JPG 50", "WebP", "GIF", "SVG", "PDF"]; + +/// The formats Format Settings has options for: (format, label). +const SETTINGS: [(&str, &str); 7] = + [("png", "PNG"), ("png8", "PNG 8"), ("jpg", "JPG"), ("webp", "WebP"), ("gif", "GIF"), ("svg", "SVG"), ("pdf", "PDF")]; +const SETTINGS_LABELS: [&str; 7] = ["PNG", "PNG 8", "JPG", "WebP", "GIF", "SVG", "PDF"]; + +/// The sizes a row's scale list offers (any can be typed). +const SIZES: [&str; 12] = ["0.5x", "0.75x", "1x", "1.5x", "2x", "3x", "4x", "512w", "512h", "72ppi", "150ppi", "300ppi"]; + +/// Open the dialog on the settings the active document last exported with (else one PNG row at +/// 1x, into the Desktop). pub fn open(app: &mut VectorcraftApp) { - let n = app.session.active().map_or(0, |d| d.doc.artboards.len()); - app.ui.dialog = Some(Dialog::new( - KIND, - json!({"boards": vec![true; n], "formats": [{"format": "png", "scale": 1, "suffix": ""}, {"format": "png", "scale": 2, "suffix": "@2x"}], "folder": fileio::export_folder().unwrap_or_default(), "prefix": ""}), - )); + let (n, saved) = app.session.active().map(|st| (st.doc.artboards.len(), st.doc.export_settings.clone())).unwrap_or_default(); + let get = |k: &str| saved.get(k).filter(|v| !v.is_null()); + let flag = |k: &str, default: bool| get(k).and_then(Value::as_bool).unwrap_or(default); + // The artboards: Full Document, a range (or a list of them), else all. + let chosen = ArtboardPick::deserialize(&Value::Object(saved.clone())).ok().and_then(|p| p.resolve(n).ok().flatten()); + let select = match (flag("fullDocument", false), &chosen) { + (true, _) => "full", + (false, Some(c)) if c.len() < n => "range", + _ => "all", + }; + let boards: Vec = (0..n).map(|i| chosen.as_ref().is_none_or(|c| c.contains(&i))).collect(); + // The rows: the saved ones (or the saved preset's), sizes shown as text. + let preset = get("preset").and_then(Value::as_str).filter(|p| fileio::screen_preset_rows(p).is_some()); + let rows = match (get("formats").and_then(Value::as_array), preset) { + (Some(rows), _) if rows.iter().any(Value::is_object) => rows.iter().filter(|r| r.is_object()).map(editable_row).collect(), + (_, Some(p)) => fileio::screen_preset_rows(p).unwrap_or_default(), + _ => vec![json!({"format": "png", "scale": "1x", "suffix": ""})], + }; + // Every format's settings: its defaults, then the saved ones. + let mut settings = Map::new(); + for (id, _) in SETTINGS { + let mut s = defaults(app, id); + if let Some(o) = get("settings").and_then(|v| v.get(id)).and_then(Value::as_object) { + s.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))); + } + settings.insert(id.into(), Value::Object(s)); + } + let folder = get("folder").and_then(Value::as_str).filter(|f| !f.is_empty()).map(str::to_string).or_else(fileio::export_folder); + let fields = json!({ + "tab": "artboards", + "select": select, + "range": range_text(&boards), + "boards": boards, + "includeBleed": flag("includeBleed", false), + "folder": folder.unwrap_or_default(), + "openLocation": flag("openLocation", true), + "subfolders": flag("subfolders", preset.is_some()), + "prefix": get("prefix").and_then(Value::as_str).unwrap_or_default(), + "preset": preset.unwrap_or_default(), + "formats": rows, + "settings": settings, + "__settings": "", + "__presets": pdf::presets(&app.session), + }); + app.ui.dialog = Some(Dialog::new(KIND, fields)); } -/// Export the checked artboards (the web: no folder, one ZIP for several files); the dialog stays -/// open when the export fails. -fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { - let boards: Vec = d - .fields - .get("boards") - .and_then(Value::as_array) - .map(|a| a.iter().enumerate().filter(|(_, b)| b.as_bool() == Some(true)).map(|(i, _)| i).collect()) - .unwrap_or_default(); - let formats = d.fields.get("formats").cloned().unwrap_or(json!([])); - let files = boards.len() * formats.as_array().map_or(0, Vec::len); - let mut params = json!({"artboards": boards, "formats": formats, "prefix": d.str("prefix")}); +/// The options format `id` starts with in Format Settings: the raster options dialogs' defaults +/// (without quality and image maps: the rows choose quality, a map is another file), the SVG +/// Options' first-use ones (without artboards and images: a linked image is another file), or the +/// default PDF preset. +fn defaults(app: &VectorcraftApp, id: &str) -> Map { + let mut o = match (id, fileio::format(id)) { + ("svg", _) => svg_options::first_use(), + ("pdf", _) => Map::from_iter([("preset".to_string(), json!(pdf::DEFAULT_PRESET))]), + (_, Some(f)) => png_options::defaults(app, f), + _ => Map::new(), + }; + for k in ["quality", "imageMap", "useArtboards", "images"] { + o.remove(k); + } + o +} + +/// A row with its size as the text the Scale field shows (`2x`, `100w`, `100h`, `72ppi`). +fn editable_row(row: &Value) -> Value { + let num = |k: &str| row.get(k).and_then(Value::as_f64); + let text = match (num("width"), num("height"), num("ppi"), row.get("scale")) { + (Some(w), ..) => format!("{w}w"), + (_, Some(h), ..) => format!("{h}h"), + (_, _, Some(p), _) => format!("{p}ppi"), + (.., Some(Value::String(s))) => s.clone(), + (.., Some(v)) => format!("{}x", v.as_f64().unwrap_or(1.0)), + _ => "1x".into(), + }; + let mut r = row.clone(); + if let Some(o) = r.as_object_mut() { + for k in ["width", "height", "ppi"] { + o.remove(k); + } + o.insert("scale".into(), json!(text)); + } + r +} + +/// The checked artboards as a 1-based range (`1-3, 5`). +pub(super) fn range_text(boards: &[bool]) -> String { + let mut parts = vec![]; + let mut start = None; + // A false past the end closes the last run. + for (i, on) in boards.iter().copied().chain([false]).enumerate() { + match (on, start) { + (true, None) => start = Some(i), + (false, Some(s)) => { + parts.push(if i - 1 > s { format!("{}-{i}", s + 1) } else { i.to_string() }); + start = None; + } + _ => {} + } + } + parts.join(", ") +} + +/// The `document.exportForScreens` params the dialog describes (the web: no folder, one ZIP for +/// several files). +fn params(app: &VectorcraftApp, d: &Dialog) -> Result { + let n = app.session.active().map_or(0, |st| st.doc.artboards.len()); + let rows = d.fields.get("formats").and_then(Value::as_array).cloned().unwrap_or_default(); + if rows.is_empty() { + return Err("add a format to export".into()); + } + let mut p = Map::new(); + let files = match d.str("select").as_str() { + "full" => { + p.insert("fullDocument".into(), json!(true)); + rows.len() + } + "range" => { + let range = d.str("range"); + // A bad range keeps the dialog open. + let chosen = ArtboardPick { range: Some(range.clone()), ..Default::default() }.resolve(n)?.unwrap_or_default(); + p.insert("range".into(), json!(range)); + chosen.len() * rows.len() + } + _ => n * rows.len(), + }; + for k in ["includeBleed", "subfolders"] { + p.insert(k.into(), json!(d.bool(k))); + } + p.insert("prefix".into(), json!(d.str("prefix"))); + p.insert("formats".into(), Value::Array(rows)); + p.insert("settings".into(), d.fields.get("settings").cloned().unwrap_or(json!({}))); if io::is_web(app) { - params["zip"] = json!(files > 1); + p.insert("zip".into(), json!(files > 1)); } else { let folder = d.str("folder"); if folder.trim().is_empty() { return Err("choose a folder to export to".into()); } - params["folder"] = json!(folder.trim()); + p.insert("folder".into(), json!(folder.trim())); + p.insert("openLocation".into(), json!(d.bool("openLocation"))); } - let r = io::export_for_screens(app, params); + Ok(Value::Object(p)) +} + +/// Export; the dialog stays open when the export fails. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let p = params(app, d)?; + let r = io::export_for_screens(app, p); if r.is_ok() { app.ui.dialog = None; } r } -/// Export for Screens: artboard picker (thumbnails + checkboxes), format/scale rows, destination. fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let t = Tokens::get(ui.ctx()); - let names: Vec = app.session.active().map(|s| s.doc.artboards.iter().map(|a| a.name.clone()).collect()).unwrap_or_default(); - let mut boards: Vec = - d.fields.get("boards").and_then(Value::as_array).map(|a| a.iter().map(|b| b.as_bool().unwrap_or(false)).collect()).unwrap_or_default(); - boards.resize(names.len(), true); + let tab = usize::from(d.str("tab") == "assets"); + if let Some(i) = widgets::tab_bar(ui, &["Artboards", "Assets"], tab) { + d.fields.insert("tab".into(), json!(if i == 1 { "assets" } else { "artboards" })); + } + ui.add_space(8.0); ui.horizontal_top(|ui| { ui.vertical(|ui| { - ui.set_width(260.0); - ui.label(egui::RichText::new("Artboards").color(t.text)); - ui.horizontal(|ui| { - if ui.small_button("Select All").clicked() { - boards.iter_mut().for_each(|b| *b = true); - } - if ui.small_button("Clear").clicked() { - boards.iter_mut().for_each(|b| *b = false); - } - }); - egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { - for (i, name) in names.iter().enumerate() { - ui.horizontal(|ui| { - let (r, _) = ui.allocate_exact_size(egui::vec2(46.0, 46.0), egui::Sense::hover()); - match artboard_thumb(app, ui.ctx(), i) { - Some(tex) => { - let sz = tex.size_vec2(); - let s = (46.0 / sz.x.max(sz.y)).min(1.0); - let ir = egui::Rect::from_center_size(r.center(), sz * s); - ui.painter().image( - tex.id(), - ir, - egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), - egui::Color32::WHITE, - ); - } - None => { - ui.painter().rect_filled(r, 2.0, egui::Color32::WHITE); - } - } - ui.checkbox(&mut boards[i], name); - }); - } - }); + ui.set_width(270.0); + if tab == 1 { + ui.add_space(24.0); + widgets::dim_label(ui, "No assets to export yet: artwork collected for export shows here."); + } else { + artboards(app, ui, d); + } }); - let (sep, _) = ui.allocate_exact_size(egui::vec2(9.0, 360.0), egui::Sense::hover()); + let (sep, _) = ui.allocate_exact_size(egui::vec2(17.0, 380.0), egui::Sense::hover()); ui.painter().line_segment([sep.center_top(), sep.center_bottom()], egui::Stroke::new(1.0, t.divider)); ui.vertical(|ui| { - ui.set_width(330.0); - ui.label(egui::RichText::new("Export to").color(t.text)); - if io::is_web(app) { - widgets::dim_label(ui, "Your browser downloads the files (several as one .zip)."); + ui.set_width(350.0); + let shown = d.str("__settings"); + if SETTINGS.iter().any(|(id, _)| *id == shown) { + format_settings(ui, d, &shown); } else { - let pick = app.services.pick_folder.is_some(); + destination(app, ui, d); + ui.add_space(10.0); + formats(ui, d); + } + }); + }); + false +} + +fn set_select(d: &mut Dialog, select: &str) { + d.fields.insert("select".into(), json!(select)); +} + +/// Select (All, Range, Full Document), Include Bleed, and the artboards' thumbnails and checkboxes. +fn artboards(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + let n = app.session.active().map_or(0, |st| st.doc.artboards.len()); + let mut boards: Vec = + d.fields.get("boards").and_then(Value::as_array).map(|a| a.iter().map(|b| b.as_bool().unwrap_or(false)).collect()).unwrap_or_default(); + boards.resize(n, true); + let before = boards.clone(); + let select = d.str("select"); + widgets::dim_label(ui, "Select:"); + ui.horizontal(|ui| { + if widgets::radio(ui, "All", select == "all", true) { + set_select(d, "all"); + boards.iter_mut().for_each(|b| *b = true); + } + if widgets::radio(ui, "Range:", select == "range", true) { + set_select(d, "range"); + } + ui.add_enabled_ui(select == "range", |ui| { + if form::text(ui, d, "range", 90.0) + && let Ok(Some(chosen)) = (ArtboardPick { range: Some(d.str("range")), ..Default::default() }).resolve(n) + { + boards = (0..n).map(|i| chosen.contains(&i)).collect(); + } + }); + }); + if widgets::radio(ui, "Full Document", select == "full", true) { + set_select(d, "full"); + } + ui.add_space(4.0); + form::check(ui, d, "includeBleed", "Include Bleed"); + ui.add_space(6.0); + let typed = boards != before; + let checked = boards.clone(); + ui.add_enabled_ui(d.str("select") != "full", |ui| { + ui.horizontal(|ui| { + if ui.small_button("Select All").clicked() { + boards.iter_mut().for_each(|b| *b = true); + } + if ui.small_button("Clear").clicked() { + boards.iter_mut().for_each(|b| *b = false); + } + }); + let names: Vec = app.session.active().map(|s| s.doc.artboards.iter().map(|a| a.name.clone()).collect()).unwrap_or_default(); + egui::ScrollArea::vertical().max_height(250.0).show(ui, |ui| { + for (i, name) in names.iter().enumerate() { ui.horizontal(|ui| { - let mut folder = d.str("folder"); - if ui.add(egui::TextEdit::singleline(&mut folder).desired_width(if pick { 290.0 } else { 320.0 })).changed() { - d.fields.insert("folder".into(), json!(folder)); + let (r, _) = ui.allocate_exact_size(egui::vec2(46.0, 46.0), egui::Sense::hover()); + match artboard_thumb(app, ui.ctx(), i) { + Some(tex) => { + let sz = tex.size_vec2(); + let s = (46.0 / sz.x.max(sz.y)).min(1.0); + let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)); + ui.painter().image(tex.id(), egui::Rect::from_center_size(r.center(), sz * s), uv, egui::Color32::WHITE); + } + None => { + ui.painter().rect_filled(r, 2.0, egui::Color32::WHITE); + } } - if pick - && widgets::icon_button(ui, "folder-open", "Choose a folder", false, 26.0).clicked() - && let Some(f) = app.services.pick_folder.as_mut().and_then(|pick| pick()) - { - d.fields.insert("folder".into(), json!(f)); + if let Some(b) = boards.get_mut(i) { + ui.checkbox(b, name); } }); } - ui.add_space(8.0); - ui.label(egui::RichText::new("Formats").color(t.text)); - let mut formats: Vec = d.fields.get("formats").and_then(Value::as_array).cloned().unwrap_or_default(); - let mut remove = None; - egui::Grid::new("efs-formats").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { - ui.label(egui::RichText::new("Scale").color(t.text_dim)); - ui.label(egui::RichText::new("Suffix").color(t.text_dim)); - ui.label(egui::RichText::new("Format").color(t.text_dim)); - ui.label(""); - ui.end_row(); - for (i, f) in formats.iter_mut().enumerate() { - let mut sc = f["scale"].as_f64().unwrap_or(1.0); - if ui.add(egui::DragValue::new(&mut sc).range(0.1..=10.0).speed(0.5).suffix("x")).changed() { - f["scale"] = json!(sc); - } - let mut suffix = f["suffix"].as_str().unwrap_or("").to_string(); - if ui.add(egui::TextEdit::singleline(&mut suffix).desired_width(60.0)).changed() { - f["suffix"] = json!(suffix); - } - let cur = f["format"].as_str().unwrap_or("png").to_string(); - egui::ComboBox::from_id_salt(("efs-fmt", i)).selected_text(cur.to_uppercase()).width(70.0).show_ui(ui, |ui| { - for fm in ["png", "jpg", "webp", "gif", "png8", "svg", "pdf"] { - if ui.selectable_label(cur == fm, fm.to_uppercase()).clicked() { - f["format"] = json!(fm); - } - } - }); - if ui.small_button("×").clicked() { - remove = Some(i); - } - ui.end_row(); - } - }); - if let Some(i) = remove { - formats.remove(i); - } - if ui.button("+ Add Scale").clicked() { - let next = formats.len() as f64 + 1.0; - formats.push(json!({"format": "png", "scale": next, "suffix": format!("@{next}x")})); - } - d.fields.insert("formats".into(), Value::Array(formats)); - ui.add_space(6.0); - ui.horizontal(|ui| { - ui.label(egui::RichText::new("Prefix").color(t.text_dim)); - let mut prefix = d.str("prefix"); - if ui.add(egui::TextEdit::singleline(&mut prefix).desired_width(120.0)).changed() { - d.fields.insert("prefix".into(), json!(prefix)); - } - }); }); }); - d.fields.insert("boards".into(), json!(boards)); - false + // Checking artboards picks them as the range (all of them: All); a typed range keeps its text. + if boards != checked { + set_select(d, if boards.iter().all(|b| *b) { "all" } else { "range" }); + d.fields.insert("range".into(), json!(range_text(&boards))); + } + if typed || boards != checked { + d.fields.insert("boards".into(), json!(boards)); + } +} + +/// Export to: the folder (typed or picked), Open Location after Export and Create Sub-folders; the +/// web downloads instead. +fn destination(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + widgets::dim_label(ui, "Export to:"); + if io::is_web(app) { + widgets::dim_label(ui, "Your browser downloads the files (several as one .zip)."); + } else { + let pick = app.services.pick_folder.is_some(); + ui.horizontal(|ui| { + form::text(ui, d, "folder", if pick { 300.0 } else { 334.0 }); + if pick + && widgets::icon_button(ui, "folder-open", "Choose a folder", false, 26.0).clicked() + && let Some(f) = app.services.pick_folder.as_mut().and_then(|pick| pick()) + { + d.fields.insert("folder".into(), json!(f)); + } + }); + form::check(ui, d, "openLocation", "Open Location after Export"); + } + form::check(ui, d, "subfolders", "Create Sub-folders"); +} + +/// The preset, the Format Settings gear, the format rows (scale, suffix, format), Add Scale and the +/// prefix. +fn formats(ui: &mut egui::Ui, d: &mut Dialog) { + let mut rows: Vec = d.fields.get("formats").and_then(Value::as_array).cloned().unwrap_or_default(); + let mut edited = false; + ui.horizontal(|ui| { + widgets::dim_label(ui, "Formats:"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if widgets::icon_button(ui, "settings", "Format Settings", false, 24.0).clicked() { + let first = rows.first().and_then(|r| r["format"].as_str()).filter(|f| SETTINGS.iter().any(|(id, _)| id == f)); + d.fields.insert("__settings".into(), json!(first.unwrap_or("png"))); + } + let preset = d.str("preset"); + let labels: Vec<&str> = std::iter::once("Custom").chain(SCREEN_PRESETS.iter().map(|(_, l)| *l)).collect(); + let at = SCREEN_PRESETS.iter().position(|(id, _)| *id == preset).map_or(0, |i| i + 1); + if let Some(i) = widgets::dropdown(ui, "efs-preset", labels[at], &labels, 180.0) { + let id = i.checked_sub(1).and_then(|i| SCREEN_PRESETS.get(i)).map_or("", |(id, _)| *id); + if let Some(preset_rows) = fileio::screen_preset_rows(id) { + rows = preset_rows; + d.fields.insert("subfolders".into(), json!(true)); + d.fields.insert("formats".into(), json!(rows)); + } + d.fields.insert("preset".into(), json!(id)); + } + }); + }); + let mut remove = None; + let one = rows.len() == 1; + egui::Grid::new("efs-formats").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { + for head in ["Scale", "Suffix", "Format", ""] { + widgets::dim_label(ui, head); + } + ui.end_row(); + for (i, row) in rows.iter_mut().enumerate() { + let raster = row["format"].as_str().and_then(fileio::format).is_none_or(|f| f.raster); + let scale = row["scale"].as_str().unwrap_or("1x").to_string(); + ui.add_enabled_ui(raster, |ui| { + if let Some(s) = widgets::text_presets(ui, ("efs-scale", i), &scale, &SIZES, 96.0) { + // The suffix follows the size while it is the automatic one. + let auto = |text: &str| ScreenSize::parse(text).map(ScreenSize::suffix); + if row["suffix"].as_str().is_none_or(|x| Some(x.to_string()) == auto(&scale)) + && let Some(suffix) = auto(&s) + { + row["suffix"] = json!(suffix); + } + row["scale"] = json!(s); + edited = true; + } + }); + if let Some(s) = widgets::text_field(ui, ("efs-suffix", i), row["suffix"].as_str(), 70.0, 1) { + row["suffix"] = json!(s); + edited = true; + } + let (format, quality) = (row["format"].as_str().unwrap_or("png"), row["quality"].as_u64()); + let current = FORMATS.iter().position(|(_, f, q)| *f == format && q.is_none_or(|q| Some(u64::from(q)) == quality)); + let label = current.map_or_else(|| format.to_uppercase(), |i| FORMAT_LABELS[i].to_string()); + if let Some((_, f, q)) = widgets::dropdown(ui, ("efs-fmt", i), &label, &FORMAT_LABELS, 96.0).and_then(|i| FORMATS.get(i)) { + row["format"] = json!(f); + if let Some(o) = row.as_object_mut() { + match q { + Some(q) => _ = o.insert("quality".into(), json!(q)), + None => _ = o.remove("quality"), + } + } + edited = true; + } + ui.add_enabled_ui(!one, |ui| { + if widgets::icon_button(ui, "x", "Remove", false, 22.0).clicked() { + remove = Some(i); + } + }); + ui.end_row(); + } + }); + if let Some(i) = remove { + rows.remove(i); + edited = true; + } + if widgets::secondary_button(ui, "+ Add Scale").clicked() { + // One more than the largest factor so far. + let largest = rows + .iter() + .filter_map(|r| match r["scale"].as_str().and_then(ScreenSize::parse) { + Some(ScreenSize::Scale(f)) => Some(f), + _ => None, + }) + .fold(0.0, f64::max); + let size = ScreenSize::Scale(largest.floor() + 1.0); + rows.push(json!({"format": "png", "scale": size.label(), "suffix": size.suffix()})); + edited = true; + } + // Rows changed by hand are no longer a preset's. + if edited { + d.fields.insert("formats".into(), json!(rows)); + d.fields.insert("preset".into(), json!("")); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + widgets::dim_label(ui, "Prefix:"); + form::text(ui, d, "prefix", 140.0); + }); +} + +/// Format Settings: the options of format `id` that apply to every row of it, then Done. +fn format_settings(ui: &mut egui::Ui, d: &mut Dialog, id: &str) { + let t = Tokens::get(ui.ctx()); + widgets::subheader(ui, "Format Settings"); + ui.add_space(6.0); + let at = SETTINGS.iter().position(|(f, _)| *f == id).unwrap_or(0); + if let Some((f, _)) = widgets::dropdown(ui, "efs-settings-format", SETTINGS_LABELS[at], &SETTINGS_LABELS, 150.0).and_then(|i| SETTINGS.get(i)) { + d.fields.insert("__settings".into(), json!(f)); + } + ui.add_space(8.0); + // The format's options as a dialog of their own, for the options dialogs' rows. + let fields = d.fields.get("settings").and_then(|all| all.get(id)).and_then(Value::as_object).cloned().unwrap_or_default(); + let mut s = Dialog { kind: KIND.into(), fields }; + match id { + "svg" => svg_options::option_fields(ui, &mut s, true), + "pdf" => { + let labels: Vec<&str> = + d.fields.get("__presets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("PDF Preset:").color(t.text_dim)); + if let Some(p) = widgets::dropdown(ui, "efs-pdf-preset", &s.str("preset"), &labels, 220.0).and_then(|i| labels.get(i)) { + s.fields.insert("preset".into(), json!(p)); + } + }); + } + _ => { + egui::Grid::new("efs-settings").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| png_options::option_rows(ui, &mut s, id, true)); + } + } + if let Some(all) = d.fields.get_mut("settings").and_then(Value::as_object_mut) { + all.insert(id.into(), Value::Object(s.fields)); + } + ui.add_space(10.0); + if widgets::secondary_button(ui, "Done").clicked() { + d.fields.insert("__settings".into(), json!("")); + } } /// A small cached rendering of artboard `i` (keyed by document revision). diff --git a/crates/ui-egui/src/dialogs/png_options.rs b/crates/ui-egui/src/dialogs/png_options.rs index c457b98b7..edae47aa2 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 4b207b649..720822dd9 100644 --- a/crates/ui-egui/src/dialogs/svg_options.rs +++ b/crates/ui-egui/src/dialogs/svg_options.rs @@ -61,7 +61,7 @@ const UI_KEYS: [&str; 5] = ["mode", "path", "showCode", "allArtboards", "range"] /// The options a first SVG Options dialog starts from: the engine's defaults with Internal CSS /// and Responsive on (as the reference app's Export As starts). Hidden layers are left to the /// command (Save keeps them, Export leaves them out). -fn first_use() -> Map { +pub(super) fn first_use() -> Map { let mut m = serde_json::to_value(vectorcraft_svg::ExportOptions::default()).ok().and_then(|v| v.as_object().cloned()).unwrap_or_default(); m.remove("hiddenLayers"); m.insert("styling".into(), json!("css")); @@ -158,12 +158,35 @@ fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, enabled: boo } fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + option_fields(ui, d, false); + if Mode::of(d) == Mode::Export { + artboards(app, ui, d); + } else { + check(ui, d, "preserveEditing", "Preserve Editing Capabilities", true); + } + ui.add_space(10.0); + let show = d.bool("showCode"); + if widgets::secondary_button(ui, if show { "Hide Code" } else { "Show Code" }).clicked() { + d.fields.insert("showCode".into(), json!(!show)); + } + if show { + ui.add_space(6.0); + code_view(app, ui, d); + } + false +} + +/// The SVG options: styling, fonts, images, object ids, decimals, minify, responsive, ``s +/// and metadata. `screens` (Export for Screens' Format Settings) leaves out Images: a linked +/// image is another file. +pub(super) fn option_fields(ui: &mut egui::Ui, d: &mut Dialog, screens: bool) { let t = Tokens::get(ui.ctx()); - let mode = Mode::of(d); egui::Grid::new("svg-options").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { choice(ui, d, "styling", "Styling:", &STYLING); choice(ui, d, "outlineText", "Font:", &FONTS); - choice(ui, d, "images", "Images:", &IMAGES); + if !screens { + choice(ui, d, "images", "Images:", &IMAGES); + } choice(ui, d, "objectIds", "Object IDs:", &OBJECT_IDS); ui.label(egui::RichText::new("Decimal:").color(t.text_dim)); let decimals = d.f64("decimals", 3.0); @@ -181,21 +204,6 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { }); check(ui, d, "fewerTspans", "Fewer Elements", !d.bool("outlineText")); check(ui, d, "metadata", "Include Metadata", true); - if mode == Mode::Export { - artboards(app, ui, d); - } else { - check(ui, d, "preserveEditing", "Preserve Editing Capabilities", true); - } - ui.add_space(10.0); - let show = d.bool("showCode"); - if widgets::secondary_button(ui, if show { "Hide Code" } else { "Show Code" }).clicked() { - d.fields.insert("showCode".into(), json!(!show)); - } - if show { - ui.add_space(6.0); - code_view(app, ui, d); - } - false } /// Use Artboards: all of them or a range; off = the bounds of all art. diff --git a/crates/ui-egui/src/dialogs/tests_screens.rs b/crates/ui-egui/src/dialogs/tests_screens.rs index 5e04818b6..55a48f7dc 100644 --- a/crates/ui-egui/src/dialogs/tests_screens.rs +++ b/crates/ui-egui/src/dialogs/tests_screens.rs @@ -1,4 +1,5 @@ -//! Export for Screens, drawn headlessly: desktop folders and web downloads. +//! Export for Screens, drawn headlessly: the fields it opens on (defaults, then the document's last +//! settings), the artboard choice, Format Settings, desktop folders and web downloads. use std::cell::RefCell; use std::rc::Rc; @@ -14,7 +15,7 @@ use crate::Services; type Log = Rc)>>>; /// An app with `artboards` 60×40 pt artboards and an ellipse on the first; `web` gives it a -/// download service (recorded in the log) instead of a folder picker. +/// download service (recorded in the log) instead of a file manager (whose reveals are recorded). fn app(artboards: usize, web: bool) -> (VectorcraftApp, Log) { let log = Log::default(); let l = log.clone(); @@ -22,6 +23,10 @@ fn app(artboards: usize, web: bool) -> (VectorcraftApp, Log) { if web { services.download = Some(Box::new(move |name: &str, bytes: &[u8]| l.borrow_mut().push((name.to_string(), bytes.to_vec())))); } else { + services.reveal = Some(Box::new(move |path: &str| { + l.borrow_mut().push((path.to_string(), vec![])); + Ok(()) + })); services.pick_folder = Some(Box::new(|| None)); } let mut app = VectorcraftApp::new(Session::new(), services); @@ -39,27 +44,92 @@ fn open(app: &mut VectorcraftApp) { assert_eq!(kind(app), Some(export_for_screens::KIND)); } +fn temp_folder(tag: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("vc-efs-{tag}-{}", std::process::id())) +} + #[test] -fn exports_into_the_folder() { - let (mut app, _) = app(2, false); +fn opens_on_defaults_and_draws_every_view() { + let (mut app, _) = app(3, false); open(&mut app); + assert_eq!((field(&app, "select"), field(&app, "range"), field(&app, "boards")), (json!("all"), json!("1-3"), json!([true, true, true]))); + assert_eq!(field(&app, "formats"), json!([{"format": "png", "scale": "1x", "suffix": ""}])); assert_eq!(field(&app, "folder"), json!(fileio::export_folder().unwrap_or_default()), "the Desktop, else home"); - assert_eq!((spec(export_for_screens::KIND).ok_label.unwrap())(&app), "Export Artboard"); + assert_eq!((field(&app, "openLocation"), field(&app, "subfolders"), field(&app, "preset")), (json!(true), json!(false), json!(""))); + let settings = field(&app, "settings"); + for f in ["png", "png8", "jpg", "webp", "gif", "svg", "pdf"] { + assert!(settings[f].is_object(), "{f} has settings"); + } + assert!(settings["jpg"].get("quality").is_none() && settings["jpg"].get("imageMap").is_none(), "rows choose quality; no maps"); + assert!(settings["svg"].get("images").is_none(), "no linked images"); + assert_eq!(settings["pdf"]["preset"], json!(fileio::pdf::DEFAULT_PRESET)); frame(&mut app); - let dir = std::env::temp_dir().join(format!("vc-efs-desk-{}", std::process::id())); + // Assets, Full Document, each format's Format Settings: all draw. + set(&mut app, "tab", json!("assets")); + frame(&mut app); + set(&mut app, "tab", json!("artboards")); + set(&mut app, "select", json!("full")); + for f in ["png", "png8", "jpg", "webp", "gif", "svg", "pdf"] { + set(&mut app, "__settings", json!(f)); + frame(&mut app); + assert_eq!(field(&app, "__settings"), json!(f)); + } + assert_eq!(kind(&app), Some(export_for_screens::KIND)); + assert_eq!((spec(export_for_screens::KIND).ok_label.unwrap())(&app), "Export Artboard"); +} + +#[test] +fn checked_artboards_read_as_a_range() { + let (mut app, _) = app(5, false); + open(&mut app); + set(&mut app, "boards", json!([true, false, true, true, false])); + frame(&mut app); + // Nothing clicked: the fields stay as set. + assert_eq!(field(&app, "boards"), json!([true, false, true, true, false])); + for (boards, text) in [ + (vec![true, true, true], "1-3"), + (vec![false, true, false, true], "2, 4"), + (vec![true, false, true, true, false, true], "1, 3-4, 6"), + (vec![false], ""), + ] { + assert_eq!(export_for_screens::range_text(&boards), text); + } +} + +#[test] +fn exports_into_the_folder_and_shows_it() { + let (mut app, log) = app(2, false); + let dir = temp_folder("desk"); let folder = dir.to_string_lossy().replace('\\', "/"); - set(&mut app, "boards", json!([false, true])); - set(&mut app, "formats", json!([{"format": "png", "scale": 1, "suffix": ""}, {"format": "svg", "scale": 1, "suffix": ""}])); - // No folder keeps the dialog open. - set(&mut app, "folder", json!(" ")); + open(&mut app); + set(&mut app, "folder", json!(folder)); + set(&mut app, "subfolders", json!(true)); + set( + &mut app, + "formats", + json!([{"format": "png", "scale": "1x", "suffix": ""}, {"format": "png", "scale": "120w", "suffix": "@120w"}, {"format": "svg", "scale": "1x", "suffix": ""}]), + ); + // A bad range keeps the dialog open; so does no folder. + set(&mut app, "select", json!("range")); + set(&mut app, "range", json!("7")); assert!(confirm(&mut app).is_err()); assert_eq!(kind(&app), Some(export_for_screens::KIND)); + set(&mut app, "range", json!("2")); + set(&mut app, "folder", json!(" ")); + assert!(confirm(&mut app).is_err()); set(&mut app, "folder", json!(folder)); let r = confirm(&mut app).unwrap(); assert!(app.ui.dialog.is_none()); let files: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect(); - assert_eq!(files, [format!("{folder}/Artboard-2.png"), format!("{folder}/Artboard-2.svg")]); - assert!(std::fs::metadata(dir.join("Artboard-2.png")).unwrap().len() > 50); + assert_eq!(files, [format!("{folder}/1x/Artboard-2.png"), format!("{folder}/120w/Artboard-2@120w.png"), format!("{folder}/SVG/Artboard-2.svg")]); + let png = image::load_from_memory(&std::fs::read(dir.join("120w").join("Artboard-2@120w.png")).unwrap()).unwrap(); + assert_eq!((png.width(), png.height()), (120, 80)); + assert_eq!(log.borrow().iter().map(|(p, _)| p.as_str()).collect::>(), [files[0]], "Open Location shows the first file"); + // It reopens on those settings. + open(&mut app); + assert_eq!((field(&app, "select"), field(&app, "range"), field(&app, "boards")), (json!("range"), json!("2"), json!([false, true]))); + assert_eq!(field(&app, "formats")[1]["scale"], json!("120w")); + assert_eq!((field(&app, "subfolders"), field(&app, "folder")), (json!(true), json!(folder))); let _ = std::fs::remove_dir_all(dir); } @@ -69,7 +139,12 @@ fn the_web_downloads_one_zip_or_the_file() { assert_eq!((spec(export_for_screens::KIND).ok_label.unwrap())(&app), "Download"); open(&mut app); frame(&mut app); - set(&mut app, "formats", json!([{"format": "png", "scale": 1, "suffix": ""}, {"format": "jpg", "scale": 2, "suffix": "@2x"}])); + set( + &mut app, + "formats", + json!([{"format": "png", "scale": "1x", "suffix": ""}, {"format": "jpg", "quality": 80, "scale": "2x", "suffix": "@2x"}]), + ); + set(&mut app, "settings", json!({"png": {"background": "black"}})); let r = confirm(&mut app).unwrap(); assert!(r.get("dataBase64").is_some(), "four files: one zip"); { @@ -79,14 +154,39 @@ fn the_web_downloads_one_zip_or_the_file() { assert_eq!(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(), zip); } assert_eq!(r["files"], json!(["Artboard-1.png", "Artboard-1@2x.jpg", "Artboard-2.png", "Artboard-2@2x.jpg"])); - // One file downloads as itself. + // One file downloads as itself, with the format's settings. log.borrow_mut().clear(); open(&mut app); - set(&mut app, "boards", json!([true, false])); - set(&mut app, "formats", json!([{"format": "png", "scale": 1, "suffix": ""}])); + assert_eq!(field(&app, "settings")["png"]["background"], json!("black"), "remembered"); + set(&mut app, "select", json!("range")); + set(&mut app, "range", json!("1")); + set(&mut app, "formats", json!([{"format": "png", "scale": "1x", "suffix": ""}])); confirm(&mut app).unwrap(); let log = log.borrow(); assert_eq!(log.len(), 1); assert_eq!(log[0].0, "Artboard-1.png"); - assert_eq!(image::load_from_memory(&log[0].1).unwrap().width(), 60); + assert_eq!(image::load_from_memory(&log[0].1).unwrap().to_rgba8().get_pixel(1, 1).0, [0, 0, 0, 255], "the PNG settings' background"); +} + +#[test] +fn a_preset_export_reopens_on_its_rows() { + let (mut app, _) = app(1, true); + app.run("file.exportForScreens", json!({"preset": "density", "fullDocument": true})).unwrap(); + open(&mut app); + assert_eq!(field(&app, "preset"), json!("density")); + assert_eq!(field(&app, "select"), json!("full")); + assert_eq!(field(&app, "subfolders"), json!(true)); + let rows = field(&app, "formats"); + assert_eq!(rows.as_array().unwrap().len(), 6); + assert_eq!((rows[0]["scale"].clone(), rows[0]["folder"].clone()), (json!("0.75x"), json!("ldpi"))); + frame(&mut app); + // Agents' numeric sizes show as text. + app.run( + "file.exportForScreens", + json!({"formats": [{"format": "png", "width": 64}, {"format": "png", "scale": 3}, {"format": "png", "ppi": 144}]}), + ) + .unwrap(); + open(&mut app); + let scales: Vec = field(&app, "formats").as_array().unwrap().iter().map(|r| r["scale"].clone()).collect(); + assert_eq!(scales, [json!("64w"), json!("3x"), json!("144ppi")]); } diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 8a85948b8..fd1808c8c 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -509,8 +509,9 @@ pub(crate) fn open_in_app(app: &mut VectorcraftApp, path: &str) -> Result<(), St /// File → Export for Screens with `document.exportForScreens` params: the files go into `folder`; /// without one the web downloads them (the ZIP, or each file) and the other frontends get them -/// back as base64. +/// back as base64. With `openLocation`, the file manager shows the first file written. pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result { + let open_location = params.get("openLocation").and_then(Value::as_bool).unwrap_or(false); let folder = params.get("folder").and_then(Value::as_str).unwrap_or_default().to_string(); let r = app.run("document.exportForScreens", params)?; let count = r["files"].as_array().map_or(0, Vec::len); @@ -522,7 +523,15 @@ pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result Response { + let t = Tokens::get(ui.ctx()); + let (dr, dresp) = ui.allocate_exact_size(vec2(20.0, h), Sense::click()); + ui.painter().rect_filled(dr, CornerRadius { nw: 0, sw: 0, ne: 2, se: 2 }, t.input); + ui.painter().rect_stroke(dr, CornerRadius { nw: 0, sw: 0, ne: 2, se: 2 }, Stroke::new(1.0, t.input_border), StrokeKind::Inside); + let c = if !ui.is_enabled() { + t.text_disabled + } else if dresp.hovered() { + t.text_strong + } else { + t.icon + }; + icons::paint(ui, "chevron-down", Rect::from_center_size(dr.center(), Vec2::splat(12.0)), c); + dresp +} + +/// A [`text_field`] with a preset list on its right (Export for Screens' sizes: `2x`, `100w`…). +/// Returns the new text when a change is committed (Enter or focus loss) or a preset is picked. +pub fn text_presets(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: &str, presets: &[&str], width: f32) -> Option { + let mut out = None; + ui.scope(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + out = text_field(ui, &id, Some(value), width - 20.0, 1); + let resp = chevron_cell(ui, 26.0); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(width - 10.0); + for p in presets { + if ui.selectable_label(*p == value, *p).clicked() { + out = Some((*p).to_string()); + } + } + }); + }); + out +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 86200a061..3e2704582 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 854c751f8..40a4c6bb9 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":{}}} +```