mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
@@ -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");
|
||||
|
||||
@@ -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,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");
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))?;
|
||||
|
||||
@@ -43,6 +43,18 @@ fn libraries_are_made_renamed_and_deleted() {
|
||||
assert!(s.execute("library.get", &json!({"library": "Client"})).is_err());
|
||||
}
|
||||
|
||||
/// A library's file is named after it, and a Windows device name (`CON`, `NUL`, `COM1`…) gets a
|
||||
/// `-` after it, on every platform.
|
||||
#[test]
|
||||
fn a_library_named_after_a_windows_device_gets_a_dash() {
|
||||
let mut s = session();
|
||||
let ids: Vec<String> = ["CON", "nul", "Com1", "LPT³", "Console", "COM10"]
|
||||
.iter()
|
||||
.map(|name| run(&mut s, "library.create", json!({"name": name}))["id"].as_str().unwrap().to_string())
|
||||
.collect();
|
||||
assert_eq!(ids, ["CON-", "nul-", "Com1-", "LPT³-", "Console", "COM10"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_graphic_goes_into_a_library_and_comes_out_in_another_document() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -262,6 +262,36 @@ fn slices_give_one_image_each_and_a_page() {
|
||||
assert_eq!((one_slice["width"].as_f64(), one_slice["height"].as_f64()), (layout[0]["width"].as_f64(), layout[0]["height"].as_f64()));
|
||||
}
|
||||
|
||||
/// Save for Web writes the image of a slice named after a Windows device (`aux`, `NUL`, `COM1`…)
|
||||
/// with a `-` after the device name, on every platform.
|
||||
#[test]
|
||||
fn a_slice_named_after_a_windows_device_gets_a_dash() {
|
||||
let mut s = session(300.0, 200.0);
|
||||
let a = rect(&mut s, [20.0, 20.0, 100.0, 60.0], "#ff8800");
|
||||
run(&mut s, "object.slice.make", json!({"ids": [a]}));
|
||||
run(&mut s, "object.slice.options", json!({"name": "aux"}));
|
||||
let b = run(&mut s, "object.slice.create", json!({"x": 150, "y": 100, "width": 100, "height": 50}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "object.slice.select", json!({"slices": [b]}));
|
||||
run(&mut s, "object.slice.options", json!({"name": "aux-"}));
|
||||
run(&mut s, "object.slice.select", json!({"slices": [a, b]}));
|
||||
let mut names: Vec<String> = files(&run(&mut s, "document.exportForWeb", json!({"slices": "selected"}))).into_iter().map(|(n, _)| n).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["images/aux- 2.gif", "images/aux-.gif"]);
|
||||
}
|
||||
|
||||
/// Without a path the files are named after the document's title, with the characters Windows does
|
||||
/// not allow in a file name as `-` and a `-` after a Windows device name, on every platform.
|
||||
#[test]
|
||||
fn files_named_after_the_title_follow_windows_naming_rules() {
|
||||
for (title, page, image) in [("a|b?c*\"<d>", "a-b-c---d-.html", "images/a-b-c---d-.gif"), ("nul", "nul-.html", "images/nul-.gif")] {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"width": 40, "height": 30, "name": title}));
|
||||
rect(&mut s, [0.0, 0.0, 20.0, 30.0], "#ff0000");
|
||||
let names: Vec<String> = files(&run(&mut s, "document.exportForWeb", json!({"output": "html"}))).into_iter().map(|(n, _)| n).collect();
|
||||
assert_eq!(names, [page, image], "{title}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_are_written_beside_the_path() {
|
||||
let dir = vectorcraft_testkit::temp_dir("webexport");
|
||||
|
||||
+14
-6
@@ -1441,7 +1441,8 @@ under `ui.library_panels`.
|
||||
## Libraries
|
||||
|
||||
The Libraries panel's libraries hold graphics, colours and character and paragraph styles that any document can use.
|
||||
They are kept on this machine: each is a `.vclibrary` file (JSON) in the `Libraries` folder next to the preferences
|
||||
They are kept on this machine: each is a `.vclibrary` file (JSON) named after the library (`CON-.vclibrary` for a
|
||||
library named after a Windows device such as `CON`) in the `Libraries` folder next to the preferences
|
||||
(none in the web app and headless sessions, where they last for the session). `library.list` lists them (`id`, `name`,
|
||||
the count of each kind, `current`, `folder`); `library.get {library?}` returns one's items (graphics with `id`, `name`,
|
||||
size and a PNG `thumbnail` in base64; colours with `name`, `hex` and `color`; styles with `name` and `attrs`).
|
||||
@@ -2087,8 +2088,8 @@ Actions panel plays an action as a `command.batch`. The Actions panel menu's Bat
|
||||
`action.batch`: the same steps on every readable file in a folder (or every data set of the open document), with
|
||||
destination `none` | `saveAndClose` | `folder` and Override Open / Save / Export. Over data sets, the folder
|
||||
destination and the overrides name each file after the document and the data set (`<document>_<data set>.<ext>`).
|
||||
Batch writes `/`, `\` and `:` in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set
|
||||
used (compared without case). Caps: 500 files or data sets, 8
|
||||
Batch writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and
|
||||
adds `-2`, `-3`… to a name an earlier data set used (compared without case). Caps: 500 files or data sets, 8
|
||||
folder levels, 256 steps. An action recorded in the Actions panel leaves the artboard out,
|
||||
and its steps of these commands get the artboard that is active when the action plays. A press with the Selection,
|
||||
Direct Selection or Group Selection tool inside an artboard makes that artboard the active one; with the Selection
|
||||
@@ -2898,7 +2899,11 @@ otherwise. `fullDocument` writes one file per row instead (a PDF of every artboa
|
||||
art), `includeBleed` grows each artboard by the document's bleed, `subfolders` puts each row's files in a sub-folder
|
||||
(its size or format, or its own `folder`), `preset: "mobile"` or `"density"` (Android-style ldpi…xxxhdpi
|
||||
sub-folders) replaces the rows, and `settings: {png: {…}, jpg: {…}, svg: {…}, pdf: {preset}}` gives every row of a
|
||||
format its options. The document remembers the last params: `document.exportSettings`.
|
||||
format its options. On every platform, `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters from a
|
||||
prefix, a suffix, a name pattern or an item's name become `-` in file and folder names, a folder name ends without
|
||||
periods or spaces, and a Windows device name (`CON`, `PRN`, `AUX`, `NUL`, `COM1` to `COM9`, `LPT1` to `LPT9`) that
|
||||
is the whole name or comes before its first period gets a `-` after it (`CON-.png`). The document remembers the last
|
||||
params: `document.exportSettings`.
|
||||
|
||||
Batch exports (#1935):
|
||||
- **What:** `layers: [id…]` (each layer's art alone) or `selection: "one" | "each"` instead of artboards
|
||||
@@ -3107,8 +3112,11 @@ File → Export → Save for Web (Legacy) writes optimised web images: `document
|
||||
`dither`, `ditherAmount`, `transparency`, `matte`, `interlaced`, `webSnap`, `lossy` (GIF) and `colorTable {locked,
|
||||
transparent, webShift, sort}`; for JPEG `quality`, `progressive`, `optimized`, `embedProfile` and `matte`; for PNG-24
|
||||
`transparency`, `matte` and `interlaced`. `metadata` (none, copyright, contact, all) writes File Info as PNG text or a
|
||||
GIF/JPEG comment; `convertToSrgb` marks PNGs as sRGB. With slices (`slice.list`) each image slice becomes
|
||||
`images/<slice name>.<ext>` (`slices: selected` writes the selected ones, `none` one image); `output: html` adds
|
||||
GIF/JPEG comment; `convertToSrgb` marks PNGs as sRGB. `<stem>` is the name of `path` without its extension, or
|
||||
without `path` the document's title with the characters Windows does not allow in a file name as `-` and a `-` after
|
||||
a Windows device name. With slices (`slice.list`) each image slice becomes `images/<slice name>.<ext>`, with a `-`
|
||||
after a Windows device name such as `aux` (`slices: selected` writes the selected ones, `none` one image);
|
||||
`output: html` adds
|
||||
`<stem>.html` placing them, with each slice's URL, target, alt text and status message, and No Image / HTML Text cells.
|
||||
Without `path` the files come back as `{name, dataBase64}`.
|
||||
|
||||
|
||||
@@ -429,7 +429,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- Variables: Lock Variables (`variable.lock`, in the Variables panel's bottom bar and menu) stops variables from being defined, deleted or renamed until they are unlocked, while objects can still be bound and unbound and data sets still change and apply; the lock is saved with the document.
|
||||
- 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 `:` in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used.
|
||||
- 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, 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.
|
||||
- JSON-lines control channel with real egui pointer and keyboard injection.
|
||||
|
||||
Reference in New Issue
Block a user