From 3c0c112fbd91f82d22880de0130fa9ef49892428 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 17:26:49 -0700 Subject: [PATCH] Export for Screens: the dialog's example names the file the export writes The Export for Screens dialog built its Example line from its fields as typed, so it often showed a name the export does not write. It used the artboard's name with its spaces (Artboard 1.png where the export writes Artboard-1.png) and the first artboard whatever the range, kept a prefix, a suffix or a device name such as CON as typed, used the scale text as the row folder instead of the row's own folder or its size, kept a vector row's @2x suffix, left out the folder that Create Sub-folders adds, and read a counter set as text as 1. The line now comes from the export's own naming. The naming part of document.exportForScreens moves into a Naming type that the export uses for every file, and fileio::screen_first_file returns the path of the first file the export writes for the given params, without encoding or writing anything. The dialog passes it the params it exports with, without the destination, so the line shows before a folder is chosen and is left out while the settings choose nothing to export. The Start at field shows the counter the export uses when it was set as text. Docs: docs/target-app-parity.md. --- crates/engine/src/cmd/fileio/export.rs | 2 +- crates/engine/src/cmd/fileio/mod.rs | 2 +- crates/engine/src/cmd/fileio/screens.rs | 197 +++++++++++++----- crates/engine/src/cmd/fileio/tests_screens.rs | 57 +++++ .../ui-egui/src/dialogs/export_for_screens.rs | 83 +++----- crates/ui-egui/src/dialogs/tests_screens.rs | 74 +++++++ docs/target-app-parity.md | 1 + 7 files changed, 306 insertions(+), 110 deletions(-) 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.