Export for Screens: a second file with a taken name is numbered instead of skipped

Squashed from #5028 onto main.
This commit is contained in:
dotCooCoo
2026-11-01 11:09:08 +07:00
committed by bflatastic
parent ff036557fb
commit 7e5e099f8e
7 changed files with 147 additions and 27 deletions
+1 -1
View File
@@ -121,7 +121,7 @@ use access::{may_read, may_write};
pub use commands::specs;
pub use export_names::artboard_file_names;
pub(crate) use export_names::{default_name, merge, unique_file_names, with_black_output, write_or_return};
use export_names::{distinct_names, write_encoded};
use export_names::{distinct_names_by, write_encoded};
use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, writable};
pub use formats::{
DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, export_extensions, extension,
+1 -1
View File
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export for Screens",
["File", "Export"],
None,
"{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)",
"{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N; a row whose file would take a name another row's file has, in any case, gets -2, -3… too, and identical rows write one file; row folders that differ only in case are one folder, spelled as the first row spells it) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)",
has_doc,
screens::export_for_screens
),
+8 -2
View File
@@ -20,6 +20,12 @@ pub(crate) fn unique_file_names<'a>(names: impl IntoIterator<Item = (&'a str, St
/// `names` with `-2`, `-3`… after a name already used (compared without case).
pub(super) fn distinct_names(names: impl IntoIterator<Item = String>) -> Vec<String> {
distinct_names_by(names, str::to_lowercase)
}
/// `names` with `-2`, `-3`… after a name already used, two names matching when `key` gives the same
/// key for them.
pub(super) fn distinct_names_by(names: impl IntoIterator<Item = String>, key: impl Fn(&str) -> String) -> Vec<String> {
let mut taken = std::collections::HashSet::new();
// The next number to try for each base: a name repeated many times doesn't search the numbers
// taken before it.
@@ -28,8 +34,8 @@ pub(super) fn distinct_names(names: impl IntoIterator<Item = String>) -> Vec<Str
.into_iter()
.map(|base| {
let mut name = base.clone();
while !taken.insert(name.to_lowercase()) {
let n = next.entry(base.to_lowercase()).or_insert(1);
while !taken.insert(key(&name)) {
let n = next.entry(key(&base)).or_insert(1);
*n += 1;
name = format!("{base}-{n}");
}
+45 -19
View File
@@ -21,7 +21,7 @@ use super::encode::single_artboard;
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, merge, unique_file_names,
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, distinct_names_by, encode_with_warnings, merge, unique_file_names,
with_black_output, writable, write_file,
};
@@ -490,13 +490,31 @@ pub(crate) fn check_settings(s: &Session, p: &Value) -> Result<()> {
rows_of(s, p, ScreenParams::of(p)?.preset.as_deref()).map(|_| ())
}
/// `name` the way a case-insensitive file system compares names: lower-cased, then each character
/// upper-cased where its upper case is one character. Names with the same lower case give the
/// same key, and so do `ΟΣ`, `ος` and `οσ` (`ΟΣ`); `ᾈ` and `ᾀ` give `ᾀ`, and `ß` stays `ß`.
fn fold(name: &str) -> String {
name.to_lowercase()
.chars()
.map(|c| {
let mut upper = c.to_uppercase();
match (upper.next(), upper.next()) {
(Some(u), None) => u,
_ => c,
}
})
.collect()
}
/// The file names (and bytes) an export wrote, or the files it returns.
#[derive(Default)]
struct Output {
/// Lower-case relative names already used, with what wrote each (two identical rows write
/// one file; different files that would share a name get `-2`, `-3`…).
/// Relative names already used, by their [`fold`]ed spelling, with what wrote each (two
/// identical rows write one file; different files that would share a name get `-2`, `-3`…).
taken: std::collections::HashMap<String, String>,
/// Sub-folders already made.
/// Each sub-folder as the first file in it spells it, by its [`fold`]ed name.
folders: std::collections::HashMap<String, String>,
/// Sub-folders already made, by their [`fold`]ed names.
made: HashSet<String>,
/// `(relative name, bytes)` kept to return or zip (empty when written to a folder).
kept: Vec<(String, Vec<u8>)>,
@@ -505,12 +523,19 @@ struct Output {
}
impl Output {
/// The name the file `key` writes as `name` takes: `name`, else (with `number`) `name-2`,
/// `name-3`… when another file has it; `None` when the same file (`key`) was written as `name`
/// already, or, without `number`, when any file was (the first row wins, as Export for Screens
/// always did when no name pattern is given).
fn claim(&mut self, name: &str, key: &str, number: bool) -> Option<String> {
let (base, ext) = name.rsplit_once('.').map_or((name, ""), |(b, e)| (b, e));
/// The name the file `key` writes as `name` takes: `name`, else `name-2`, `name-3`… when
/// another file has it, with or without a name pattern; `None` when the same file (`key`) was
/// written as `name` already. Names are compared without case, and a sub-folder keeps the
/// spelling of the first file put in it.
fn claim(&mut self, name: &str, key: &str) -> Option<String> {
let name = match name.rsplit_once('/') {
Some((dir, file)) => {
let dir = self.folders.entry(fold(dir)).or_insert_with(|| dir.to_string());
format!("{dir}/{file}")
}
None => name.to_string(),
};
let (base, ext) = name.rsplit_once('.').map_or((name.as_str(), ""), |(b, e)| (b, e));
for n in 1.. {
let candidate = if n == 1 {
name.to_string()
@@ -519,11 +544,11 @@ impl Output {
} else {
format!("{base}-{n}.{ext}")
};
match self.taken.get(&candidate.to_lowercase()) {
Some(k) if k == key || !number => return None,
match self.taken.get(&fold(&candidate)) {
Some(k) if k == key => return None,
Some(_) => continue,
None => {
self.taken.insert(candidate.to_lowercase(), key.to_string());
self.taken.insert(fold(&candidate), key.to_string());
return Some(candidate);
}
}
@@ -543,7 +568,7 @@ impl Output {
match folder {
Some(dir) if !keep => {
if let Some((sub, _)) = name.rsplit_once('/')
&& self.made.insert(sub.to_lowercase())
&& self.made.insert(fold(sub))
{
create_dir(&format!("{dir}/{sub}"))?;
}
@@ -686,8 +711,8 @@ pub(super) fn presets_delete(s: &mut Session, p: &Value) -> Result<Value> {
/// Every chosen artboard (asset, layer, selected object) in every format row, one file each (a PDF
/// holds its artboard alone), or with `fullDocument` one file per row (a PDF of every artboard,
/// else the bounds of all art). Items that share a name get `-2`, `-3`… instead of overwriting, and
/// so do different files the `name` pattern gives one name. The reply's `warnings` lists every
/// warning the encoders give for the files, each once.
/// so do different files that would share a name, with or without the `name` pattern. The reply's
/// `warnings` lists every warning the encoders give for the files, each once.
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())?;
@@ -818,7 +843,7 @@ fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) ->
});
let (sub, file) = naming.file(document.as_ref().unwrap_or(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 Some(name) = out.claim(&name, &format!("{}|{}", item.stem, r.key)) else { continue };
let bytes = if let Some(boards) = single {
let mut options = r.options.clone();
options["range"] = json!(boards.iter().map(|b| (b + 1).to_string()).collect::<Vec<_>>().join(", "));
@@ -938,8 +963,9 @@ fn items(
}
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)));
// Each item's stem follows Windows' file-naming rules, and no two items share one, compared as
// the item folders are (`fold`).
let safe = |stems: Vec<String>| distinct_names_by(stems.iter().map(|s| safe_file_name(s)), fold);
let named = |pieces: Vec<(String, Arc<Document>, Option<usize>, Rect)>, fallback: &str| -> Vec<Item> {
let stems = safe(unique_file_names(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string()))));
pieces
+86 -2
View File
@@ -533,13 +533,97 @@ fn png_8_rows_write_indexed_colour() {
let f = files(&r);
// The IHDR colour type: 3 (indexed) for PNG 8, 6 (RGBA) for PNG; both are .png, the PNG 8 row
// written first.
assert_eq!(names(&r), ["Artboard-1.png"], "the same file name: the first row wins");
assert_eq!(f[0].1[25], 3);
assert_eq!(names(&r), ["Artboard-1.png", "Artboard-1-2.png"], "the same file name: the second row's file is numbered");
assert_eq!((f[0].1[25], f[1].1[25]), (3, 6));
let r = export(&mut s, json!({"formats": [{"format": "png8", "suffix": "-8"}, {"format": "png"}]}));
let f = files(&r);
assert_eq!((f[0].1[25], f[1].1[25]), (3, 6));
}
/// Without a name pattern, a row that writes a different file under a name another row's file
/// already has gets `-2`, `-3`… as a name pattern's files do, compared without case; the first
/// file keeps its name, and two identical rows still write one file.
#[test]
fn a_second_file_with_a_taken_name_is_numbered() {
let mut s = session(1);
let px = |png: &[u8]| image::load_from_memory(png).unwrap().to_rgba8().get_pixel(1, 1).0;
let rows = json!([{"format": "png", "background": "black"}, {"format": "png", "background": "white"}, {"format": "png", "background": "black"}]);
let r = export(&mut s, json!({ "formats": rows }));
assert_eq!(names(&r), ["Artboard-1.png", "Artboard-1-2.png"]);
let f = files(&r);
assert_eq!((px(&f[0].1), px(&f[1].1)), ([0, 0, 0, 255], [255, 255, 255, 255]));
// Row folders that differ only in case are one folder, spelled as the first row spells it.
let rows = json!([{"format": "png", "folder": "Print"}, {"format": "png", "folder": "print", "scale": 2, "suffix": ""}, {"format": "svg", "folder": "PRINT"}]);
let r = export(&mut s, json!({"subfolders": true, "formats": rows}));
assert_eq!(names(&r), ["Print/Artboard-1.png", "Print/Artboard-1-2.png", "Print/Artboard-1.svg"]);
assert_eq!(size(&files(&r)[1].1), (80, 60));
}
/// Names and folders that differ only in case match when a letter's lower case depends on where
/// it stands: Greek `ΟΣ` lower-cases to `ος`, which a case-insensitive file system treats as `οσ`.
#[test]
fn names_that_differ_only_in_greek_sigmas_are_numbered() {
let mut s = session(2);
s.execute("artboard.setProps", &json!({"index": 0, "name": "ΟΣ"})).unwrap();
s.execute("artboard.setProps", &json!({"index": 1, "name": "οσ"})).unwrap();
let r = export(&mut s, json!({"name": "<name>", "formats": [{"format": "svg"}]}));
assert_eq!(names(&r), ["ΟΣ.svg", "οσ-2.svg"]);
let rows = json!([{"format": "png", "folder": "ΟΣ"}, {"format": "svg", "folder": "οσ"}]);
let r = export(&mut s, json!({"range": "1", "subfolders": true, "formats": rows}));
assert_eq!(names(&r), ["ΟΣ/ΟΣ.png", "ΟΣ/ΟΣ.svg"]);
// Each item still gets a folder of its own.
let r = export(&mut s, json!({"folders": "item", "formats": [{"format": "svg"}]}));
assert_eq!(names(&r), ["ΟΣ/ΟΣ.svg", "οσ-2/οσ-2.svg"]);
// Letters whose full upper case is two characters still match their other case.
s.execute("artboard.setProps", &json!({"index": 0, "name": "ᾀ"})).unwrap();
s.execute("artboard.setProps", &json!({"index": 1, "name": "ᾈ"})).unwrap();
let r = export(&mut s, json!({"name": "<name>", "formats": [{"format": "svg"}]}));
assert_eq!(names(&r), ["ᾀ.svg", "ᾈ-2.svg"]);
// So do names whose lower case is longer than the name: `İ` lower-cases to `i̇`.
s.execute("artboard.setProps", &json!({"index": 0, "name": "İ"})).unwrap();
s.execute("artboard.setProps", &json!({"index": 1, "name": "i\u{307}"})).unwrap();
let r = export(&mut s, json!({"name": "<name>", "formats": [{"format": "svg"}]}));
assert_eq!(names(&r), ["İ.svg", "i\u{307}-2.svg"]);
}
/// A fresh folder for one test, emptied first and removed when dropped (also when an assertion
/// fails).
#[cfg(not(target_arch = "wasm32"))]
struct Folder(std::path::PathBuf);
#[cfg(not(target_arch = "wasm32"))]
impl Folder {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!("vc-screens-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
Self(dir)
}
}
#[cfg(not(target_arch = "wasm32"))]
impl Drop for Folder {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// The numbered file is written beside the first one, and the reply lists both paths.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_numbered_file_is_written_into_the_first_rows_folder() {
let mut s = session(1);
let tmp = Folder::new("taken");
let dir = &tmp.0;
let folder = dir.to_string_lossy().replace('\\', "/");
let rows = json!([{"format": "png", "folder": "Print"}, {"format": "png", "folder": "print", "background": "white"}]);
let r = export(&mut s, json!({"folder": folder, "subfolders": true, "formats": rows}));
let written: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect();
assert_eq!(written, [format!("{folder}/Print/Artboard-1.png"), format!("{folder}/Print/Artboard-1-2.png")]);
let entries: Vec<String> = std::fs::read_dir(dir.join("Print")).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
assert_eq!(entries.len(), 2, "{entries:?}");
assert_eq!(std::fs::read_dir(dir).unwrap().count(), 1, "one sub-folder");
}
#[test]
fn format_settings_apply_to_their_rows_and_rows_win() {
let mut s = session(1);
+5 -2
View File
@@ -4106,8 +4106,11 @@ sub-folders) replaces the rows, and `settings: {png: {…}, jpg: {…}, svg: {
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`. As in `document.export`,
is the whole name or comes before its first period gets a `-` after it (`CON-.png`). When a row's file would take a
name another row's file already has, compared without case (two PNG rows with different backgrounds and no suffix),
the later file is written as `-2`, `-3`… (`Artboard-1-2.png`) and listed under that name in `files`; two identical
rows write one file, and row folders that differ only in case (`Print`, `print`) are one folder, spelled as the first
row spells it. The document remembers the last params: `document.exportSettings`. As in `document.export`,
the reply's `warnings` lists the notes of every file exported (an image that couldn't be decoded, type written in the
fallback font…), each note once; the export still writes or returns every file.
+1
View File
@@ -555,6 +555,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Export for Screens is laid out as the reference app's dialog: on the left the Artboards and Assets tabs (thumbnails with their checkboxes, number and name, in a grid or a list), Clear Selection and Prefix; on the right Select (All, Range, Include Bleed, Include Artboard Fill, Full Document), Export to, Open Location after Export, Create Sub-folders by Scale or by Format (on by default), Export PDFs As (Single File writes the chosen artboards as the pages of one PDF named after the document, `pdfs: "single"`), and the format rows with the iOS and Android buttons, the format presets (☰) and Format Settings; under them, how many items are selected and how many files the export writes. File name patterns, the Layers and Selection sources and per-item folders stay available through `document.exportForScreens` (scripts and MCP). Tests: `dialogs::tests_screens` (layout, iOS/Android, Clear Selection, sub-folders, Single File) and `fileio::tests_screens::single_file_pdfs_hold_every_chosen_artboard`.
- Save for Web: Preview in Browser writes each preview's page and images to a folder of its own inside `VectorCraft Save for Web` in the system's temporary folder. A later preview, from the same app or another one running, does not overwrite an earlier one. Each preview first removes the preview folders there that are an hour old or older and, on Linux, macOS and FreeBSD, belong to the current user; it leaves other files and folders alone. The folders a preview makes there are open to their owner only on those systems. When `VectorCraft Save for Web` is a link or is not a folder, or on those systems belongs to another user or can be written by other users, the preview goes to a new folder of its own in the temporary folder, which a later preview removes once it is an hour old (`file.saveForWeb.browser`).
- 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`).
- Export for Screens numbers distinct row files whose names collide, compared without case, as `-2`, `-3`… instead of leaving them out; identical rows still write one file. It puts row folders that differ only in case into one folder (`document.exportForScreens`).
- 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.
- Variables: Import Data Sets… (`dataset.import`) defines the variables a file's header names that the document lacks, unbound. `#Shown` defines a visibility variable, `@Photo` defines an image variable, and a header without a prefix defines a text variable. A header that is a variable's exact name keeps naming it, and any other `%` header (graph data files) is refused.