diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index c768999a8..9dce9a321 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -172,7 +172,7 @@ fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom:: /// The selected objects (their top-most selected ancestors) that are artwork: those on template /// layers are guides and are left out. -pub(crate) fn selected_artwork(s: &mut Session) -> Result> { +pub(crate) fn selected_artwork(s: &Session) -> Result> { let ids = edit::selected_roots(s)?; let st = s.doc()?; let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. })); diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 3ee4bb086..241986cba 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -75,7 +75,7 @@ pub use save::{ }; pub use screens::{ PRESET_KEYS as SCREEN_EXPORT_PRESET_KEYS, PRESETS as SCREEN_PRESETS, ScreenExportPreset, ScreenSize, - builtin_presets as screen_export_builtin_presets, preset_rows as screen_preset_rows, + builtin_presets as screen_export_builtin_presets, first_file as screen_first_file, preset_rows as screen_preset_rows, }; pub(crate) use screens::{ SHARED_KEYS as SCREEN_SETTINGS_KEYS, check_settings as check_screen_settings, export as export_screens, safe_file_name, diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index b5be4929d..288ac51ec 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -18,7 +18,7 @@ use vectorcraft_geom::Rect; use super::super::*; use super::encode::single_artboard; -use super::export::{isolated, selected_artwork, without_artboards}; +use super::export::{export_crop, isolated, selected_artwork, without_artboards}; use super::naming::{self, NameParts}; use super::{ ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, distinct_names, encode_with_warnings, unique_file_names, @@ -342,6 +342,97 @@ impl ScreenParams { fn of(p: &Value) -> Result { if p.is_object() { Self::deserialize(p).map_err(|e| bad(C, e.to_string())) } else { Ok(Self::default()) } } + + /// An error when more than one of `assets`, `layers`, `selection` and `fullDocument` is given. + fn one_source(&self) -> Result<()> { + let sources = [self.full_document, self.assets.is_some(), self.layers.is_some(), self.selection.is_some()].iter().filter(|b| **b).count(); + if sources > 1 { + return Err(bad(C, "give one of assets, layers, selection or fullDocument")); + } + Ok(()) + } + + /// `selection`: `None` without it, `Some(false)` for `one` file, `Some(true)` for `each` object. + fn selection_mode(&self) -> Result> { + match self.selection.as_deref() { + None => Ok(None), + Some("one") => Ok(Some(false)), + Some("each") => Ok(Some(true)), + Some(m) => Err(bad(C, format!("selection `{m}`: one or each"))), + } + } +} + +/// How a batch export names its files and sorts them into folders. +struct Naming<'a> { + /// The `name` pattern, when it holds more than spaces. + pattern: Option<&'a str>, + /// The `prefix`, through [`file_part`]. + prefix: String, + /// The document's name, safe as a file name (``, the Full Document's item). + filename: String, + /// The date and time the pattern's tokens use. + when: [i64; 6], + counter_start: u64, + /// The counter counts files rather than items. + per_file: bool, + folders: Folders, +} + +impl<'a> Naming<'a> { + /// The naming of params `o` for a document titled `title`. + fn of(o: &'a ScreenParams, title: &str) -> Result { + let folders = Folders::of(o)?; + // A prefix names files, not folders. + let prefix = file_part(o.prefix.as_deref().unwrap_or("")); + let when = match o.date.as_deref() { + Some(d) => naming::parse_date(d).ok_or_else(|| bad(C, format!("date `{d}`: YYYY-MM-DD")))?, + None => vectorcraft_doc::metadata::now_unix().map_or([1970, 1, 1, 0, 0, 0], vectorcraft_doc::metadata::civil), + }; + let per_file = match o.counter_per.as_deref() { + None | Some("item") => false, + Some("file") => true, + Some(c) => return Err(bad(C, format!("counterPer `{c}`: item or file"))), + }; + let filename = safe_name(&super::file_stem(title)).unwrap_or_else(|| "Untitled".into()); + let pattern = o.name.as_deref().filter(|n| !n.trim().is_empty()); + Ok(Self { pattern, prefix, filename, when, counter_start: o.counter_start.unwrap_or(1), per_file, folders }) + } + + /// The sub-folder (`None` for the export folder itself) and the file name of the file row `r` + /// writes of `item`, the `index`th item (from 0), when `files` files came before it. + fn file(&self, item: &Item, index: usize, files: u64, r: &Row) -> (Option, String) { + let counter = self.counter_start.saturating_add(if self.per_file { files } else { index as u64 }); + let ext = r.format.extensions.first().copied().unwrap_or(r.format.id); + let prefix = &self.prefix; + let stem = match self.pattern { + Some(pattern) => { + let parts = NameParts { + filename: &self.filename, + name: &item.name, + index: index + 1, + counter, + format: ext, + scale: &if r.format.raster { r.size.label() } else { String::new() }, + suffix: &r.suffix, + when: self.when, + }; + let named = naming::render(pattern, &parts); + let named = if named.is_empty() { item.stem.clone() } else { named }; + if naming::has_suffix_token(pattern) { format!("{prefix}{named}") } else { format!("{prefix}{named}{}", r.suffix) } + } + None => format!("{prefix}{}{}", item.stem, r.suffix), + }; + let file = safe_file_name(&format!("{stem}.{ext}")); + let sub = match self.folders { + Folders::None => None, + Folders::Rows => Some(safe_file_name(&r.folder)), + Folders::Format => Some(ext.to_string()), + Folders::Item => Some(item.stem.clone()), + Folders::ItemFormat => Some(format!("{}/{ext}", item.stem)), + }; + (sub, file) + } } /// The format rows `p` asks for (`formats`, else `preset`'s, else one PNG at 1x), each with the @@ -566,36 +657,15 @@ pub(super) fn presets_delete(s: &mut Session, p: &Value) -> Result { pub(crate) fn export(s: &mut Session, p: &Value) -> Result { let o = ScreenParams::of(p)?; let rows = rows_of(s, p, o.preset.as_deref())?; - let sources = [o.full_document, o.assets.is_some(), o.layers.is_some(), o.selection.is_some()].iter().filter(|b| **b).count(); - if sources > 1 { - return Err(bad(C, "give one of assets, layers, selection or fullDocument")); - } - let folders = Folders::of(&o)?; - // A prefix names files, not folders. - let prefix = file_part(o.prefix.as_deref().unwrap_or("")); + o.one_source()?; + let naming = Naming::of(&o, &s.doc()?.doc.title)?; let folder = o.folder.as_deref().filter(|f| !f.is_empty()); - let when = match o.date.as_deref() { - Some(d) => naming::parse_date(d).ok_or_else(|| bad(C, format!("date `{d}`: YYYY-MM-DD")))?, - None => vectorcraft_doc::metadata::now_unix().map_or([1970, 1, 1, 0, 0, 0], vectorcraft_doc::metadata::civil), - }; - let per_file = match o.counter_per.as_deref() { - None | Some("item") => false, - Some("file") => true, - Some(c) => return Err(bad(C, format!("counterPer `{c}`: item or file"))), - }; - let counter_start = o.counter_start.unwrap_or(1); - let selection = match o.selection.as_deref() { - None => None, - Some("one") => Some(false), - Some("each") => Some(true), - Some(m) => return Err(bad(C, format!("selection `{m}`: one or each"))), - }; + let selection = o.selection_mode()?; let selected = if selection.is_some() { selected_artwork(s)? } else { vec![] }; if let Some(dir) = folder { create_dir(dir)?; } let doc = s.doc()?.doc.clone(); - let filename = safe_name(&super::file_stem(&doc.title)).unwrap_or_else(|| "Untitled".into()); // 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(); @@ -607,7 +677,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result { doc }; let crop = rows.iter().any(|r| r.format.id != "pdf"); - let items = items(&doc, &o, selection, &selected, &filename, crop)?; + let items = items(&doc, &o, selection, &selected, &naming.filename, crop, true)?; let mut out = Output::default(); // The encoders' warnings for every file, each once. let mut warnings: Vec = Vec::new(); @@ -626,34 +696,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result { let mut files = 0u64; for (index, item) in items.iter().enumerate() { for r in &rows { - let counter = counter_start.saturating_add(if per_file { files } else { index as u64 }); - let ext = r.format.extensions.first().copied().unwrap_or(r.format.id); - let stem = match o.name.as_deref().filter(|n| !n.trim().is_empty()) { - Some(pattern) => { - let parts = NameParts { - filename: &filename, - name: &item.name, - index: index + 1, - counter, - format: ext, - scale: &if r.format.raster { r.size.label() } else { String::new() }, - suffix: &r.suffix, - when, - }; - let named = naming::render(pattern, &parts); - let named = if named.is_empty() { item.stem.clone() } else { named }; - if naming::has_suffix_token(pattern) { format!("{prefix}{named}") } else { format!("{prefix}{named}{}", r.suffix) } - } - None => format!("{prefix}{}{}", item.stem, r.suffix), - }; - let file = safe_file_name(&format!("{stem}.{ext}")); - let sub = match folders { - Folders::None => None, - Folders::Rows => Some(safe_file_name(&r.folder)), - Folders::Format => Some(ext.to_string()), - Folders::Item => Some(item.stem.clone()), - Folders::ItemFormat => Some(format!("{}/{ext}", item.stem)), - }; + let (sub, file) = naming.file(item, index, files, r); let name = sub.map_or_else(|| file.clone(), |d| format!("{d}/{file}")); let Some(name) = out.claim(&name, &format!("{}|{}", item.stem, r.key), o.name.is_some()) else { continue }; let bytes = if let (Some(all), "pdf") = (&item.every_artboard, r.format.id) { @@ -675,7 +718,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result { 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 = safe_file_name(&format!("{prefix}{filename}.zip")); + let zip_name = safe_file_name(&format!("{}{}.zip", naming.prefix, naming.filename)); return match folder { Some(dir) => { let path = format!("{dir}/{zip_name}"); @@ -697,12 +740,52 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result { }) } +/// The path, relative to the export folder, of the first file `document.exportForScreens` with +/// params `p` writes (sub-folders included: `2x/Logo@2x.png`): the first format row's file of the +/// first artboard, asset, layer or selected object, or of the whole document, named as [`export`] +/// names it. It encodes and writes nothing and cuts no art out of the document: of the objects +/// selected for a file each, it measures only the first ones, up to the first that draws something. +/// It does not refuse what the export refuses for drawing nothing: a checked asset or layer, the +/// selection as one file, or the Full Document with no visible art. +/// The Export for Screens dialog shows it on its Example line. +pub fn first_file(s: &Session, p: &Value) -> Result { + let p = with_preset(s, p)?; + let o = ScreenParams::of(&p)?; + let rows = rows_of(s, &p, o.preset.as_deref())?; + o.one_source()?; + let selection = o.selection_mode()?; + let doc = &s.doc()?.doc; + let mut selected = if selection.is_some() { selected_artwork(s)? } else { vec![] }; + // A file per object: objects that draw nothing write no file. + if selection == Some(true) { + selected = selected.into_iter().find(|id| export_crop(doc, &[*id]).is_some()).into_iter().collect(); + } + let naming = Naming::of(&o, &doc.title)?; + let items = items(doc, &o, selection, &selected, &naming.filename, false, false)?; + let (Some(item), Some(r)) = (items.first(), rows.first()) else { return Err(bad(C, "nothing to export")) }; + let (sub, file) = naming.file(item, 0, 0, r); + Ok(sub.map_or_else(|| file.clone(), |d| format!("{d}/{file}"))) +} + /// What `o` exports a file of per row ([`Item`]): the whole document, the assets, the layers, the /// selection (one item, or one per object), else the chosen artboards. -/// `crop`: some row needs the Full Document's art bounds (a format other than PDF). -fn items(doc: &Arc, o: &ScreenParams, selection: Option, selected: &[NodeId], filename: &str, crop: bool) -> Result> { +/// `crop`: some row needs the Full Document's art bounds (a format other than PDF). `art`: each +/// asset, layer and selected object is cut out of the document; without it, only the items' names +/// are made, every item holds the whole document, and none is left out for drawing nothing. +fn items( + doc: &Arc, + o: &ScreenParams, + selection: Option, + selected: &[NodeId], + filename: &str, + crop: bool, + art: bool, +) -> Result> { // The art of `ids` alone, as an item named `name`. let alone = |ids: &[NodeId], name: &str| -> Option<(Arc, Rect)> { + if !art { + return Some((doc.clone(), Rect::ZERO)); + } isolated(doc, &doc.paint_order(ids.iter().copied()), name).map(|(d, r)| (Arc::new(d), r)) }; // Each item's stem follows Windows' file-naming rules, and no two items share one. diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs index eeabf5d15..9568a1332 100644 --- a/crates/engine/src/cmd/fileio/tests_screens.rs +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -146,6 +146,63 @@ fn device_names_and_trailing_periods_follow_windows_naming_rules() { assert_eq!(r["name"], "nul-.zip"); } +/// `first_file` names the file the export writes first, for the same params: the item's file name, +/// a prefix and a suffix with characters Windows does not allow, a range, a Windows device name, +/// every kind of folder, presets, size suffixes, a name pattern with its counter and date, an +/// export preset, a ZIP, Full Document, Include Bleed, assets, layers and the selection; and it +/// refuses what the export refuses. +#[test] +fn the_first_file_is_the_file_the_export_writes_first() { + let mut s = session(3); + s.execute("artboard.setProps", &json!({"index": 1, "name": "CON"})).unwrap(); + let layer = s.doc().unwrap().doc.layers[0].id.0; + let square = s.doc().unwrap().doc.layers[0].children().unwrap()[0].id.0; + let asset = s.execute("assets.add", &json!({"ids": [square], "name": "Logo?"})).unwrap()["assets"][0].as_u64().unwrap(); + s.execute("shape.ellipse", &json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + let written = |r: &Value| r["files"][0].as_str().or_else(|| r["files"][0]["name"].as_str()).unwrap_or_default().to_string(); + let cases = [ + json!({}), + json!({"prefix": "a?b/", "formats": [{"format": "png", "suffix": ""}]}), + json!({"range": "2-3", "folders": "item"}), + json!({"range": "2", "folders": "itemFormat", "formats": [{"format": "svg", "scale": "2x", "suffix": "@2x"}]}), + json!({"preset": "mobile"}), + json!({"preset": "density"}), + json!({"subfolders": true, "formats": [{"format": "png", "scale": "144ppi", "folder": "a b"}]}), + json!({"folders": "format", "formats": [{"format": "jpg", "quality": 50}]}), + json!({ + "name": "----###-YYYYMMDD", + "counterStart": 7, + "counterPer": "file", + "date": "2026-10-17", + "formats": [{"format": "png", "scale": "2x"}] + }), + json!({"name": "", "formats": [{"format": "png", "suffix": ""}]}), + json!({"exportPreset": "Logo Package"}), + json!({"zip": true, "formats": [{"format": "svg"}, {"format": "png"}]}), + json!({"fullDocument": true, "folders": "item", "formats": [{"format": "pdf"}]}), + json!({"fullDocument": true, "formats": [{"format": "png"}]}), + json!({"includeBleed": true}), + json!({"assets": [asset], "formats": [{"format": "svg"}]}), + json!({"layers": [layer], "folders": "item"}), + json!({"selection": "one"}), + json!({"selection": "each", "name": ""}), + ]; + for p in cases { + let first = super::screens::first_file(&s, &p).unwrap(); + assert_eq!(first, written(&export(&mut s, p.clone())), "{p}"); + } + for p in [json!({"layers": [layer], "assets": [asset]}), json!({"selection": "all"}), json!({"counterPer": "row"})] { + assert!(super::screens::first_file(&s, &p).is_err() && s.execute("document.exportForScreens", &p).is_err(), "{p}"); + } + // A document named after a Windows device. + s.execute("file.new", &json!({"width": 40, "height": 30, "name": "nul"})).unwrap(); + for p in [json!({"fullDocument": true, "folders": "item", "formats": [{"format": "pdf"}]}), json!({"name": ""})] { + let first = super::screens::first_file(&s, &p).unwrap(); + assert_eq!(first, written(&export(&mut s, p.clone())), "{p}"); + } +} + #[cfg(not(target_arch = "wasm32"))] #[test] fn sub_folders_are_made_in_the_export_folder() { diff --git a/crates/ui-egui/src/dialogs/export_for_screens.rs b/crates/ui-egui/src/dialogs/export_for_screens.rs index 805dd56df..f71847f03 100644 --- a/crates/ui-egui/src/dialogs/export_for_screens.rs +++ b/crates/ui-egui/src/dialogs/export_for_screens.rs @@ -296,6 +296,28 @@ fn checked_layers(app: &VectorcraftApp, d: &Dialog) -> Vec { /// The `document.exportForScreens` params the dialog describes (the web: no folder, one ZIP for /// several files). fn params(app: &VectorcraftApp, d: &Dialog) -> Result { + let (mut p, files) = settings_params(app, d)?; + if io::is_web(app) { + p.insert("zip".into(), json!(files > 1)); + } else { + let folder = d.str("folder"); + if folder.trim().is_empty() { + return Err("choose a folder to export to".into()); + } + p.insert("folder".into(), json!(folder.trim())); + p.insert("openLocation".into(), json!(d.bool("openLocation"))); + } + Ok(Value::Object(p)) +} + +/// The counter's first value: the Start at field (a number, or text typed through `ui.dialog.set`). +fn counter_start(d: &Dialog) -> u64 { + d.fields.get("counterStart").and_then(|v| v.as_u64().or_else(|| v.as_str().and_then(|t| t.trim().parse().ok()))).unwrap_or(1) +} + +/// The params of [`params`] without the destination (what is exported, at which sizes, and how the +/// files are named and sorted), and how many files that is. +fn settings_params(app: &VectorcraftApp, d: &Dialog) -> Result<(Map, usize), String> { let n = app.session.active().map_or(0, |st| st.doc.artboards.len()); let rows = d.fields.get("formats").and_then(Value::as_array).cloned().unwrap_or_default(); if rows.is_empty() { @@ -363,21 +385,10 @@ fn params(app: &VectorcraftApp, d: &Dialog) -> Result { if !name.trim().is_empty() { p.insert("name".into(), json!(name.trim())); } - let start = d.fields.get("counterStart").and_then(|v| v.as_u64().or_else(|| v.as_str().and_then(|t| t.trim().parse().ok()))).unwrap_or(1); - p.insert("counterStart".into(), json!(start)); + p.insert("counterStart".into(), json!(counter_start(d))); p.insert("formats".into(), Value::Array(rows)); p.insert("settings".into(), d.fields.get("settings").cloned().unwrap_or(json!({}))); - if io::is_web(app) { - p.insert("zip".into(), json!(files > 1)); - } else { - let folder = d.str("folder"); - if folder.trim().is_empty() { - return Err("choose a folder to export to".into()); - } - p.insert("folder".into(), json!(folder.trim())); - p.insert("openLocation".into(), json!(d.bool("openLocation"))); - } - Ok(Value::Object(p)) + Ok((p, files)) } /// Export; the dialog stays open when the export fails. @@ -684,51 +695,21 @@ fn naming(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { widgets::dim_label(ui, tl!("Prefix:")); form::text(ui, d, "prefix", 110.0); widgets::dim_label(ui, tl!("Start at:")); - let start = d.fields.get("counterStart").and_then(Value::as_u64).unwrap_or(1) as f64; - if let Some(v) = widgets::range_field(ui, "efs-counter-start", start, 0.0..=999_999.0, "", 0, 64.0) { + if let Some(v) = widgets::range_field(ui, "efs-counter-start", counter_start(d) as f64, 0.0..=999_999.0, "", 0, 64.0) { d.fields.insert("counterStart".into(), json!(v.round().max(0.0) as u64)); } }); let example = example_name(app, d); - widgets::dim_label(ui, &crate::i18n::fmt(tl!("Example: {name}"), &[("name", &example)])); + if !example.is_empty() { + widgets::dim_label(ui, &crate::i18n::fmt(tl!("Example: {name}"), &[("name", &example)])); + } } -/// The first file the export would write, as the folders and pattern name it. +/// The first file the export writes, relative to the export folder, as `document.exportForScreens` +/// names it ([`fileio::screen_first_file`]); empty while the dialog's settings choose nothing to +/// export (no format, nothing checked or selected, a bad range). pub(super) fn example_name(app: &VectorcraftApp, d: &Dialog) -> String { - let Some(st) = app.session.active() else { return String::new() }; - let row = d.fields.get("formats").and_then(Value::as_array).and_then(|r| r.first()).cloned().unwrap_or(json!({})); - let format = row["format"].as_str().and_then(fileio::format); - let ext = format.and_then(|f| f.extensions.first().copied()).unwrap_or("png"); - let raster = format.is_none_or(|f| f.raster); - let scale = if raster { row["scale"].as_str().unwrap_or("1x").to_string() } else { String::new() }; - let suffix = row["suffix"].as_str().unwrap_or_default().to_string(); - let item = match d.str("tab").as_str() { - "assets" => checked_assets(app, d).first().and_then(|id| st.doc.asset(*id)).map(|a| a.name.clone()), - "layers" => checked_layers(app, d).first().and_then(|id| st.doc.node(NodeId(*id))).map(|n| n.display_name()), - "selection" => Some("Selection".to_string()), - _ => st.doc.artboards.first().map(|a| a.name.clone()), - } - .unwrap_or_else(|| "Artboard 1".into()); - let filename = fileio::file_stem(&st.doc.title); - let when = vectorcraft_doc::metadata::now_unix().map_or([1970, 1, 1, 0, 0, 0], vectorcraft_doc::metadata::civil); - let start = d.fields.get("counterStart").and_then(Value::as_u64).unwrap_or(1); - let pattern = d.str("name"); - let stem = if pattern.trim().is_empty() { - format!("{item}{suffix}") - } else { - let parts = - fileio::FileNameParts { filename: &filename, name: &item, index: 1, counter: start, format: ext, scale: &scale, suffix: &suffix, when }; - let named = fileio::render_file_name(&pattern, &parts); - if pattern.contains("") { named } else { format!("{named}{suffix}") } - }; - let file = format!("{}{stem}.{ext}", d.str("prefix")); - match d.str("folders").as_str() { - "rows" => format!("{}/{file}", if raster { scale } else { ext.to_uppercase() }), - "format" => format!("{ext}/{file}"), - "item" => format!("{item}/{file}"), - "itemFormat" => format!("{item}/{ext}/{file}"), - _ => file, - } + settings_params(app, d).ok().and_then(|(p, _)| fileio::screen_first_file(&app.session, &Value::Object(p)).ok()).unwrap_or_default() } /// The Layers tab: each top-level layer's thumbnail and checkbox (a file of its art alone), diff --git a/crates/ui-egui/src/dialogs/tests_screens.rs b/crates/ui-egui/src/dialogs/tests_screens.rs index 8c837bd2c..9fb40676f 100644 --- a/crates/ui-egui/src/dialogs/tests_screens.rs +++ b/crates/ui-egui/src/dialogs/tests_screens.rs @@ -567,6 +567,80 @@ fn tiff_dropdown_exports_scaled_artboards_with_options_and_reopens() { assert_eq!(field(&app, "formats")[0]["format"], "png"); } +/// The Example line names the first file the export writes for the same settings: the item's file +/// name, a prefix with characters Windows does not allow, a range, a Windows device name as an item +/// folder, the rows' folders and sizes, Create Sub-folders, a vector row's size suffix, the counter +/// typed as text, and the Layers and Selection tabs. +#[test] +fn the_example_names_the_first_file_the_export_writes() { + let (mut app, _) = app(3, false); + app.run("artboard.setProps", json!({"index": 2, "name": "CON"})).unwrap(); + app.run("select.all", json!({})).unwrap(); + let dir = temp_folder("example"); + let folder = dir.to_string_lossy().to_string(); + let png = json!([{"format": "png", "scale": "1x", "suffix": ""}]); + let cases: Vec> = vec![ + vec![], + vec![("prefix", json!("a?b"))], + vec![("select", json!("range")), ("range", json!("2-3"))], + vec![("select", json!("range")), ("range", json!("3")), ("folders", json!("item"))], + vec![("folders", json!("rows")), ("formats", json!([{"format": "png", "scale": "144ppi", "suffix": ""}]))], + vec![("subfolders", json!(true))], + vec![("folders", json!("rows")), ("formats", json!([{"format": "png", "scale": "2x", "suffix": "@2x", "folder": "retina"}]))], + vec![("formats", json!([{"format": "svg", "scale": "2x", "suffix": "@2x"}]))], + vec![("name", json!("-####")), ("counterStart", json!("7")), ("formats", json!([{"format": "png", "scale": "144ppi", "suffix": ""}]))], + vec![("tab", json!("layers"))], + vec![("tab", json!("selection")), ("selectionMode", json!("each"))], + ]; + let mut wrong = vec![]; + for case in cases { + let _ = std::fs::remove_dir_all(&dir); + open(&mut app); + let defaults = [ + ("folder", json!(folder)), + ("tab", json!("artboards")), + ("select", json!("all")), + ("prefix", json!("")), + ("name", json!("")), + ("counterStart", json!(1)), + ("folders", json!("none")), + ("subfolders", json!(false)), + ("formats", png.clone()), + ]; + for (k, v) in defaults.into_iter().chain(case.iter().cloned()) { + set(&mut app, k, v); + } + frame(&mut app); + let example = export_for_screens::example_name(&app, app.ui.dialog.as_ref().unwrap()); + let r = confirm(&mut app).unwrap(); + let first = r["files"][0].as_str().and_then(|f| f.strip_prefix(&format!("{folder}/"))).unwrap_or_default().to_string(); + if example != first { + wrong.push(format!("{case:?}: example {example:?}, written {first:?}")); + } + } + let _ = std::fs::remove_dir_all(&dir); + assert!(wrong.is_empty(), "{wrong:#?}"); +} + +/// The Example line shows before a folder is chosen and is left out while the settings choose +/// nothing to export (the Assets tab with no asset checked); Start at shows the counter the export +/// uses when it was set as text. +#[test] +fn the_example_needs_no_folder_and_start_at_shows_the_counter_used() { + let (mut app, _) = app(1, false); + open(&mut app); + for (k, v) in [("folder", json!("")), ("name", json!("####")), ("counterStart", json!("7"))] { + set(&mut app, k, v); + } + let mut h = ScreensFrame::new(); + let text = crate::tests_labels::shapes_text(&h.settle(&mut app)); + assert!(text.lines().any(|l| l == "Example: 0007.png"), "{text}"); + assert!(text.lines().any(|l| l.trim() == "7"), "{text}"); + set(&mut app, "tab", json!("assets")); + let text = crate::tests_labels::shapes_text(&h.settle(&mut app)); + assert!(!text.contains("Example:"), "{text}"); +} + /// Batch exporting (#1935): a file name pattern and a folder per format, the Layers and Selection /// tabs, and export presets applied and saved from the dialog. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 394791236..162d36c61 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -433,6 +433,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Batch…: Override Save and Override Export name each file with the extension of the format the step writes, however the step spells the format (`.svg` and `SVG` give `.svg`, `png8` gives `.png`), in `action.batch`. - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used. + - Export for Screens: the dialog's Example line shows the path of the file the export writes first, sub-folder included, named as the export names it, for the first chosen artboard, asset, layer or object (`document.exportForScreens`). - Export for Screens, Save for Web (slice images, and files named after the document's title) and the Libraries panel name files with the characters Windows does not allow written as `-` and with a `-` after a Windows device name such as `CON`, on every platform (`document.exportForScreens`, `document.exportForWeb`, `library.create`). - Variables (data merge): text, visibility and image variables bound to objects (an object may carry several), dataset rows applied in one undo step (`variable.*`, `dataset.*`) from a Variables panel and Window › Variables, with dialogs to define, bind and edit, Make Text/Visibility/Image Dynamic, Capture/Update Data Set, CSV import/export (`dataset.import` / `dataset.export`), and a row in italics when an edit leaves the art not matching it; an image dataset value is a file path and apply keeps the object's bounds; bindings let go of deleted art and locked art is left alone; every command, like the rest, is drivable over MCP and the CLI. - Variables: Import Data Sets… (`dataset.import`) reads tab-separated files as well as comma-separated ones, and files without a `name` column, whose rows take the first free names Data Set 1, Data Set 2 and so on. An import error about a row's values or name gives the file line the row starts on.