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.
This commit is contained in:
dotCooCoo
2026-10-18 12:37:29 +07:00
committed by bflatastic
parent aa9cf83fd2
commit 3c0c112fbd
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() {