Merge pull request #2839 from dotCooCoo/fix/export-for-screens-example-name

Export for Screens: the dialog's example names the file the export writes
This commit is contained in:
BFlat
2026-10-18 13:23:47 +07:00
committed by GitHub
7 changed files with 306 additions and 110 deletions
+1 -1
View File
@@ -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<Vec<NodeId>> {
pub(crate) fn selected_artwork(s: &Session) -> Result<Vec<NodeId>> {
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, .. }));
+1 -1
View File
@@ -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,
+140 -57
View File
@@ -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<Self> {
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<Option<bool>> {
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 (`<filename>`, 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<Self> {
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>, 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<Value> {
pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<String> = Vec::new();
@@ -626,34 +696,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
})
}
/// 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<String> {
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<Document>, o: &ScreenParams, selection: Option<bool>, selected: &[NodeId], filename: &str, crop: bool) -> Result<Vec<Item>> {
/// `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<Document>,
o: &ScreenParams,
selection: Option<bool>,
selected: &[NodeId],
filename: &str,
crop: bool,
art: bool,
) -> Result<Vec<Item>> {
// The art of `ids` alone, as an item named `name`.
let alone = |ids: &[NodeId], name: &str| -> Option<(Arc<Document>, 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.
@@ -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": "<x>"}]}),
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": "<filename>-<name>-<scale>-<index>-###-YYYYMMDD",
"counterStart": 7,
"counterPer": "file",
"date": "2026-10-17",
"formats": [{"format": "png", "scale": "2x"}]
}),
json!({"name": "<suffix>", "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": "<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": "<filename>"})] {
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() {
@@ -296,6 +296,28 @@ fn checked_layers(app: &VectorcraftApp, d: &Dialog) -> Vec<u64> {
/// The `document.exportForScreens` params the dialog describes (the web: no folder, one ZIP for
/// several files).
fn params(app: &VectorcraftApp, d: &Dialog) -> Result<Value, String> {
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<String, Value>, 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<Value, String> {
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("<suffix>") { 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),
@@ -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<(&str, Value)>> = 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!("<scale>-####")), ("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]
+1
View File
@@ -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.