Export: names typed into file names follow Windows' naming rules

Export for Screens, Batch over data sets, Save for Web and the
Libraries panel name files after text the person typed: a prefix, a
suffix, a name pattern, the name of an artboard, asset, layer, slice,
data set or library, or the document's title. The prefix, the suffix,
name patterns and Batch's data set names had only /, \ and : replaced,
so on Windows a name holding <, >, ", |, ?, * or a control character
failed to save, and Save for Web without a path named its files after
the document's title as it was. A row folder name could end in a
period, which Windows does not allow, and names such as CON, NUL, COM1
or LPT1, which Windows reserves for devices, were written as they
were.

file_part, which Export for Screens' prefix and suffix and Batch's
data set names use, now writes all of those characters as -, and name
patterns use it in place of their own copy. A new safe_file_name runs
a whole file or folder name through file_part, removes trailing
periods and spaces, and adds - after a device name that is the whole
name or comes before its first period (CON.png becomes CON-.png).
Export for Screens uses it for each file, folder and zip it names, and
keeps item names distinct after it. Save for Web uses it for slice
images and, without a path, for files named after the title. Library
files use it too. This happens on every platform. Other names are
written as before, except where a changed name now equals another
item's name, which then gets a number added.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-18 01:27:13 +07:00
committed by bflatastic
parent 26af00d108
commit 6b13dd776b
12 changed files with 192 additions and 38 deletions
+29 -2
View File
@@ -140,8 +140,9 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result<Value> {
}
let doc_stem = st.path.as_deref().and_then(|p| std::path::Path::new(p).file_stem().and_then(|s| s.to_str())).unwrap_or("Untitled").to_string();
// Data set files are named File + Data Set Name (helpx, for Override Save/Export; the folder
// destination uses the same name). `file_part` writes `/`, `\` and `:` in the data set's name as
// `-`, and `distinct_names` adds `-2`, `-3`… to a name an earlier data set used.
// destination uses the same name). `file_part` writes the characters Windows does not allow in a
// file name (`/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters) in the data set's
// name as `-`, and `distinct_names` adds `-2`, `-3`… to a name an earlier data set used.
let stems = distinct_names(names.iter().map(|name| format!("{doc_stem}_{}", file_part(name))));
let steps = filter_steps(run.steps, true, false);
let run = Run { steps: &steps, ..*run };
@@ -598,6 +599,32 @@ mod tests {
assert_eq!(file_names(&exports), ["Untitled_a-b.svg", "Untitled_c-d.svg", "Untitled_e-f.svg"], "{r}");
}
/// Windows does not allow `<`, `>`, `"`, `|`, `?`, `*` or control characters in a file name:
/// Batch writes them as `-` in a data set's name, on every platform.
#[test]
fn batch_saves_data_sets_named_with_characters_windows_does_not_allow() {
let dest = tmp("set-names-win");
let mut s = with_data_sets(&["a?b", "c*d<e>", "f\"g|h", "i\u{7}j"]);
let r = s
.execute(
"action.batch",
&json!({
"source": "datasets",
"destination": "folder",
"destinationFolder": dest.to_string_lossy(),
"stopOnError": false,
"commands": [{"command": "object.unlockAll", "params": {}}]
}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert_eq!(
file_names(&dest),
["Untitled_a-b.vectorcraft", "Untitled_c-d-e-.vectorcraft", "Untitled_f-g-h.vectorcraft", "Untitled_i-j.vectorcraft"],
"{r}"
);
}
#[test]
fn batch_gives_each_data_set_a_file_of_its_own() {
let dest = tmp("set-names-same");
+3 -2
View File
@@ -78,7 +78,8 @@ pub use screens::{
builtin_presets as screen_export_builtin_presets, 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, store_settings as store_screen_settings,
SHARED_KEYS as SCREEN_SETTINGS_KEYS, check_settings as check_screen_settings, export as export_screens, safe_file_name,
store_settings as store_screen_settings,
};
pub use svg::options_map as svg_options;
/// Atomic file writes (a temporary file renamed over the target: a failed write never damages the
@@ -186,7 +187,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Batch…",
[],
None,
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\` and `:` in the data set's name as `-`, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}",
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\`, `:`, `<`, `>`, `\"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}",
always,
action_batch::action_batch
),
+12 -11
View File
@@ -12,7 +12,10 @@
//! (YYYYMMDD), `<time>` (hhmmss), `<YYYY>`, `<MM>`, `<DD>`, `<hh>`, `<mm>`, `<ss>`.
//!
//! A trailing `.ext` (or `.<ext>`, `.<format>`) is dropped: the extension comes from the format.
//! Anything that could start a folder (`/`, `\`, `:`) becomes `-`.
//! The characters Windows does not allow in a file name (`/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`,
//! `*` and control characters) become `-`, in the template's text and in what the tokens put in.
use super::screens::file_part;
/// What a template's tokens stand for, for one file.
#[derive(Clone, Debug, Default, PartialEq)]
@@ -50,11 +53,6 @@ fn token(name: &str, p: &NameParts<'_>) -> Option<String> {
})
}
/// Text with `/`, `\` and `:` turned into `-`, so it can't start a folder.
fn clean(s: &str) -> String {
s.chars().map(|c| if matches!(c, '/' | '\\' | ':') { '-' } else { c }).collect()
}
/// Does `template` place the row's suffix itself (so it isn't added at the end)?
pub fn has_suffix_token(template: &str) -> bool {
template.contains("<suffix>")
@@ -72,12 +70,12 @@ pub fn render(template: &str, p: &NameParts<'_>) -> String {
let mut out = String::with_capacity(t.len() + 16);
let mut rest = t;
while let Some(c) = rest.chars().next() {
// Bracketed tokens; an unknown one stays as written.
// Bracketed tokens; an unknown one stays as written, with `-` for its brackets.
if c == '<'
&& let Some(end) = rest.find('>')
&& let Some(value) = rest.get(1..end).and_then(|name| token(name, p))
{
out.push_str(&clean(&value));
out.push_str(&value);
rest = rest.get(end + 1..).unwrap_or("");
continue;
}
@@ -94,10 +92,10 @@ pub fn render(template: &str, p: &NameParts<'_>) -> String {
rest = rest.get(pat.len()..).unwrap_or("");
continue;
}
out.push(if matches!(c, '/' | '\\' | ':') { '-' } else { c });
out.push(c);
rest = rest.get(c.len_utf8()..).unwrap_or("");
}
out.trim().to_string()
file_part(out.trim())
}
/// `YYYY-MM-DD` (or `YYYYMMDD`) as `[year, month, day, 0, 0, 0]`; `None` when it isn't a date.
@@ -133,7 +131,7 @@ mod tests {
assert_eq!(render("YYYY-MM-DD YYYY", &p), "2026-10-16 2026");
// Words are left alone: only the date patterns and bracketed tokens are replaced.
assert_eq!(render("SUMMER-DD-MM", &p), "SUMMER-DD-MM");
assert_eq!(render("<unknown>-x", &p), "<unknown>-x");
assert_eq!(render("<unknown>-x", &p), "-unknown--x");
assert_eq!(render("logo.<format>", &p), "logo", "the extension comes from the format");
assert!(has_suffix_token("a<suffix>") && !has_suffix_token("a"));
}
@@ -143,6 +141,9 @@ mod tests {
let p = NameParts { name: "../../etc", ..parts() };
let n = render("a/b\\c:<name>", &p);
assert!(!n.contains('/') && !n.contains('\\') && !n.contains(':'), "{n}");
// Nor any other character Windows does not allow in a file name, typed or put in.
let p = NameParts { name: "<a>\"b|c?d*\u{0}\u{1f}", ..parts() };
assert_eq!(render("<name>|?*\u{1}", &p), "-a--b-c-d-------");
// A long counter is padded, a short run is not cut.
assert_eq!(render("########", &NameParts { counter: 123, ..parts() }), "00000123");
assert_eq!(render("#", &NameParts { counter: 12345, ..parts() }), "12345");
+33 -10
View File
@@ -21,7 +21,8 @@ use super::encode::single_artboard;
use super::export::{isolated, selected_artwork, without_artboards};
use super::naming::{self, NameParts};
use super::{
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, encode, unique_file_names, with_black_output, writable, write_file,
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, distinct_names, encode, unique_file_names, with_black_output, writable,
write_file,
};
const C: &str = "document.exportForScreens";
@@ -148,10 +149,29 @@ fn safe_name(s: &str) -> Option<String> {
(!n.trim_matches('.').is_empty()).then_some(n)
}
/// Text that goes into a file name (prefix, suffix, a data set's name in Batch): path separators and
/// `:` become `-`, so the name cannot make a subfolder or point outside the folder the file is written to.
/// Text that goes into a file name (prefix, suffix, a data set's name in Batch, a name pattern's text
/// and the names it puts in): the characters Windows does not allow in a file name (`/`, `\`, `:`,
/// `<`, `>`, `"`, `|`, `?`, `*` and the control characters U+0000 to U+001F,
/// <https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file>) become `-` on every
/// platform, so the name cannot make a subfolder or point outside the folder the file is written to.
pub(super) fn file_part(s: &str) -> String {
s.chars().map(|c| if matches!(c, '/' | '\\' | ':') { '-' } else { c }).collect()
s.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '<' | '>' | '"' | '|' | '?' | '*') || c < ' ' { '-' } else { c }).collect()
}
/// A whole file or folder name (`Logo@2x.png`, a sub-folder) through [`file_part`], without
/// trailing periods and spaces, and with `-` after a Windows device name that is the whole name or
/// comes before its first period: `CON`, `PRN`, `AUX`, `NUL`, `COM1` to `COM9` and `LPT1` to `LPT9`,
/// also with `¹`, `²` or `³` for the digit, in any case (`CON.png` becomes `CON-.png`). The same on
/// every platform.
pub(crate) fn safe_file_name(s: &str) -> String {
let name = file_part(s);
let name = name.trim_end_matches(['.', ' ']);
let (base, rest) = name.split_at(name.find('.').unwrap_or(name.len()));
let upper = base.to_ascii_uppercase();
let numbered = |n: &str| n.chars().count() == 1 && n.chars().all(|c| matches!(c, '1'..='9' | '¹' | '²' | '³'));
let device = matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| upper.strip_prefix("COM").or_else(|| upper.strip_prefix("LPT")).is_some_and(numbered);
if device { format!("{base}-{rest}") } else { name.to_string() }
}
/// Keys of a format row that aren't encoder options.
@@ -615,10 +635,10 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
}
None => format!("{prefix}{}{}", item.stem, r.suffix),
};
let file = format!("{stem}.{ext}");
let file = safe_file_name(&format!("{stem}.{ext}"));
let sub = match folders {
Folders::None => None,
Folders::Rows => Some(r.folder.clone()),
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)),
@@ -644,7 +664,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 = format!("{prefix}{filename}.zip");
let zip_name = safe_file_name(&format!("{prefix}{filename}.zip"));
return match folder {
Some(dir) => {
let path = format!("{dir}/{zip_name}");
@@ -670,8 +690,10 @@ fn items(doc: &Arc<Document>, o: &ScreenParams, selection: Option<bool>, selecte
let alone = |ids: &[NodeId], name: &str| -> Option<(Arc<Document>, Rect)> {
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.
let safe = |stems: Vec<String>| distinct_names(stems.iter().map(|s| safe_file_name(s)));
let named = |pieces: Vec<(String, Arc<Document>, Option<usize>, Rect)>, fallback: &str| -> Vec<Item> {
let stems = unique_file_names(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string())));
let stems = safe(unique_file_names(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string()))));
pieces
.into_iter()
.zip(stems)
@@ -688,7 +710,8 @@ fn items(doc: &Arc<Document>, o: &ScreenParams, selection: Option<bool>, selecte
(doc.clone(), Rect::ZERO)
};
let name = filename.to_string();
return Ok(vec![Item { name: name.clone(), stem: name, doc: whole, artboard: Some(0), region, every_artboard: Some(doc.clone()) }]);
let stem = safe_file_name(&name);
return Ok(vec![Item { name, stem, doc: whole, artboard: Some(0), region, every_artboard: Some(doc.clone()) }]);
}
if let Some(ids) = &o.assets {
let mut pieces = vec![];
@@ -742,7 +765,7 @@ fn items(doc: &Arc<Document>, o: &ScreenParams, selection: Option<bool>, selecte
if boards.is_empty() {
return Err(bad(C, "the document has no artboard"));
}
let stems = artboard_file_names(doc, &boards);
let stems = safe(artboard_file_names(doc, &boards));
Ok(boards
.into_iter()
.zip(stems)
@@ -105,6 +105,17 @@ fn the_counter_counts_items_or_files_from_any_start() {
assert_eq!(parts, ["1_Artboard 2_2x@2x_png.png"]);
}
/// Export for Screens writes the characters Windows does not allow in a file name (`<`, `>`, `"`,
/// `|`, `?`, `*`, control characters) as `-` in a pattern's text and in the names its tokens stand
/// for, on every platform.
#[test]
fn a_pattern_writes_the_characters_windows_does_not_allow_as_dashes() {
let mut s = session(1);
s.execute("artboard.setProps", &json!({"index": 0, "name": "a|b?\"c\u{7}"})).unwrap();
let r = export(&mut s, json!({"name": "<name>*<x>", "formats": [{"format": "svg"}]}));
assert_eq!(names(&r), ["a-b--c---x-.svg"]);
}
#[test]
fn layers_and_the_selection_are_exported_by_name() {
let mut s = session(1);
@@ -110,6 +110,42 @@ fn density_buckets_go_into_sub_folders() {
assert_eq!(names(&r), ["Artboard-2-..-x-..-y.svg", "Artboard-2-ok.png"]);
}
/// Windows does not allow `<`, `>`, `"`, `|`, `?`, `*` or control characters in a file name
/// (<https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file>): Export for Screens
/// writes them as `-` in a prefix and a suffix, on every platform.
#[test]
fn a_prefix_or_suffix_writes_the_characters_windows_does_not_allow_as_dashes() {
let mut s = session(1);
let r = export(&mut s, json!({"prefix": "a<b>c\"", "formats": [{"format": "svg", "suffix": "|d?e*f\u{1}\t"}]}));
assert_eq!(names(&r), ["a-b-c-Artboard-1-d-e-f--.svg"]);
}
/// A file or folder named after a Windows device (`CON`, `NUL`, `COM1`…, alone or before an
/// extension) gets a `-` after the device name, and a folder loses a trailing period, on every
/// platform.
#[test]
fn device_names_and_trailing_periods_follow_windows_naming_rules() {
let mut s = session(3);
for (index, name) in ["CON", "CON-", "lpt1"].iter().enumerate() {
s.execute("artboard.setProps", &json!({"index": index, "name": name})).unwrap();
}
let r = export(&mut s, json!({"formats": [{"format": "svg"}]}));
assert_eq!(names(&r), ["CON-.svg", "CON--2.svg", "lpt1-.svg"]);
let r =
export(&mut s, json!({"range": "1", "subfolders": true, "formats": [{"format": "svg", "folder": "aux"}, {"format": "pdf", "folder": "x."}]}));
assert_eq!(names(&r), ["aux-/CON-.svg", "x/CON-.pdf"]);
let r = export(&mut s, json!({"range": "3", "folders": "item", "formats": [{"format": "svg"}]}));
assert_eq!(names(&r), ["lpt1-/lpt1-.svg"]);
// A name pattern, with an extension after the device name, and the document's own name.
let r = export(&mut s, json!({"range": "1", "name": "<name>.tar", "formats": [{"format": "svg"}]}));
assert_eq!(names(&r), ["CON-.tar.svg"]);
s.execute("file.new", &json!({"width": 40, "height": 30, "name": "nul"})).unwrap();
let r = export(&mut s, json!({"fullDocument": true, "folders": "item", "formats": [{"format": "pdf"}]}));
assert_eq!(names(&r), ["nul-/nul-.pdf"]);
let r = s.execute("document.exportForScreens", &json!({"zip": true, "formats": [{"format": "svg"}]})).unwrap();
assert_eq!(r["name"], "nul-.zip");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn sub_folders_are_made_in_the_export_folder() {
+3 -2
View File
@@ -391,11 +391,12 @@ fn remove_file(_: &str) -> Result<()> {
}
/// Library `name` as a file name without its extension: letters, digits, spaces, `-` and `_`
/// kept, other characters as `-` ("Library" when nothing is left).
/// kept, other characters as `-`, and a `-` after a Windows device name such as `CON` or `COM1`
/// (`fileio::safe_file_name`); "Library" when nothing is left.
pub fn file_stem_for(name: &str) -> String {
let safe: String = name.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' { c } else { '-' }).collect();
let safe = safe.trim();
if safe.is_empty() { "Library".to_string() } else { safe.to_string() }
if safe.is_empty() { "Library".to_string() } else { super::fileio::safe_file_name(safe) }
}
fn stem_of(path: &str) -> String {
+7 -4
View File
@@ -873,9 +873,11 @@ pub fn export_files(doc: &Document, selected: &[NodeId], s: &WebSettings, stem:
continue;
}
let base: String = doc.slice_name(c.area).chars().map(|ch| if ch.is_alphanumeric() || "-_.".contains(ch) { ch } else { '-' }).collect();
let base = unique_name(&base, |n| taken.contains(&n.to_lowercase()));
taken.insert(base.to_lowercase());
let name = format!("{IMAGES}/{base}.{ext}");
// A slice named after a Windows device (`aux`, `NUL`…) gets a `-` after the device name.
let file = |base: &str| super::fileio::safe_file_name(&format!("{base}.{ext}"));
let base = unique_name(&base, |n| taken.contains(&file(n).to_lowercase()));
taken.insert(file(&base).to_lowercase());
let name = format!("{IMAGES}/{}", file(&base));
let [x0, y0, x1, y1] = c.px;
images.push(WebFile { name: name.clone(), bytes: optimize(doc, &crop(&r, c.px), x1 - x0, y1 - y0, s)?.bytes });
placed.push((Some(c), Some(name)));
@@ -967,7 +969,8 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").filter(|p| !p.trim().is_empty());
let stem = match path {
Some(p) => super::fileio::file_stem(p),
None => super::fileio::file_stem(&st.doc.title),
// Without a path the files are named after the title, following Windows' file-naming rules.
None => super::fileio::safe_file_name(&super::fileio::file_stem(&st.doc.title)),
};
let stem = if stem.trim().is_empty() { "Untitled".to_string() } else { stem };
let (files, (width, height)) = export_files(&st.doc, &selected, &settings, &stem).map_err(|e| bad(C, e))?;