Merge pull request #4895 from storytold/feat/export-for-screens-parity

Export for Screens: the dialog works as Illustrator's
This commit is contained in:
BFlat
2026-10-31 21:58:40 +07:00
committed by GitHub
39 changed files with 736 additions and 564 deletions
+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), 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), 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)",
has_doc,
screens::export_for_screens
),
+32 -3
View File
@@ -309,6 +309,9 @@ struct ScreenParams {
/// Where files go: `none` (all in the folder), `rows` (the rows' sub-folders, as
/// `subfolders`), `format` (a folder per extension), `item`, or `itemFormat`.
folders: Option<String>,
/// Export PDFs As: `single` (the artboards a PDF row writes are the pages of one file) or
/// `multiple` (a file per artboard, the default).
pdfs: Option<String>,
}
/// How exported files are sorted into folders.
@@ -389,6 +392,8 @@ struct Naming {
/// The counter counts files rather than items.
per_file: bool,
folders: Folders,
/// Export PDFs As › Single file.
single_pdf: bool,
}
impl Naming {
@@ -406,9 +411,14 @@ impl Naming {
Some("file") => true,
Some(c) => return Err(bad(C, format!("counterPer `{c}`: item or file"))),
};
let single_pdf = match o.pdfs.as_deref() {
None | Some("multiple") => false,
Some("single") => true,
Some(m) => return Err(bad(C, format!("pdfs `{m}`: single or multiple"))),
};
let filename = safe_name(&super::file_stem(title)).unwrap_or_else(|| "Untitled".into());
let pattern = o.name.clone().filter(|n| !n.trim().is_empty());
Ok(Self { pattern, prefix, filename, when, counter_start: o.counter_start.unwrap_or(1), per_file, folders })
Ok(Self { pattern, prefix, filename, when, counter_start: o.counter_start.unwrap_or(1), per_file, folders, single_pdf })
}
/// The sub-folder (`None` for the export folder itself) and the file name of the file row `r`
@@ -781,15 +791,34 @@ fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) ->
// One step per item and row, the files a name collision skips included.
let steps = (items.len() * rows.len()) as u64;
let mut step = 0u64;
// Export PDFs As › Single file: with several artboards, a PDF row writes them all as the pages
// of one file, named as the document, with the first of them.
let artboards: Option<Vec<usize>> = items.iter().map(|i| i.artboard).collect::<Option<Vec<_>>>().filter(|a| naming.single_pdf && a.len() > 1);
for (index, item) in items.iter().enumerate() {
for r in &rows {
vectorcraft_doc::cancel::check()?;
vectorcraft_doc::cancel::progress(step, steps, &format!("Exporting file {} of {steps}", step + 1));
step += 1;
let (sub, file) = naming.file(item, index, files, r);
let single = artboards.as_ref().filter(|_| r.format.id == "pdf");
if single.is_some() && index > 0 {
continue;
}
let document = single.map(|_| Item {
name: naming.filename.clone(),
stem: naming.filename.clone(),
doc: item.doc.clone(),
artboard: None,
region: item.region,
every_artboard: None,
});
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 bytes = if let (Some(all), "pdf") = (&item.every_artboard, r.format.id) {
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(", "));
write(&item.doc, "pdf", &options)?
} else if let (Some(all), "pdf") = (&item.every_artboard, r.format.id) {
// One page per artboard.
let mut options = r.options.clone();
options["range"] = json!("all");
@@ -811,3 +811,25 @@ fn the_document_remembers_its_export_settings() {
assert!(!plain.contains("export_settings") && !plain.contains("exportSettings"), "not written when empty");
assert!(vectorcraft_format::load(plain.as_bytes()).unwrap().export_settings.is_empty());
}
/// Export PDFs As › Single file: with several artboards, a PDF row writes them as the pages of one
/// file named after the document (raster rows still write a file per artboard); with one artboard
/// the PDF is that artboard's, as Multiple files writes it.
#[test]
fn single_file_pdfs_hold_every_chosen_artboard() {
let mut s = session(3);
let title = s.doc().unwrap().doc.title.clone();
let rows = json!([{"format": "png", "scale": "1x"}, {"format": "pdf"}]);
let r = export(&mut s, json!({"range": "1-2", "pdfs": "single", "formats": rows}));
let got = files(&r);
let pdf: Vec<&(String, Vec<u8>)> = got.iter().filter(|(n, _)| n.ends_with(".pdf")).collect();
assert_eq!(pdf.len(), 1, "{:?}", names(&r));
assert_eq!(pdf[0].0, format!("{title}.pdf"));
assert_eq!(vectorcraft_pdf::import(&pdf[0].1).unwrap().artboards.len(), 2, "one page per artboard");
assert_eq!(got.iter().filter(|(n, _)| n.ends_with(".png")).count(), 2);
let r = export(&mut s, json!({"range": "2", "pdfs": "single", "formats": [{"format": "pdf"}]}));
assert_eq!(names(&r), ["Artboard-2.pdf"]);
let r = export(&mut s, json!({"range": "1-2", "pdfs": "multiple", "formats": [{"format": "pdf"}]}));
assert_eq!(names(&r), ["Artboard-1.pdf", "Artboard-2.pdf"]);
assert!(s.execute("document.exportForScreens", &json!({"pdfs": "both", "formats": [{"format": "pdf"}]})).is_err());
}
+349 -391
View File
@@ -1,34 +1,31 @@
//! File → Export → Export for Screens: the Artboards tab (All, a range or Full Document, Include
//! Bleed, thumbnails with checkboxes) and the Assets tab (the Asset Export panel's assets, with
//! checkboxes), the destination (folder picker, Open Location after Export, Create Sub-folders;
//! the web downloads instead), the format rows (scale as `2x`, `100w`, `100h` or `72ppi`, suffix,
//! format), the presets, Format Settings (the gear) and the prefix. It opens on the settings the
//! document last exported with, except that while the Artboard tool is in use the artboards it has
//! selected are the ones chosen; the format rows are the Asset Export panel's too ([`formats`]).
//! File → Export → Export for Screens, laid out as Illustrator's: on the left the Artboards and
//! Assets tabs (thumbnails with checkboxes, in a grid or a list), Clear Selection and the prefix;
//! on the right Select (All, a range or Full Document, Include Bleed), the destination (folder
//! picker, Open Location after Export, Create Sub-folders by scale or by format; the web downloads
//! instead), Export PDFs As (one file of every artboard or a file each), and the format rows (scale
//! as `2x`, `100w`, `100h` or `72ppi`, suffix, format) with the iOS and Android rows, the format
//! presets (the ☰ menu) and Format Settings (the gear); below, how many items are selected and
//! how many files the export writes. It opens on the settings the document last exported with,
//! except that while the Artboard tool is in use the artboards it has selected are the ones chosen;
//! the format rows are the Asset Export panel's too ([`formats`]). On the desktop with Background
//! Export on, the files are encoded and written on the background thread
//! ([`crate::background`]) after the dialog closes.
//!
//! The Layers tab exports each checked layer's art alone, and the Selection tab the selected art as
//! one file or a file per object. A file name pattern (`name`: `<filename>`, `<name>`, `####`,
//! `YYYYMMDD`…, see `document.exportForScreens`) names every file alike, with a live example, and
//! the files go into one folder, or a folder per row, format, item, or item and format
//! (`folders`). Export presets (built-in and saved) set the formats, naming and folders at once.
//! On the desktop with Background Export on, the files are encoded and written on the background
//! thread ([`crate::background`]) after the dialog closes.
//! `document.exportForScreens` does more than the dialog shows (layers, the selection, file name
//! patterns, a folder per item): those are for scripts and MCP.
//!
//! Fields (`ui.dialog.set`): `tab` (artboards|assets|layers|selection), `select` (all|range|full), `range`,
//! Fields (`ui.dialog.set`): `tab` (artboards|assets), `select` (all|range|full), `range`,
//! `boards` ([bool] per artboard), `assets` ([asset id] checked on the Assets tab), `includeBleed`,
//! `folder`, `openLocation`, `subfolders`, `prefix`, `preset` (""|mobile|density: where the rows
//! came from), `formats` (rows of `document.exportForScreens`, `scale` as text), `settings`
//! ({png|png8|jpg|webp|gif|tiff|svg|pdf: {…}}), `layers` ([layer id] checked on the Layers tab),
//! `selectionMode` (one|each), `name` (the pattern), `counterStart`, `folders`
//! (none|rows|format|item|itemFormat), `exportPreset` (the preset applied last, "" for none);
//! `folder`, `openLocation`, `subfolders`, `subfolderBy` (scale|format), `pdfs` (single|multiple),
//! `prefix`, `preset` (""|mobile|density: where the rows came from), `formats` (rows of
//! `document.exportForScreens`, `scale` as text), `settings` ({png|png8|jpg|webp|gif|tiff|svg|pdf:
//! {…}}), `view` (grid|list), `exportPreset` (the format preset applied last, "" for none);
//! `__settings` names the format whose Format Settings show ("" when closed), `__savePreset` is the
//! name being typed to save a preset (absent when not saving).
use serde::Deserialize;
use serde_json::{Map, Value, json};
use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, SCREEN_PRESETS, ScreenSize, pdf};
use vectorcraft_doc::{ExportAsset, NodeId};
use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, ScreenSize, pdf};
use super::{DialogSpec, form, png_options, svg_options};
use crate::state::Dialog;
@@ -43,11 +40,9 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
ok_label: Some(|app| match (io::is_web(app), app.ui.dialog.as_ref().map(|d| d.str("tab")).as_deref()) {
(true, _) => "Download",
(false, Some("assets")) => "Export Asset",
(false, Some("layers")) => "Export Layer",
(false, Some("selection")) => "Export Selection",
(false, _) => "Export Artboard",
}),
min_width: 660.0,
min_width: 720.0,
..DialogSpec::FORM
};
@@ -80,32 +75,13 @@ fn print_marks() -> Value {
json!({"trim": true, "registration": true, "colorBars": true, "pageInfo": true})
}
/// Where the files go: (`folders` value, label).
const FOLDERS: [(&str, &str); 5] = [
("none", "All in One Folder"),
("rows", "By Size or Row"),
("format", "By Format"),
("item", "By Artboard or Item"),
("itemFormat", "By Item, Then Format"),
];
const FOLDER_LABELS: [&str; 5] = ["All in One Folder", "By Size or Row", "By Format", "By Artboard or Item", "By Item, Then Format"];
/// The tokens Insert adds to the file name pattern: (label, token).
const TOKENS: [(&str, &str); 8] = [
("Document Name", "<filename>"),
("Artboard or Item Name", "<name>"),
("Number (####)", "####"),
("Date (YYYYMMDD)", "YYYYMMDD"),
("Date (YYYY-MM-DD)", "YYYY-MM-DD"),
("Time (hhmmss)", "<time>"),
("Format", "<format>"),
("Scale", "<scale>"),
];
const TOKEN_LABELS: [&str; 8] =
["Document Name", "Artboard or Item Name", "Number (####)", "Date (YYYYMMDD)", "Date (YYYY-MM-DD)", "Time (hhmmss)", "Format", "Scale"];
/// The tabs: (`tab` value, label).
const TABS: [&str; 4] = ["artboards", "assets", "layers", "selection"];
const TABS: [&str; 2] = ["artboards", "assets"];
/// The left pane's width and the height of its thumbnails' area; the right pane's width.
const LEFT_W: f32 = 300.0;
const PANE_H: f32 = 360.0;
const RIGHT_W: f32 = 370.0;
/// The formats Format Settings has options for: (format, label).
const SETTINGS: [(&str, &str); 8] =
@@ -144,8 +120,9 @@ pub(crate) fn open_assets(app: &mut VectorcraftApp, checked: Option<&[u64]>) {
}
/// Open the dialog on the settings the active document last exported with (else one PNG row at
/// 1x, into the Desktop), every asset checked. While the Artboard tool is in use, the artboards it
/// has selected (the rows the Artboards panel highlights) are the ones chosen instead.
/// 1x, into the Desktop, with Create Sub-folders on), every asset checked. While the Artboard tool
/// is in use, the artboards it has selected (the rows the Artboards panel highlights) are the ones
/// chosen instead.
pub fn open(app: &mut VectorcraftApp) {
let (n, saved, assets) = app
.session
@@ -172,12 +149,14 @@ pub fn open(app: &mut VectorcraftApp) {
settings.insert(id.into(), Value::Object(saved_options(app, &saved, id)));
}
let folder = get("folder").and_then(Value::as_str).filter(|f| !f.is_empty()).map(str::to_string).or_else(fileio::export_folder);
// Where files go: the saved choice, else a folder per row as Create Sub-folders did.
let folders = get("folders")
.and_then(Value::as_str)
.filter(|f| FOLDERS.iter().any(|(id, _)| id == f))
.map_or_else(|| if flag("subfolders", preset.is_some()) { "rows" } else { "none" }.to_string(), str::to_string);
let layers: Vec<u64> = app.session.active().map(|st| st.doc.layers.iter().map(|l| l.id.0).collect()).unwrap_or_default();
// Create Sub-folders is on until an export turns it off; a folder per format is the saved
// `folders: "format"`, any other sorting a folder per scale.
let folders = get("folders").and_then(Value::as_str);
let subfolders = match folders {
Some("none") => false,
Some(_) => true,
None => flag("subfolders", saved.is_empty() || preset.is_some()),
};
let fields = json!({
"tab": "artboards",
"select": select,
@@ -187,18 +166,16 @@ pub fn open(app: &mut VectorcraftApp) {
"includeBleed": flag("includeBleed", false),
"folder": folder.unwrap_or_default(),
"openLocation": flag("openLocation", true),
"subfolders": flag("subfolders", preset.is_some()),
"subfolders": subfolders,
"subfolderBy": if folders == Some("format") { "format" } else { "scale" },
"pdfs": if get("pdfs").and_then(Value::as_str) == Some("single") { "single" } else { "multiple" },
"prefix": get("prefix").and_then(Value::as_str).unwrap_or_default(),
"preset": preset.unwrap_or_default(),
"formats": rows,
"settings": settings,
"view": "grid",
"__settings": "",
"__presets": pdf::presets(&app.session),
"layers": layers,
"selectionMode": get("selection").and_then(Value::as_str).filter(|m| *m == "each").unwrap_or("one"),
"name": get("name").and_then(Value::as_str).unwrap_or_default(),
"counterStart": get("counterStart").and_then(Value::as_u64).unwrap_or(1),
"folders": folders,
"exportPreset": "",
"__exportPresets": export_presets(app),
});
@@ -281,24 +258,6 @@ pub(super) fn export_presets(app: &mut VectorcraftApp) -> Value {
app.run("document.exportPresets.list", json!({})).ok().and_then(|r| r.get("presets").cloned()).unwrap_or_else(|| json!([]))
}
/// The art of layer `id` (sublayers unfolded), back to front: what its file holds.
fn layer_art(doc: &vectorcraft_doc::Document, id: NodeId, out: &mut Vec<NodeId>) {
let Some(n) = doc.node(id) else { return };
if n.is_layer() {
for c in n.children().into_iter().flatten() {
layer_art(doc, c.id, out);
}
} else {
out.push(id);
}
}
/// The checked layers that are still in the document, in stacking order.
fn checked_layers(app: &VectorcraftApp, d: &Dialog) -> Vec<u64> {
let on: Vec<u64> = d.fields.get("layers").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
app.session.active().map(|st| st.doc.layers.iter().map(|l| l.id.0).filter(|id| on.contains(id)).collect()).unwrap_or_default()
}
/// The `document.exportForScreens` params the dialog describes (the web: no folder, one ZIP for
/// several files).
fn params(app: &VectorcraftApp, d: &Dialog) -> Result<Value, String> {
@@ -316,24 +275,24 @@ fn params(app: &VectorcraftApp, d: &Dialog) -> Result<Value, String> {
Ok(Value::Object(p))
}
/// The counter's first value: the Start at field (a number, or text typed through `ui.dialog.set`).
fn counter_start(d: &Dialog) -> u64 {
d.fields.get("counterStart").and_then(|v| v.as_u64().or_else(|| v.as_str().and_then(|t| t.trim().parse().ok()))).unwrap_or(1)
}
/// The params of [`params`] without the destination (what is exported, at which sizes, and how the
/// files are named and sorted), and how many files that is.
fn settings_params(app: &VectorcraftApp, d: &Dialog) -> Result<(Map<String, Value>, usize), String> {
let (p, files, _) = selection_params(app, d)?;
Ok((p, files))
}
/// [`settings_params`] and how many artboards or assets are chosen.
fn selection_params(app: &VectorcraftApp, d: &Dialog) -> Result<(Map<String, Value>, usize, usize), String> {
let n = app.session.active().map_or(0, |st| st.doc.artboards.len());
let rows = d.fields.get("formats").and_then(Value::as_array).cloned().unwrap_or_default();
if rows.is_empty() {
return Err("add a format to export".into());
}
let mut p = Map::new();
// What each row writes a file of: the checked assets, layers, the selection, else the
// artboards.
let tab = d.str("tab");
let pieces = if tab == "assets" {
// What each row writes a file of: the checked assets, else the artboards.
let artboards = d.str("tab") != "assets";
let pieces = if !artboards {
let checked = checked_assets(app, d);
if checked.is_empty() {
return Err("check an asset to export".into());
@@ -341,22 +300,6 @@ fn settings_params(app: &VectorcraftApp, d: &Dialog) -> Result<(Map<String, Valu
let count = checked.len();
p.insert("assets".into(), json!(checked));
count
} else if tab == "layers" {
let checked = checked_layers(app, d);
if checked.is_empty() {
return Err("check a layer to export".into());
}
let count = checked.len();
p.insert("layers".into(), json!(checked));
count
} else if tab == "selection" {
let selected = app.session.active().map_or(0, |st| st.selection.objects.len());
if selected == 0 {
return Err("select the art to export first".into());
}
let each = d.str("selectionMode") == "each";
p.insert("selection".into(), json!(if each { "each" } else { "one" }));
if each { selected } else { 1 }
} else {
match d.str("select").as_str() {
"full" => {
@@ -367,34 +310,33 @@ fn settings_params(app: &VectorcraftApp, d: &Dialog) -> Result<(Map<String, Valu
let range = d.str("range");
// A bad range keeps the dialog open.
let chosen = ArtboardPick { range: Some(range.clone()), ..Default::default() }.resolve(n)?.unwrap_or_default();
if chosen.is_empty() {
return Err("check an artboard to export".into());
}
p.insert("range".into(), json!(range));
chosen.len()
}
_ => n,
}
};
let files = pieces * rows.len();
// Export PDFs As › Single file: every chosen artboard in one PDF per PDF row.
let single = artboards && pieces > 1 && d.str("select") != "full" && d.str("pdfs") == "single";
let pdf_rows = rows.iter().filter(|r| r["format"].as_str().and_then(fileio::format).is_some_and(|f| f.id == "pdf")).count();
let files = if single { (rows.len() - pdf_rows) * pieces + pdf_rows } else { rows.len() * pieces };
p.insert("includeBleed".into(), json!(d.bool("includeBleed")));
let folders = d.str("folders");
let folders = match (FOLDERS.iter().any(|(id, _)| *id == folders), d.bool("subfolders")) {
// Create Sub-folders (as the Asset Export panel and older scripts set it) is a folder per
// row while Folders is still All in One Folder.
(true, true) if folders == "none" => "rows".to_string(),
(true, _) => folders,
(false, sub) => if sub { "rows" } else { "none" }.to_string(),
let folders = match (d.bool("subfolders"), d.str("subfolderBy").as_str()) {
(false, _) => "none",
(true, "format") => "format",
(true, _) => "rows",
};
// Create Sub-folders, as the Asset Export panel (which shares these settings) still names it.
// Create Sub-folders, as the Asset Export panel (which shares these settings) names it.
p.insert("subfolders".into(), json!(folders == "rows"));
p.insert("folders".into(), json!(folders));
p.insert("pdfs".into(), json!(if single { "single" } else { "multiple" }));
p.insert("prefix".into(), json!(d.str("prefix")));
let name = d.str("name");
if !name.trim().is_empty() {
p.insert("name".into(), json!(name.trim()));
}
p.insert("counterStart".into(), json!(counter_start(d)));
p.insert("formats".into(), Value::Array(rows));
p.insert("settings".into(), d.fields.get("settings").cloned().unwrap_or(json!({})));
Ok((p, files))
Ok((p, files, pieces))
}
/// Export; the dialog stays open when the export fails.
@@ -410,130 +352,218 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let tab = TABS.iter().position(|k| *k == d.str("tab")).unwrap_or(0);
if let Some(i) = widgets::tab_bar(ui, &[tl!("Artboards"), tl!("Assets"), tl!("Layers"), tl!("Selection")], tab) {
d.fields.insert("tab".into(), json!(TABS.get(i).copied().unwrap_or("artboards")));
}
ui.add_space(8.0);
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(270.0);
match tab {
1 => assets(app, ui, d),
2 => layers(app, ui, d),
3 => selection(app, ui, d),
_ => artboards(app, ui, d),
ui.set_width(LEFT_W);
if let Some(i) = widgets::tab_bar(ui, &[tl!("Artboards"), tl!("Assets")], tab) {
d.fields.insert("tab".into(), json!(TABS.get(i).copied().unwrap_or("artboards")));
}
ui.add_space(6.0);
let list = d.str("view") == "list";
let (pane, _) = ui.allocate_exact_size(egui::vec2(LEFT_W, PANE_H), egui::Sense::hover());
ui.painter().rect_filled(pane, 2.0, t.panel_darker);
ui.painter().rect_stroke(pane, 2.0, egui::Stroke::new(1.0, t.divider), egui::StrokeKind::Inside);
let mut inner = ui.new_child(egui::UiBuilder::new().max_rect(pane.shrink(8.0)).layout(egui::Layout::top_down(egui::Align::Min)));
match tab {
1 => assets(app, &mut inner, d, list),
_ => artboard_tiles(app, &mut inner, d, list),
}
ui.add_space(6.0);
ui.horizontal(|ui| {
for (view, icon, tip) in [("grid", "layout-grid", tl!("Grid View")), ("list", "dc-list-view", tl!("List View"))] {
if widgets::icon_button(ui, icon, tip, d.str("view") == view || (view == "grid" && !list && d.str("view") != "list"), 22.0)
.clicked()
{
d.fields.insert("view".into(), json!(view));
}
}
ui.add_space(6.0);
if ui.small_button(tl!("Clear Selection")).clicked() {
clear_selection(app, d, tab);
}
widgets::dim_label(ui, tl!("Prefix:"));
form::text(ui, d, "prefix", (ui.available_width() - 4.0).max(60.0));
});
});
let (sep, _) = ui.allocate_exact_size(egui::vec2(17.0, 380.0), egui::Sense::hover());
let (sep, _) = ui.allocate_exact_size(egui::vec2(17.0, PANE_H + 60.0), egui::Sense::hover());
ui.painter().line_segment([sep.center_top(), sep.center_bottom()], egui::Stroke::new(1.0, t.divider));
ui.vertical(|ui| {
ui.set_width(350.0);
ui.set_width(RIGHT_W);
let shown = d.str("__settings");
if SETTINGS.iter().any(|(id, _)| *id == shown) {
format_settings(ui, d, &shown);
} else {
presets(app, ui, d);
ui.add_space(8.0);
if tab == 0 {
select_options(app, ui, d);
ui.add_space(8.0);
}
destination(app, ui, d);
ui.add_space(6.0);
pdf_mode(ui, d, tab == 0);
ui.add_space(10.0);
formats(ui, d);
ui.add_space(8.0);
naming(app, ui, d);
formats(ui, d, Some(&mut |ui: &mut egui::Ui, d: &mut Dialog| preset_menu(app, ui, d)));
save_preset_row(app, ui, d);
}
});
});
ui.add_space(8.0);
let (picked, files) = counts(app, d);
let status =
crate::i18n::fmt(tl!("Selected: {n}, Total number of files to export: {m}"), &[("n", &picked.to_string()), ("m", &files.to_string())]);
widgets::dim_label(ui, &status);
false
}
/// How many artboards or assets are chosen and how many files the export writes (0 while the
/// settings choose nothing to export).
fn counts(app: &VectorcraftApp, d: &Dialog) -> (usize, usize) {
match selection_params(app, d) {
Ok((_, files, pieces)) => (pieces, files),
Err(_) => (if d.str("tab") == "assets" { checked_assets(app, d).len() } else { 0 }, 0),
}
}
/// Clear Selection: no artboard (or, on the Assets tab, no asset) is checked.
fn clear_selection(app: &VectorcraftApp, d: &mut Dialog, tab: usize) {
if tab == 1 {
d.fields.insert("assets".into(), json!([]));
} else {
let n = app.session.active().map_or(0, |st| st.doc.artboards.len());
d.fields.insert("boards".into(), json!(vec![false; n]));
set_select(d, "range");
d.fields.insert("range".into(), json!(""));
}
}
fn set_select(d: &mut Dialog, select: &str) {
d.fields.insert("select".into(), json!(select));
}
/// Select (All, Range, Full Document), Include Bleed, and the artboards' thumbnails and checkboxes.
fn artboards(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
/// The checked artboards, one per artboard (all checked when the field is short).
fn boards(app: &VectorcraftApp, d: &Dialog) -> Vec<bool> {
let n = app.session.active().map_or(0, |st| st.doc.artboards.len());
let mut boards: Vec<bool> =
d.fields.get("boards").and_then(Value::as_array).map(|a| a.iter().map(|b| b.as_bool().unwrap_or(false)).collect()).unwrap_or_default();
boards.resize(n, true);
let before = boards.clone();
boards
}
/// Select: All or a Range (which checks the artboards it names), Include Bleed and Full Document.
fn select_options(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
let n = app.session.active().map_or(0, |st| st.doc.artboards.len());
let select = d.str("select");
widgets::dim_label(ui, tl!("Select:"));
ui.horizontal(|ui| {
if widgets::radio(ui, tl!("All"), select == "all", true) {
set_select(d, "all");
boards.iter_mut().for_each(|b| *b = true);
d.fields.insert("boards".into(), json!(vec![true; n]));
d.fields.insert("range".into(), json!(range_text(&vec![true; n])));
}
if widgets::radio(ui, tl!("Range:"), select == "range", true) {
set_select(d, "range");
}
ui.add_enabled_ui(select == "range", |ui| {
if form::text_named(ui, d, "range", tl!("Range:"), 90.0)
if form::text_named(ui, d, "range", tl!("Range:"), 150.0)
&& let Ok(Some(chosen)) = (ArtboardPick { range: Some(d.str("range")), ..Default::default() }).resolve(n)
{
boards = (0..n).map(|i| chosen.contains(&i)).collect();
d.fields.insert("boards".into(), json!((0..n).map(|i| chosen.contains(&i)).collect::<Vec<_>>()));
}
});
});
if widgets::radio(ui, tl!("Full Document"), select == "full", true) {
form::check(ui, d, "includeBleed", tl!("Include Bleed"));
if widgets::radio(ui, tl!("Full Document"), d.str("select") == "full", true) {
set_select(d, "full");
}
ui.add_space(4.0);
form::check(ui, d, "includeBleed", tl!("Include Bleed"));
ui.add_space(6.0);
let typed = boards != before;
let checked = boards.clone();
}
/// The artboards' thumbnails, each with its checkbox, number and name, in a grid or a list;
/// checking them picks them as the range (all of them: All). Dimmed with Full Document.
fn artboard_tiles(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, list: bool) {
let mut boards = boards(app, d);
let before = boards.clone();
let names: Vec<String> = app.session.active().map(|s| s.doc.artboards.iter().map(|a| a.name.clone()).collect()).unwrap_or_default();
ui.add_enabled_ui(d.str("select") != "full", |ui| {
ui.horizontal(|ui| {
if ui.small_button(tl!("Select All")).clicked() {
boards.iter_mut().for_each(|b| *b = true);
tiles(ui, "efs-artboards", names.len(), list, |ui, i, r| {
match artboard_thumb(app, ui.ctx(), i) {
Some(tex) => {
let sz = tex.size_vec2();
let s = (r.width() / sz.x).min(r.height() / sz.y).min(1.0);
let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0));
ui.painter().image(tex.id(), egui::Rect::from_center_size(r.center(), sz * s), uv, egui::Color32::WHITE);
}
None => {
ui.painter().rect_filled(r, 2.0, egui::Color32::WHITE);
}
}
if ui.small_button(tl!("Clear")).clicked() {
boards.iter_mut().for_each(|b| *b = false);
}
});
let names: Vec<String> = app.session.active().map(|s| s.doc.artboards.iter().map(|a| a.name.clone()).collect()).unwrap_or_default();
egui::ScrollArea::vertical().max_height(250.0).show(ui, |ui| {
for (i, name) in names.iter().enumerate() {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(egui::vec2(46.0, 46.0), egui::Sense::hover());
match artboard_thumb(app, ui.ctx(), i) {
Some(tex) => {
let sz = tex.size_vec2();
let s = (46.0 / sz.x.max(sz.y)).min(1.0);
let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0));
ui.painter().image(tex.id(), egui::Rect::from_center_size(r.center(), sz * s), uv, egui::Color32::WHITE);
}
None => {
ui.painter().rect_filled(r, 2.0, egui::Color32::WHITE);
}
}
if let Some(b) = boards.get_mut(i)
&& crate::widgets::check_name(ui, name, *b)
{
*b ^= true;
}
});
let name = names.get(i).cloned().unwrap_or_default();
(format!("{} {name}", i + 1), name, boards.get(i).copied().unwrap_or(false))
})
.into_iter()
.for_each(|i| {
if let Some(b) = boards.get_mut(i) {
*b ^= true;
}
});
});
// Checking artboards picks them as the range (all of them: All); a typed range keeps its text.
if boards != checked {
if boards != before {
set_select(d, if boards.iter().all(|b| *b) { "all" } else { "range" });
d.fields.insert("range".into(), json!(range_text(&boards)));
}
if typed || boards != checked {
d.fields.insert("boards".into(), json!(boards));
}
}
/// `count` thumbnails in a grid (or, with `list`, a list) that scrolls: `paint` draws thumbnail `i`
/// into its rect and gives its caption, its checkbox's name and whether it is checked → the
/// thumbnails whose checkbox (or picture) was clicked.
fn tiles(
ui: &mut egui::Ui,
id: &str,
count: usize,
list: bool,
mut paint: impl FnMut(&mut egui::Ui, usize, egui::Rect) -> (String, String, bool),
) -> Vec<usize> {
let t = Tokens::get(ui.ctx());
let mut toggled = vec![];
let side = if list { 40.0 } else { 84.0 };
let width = ui.available_width();
let columns = if list { 1 } else { ((width + 10.0) / (side + 10.0)).floor().max(1.0) as usize };
egui::ScrollArea::vertical().id_salt(id).auto_shrink([false, false]).show(ui, |ui| {
for start in (0..count).step_by(columns) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 10.0;
for i in start..(start + columns).min(count) {
let cell = if list { egui::vec2(width - 16.0, side) } else { egui::vec2(side, side + 18.0) };
let (r, resp) = ui.allocate_exact_size(cell, egui::Sense::click());
let pic = egui::Rect::from_min_size(r.min, egui::vec2(side, side));
let (caption, name, checked) = paint(ui, i, pic.shrink(3.0));
let stroke = if checked { egui::Stroke::new(2.0, t.accent) } else { egui::Stroke::new(1.0, t.divider) };
ui.painter().rect_stroke(pic, 2.0, stroke, egui::StrokeKind::Inside);
let label_at = if list { egui::pos2(pic.right() + 8.0, pic.center().y) } else { egui::pos2(pic.left(), pic.bottom() + 9.0) };
let galley =
ui.painter().layout(caption, egui::FontId::proportional(11.0), t.text, if list { cell.x - side - 12.0 } else { side });
ui.painter().galley(egui::pos2(label_at.x, label_at.y - galley.size().y / 2.0), galley, t.text);
// The checkbox sits on the thumbnail's lower left corner, as Illustrator's.
let bx = egui::Rect::from_min_size(egui::pos2(pic.left() + 4.0, pic.bottom() - 18.0), egui::vec2(14.0, 14.0));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(bx));
if widgets::check_box(&mut child, &name, checked, true) || resp.clicked() {
toggled.push(i);
}
}
});
ui.add_space(6.0);
}
});
toggled
}
/// The checked assets that are still in the document, in panel order.
fn checked_assets(app: &VectorcraftApp, d: &Dialog) -> Vec<u64> {
let on: Vec<u64> = d.fields.get("assets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
app.session.active().map(|st| st.doc.assets.iter().map(|a| a.id).filter(|id| on.contains(id)).collect()).unwrap_or_default()
}
/// The Assets tab: each asset's thumbnail and checkbox, Select All and Clear.
fn assets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
/// The Assets tab: each asset's thumbnail with its checkbox and name, in a grid or a list.
fn assets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, list: bool) {
let Some(st) = app.session.active() else { return };
if st.doc.assets.is_empty() {
ui.add_space(24.0);
@@ -542,39 +572,30 @@ fn assets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
}
let mut on = checked_assets(app, d);
let before = on.clone();
ui.horizontal(|ui| {
if ui.small_button(tl!("Select All")).clicked() {
on = st.doc.assets.iter().map(|a| a.id).collect();
}
if ui.small_button(tl!("Clear")).clicked() {
on.clear();
}
});
// Asked for until the row is in view: the dialog's first frame only measures it.
// Asked for until its row is in view: the dialog's first frame only measures it.
let scroll_to = d.fields.get("__scrollTo").and_then(Value::as_u64).filter(|id| st.doc.assets.iter().any(|a| a.id == *id));
let mut in_view = scroll_to.is_none();
egui::ScrollArea::vertical().max_height(330.0).show(ui, |ui| {
for a in &st.doc.assets {
let row = ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(egui::vec2(46.0, 46.0), egui::Sense::hover());
crate::panels::asset_export::paint_thumb(ui, st, a, r);
let checked = on.contains(&a.id);
if crate::widgets::check_name(ui, &a.name, checked) {
on.retain(|id| *id != a.id);
if !checked {
on.push(a.id);
}
}
});
if scroll_to == Some(a.id) {
if ui.clip_rect().contains_rect(row.response.rect) {
in_view = true;
} else {
row.response.scroll_to_me(Some(egui::Align::Center));
}
let clicked = tiles(ui, "efs-assets", st.doc.assets.len(), list, |ui, i, r| {
let Some(a) = st.doc.assets.get(i) else { return (String::new(), String::new(), false) };
crate::panels::asset_export::paint_thumb(ui, st, a, r);
if scroll_to == Some(a.id) {
if ui.clip_rect().contains_rect(r) {
in_view = true;
} else {
ui.scroll_to_rect(r, Some(egui::Align::Center));
}
}
(a.name.clone(), a.name.clone(), on.contains(&a.id))
});
for i in clicked {
if let Some(a) = st.doc.assets.get(i) {
if on.contains(&a.id) {
on.retain(|id| *id != a.id);
} else {
on.push(a.id);
}
}
}
if in_view {
d.fields.remove("__scrollTo");
}
@@ -583,8 +604,8 @@ fn assets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
}
}
/// Export to: the folder (typed or picked), Open Location after Export and Create Sub-folders; the
/// web downloads instead.
/// Export to: the folder (typed or picked), Open Location after Export, and Create Sub-folders,
/// a folder per scale or per format; the web downloads instead.
fn destination(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
widgets::dim_label(ui, tl!("Export to:"));
if io::is_web(app) {
@@ -592,78 +613,111 @@ fn destination(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
} else {
let pick = app.services.pick_folder.is_some();
ui.horizontal(|ui| {
form::text(ui, d, "folder", if pick { 300.0 } else { 334.0 });
form::text(ui, d, "folder", if pick { RIGHT_W - 40.0 } else { RIGHT_W - 6.0 });
if pick && widgets::icon_button(ui, "folder-open", tl!("Choose a folder"), false, 26.0).clicked() {
crate::picks::folder_field(app, d, "folder");
}
});
form::check(ui, d, "openLocation", tl!("Open Location after Export"));
}
form::check(ui, d, "subfolders", tl!("Create Sub-folders"));
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Folders:"));
let current = d.str("folders");
let at = FOLDERS.iter().position(|(id, _)| *id == current).unwrap_or(0);
if let Some((id, _)) = widgets::dropdown(ui, "efs-folders", FOLDER_LABELS[at], &FOLDER_LABELS, 190.0).and_then(|i| FOLDERS.get(i)) {
d.fields.insert("folders".into(), json!(id));
d.fields.insert("subfolders".into(), json!(*id == "rows"));
ui.add_space(22.0);
let on = d.bool("subfolders");
let by = d.str("subfolderBy");
if widgets::radio(ui, tl!("Scale"), by != "format", on) {
d.fields.insert("subfolderBy".into(), json!("scale"));
}
if widgets::radio(ui, tl!("Format"), by == "format", on) {
d.fields.insert("subfolderBy".into(), json!("format"));
}
});
}
/// Export Preset: apply a built-in or saved preset (its formats, settings, naming and folders),
/// save the dialog's settings as one (Save…, then a name), or delete a saved one.
fn presets(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
/// Export PDFs As: one file of every chosen artboard, or a file each; only for artboards (not
/// Full Document) with a PDF row.
fn pdf_mode(ui: &mut egui::Ui, d: &mut Dialog, artboards: bool) {
let pdf = d
.fields
.get("formats")
.and_then(Value::as_array)
.is_some_and(|rows| rows.iter().any(|r| r["format"].as_str().and_then(fileio::format).is_some_and(|f| f.id == "pdf")));
let on = artboards && pdf && d.str("select") != "full";
ui.horizontal(|ui| {
ui.add_enabled_ui(on, |ui| widgets::dim_label(ui, tl!("Export PDFs As:")));
let single = d.str("pdfs") == "single";
if widgets::radio(ui, tl!("Single File"), single, on) {
d.fields.insert("pdfs".into(), json!("single"));
}
if widgets::radio(ui, tl!("Multiple Files"), !single, on) {
d.fields.insert("pdfs".into(), json!("multiple"));
}
});
}
/// The format presets (the ☰ by iOS and Android): apply a built-in or saved one (its rows, their
/// settings, the prefix and the sub-folders), save the dialog's rows as one, or delete the saved
/// one applied last.
fn preset_menu(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
let list: Vec<Value> = d.fields.get("__exportPresets").and_then(Value::as_array).cloned().unwrap_or_default();
let names: Vec<&str> = list.iter().filter_map(|p| p["name"].as_str()).collect();
let current = d.str("exportPreset");
let saved = list.iter().any(|p| p["name"] == current && p["builtIn"] == false);
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Export Preset:"));
let labels: Vec<&str> = std::iter::once("Custom").chain(names.iter().copied()).collect();
let at = names.iter().position(|n| *n == current).map_or(0, |i| i + 1);
if let Some(i) = widgets::dropdown(ui, "efs-export-preset", labels[at], &labels, 150.0)
&& let Some(preset) = i.checked_sub(1).and_then(|i| list.get(i))
{
apply_preset(d, preset);
}
if widgets::icon_button(ui, "save", tl!("Save Preset…"), false, 24.0).clicked() {
let name = if saved { current.clone() } else { String::new() };
d.fields.insert("__savePreset".into(), json!(name));
}
ui.add_enabled_ui(saved, |ui| {
if widgets::icon_button(ui, "trash-2", tl!("Delete Preset"), false, 24.0).clicked()
&& app.run("document.exportPresets.delete", json!({"name": current})).is_ok()
{
d.fields.insert("exportPreset".into(), json!(""));
d.fields.insert("__exportPresets".into(), export_presets(app));
let resp = widgets::icon_button(ui, "menu", tl!("Format Presets"), false, 24.0);
let clicked = resp.clicked();
let _ = widgets::popover(&resp, clicked, |ui| {
for p in &list {
let name = p["name"].as_str().unwrap_or_default();
if widgets::menu_item_name(ui, name, true, name == current) {
apply_preset(d, p);
ui.close();
}
});
}
ui.separator();
if widgets::menu_item(ui, tl!("Save Preset…"), true, false) {
d.fields.insert("__savePreset".into(), json!(if saved { current.clone() } else { String::new() }));
ui.close();
}
if widgets::menu_item(ui, tl!("Delete Preset"), saved, false) && app.run("document.exportPresets.delete", json!({"name": current})).is_ok() {
d.fields.insert("exportPreset".into(), json!(""));
d.fields.insert("__exportPresets".into(), export_presets(app));
ui.close();
}
});
if d.fields.contains_key("__savePreset") {
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Name:"));
form::text(ui, d, "__savePreset", 150.0);
let name = d.str("__savePreset").trim().to_string();
if ui.add_enabled(!name.is_empty(), egui::Button::new(tl!("Save"))).clicked() {
let settings = params(app, d).unwrap_or_else(|_| json!({"formats": d.fields.get("formats").cloned().unwrap_or(json!([]))}));
match app.run("document.exportPresets.save", json!({"name": name, "settings": settings})) {
Ok(_) => {
d.fields.remove("__savePreset");
d.fields.insert("exportPreset".into(), json!(name));
d.fields.insert("__exportPresets".into(), export_presets(app));
}
Err(e) => app.ui.status = e,
}
}
if ui.small_button(tl!("Cancel")).clicked() {
d.fields.remove("__savePreset");
}
});
}
}
/// Set the dialog to preset `preset` (`{name, settings}`): its formats, settings, naming and
/// folders; what it doesn't name keeps the dialog's value.
/// The row that names a format preset being saved (Save Preset… in the ☰ menu).
fn save_preset_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
if !d.fields.contains_key("__savePreset") {
return;
}
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Name:"));
form::text(ui, d, "__savePreset", 150.0);
let name = d.str("__savePreset").trim().to_string();
if ui.add_enabled(!name.is_empty(), egui::Button::new(tl!("Save"))).clicked() {
let settings = json!({
"formats": d.fields.get("formats").cloned().unwrap_or(json!([])),
"settings": d.fields.get("settings").cloned().unwrap_or(json!({})),
"prefix": d.str("prefix"),
"folders": if !d.bool("subfolders") { "none" } else if d.str("subfolderBy") == "format" { "format" } else { "rows" },
});
match app.run("document.exportPresets.save", json!({"name": name, "settings": settings})) {
Ok(_) => {
d.fields.remove("__savePreset");
d.fields.insert("exportPreset".into(), json!(name));
d.fields.insert("__exportPresets".into(), export_presets(app));
}
Err(e) => app.ui.status = e,
}
}
if ui.small_button(tl!("Cancel")).clicked() {
d.fields.remove("__savePreset");
}
});
}
/// Set the dialog to format preset `preset` (`{name, settings}`): its rows, their settings, the
/// prefix and the sub-folders; what it doesn't name keeps the dialog's value.
pub(super) fn apply_preset(d: &mut Dialog, preset: &Value) {
let st = &preset["settings"];
if let Some(rows) = st["formats"].as_array() {
@@ -680,127 +734,29 @@ pub(super) fn apply_preset(d: &mut Dialog, preset: &Value) {
}
let folders = st["folders"].as_str().or_else(|| st["subfolders"].as_bool().map(|b| if b { "rows" } else { "none" }));
if let Some(f) = folders {
d.fields.insert("folders".into(), json!(f));
d.fields.insert("subfolders".into(), json!(f == "rows"));
d.fields.insert("subfolders".into(), json!(f != "none"));
d.fields.insert("subfolderBy".into(), json!(if f == "format" { "format" } else { "scale" }));
}
for k in ["name", "prefix"] {
d.fields.insert(k.into(), json!(st[k].as_str().unwrap_or_default()));
if let Some(prefix) = st["prefix"].as_str() {
d.fields.insert("prefix".into(), json!(prefix));
}
d.fields.insert("counterStart".into(), json!(st["counterStart"].as_u64().unwrap_or(1)));
if let Some(b) = st["includeBleed"].as_bool() {
d.fields.insert("includeBleed".into(), json!(b));
}
if let Some(f) = st["folder"].as_str().filter(|f| !f.is_empty()) {
d.fields.insert("folder".into(), json!(f));
}
d.fields.insert("exportPreset".into(), json!(preset["name"]));
}
/// File Name: the pattern (with Insert for its tokens), the counter's start, the prefix and an
/// example of the first file's name.
fn naming(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("File Name:"));
// A pattern typed by hand is no longer the preset's.
if form::text(ui, d, "name", 170.0) {
d.fields.insert("exportPreset".into(), json!(""));
}
if let Some((_, token)) =
widgets::dropdown_named(ui, "efs-name-token", Some(tl!("Insert")), tl!("Insert"), &TOKEN_LABELS, 90.0).and_then(|i| TOKENS.get(i))
{
let name = d.str("name");
d.fields.insert("name".into(), json!(format!("{name}{token}")));
}
});
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Prefix:"));
form::text(ui, d, "prefix", 110.0);
widgets::dim_label(ui, tl!("Start at:"));
if let Some(v) = widgets::range_field(ui, "efs-counter-start", counter_start(d) as f64, 0.0..=999_999.0, "", 0, 64.0) {
d.fields.insert("counterStart".into(), json!(v.round().max(0.0) as u64));
}
});
let example = example_name(app, d);
if !example.is_empty() {
widgets::dim_label(ui, &crate::i18n::fmt(tl!("Example: {name}"), &[("name", &example)]));
}
}
/// What the dialog adds by the iOS and Android buttons: its format presets.
pub(crate) type FormatMenu<'a> = &'a mut dyn FnMut(&mut egui::Ui, &mut Dialog);
/// The first file the export writes, relative to the export folder, as `document.exportForScreens`
/// names it ([`fileio::screen_first_file`]); empty while the dialog's settings choose nothing to
/// export (no format, nothing checked or selected, a bad range).
pub(super) fn example_name(app: &VectorcraftApp, d: &Dialog) -> String {
settings_params(app, d).ok().and_then(|(p, _)| fileio::screen_first_file(&app.session, &Value::Object(p)).ok()).unwrap_or_default()
}
/// The iOS and Android rows (the engine's `mobile` and `density` presets) and their labels.
const PLATFORMS: [(&str, &str); 2] = [("mobile", "iOS"), ("density", "Android")];
/// The Layers tab: each top-level layer's thumbnail and checkbox (a file of its art alone),
/// Select All and Clear.
fn layers(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
let Some(st) = app.session.active() else { return };
let mut on = checked_layers(app, d);
let before = on.clone();
ui.horizontal(|ui| {
if ui.small_button(tl!("Select All")).clicked() {
on = st.doc.layers.iter().map(|l| l.id.0).collect();
}
if ui.small_button(tl!("Clear")).clicked() {
on.clear();
}
});
egui::ScrollArea::vertical().max_height(330.0).show(ui, |ui| {
for l in &st.doc.layers {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(egui::vec2(46.0, 46.0), egui::Sense::hover());
let mut art = vec![];
layer_art(&st.doc, l.id, &mut art);
// An id of its own for the thumbnail cache, away from the assets' ids.
let a = ExportAsset { id: u64::MAX - l.id.0, name: l.display_name(), nodes: art };
crate::panels::asset_export::paint_thumb(ui, st, &a, r);
let checked = on.contains(&l.id.0);
if crate::widgets::check_name(ui, &l.display_name(), checked) {
on.retain(|id| *id != l.id.0);
if !checked {
on.push(l.id.0);
}
}
});
}
});
if on != before {
d.fields.insert("layers".into(), json!(on));
}
}
/// The Selection tab: what is selected (a thumbnail and the count), as one file or a file per
/// object.
fn selection(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
let Some(st) = app.session.active() else { return };
let ids = st.selection.objects.clone();
if ids.is_empty() {
ui.add_space(24.0);
widgets::dim_label(ui, tl!("Nothing is selected. Select the art to export, then open this tab again."));
return;
}
let (r, _) = ui.allocate_exact_size(egui::vec2(120.0, 120.0), egui::Sense::hover());
// The selection changes without a new revision: its ids name its thumbnail.
let key = ids.iter().fold(0x005e_1ec7_u64, |h, id| h.rotate_left(7) ^ id.0);
let a = ExportAsset { id: u64::MAX / 2 + (key % (u64::MAX / 4)), name: "Selection".into(), nodes: ids.clone() };
crate::panels::asset_export::paint_thumb(ui, st, &a, r);
let count = ids.len().to_string();
widgets::dim_label(ui, &crate::i18n::fmt(tl!("{n} objects selected"), &[("n", &count)]));
ui.add_space(6.0);
let mode = d.str("selectionMode");
if widgets::radio(ui, tl!("One File of the Selection"), mode != "each", true) {
d.fields.insert("selectionMode".into(), json!("one"));
}
if widgets::radio(ui, tl!("A File per Object"), mode == "each", true) {
d.fields.insert("selectionMode".into(), json!("each"));
}
}
/// The preset, the Format Settings gear (which sets `__settings`), the format rows (scale, suffix,
/// format) and Add Scale, as wide as `ui` allows: the dialog's and the Asset Export panel's rows.
pub(crate) fn formats(ui: &mut egui::Ui, d: &mut Dialog) {
/// Formats: the iOS and Android buttons (each sets the rows that platform uses), `menu` (the
/// dialog's format presets), the Format Settings gear (which sets `__settings`), the format rows
/// (scale, suffix, format) and Add Scale, as wide as `ui` allows: the dialog's and the Asset
/// Export panel's rows.
pub(crate) fn formats(ui: &mut egui::Ui, d: &mut Dialog, mut menu: Option<FormatMenu<'_>>) {
let mut rows: Vec<Value> = d.fields.get("formats").and_then(Value::as_array).cloned().unwrap_or_default();
let mut edited = false;
ui.horizontal(|ui| {
@@ -816,19 +772,21 @@ pub(crate) fn formats(ui: &mut egui::Ui, d: &mut Dialog) {
.filter(|f| SETTINGS.iter().any(|(id, _)| id == f));
d.fields.insert("__settings".into(), json!(first.unwrap_or("png")));
}
let preset = d.str("preset");
let labels: Vec<&str> = std::iter::once("Custom").chain(SCREEN_PRESETS.iter().map(|(_, l)| *l)).collect();
let at = SCREEN_PRESETS.iter().position(|(id, _)| *id == preset).map_or(0, |i| i + 1);
if let Some(i) =
widgets::dropdown_named(ui, "efs-preset", Some(tl!("Preset:")), labels[at], &labels, (ui.available_width() - 8.0).clamp(90.0, 180.0))
{
let id = i.checked_sub(1).and_then(|i| SCREEN_PRESETS.get(i)).map_or("", |(id, _)| *id);
if let Some(preset_rows) = fileio::screen_preset_rows(id) {
if let Some(menu) = menu.as_mut() {
menu(ui, d);
}
// Right to left: Android, then iOS before it.
for (id, label) in PLATFORMS.iter().rev() {
let on = d.str("preset") == *id;
if ui.add(egui::Button::new(egui::RichText::new(tl!(label)).size(12.0)).selected(on).frame(on)).clicked()
&& let Some(preset_rows) = fileio::screen_preset_rows(id)
{
rows = preset_rows;
d.fields.insert("subfolders".into(), json!(true));
d.fields.insert("subfolderBy".into(), json!("scale"));
d.fields.insert("formats".into(), json!(rows));
d.fields.insert("preset".into(), json!(id));
}
d.fields.insert("preset".into(), json!(id));
}
});
});
@@ -39,16 +39,16 @@ fn strict(app: VectorcraftApp) -> Harness {
h
}
/// Export for Screens names its three text fields and exposes no raw widget ids.
/// Export for Screens names its range and prefix fields and exposes no raw widget ids.
#[test]
fn export_for_screens_names_the_form_text_fields() {
let mut app = app();
app.run("file.exportForScreens", json!({})).unwrap();
let h = strict(app);
for name in ["File Name:", "Prefix:", "Start at:"] {
for name in ["Range:", "Prefix:"] {
let _ = h.get_by_role_and_label(Role::TextInput, name);
}
assert!(h.query_by_role_and_label(Role::TextInput, "name").is_none(), "no internal widget id");
assert!(h.query_by_role_and_label(Role::TextInput, "range").is_none(), "no internal widget id");
assert!(h.query_by_role_and_label(Role::TextInput, "prefix").is_none(), "no internal widget id");
}
@@ -65,10 +65,10 @@ fn export_selection_collects_the_selection_and_opens_the_assets_tab() {
assert_eq!(assets(&mut app).len(), 2);
assert_eq!(field(&app, "assets"), json!(ids));
let text = painted_text(&mut app, |app, ui| super::show(app, ui.ctx()));
assert!(text.contains("Asset 1") && text.contains("Asset 2") && text.contains("Select All"), "{text}");
// The web downloads both as one zip.
assert!(text.contains("Asset 1") && text.contains("Asset 2") && text.contains("Clear Selection"), "{text}");
// The web downloads both as one zip, in the 1x sub-folder Create Sub-folders makes by default.
let r = confirm(&mut app).unwrap();
assert_eq!(r["files"], json!(["Asset-1.png", "Asset-2.png"]));
assert_eq!(r["files"], json!(["1x/Asset-1.png", "1x/Asset-2.png"]));
assert_eq!(log.borrow().len(), 1);
assert!(app.ui.dialog.is_none());
// Nothing selected: Export Selection is off; document.exportSelection still exports in one go.
@@ -112,8 +112,9 @@ fn the_assets_tab_exports_the_checked_assets_into_the_folder() {
set(&mut app, "assets", json!([ids[1], 999]));
set(&mut app, "formats", json!([{"format": "png", "scale": "2x", "suffix": "@2x"}]));
let r = confirm(&mut app).unwrap();
assert_eq!(r["files"], json!([format!("{folder}/Asset-2@2x.png")]));
let png = image::load_from_memory(&std::fs::read(dir.join("Asset-2@2x.png")).unwrap()).unwrap();
// Create Sub-folders is on by default: a folder per scale.
assert_eq!(r["files"], json!([format!("{folder}/2x/Asset-2@2x.png")]));
let png = image::load_from_memory(&std::fs::read(dir.join("2x").join("Asset-2@2x.png")).unwrap()).unwrap();
assert_eq!((png.width(), png.height()), (60, 40), "the asset's art at 2x");
assert_eq!(log.borrow().len(), 1, "Open Location shows the file");
// The rows are remembered for the dialog and the Asset Export panel alike.
+103 -154
View File
@@ -63,7 +63,8 @@ fn opens_on_defaults_and_draws_every_view() {
assert_eq!((field(&app, "select"), field(&app, "range"), field(&app, "boards")), (json!("all"), json!("1-3"), json!([true, true, true])));
assert_eq!(field(&app, "formats"), json!([{"format": "png", "scale": "1x", "suffix": ""}]));
assert_eq!(field(&app, "folder"), json!(fileio::export_folder().unwrap_or_default()), "the Desktop, else home");
assert_eq!((field(&app, "openLocation"), field(&app, "subfolders"), field(&app, "preset")), (json!(true), json!(false), json!("")));
assert_eq!((field(&app, "openLocation"), field(&app, "subfolders"), field(&app, "preset")), (json!(true), json!(true), json!("")));
assert_eq!((field(&app, "subfolderBy"), field(&app, "pdfs"), field(&app, "view")), (json!("scale"), json!("multiple"), json!("grid")));
let settings = field(&app, "settings");
for f in ["png", "png8", "jpg", "webp", "gif", "tiff", "svg", "pdf"] {
assert!(settings[f].is_object(), "{f} has settings");
@@ -128,7 +129,8 @@ fn opens_with_the_artboards_the_artboard_tool_selected_and_exports_only_them() {
frame(&mut app);
assert_eq!(field(&app, "boards"), json!([false, true, true, false]), "drawing the dialog keeps them");
let r = confirm(&mut app).unwrap();
assert_eq!(r["files"], json!(["Artboard-2.png", "Artboard-3.png"]));
// Create Sub-folders is on by default, a folder per scale.
assert_eq!(r["files"], json!(["1x/Artboard-2.png", "1x/Artboard-3.png"]));
assert_eq!(log.borrow().len(), 1, "one zip");
// With every artboard selected it opens on All.
click(&mut app, 0, true);
@@ -433,7 +435,7 @@ fn the_web_downloads_one_zip_or_the_file() {
assert_eq!(name, "Untitled-1.zip");
assert_eq!(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(), zip);
}
assert_eq!(r["files"], json!(["Artboard-1.png", "Artboard-1@2x.jpg", "Artboard-2.png", "Artboard-2@2x.jpg"]));
assert_eq!(r["files"], json!(["1x/Artboard-1.png", "2x/Artboard-1@2x.jpg", "1x/Artboard-2.png", "2x/Artboard-2@2x.jpg"]));
// One file downloads as itself, with the format's settings.
log.borrow_mut().clear();
open(&mut app);
@@ -602,7 +604,7 @@ fn screen_format_gear(out: &egui::FullOutput, width: f32) -> egui::Pos2 {
}
}
let label = out.shapes.iter().find_map(|s| label(&s.shape)).expect("painted Formats row");
// The existing 24 pt gear is right-aligned in the 350 pt dialog or 236 pt panel.
// The existing 24 pt gear is right-aligned in the dialog's 370 pt column or the 236 pt panel.
egui::pos2(label.left() + width - 12.0, label.center().y)
}
@@ -635,7 +637,7 @@ fn saved_tiff_alias_gear(alias: &str, asset_panel: bool) {
};
draw(&mut h, &mut app, vec![]);
let out = draw(&mut h, &mut app, vec![]);
let gear = screen_format_gear(&out, if asset_panel { 236.0 } else { 350.0 });
let gear = screen_format_gear(&out, if asset_panel { 236.0 } else { 370.0 });
draw(&mut h, &mut app, vec![egui::Event::PointerMoved(gear)]);
for pressed in [true, false] {
draw(
@@ -707,11 +709,12 @@ fn tiff_dropdown_exports_scaled_artboards_with_options_and_reopens() {
// Begin with an existing PNG row; TIFF must be chosen from the actual popup.
set(&mut app, "formats", json!([{"format": "png", "scale": "2x", "suffix": "@2x"}]));
set(&mut app, "prefix", json!("proof-"));
set(&mut app, "subfolders", json!(false));
let mut h = ScreensFrame::new();
h.choose(&mut app, "PNG", "TIFF");
assert_eq!(field(&app, "formats"), json!([{"format": "tiff", "scale": "2x", "suffix": "@2x"}]));
// Leave only the first of two boards checked.
h.click_label(&mut app, "Artboard 2");
h.click_label(&mut app, "2 Artboard 2");
assert_eq!(field(&app, "boards"), json!([true, false]));
assert_eq!(field(&app, "range"), json!("1"));
@@ -766,168 +769,114 @@ fn tiff_dropdown_exports_scaled_artboards_with_options_and_reopens() {
/// name, a prefix with characters Windows does not allow, a range, a Windows device name as an item
/// folder, the rows' folders and sizes, Create Sub-folders, a vector row's size suffix, the counter
/// typed as text, and the Layers and Selection tabs.
/// The dialog is laid out as Illustrator's: two tabs (Artboards, Assets), Clear Selection and the
/// prefix under the thumbnails, Create Sub-folders by scale or format, Export PDFs As, the iOS and
/// Android rows, and how many items and files the export takes; no naming, layer or selection
/// pages, presets bar or folders list.
#[test]
fn the_example_names_the_first_file_the_export_writes() {
fn the_dialog_is_laid_out_as_illustrators() {
let (mut app, _) = app(3, false);
app.run("artboard.setProps", json!({"index": 2, "name": "CON"})).unwrap();
app.run("select.all", json!({})).unwrap();
let dir = temp_folder("example");
let folder = dir.to_string_lossy().to_string();
let png = json!([{"format": "png", "scale": "1x", "suffix": ""}]);
let cases: Vec<Vec<(&str, Value)>> = vec![
vec![],
vec![("prefix", json!("a?b"))],
vec![("select", json!("range")), ("range", json!("2-3"))],
vec![("select", json!("range")), ("range", json!("3")), ("folders", json!("item"))],
vec![("folders", json!("rows")), ("formats", json!([{"format": "png", "scale": "144ppi", "suffix": ""}]))],
vec![("subfolders", json!(true))],
vec![("folders", json!("rows")), ("formats", json!([{"format": "png", "scale": "2x", "suffix": "@2x", "folder": "retina"}]))],
vec![("formats", json!([{"format": "svg", "scale": "2x", "suffix": "@2x"}]))],
vec![("name", json!("<scale>-####")), ("counterStart", json!("7")), ("formats", json!([{"format": "png", "scale": "144ppi", "suffix": ""}]))],
vec![("tab", json!("layers"))],
vec![("tab", json!("selection")), ("selectionMode", json!("each"))],
];
let mut wrong = vec![];
for case in cases {
let _ = std::fs::remove_dir_all(&dir);
open(&mut app);
let defaults = [
("folder", json!(folder)),
("tab", json!("artboards")),
("select", json!("all")),
("prefix", json!("")),
("name", json!("")),
("counterStart", json!(1)),
("folders", json!("none")),
("subfolders", json!(false)),
("formats", png.clone()),
];
for (k, v) in defaults.into_iter().chain(case.iter().cloned()) {
set(&mut app, k, v);
}
frame(&mut app);
let example = export_for_screens::example_name(&app, app.ui.dialog.as_ref().unwrap());
let r = confirm(&mut app).unwrap();
let first = r["files"][0].as_str().and_then(|f| f.strip_prefix(&format!("{folder}/"))).unwrap_or_default().to_string();
if example != first {
wrong.push(format!("{case:?}: example {example:?}, written {first:?}"));
}
open(&mut app);
let text = crate::tests_labels::shapes_text(&ScreensFrame::new().settle(&mut app));
for label in [
"Artboards",
"Assets",
"Select:",
"Full Document",
"Include Bleed",
"Export to:",
"Open Location after Export",
"Create Sub-folders",
"Scale",
"Format",
"Export PDFs As:",
"Single File",
"Multiple Files",
"Formats:",
"iOS",
"Android",
"+ Add Scale",
"Clear Selection",
"Prefix:",
"Selected: 3, Total number of files to export: 3",
] {
assert!(text.lines().any(|l| l.trim() == label), "{label} missing:\n{text}");
}
for gone in ["Layers", "Selection", "Export Preset:", "File Name:", "Folders:", "Start at:"] {
assert!(!text.lines().any(|l| l.trim() == gone), "{gone} shown:\n{text}");
}
let _ = std::fs::remove_dir_all(&dir);
assert!(wrong.is_empty(), "{wrong:#?}");
}
/// The Example line shows before a folder is chosen and is left out while the settings choose
/// nothing to export (the Assets tab with no asset checked); Start at shows the counter the export
/// uses when it was set as text.
/// iOS and Android set the rows those platforms use (in sub-folders); Clear Selection unchecks
/// every artboard, so nothing is exported until one is checked again.
#[test]
fn the_example_needs_no_folder_and_start_at_shows_the_counter_used() {
let (mut app, _) = app(1, false);
fn ios_android_and_clear_selection() {
let (mut app, _) = app(2, false);
open(&mut app);
for (k, v) in [("folder", json!("")), ("name", json!("####")), ("counterStart", json!("7"))] {
set(&mut app, "subfolders", json!(false));
let mut h = ScreensFrame::new();
h.click_label(&mut app, "iOS");
let scales: Vec<Value> = field(&app, "formats").as_array().unwrap().iter().map(|r| r["scale"].clone()).collect();
assert_eq!((field(&app, "preset"), field(&app, "subfolders")), (json!("mobile"), json!(true)));
assert_eq!(scales, [json!("1x"), json!("2x"), json!("3x")]);
h.click_label(&mut app, "Android");
assert_eq!((field(&app, "preset"), field(&app, "formats").as_array().unwrap().len()), (json!("density"), 6));
h.click_label(&mut app, "Clear Selection");
assert_eq!((field(&app, "boards"), field(&app, "select")), (json!([false, false]), json!("range")));
let text = crate::tests_labels::shapes_text(&h.settle(&mut app));
assert!(text.contains("Selected: 0, Total number of files to export: 0"), "{text}");
set(&mut app, "folder", json!(temp_folder("cleared").to_string_lossy()));
assert!(confirm(&mut app).is_err(), "nothing to export");
}
/// Create Sub-folders sorts the files into a folder per scale or per format; Export PDFs As ›
/// Single file writes the chosen artboards as one PDF named after the document.
#[test]
fn sub_folders_and_single_file_pdfs() {
let (mut app, _) = app(3, false);
let dir = temp_folder("subfolders");
let _ = std::fs::remove_dir_all(&dir);
let folder = dir.to_string_lossy().to_string();
let title = app.session.active().unwrap().doc.title.clone();
let rows = json!([
{"format": "png", "scale": "1x", "suffix": ""},
{"format": "png", "scale": "2x", "suffix": "@2x"},
{"format": "pdf", "scale": "1x", "suffix": ""}
]);
let files = |app: &mut VectorcraftApp, extra: &[(&str, Value)]| -> Vec<String> {
open(app);
let base = [("folder", json!(folder)), ("select", json!("range")), ("range", json!("1-2")), ("formats", rows.clone())];
for (k, v) in base.into_iter().chain(extra.iter().cloned()) {
set(app, k, v);
}
frame(app);
let r = confirm(app).unwrap();
r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().strip_prefix(&format!("{folder}/")).unwrap().to_string()).collect()
};
let by_scale = files(&mut app, &[("subfolders", json!(true)), ("subfolderBy", json!("scale"))]);
assert_eq!(&by_scale[..2], ["1x/Artboard-1.png", "2x/Artboard-1@2x.png"]);
let by_format = files(&mut app, &[("subfolders", json!(true)), ("subfolderBy", json!("format")), ("pdfs", json!("single"))]);
let pdf = format!("pdf/{title}.pdf");
assert_eq!(by_format, ["png/Artboard-1.png", "png/Artboard-1@2x.png", pdf.as_str(), "png/Artboard-2.png", "png/Artboard-2@2x.png"]);
let flat = files(&mut app, &[("subfolders", json!(false)), ("pdfs", json!("multiple"))]);
assert_eq!(flat.len(), 6);
assert!(flat.contains(&"Artboard-2.pdf".to_string()), "{flat:?}");
// The status line counts the files Single file writes.
open(&mut app);
for (k, v) in [("select", json!("range")), ("range", json!("1-2")), ("formats", rows.clone()), ("pdfs", json!("single"))] {
set(&mut app, k, v);
}
let mut h = ScreensFrame::new();
let text = crate::tests_labels::shapes_text(&h.settle(&mut app));
assert!(text.lines().any(|l| l == "Example: 0007.png"), "{text}");
assert!(text.lines().any(|l| l.trim() == "7"), "{text}");
set(&mut app, "tab", json!("assets"));
let text = crate::tests_labels::shapes_text(&h.settle(&mut app));
assert!(!text.contains("Example:"), "{text}");
}
/// Batch exporting (#1935): a file name pattern and a folder per format, the Layers and Selection
/// tabs, and export presets applied and saved from the dialog.
#[test]
fn batch_naming_folders_layers_selection_and_presets() {
let (mut app, _) = app(3, false);
let dir = temp_folder("batch");
let _ = std::fs::remove_dir_all(&dir);
let folder = dir.to_string_lossy().to_string();
open(&mut app);
assert_eq!(field(&app, "folders"), json!("none"));
// Every tab draws; the OK button says what is exported.
for (tab, ok) in [("layers", "Export Layer"), ("selection", "Export Selection"), ("assets", "Export Asset"), ("artboards", "Export Artboard")] {
set(&mut app, "tab", json!(tab));
frame(&mut app);
assert_eq!((spec(export_for_screens::KIND).ok_label.unwrap())(&app), ok);
}
// Artboards 1–2 as SVG and print PDF, named by pattern, a folder per format.
set(&mut app, "folder", json!(folder));
set(&mut app, "select", json!("range"));
set(&mut app, "range", json!("1-2"));
set(&mut app, "name", json!("ACME-<name>-##"));
set(&mut app, "folders", json!("format"));
set(
&mut app,
"formats",
json!([{"format": "svg", "scale": "1x", "suffix": ""}, {"format": "pdf", "scale": "1x", "suffix": "-print", "marks": {"trim": true}}]),
);
frame(&mut app);
{
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!(export_for_screens::example_name(&app, d), "svg/ACME-Artboard 1-01.svg");
}
let r = confirm(&mut app).unwrap();
let files: Vec<String> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect();
assert_eq!(
files,
[
format!("{folder}/svg/ACME-Artboard 1-01.svg"),
format!("{folder}/pdf/ACME-Artboard 1-01-print.pdf"),
format!("{folder}/svg/ACME-Artboard 2-02.svg"),
format!("{folder}/pdf/ACME-Artboard 2-02-print.pdf")
]
);
// It reopens on the pattern and the folders.
open(&mut app);
assert_eq!((field(&app, "name"), field(&app, "folders")), (json!("ACME-<name>-##"), json!("format")));
// The Layers tab: one file of a layer's art.
let layer = app.session.active().unwrap().doc.layers[0].id.0;
set(&mut app, "folder", json!(folder));
set(&mut app, "tab", json!("layers"));
set(&mut app, "layers", json!([layer]));
set(&mut app, "name", json!(""));
set(&mut app, "folders", json!("none"));
set(&mut app, "formats", json!([{"format": "png", "scale": "1x", "suffix": ""}]));
let r = confirm(&mut app).unwrap();
assert_eq!(r["files"][0], json!(format!("{folder}/Layer-1.png")));
// The Selection tab: nothing selected keeps the dialog open; a file per selected object.
open(&mut app);
app.run("select.none", json!({})).unwrap();
set(&mut app, "folder", json!(folder));
set(&mut app, "tab", json!("selection"));
frame(&mut app);
assert!(confirm(&mut app).is_err());
app.run("select.all", json!({})).unwrap();
set(&mut app, "selectionMode", json!("each"));
set(&mut app, "name", json!("piece-#"));
set(&mut app, "formats", json!([{"format": "svg", "scale": "1x", "suffix": ""}]));
frame(&mut app);
let r = confirm(&mut app).unwrap();
assert_eq!(r["files"][0], json!(format!("{folder}/piece-1.svg")));
// Presets: the Logo Package sets the formats, naming and folders; the dialog saves its own.
open(&mut app);
let text = crate::tests_labels::shapes_text(&ScreensFrame::new().settle(&mut app));
assert!(text.contains("Selected: 2, Total number of files to export: 5"), "{text}");
// A format preset sets the rows, their settings and the sub-folders, not a prefix it lacks.
set(&mut app, "prefix", json!("x-"));
let presets = field(&app, "__exportPresets");
let logo = presets.as_array().unwrap().iter().find(|p| p["name"] == "Logo Package").unwrap().clone();
export_for_screens::apply_preset(app.ui.dialog.as_mut().unwrap(), &logo);
assert_eq!(field(&app, "formats").as_array().unwrap().len(), 5);
assert_eq!(
(field(&app, "folders"), field(&app, "name"), field(&app, "exportPreset")),
(json!("format"), json!("<filename>-<name>"), json!("Logo Package"))
(field(&app, "subfolders"), field(&app, "subfolderBy"), field(&app, "exportPreset")),
(json!(true), json!("format"), json!("Logo Package"))
);
frame(&mut app);
set(&mut app, "folder", json!(folder));
set(&mut app, "__savePreset", json!("Client Pack"));
frame(&mut app);
let saved = app
.run("document.exportPresets.save", json!({"name": "Client Pack", "settings": {"formats": field(&app, "formats"), "folders": "item"}}))
.unwrap();
assert_eq!(saved["name"], "Client Pack");
let list = export_for_screens::export_presets(&mut app);
assert!(list.as_array().unwrap().iter().any(|p| p["name"] == "Client Pack" && p["builtIn"] == false));
let _ = std::fs::remove_dir_all(&dir);
}
+8
View File
@@ -649,6 +649,13 @@ font-technology Variable Променливи
Create Report Създаване на отчет
Create Slice Създаване на отрязък
Create Sub-folders Създаване на подпапки
Clear Selection Изчистване на избора
Export PDFs As: Експортиране на PDF като:
Single File Един файл
Multiple Files Няколко файла
Format Presets Предварителни настройки на формата
Grid View Изглед на мрежа
Selected: {n}, Total number of files to export: {m} Избрани: {n}, общ брой файлове за експортиране: {m}
Create Text Създаване на текст
Create Trim Marks Създаване на режещи знаци
Created Създадено
@@ -3952,6 +3959,7 @@ axis Both И двете
@msg cannot split an open path at its end point отворен път не може да бъде разделен в крайната му точка
@msg closing needs three anchors затварянето изисква три опорни точки
@msg check an asset to export изберете актив за експортиране
@msg check an artboard to export изберете монтажна област за експортиране
@msg check a layer to export отметнете слой за експортиране
@msg select the art to export first първо изберете графиката за експортиране
@msg choice must be "merge" or "add", not `{choice}` изборът трябва да бъде "merge" или "add", а не `{choice}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -2228,6 +2228,13 @@ lighting Light Svjetlo
Use Artboards Koristi radne ploče
Choose a folder Odaberite folder
Create Sub-folders Kreiraj podfoldere
Clear Selection Poništi odabir
Export PDFs As: Izvezi PDF kao:
Single File Jedna datoteka
Multiple Files Više datoteka
Format Presets Postavke formata
Grid View Prikaz mreže
Selected: {n}, Total number of files to export: {m} Odabrano: {n}, ukupan broj datoteka za izvoz: {m}
Export Asset Izvezi resurs
Export to: Izvezi u:
Format Settings Postavke formata
@@ -4339,6 +4346,7 @@ imageTrace Outline Obris
@msg cannot split an open path at its end point nije moguće razdvojiti otvorenu putanju na njenoj krajnjoj tački
@msg closing needs three anchors zatvaranje zahtijeva tri sidra
@msg check an asset to export označite resurs za izvoz
@msg check an artboard to export označite radnu površinu za izvoz
@msg check a layer to export označite sloj za izvoz
@msg select the art to export first prvo odaberite crtež za izvoz
@msg choice must be "merge" or "add", not `{choice}` choice mora biti "merge" ili "add", a ne `{choice}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -918,6 +918,13 @@
Create Slice from Selection Crea un tall a partir de la selecció
Create Slices from Guides Crea talls a partir de guies
Create Sub-folders Crea subcarpetes
Clear Selection Esborra la selecció
Export PDFs As: Exporta els PDF com a:
Single File Un sol fitxer
Multiple Files Diversos fitxers
Format Presets Preajustos de format
Grid View Vista de quadrícula
Selected: {n}, Total number of files to export: {m} Seleccionats: {n}, nombre total de fitxers per exportar: {m}
Create Text Crea text
Create Trim Marks Crea marques de retall
Create: Crea:
@@ -4885,6 +4892,7 @@
@msg cannot split an open path at its end point no es pot dividir un traçat obert al seu punt final
@msg check a layer to export marqueu una capa per exportar
@msg check an asset to export marqueu un recurs per exportar
@msg check an artboard to export marqueu una mesa de treball per exportar
@msg choice must be "merge" or "add", not `{choice}` l’opció ha de ser "merge" o "add", no `{choice}`
@msg Choose a circle or a perfect arc Trieu un cercle o un arc perfecte
@msg choose a destination folder trieu una carpeta de destinació
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -2257,6 +2257,13 @@ lighting Light Světlo
Use Artboards Použít pracovní plochy
Choose a folder Vyberte složku
Create Sub-folders Vytvořit podsložky
Clear Selection Vymazat výběr
Export PDFs As: Exportovat PDF jako:
Single File Jeden soubor
Multiple Files Více souborů
Format Presets Přednastavení formátů
Grid View Zobrazení mřížky
Selected: {n}, Total number of files to export: {m} Vybráno: {n}, celkový počet souborů k exportu: {m}
Export Asset Exportovat prvek
Export to: Exportovat do:
Format Settings Nastavení formátu
@@ -4375,6 +4382,7 @@ imageTrace Outline Obrys
@msg cannot split an open path at its end point otevřenou cestu nelze rozdělit v jejím koncovém bodu
@msg closing needs three anchors uzavření vyžaduje tři kotevní body
@msg check an asset to export zaškrtněte prvek k exportu
@msg check an artboard to export zaškrtněte kreslicí plátno k exportu
@msg check a layer to export zaškrtněte vrstvu k exportu
@msg select the art to export first nejprve vyberte kresbu k exportu
@msg choice must be "merge" or "add", not `{choice}` volba musí být "merge" nebo "add", ne `{choice}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -2061,6 +2061,13 @@ lighting Light Lys
Use Artboards Brug tegnebrætter
Choose a folder Vælg en mappe
Create Sub-folders Opret undermapper
Clear Selection Ryd markering
Export PDFs As: Eksportér PDF'er som:
Single File Én fil
Multiple Files Flere filer
Format Presets Formatforudindstillinger
Grid View Gittervisning
Selected: {n}, Total number of files to export: {m} Valgt: {n}, samlet antal filer, der eksporteres: {m}
Export Asset Eksportér aktiv
Export to: Eksportér til:
Format Settings Formatindstillinger
@@ -4067,6 +4074,7 @@ imageTrace Outline Kontur
@msg cannot split an open path at its end point kan ikke opdele en åben kurve i dens endepunkt
@msg closing needs three anchors lukning kræver tre ankerpunkter
@msg check an asset to export afkryds et aktiv, der skal eksporteres
@msg check an artboard to export afkryds en tegneflade, der skal eksporteres
@msg choice must be "merge" or "add", not `{choice}` valget skal være "merge" eller "add", ikke `{choice}`
@msg choose a direction to divide in vælg en retning at opdele i
@msg choose a folder to export to vælg en mappe at eksportere til
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -642,6 +642,13 @@ font-technology Variable Variabel
Create Report Bericht erstellen
Create Slice Slice erstellen
Create Sub-folders Unterordner erstellen
Clear Selection Auswahl aufheben
Export PDFs As: PDFs exportieren als:
Single File Einzelne Datei
Multiple Files Mehrere Dateien
Format Presets Formatvorgaben
Grid View Rasteransicht
Selected: {n}, Total number of files to export: {m} Ausgewählt: {n}, Gesamtzahl der zu exportierenden Dateien: {m}
Create Text Text erstellen
Create Trim Marks Schnittmarken erstellen
Created Erstellt
@@ -3988,6 +3995,7 @@ theme Appearance Erscheinungsbild
@msg cannot split an open path at its end point ein offener Pfad kann nicht an seinem Endpunkt geteilt werden
@msg closing needs three anchors zum Schließen sind drei Ankerpunkte nötig
@msg check an asset to export markieren Sie ein Asset für den Export
@msg check an artboard to export markieren Sie eine Zeichenfläche für den Export
@msg check a layer to export wählen Sie eine Ebene für den Export
@msg select the art to export first wählen Sie zuerst das zu exportierende Bildmaterial aus
@msg choice must be "merge" or "add", not `{choice}` choice muss "merge" oder "add" sein, nicht `{choice}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -623,6 +623,13 @@ font-technology Variable Μεταβλητές
Create Report Δημιουργία αναφοράς
Create Slice Δημιουργία τομής
Create Sub-folders Δημιουργία υποφακέλων
Clear Selection Απαλοιφή επιλογής
Export PDFs As: Εξαγωγή PDF ως:
Single File Ένα αρχείο
Multiple Files Πολλά αρχεία
Format Presets Προεπιλογές μορφής
Grid View Προβολή πλέγματος
Selected: {n}, Total number of files to export: {m} Επιλεγμένα: {n}, συνολικός αριθμός αρχείων για εξαγωγή: {m}
Create Text Δημιουργία κειμένου
Create Trim Marks Δημιουργία σημαδιών ξακρίσματος
Created Δημιουργήθηκε
@@ -4028,6 +4035,7 @@ theme Appearance Εμφάνιση
@msg cannot split an open path at its end point δεν είναι δυνατή η διαίρεση ανοιχτής διαδρομής στο τελικό σημείο της
@msg closing needs three anchors το κλείσιμο απαιτεί τρία σημεία αγκύρωσης
@msg check an asset to export επιλέξτε στοιχείο για εξαγωγή
@msg check an artboard to export επιλέξτε πινακίδα σχεδίασης για εξαγωγή
@msg check a layer to export επιλέξτε ένα επίπεδο για εξαγωγή
@msg select the art to export first επιλέξτε πρώτα το γραφικό για εξαγωγή
@msg choice must be "merge" or "add", not `{choice}` η επιλογή πρέπει να είναι "merge" ή "add", όχι `{choice}`
Can't render this file because it contains an unexpected character in line 164 and column 5.
+8
View File
@@ -663,6 +663,13 @@ font-technology Variable Variables
Create Report Crear informe
Create Slice Crear sector
Create Sub-folders Crear subcarpetas
Clear Selection Borrar selección
Export PDFs As: Exportar PDF como:
Single File Un solo archivo
Multiple Files Varios archivos
Format Presets Preajustes de formato
Grid View Vista de cuadrícula
Selected: {n}, Total number of files to export: {m} Seleccionados: {n}, número total de archivos para exportar: {m}
Create Text Crear texto
Create Trim Marks Crear marcas de recorte
Created Creado
@@ -4068,6 +4075,7 @@ axis Both Ambos
@msg cannot split an open path at its end point no se puede dividir un trazado abierto en su punto final
@msg closing needs three anchors cerrar necesita tres puntos de ancla
@msg check an asset to export marca un recurso para exportar
@msg check an artboard to export marca una mesa de trabajo para exportar
@msg check a layer to export marque una capa para exportar
@msg select the art to export first seleccione primero la ilustración que desea exportar
@msg choice must be "merge" or "add", not `{choice}` choice debe ser "merge" o "add", no `{choice}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -2213,6 +2213,13 @@ lighting Light Valo
Use Artboards Käytä piirtoalustoja
Choose a folder Valitse kansio
Create Sub-folders Luo alikansiot
Clear Selection Tyhjennä valinta
Export PDFs As: Vie PDF-tiedostot muodossa:
Single File Yksi tiedosto
Multiple Files Useita tiedostoja
Format Presets Muotoesiasetukset
Grid View Ruudukkonäkymä
Selected: {n}, Total number of files to export: {m} Valittu: {n}, vietävien tiedostojen määrä yhteensä: {m}
Export Asset Vie resurssi
Export to: Vie kohteeseen:
Format Settings Muotoasetukset
@@ -4284,6 +4291,7 @@ imageTrace Outline Ääriviiva
@msg cannot split an open path at its end point avointa polkua ei voi jakaa sen päätepisteestä
@msg closing needs three anchors sulkeminen vaatii kolme ankkuria
@msg check an asset to export valitse vietävä resurssi
@msg check an artboard to export valitse vietävä piirtoalue
@msg check a layer to export valitse vietävä taso
@msg select the art to export first valitse ensin vietävä kuvitus
@msg choice must be "merge" or "add", not `{choice}` choice-arvon on oltava "merge" tai "add", ei `{choice}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -665,6 +665,13 @@ font-technology Variable Variables
Create Report Créer un rapport
Create Slice Créer une tranche
Create Sub-folders Créer des sous-dossiers
Clear Selection Effacer la sélection
Export PDFs As: Exporter les PDF sous :
Single File Un seul fichier
Multiple Files Plusieurs fichiers
Format Presets Paramètres prédéfinis de format
Grid View Vue en grille
Selected: {n}, Total number of files to export: {m} Sélectionnés : {n}, nombre total de fichiers à exporter : {m}
Create Text Créer du texte
Create Trim Marks Créer des traits de coupe
Created Création
@@ -4065,6 +4072,7 @@ axis Both Les deux
@msg cannot split an open path at its end point impossible de scinder un tracé ouvert à son extrémité
@msg closing needs three anchors la fermeture nécessite trois points d’ancrage
@msg check an asset to export cochez une ressource à exporter
@msg check an artboard to export cochez un plan de travail à exporter
@msg check a layer to export cochez un calque à exporter
@msg select the art to export first sélectionnez d’abord l’illustration à exporter
@msg choice must be "merge" or "add", not `{choice}` choice doit valoir "merge" ou "add", pas `{choice}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -859,6 +859,13 @@ font-technology Variable Változó
Create Report Jelentés készítése
Create Slice Szelet létrehozása
Create Sub-folders Almappák létrehozása
Clear Selection Kijelölés törlése
Export PDFs As: PDF-ek exportálása:
Single File Egy fájlba
Multiple Files Több fájlba
Format Presets Formátum-előbeállítások
Grid View Rácsnézet
Selected: {n}, Total number of files to export: {m} Kijelölve: {n}, exportálandó fájlok száma összesen: {m}
Create Text Szöveg létrehozása
Create Trim Marks Vágójelek létrehozása
Create: Létrehozás:
@@ -4624,6 +4631,7 @@ imageTrace Outline Körvonal
@msg cannot read PDF: {0} a PDF nem olvasható: {0}
@msg cannot split an open path at its end point nyitott útvonal a végpontjánál nem vágható ketté
@msg check an asset to export jelölj be egy exportálandó elemet
@msg check an artboard to export jelölj be egy exportálandó rajztáblát
@msg check a layer to export jelöljön be egy exportálandó réteget
@msg select the art to export first először jelölje ki az exportálandó grafikát
@msg choice must be "merge" or "add", not `{choice}` a choice értéke "merge" vagy "add" lehet, ez nem az: `{choice}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -2061,6 +2061,13 @@ lighting Light Cahaya
Use Artboards Gunakan artboard
Choose a folder Pilih folder
Create Sub-folders Buat subfolder
Clear Selection Hapus pilihan
Export PDFs As: Ekspor PDF sebagai:
Single File Satu file
Multiple Files Beberapa file
Format Presets Preset format
Grid View Tampilan kisi
Selected: {n}, Total number of files to export: {m} Dipilih: {n}, jumlah total file yang akan diekspor: {m}
Export Asset Ekspor aset
Export to: Ekspor ke:
Format Settings Pengaturan format
@@ -4067,6 +4074,7 @@ imageTrace Outline Kontur
@msg cannot split an open path at its end point tidak dapat membelah jalur terbuka pada titik ujungnya
@msg closing needs three anchors penutupan memerlukan tiga jangkar
@msg check an asset to export centang aset yang akan diekspor
@msg check an artboard to export centang artboard yang akan diekspor
@msg choice must be "merge" or "add", not `{choice}` choice harus "merge" atau "add", bukan `{choice}`
@msg choose a direction to divide in pilih arah untuk membagi
@msg choose a folder to export to pilih folder tujuan ekspor
Can't render this file because it contains an unexpected character in line 2175 and column 5.
+8
View File
@@ -663,6 +663,13 @@ font-technology Variable Variabili
Create Report Crea rapporto
Create Slice Crea sezione
Create Sub-folders Crea sottocartelle
Clear Selection Cancella selezione
Export PDFs As: Esporta PDF come:
Single File File singolo
Multiple Files Più file
Format Presets Predefiniti formato
Grid View Vista griglia
Selected: {n}, Total number of files to export: {m} Selezionati: {n}, numero totale di file da esportare: {m}
Create Text Crea testo
Create Trim Marks Crea segni di taglio
Created Creato
@@ -4108,6 +4115,7 @@ axis Both Entrambi
@msg cannot split an open path at its end point impossibile dividere un tracciato aperto nel suo punto finale
@msg closing needs three anchors per chiudere servono tre punti di ancoraggio
@msg check an asset to export spunta una risorsa da esportare
@msg check an artboard to export spunta una tavola da disegno da esportare
@msg check a layer to export seleziona un livello da esportare
@msg select the art to export first seleziona prima la grafica da esportare
@msg choice must be "merge" or "add", not `{choice}` choice deve essere "merge" o "add", non `{choice}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -1231,6 +1231,13 @@ font-technology Variable バリアブル
Create Path パスを作成
Create Slice スライスを作成
Create Sub-folders サブフォルダーを作成
Clear Selection 選択を解除
Export PDFs As: PDF の書き出し形式:
Single File 単一ファイル
Multiple Files 複数ファイル
Format Presets 形式プリセット
Grid View グリッド表示
Selected: {n}, Total number of files to export: {m} 選択済み:{n}、書き出すファイルの合計数:{m}
Create Text テキストを作成
Created 作成日
Created: 作成日:
@@ -4425,6 +4432,7 @@ swatch Bright Violet ビビッドバイオレット
@msg nothing to export: the document has no visible art (or turn on Use Artboards) 書き出すものがありません: ドキュメントに表示されているアートがありません (または「アートボードを使用」をオンにしてください)
@msg add a format to export 書き出す形式を追加してください
@msg check an asset to export 書き出すアセットにチェックを入れてください
@msg check an artboard to export 書き出すアートボードにチェックを入れてください
@msg check a layer to export 書き出すレイヤーにチェックを入れてください
@msg select the art to export first 先に書き出すアートを選択してください
@msg `{tool}` isn't a freehand tool `{tool}` はフリーハンドツールではありません
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -1961,6 +1961,13 @@ lighting Light სინათლე
Use Artboards სამუშაო დაფების გამოყენება
Choose a folder საქაღალდის არჩევა
Create Sub-folders ქვესაქაღალდეების შექმნა
Clear Selection მონიშვნის გასუფთავება
Export PDFs As: PDF-ების ექსპორტი როგორც:
Single File ერთი ფაილი
Multiple Files რამდენიმე ფაილი
Format Presets ფორმატის პრესეტები
Grid View ბადის ხედი
Selected: {n}, Total number of files to export: {m} არჩეულია: {n}, ექსპორტის ფაილების საერთო რაოდენობა: {m}
Export Asset რესურსის ექსპორტირება
Export to: ექსპორტის ადგილი:
Format Settings ფორმატის პარამეტრები
@@ -3851,6 +3858,7 @@ font-technology Variable ცვლადი
@msg cannot split an open path at its end point ღია ტრაექტორიის ბოლო წერტილში გაყოფა შეუძლებელია
@msg closing needs three anchors შეკვრას სამი საყრდენი წერტილი სჭირდება
@msg check an asset to export მონიშნეთ ექსპორტის რესურსი
@msg check an artboard to export მონიშნეთ ექსპორტის სახატავი დაფა
@msg choice must be "merge" or "add", not `{choice}` choice უნდა იყოს "merge" ან "add" და არა `{choice}`
@msg choose a direction to divide in აირჩიეთ დაყოფის მიმართულება
@msg choose a folder to export to აირჩიეთ ექსპორტის საქაღალდე
Can't render this file because it is too large.
+8
View File
@@ -1222,6 +1222,13 @@ font-technology Variable 가변
Create Path 패스 만들기
Create Slice 슬라이스 만들기
Create Sub-folders 하위 폴더 만들기
Clear Selection 선택 지우기
Export PDFs As: PDF 내보내기 방식:
Single File 단일 파일
Multiple Files 여러 파일
Format Presets 형식 사전 설정
Grid View 격자 보기
Selected: {n}, Total number of files to export: {m} 선택됨: {n}, 내보낼 총 파일 수: {m}
Create Text 텍스트 만들기
Created 만든 날짜
Created: 만든 날짜:
@@ -4353,6 +4360,7 @@ theme Appearance 외관
@msg nothing to export: the document has no visible art (or turn on Use Artboards) 내보낼 항목이 없습니다: 문서에 보이는 아트웍이 없습니다(또는 아트보드 사용을 켜세요)
@msg add a format to export 내보낼 형식을 추가하세요
@msg check an asset to export 내보낼 자산을 체크하세요
@msg check an artboard to export 내보낼 대지를 체크하세요
@msg check a layer to export 내보낼 레이어를 선택하십시오
@msg select the art to export first 먼저 내보낼 아트를 선택하십시오
@msg `{tool}` isn't a freehand tool `{tool}`은(는) 프리핸드 도구가 아닙니다
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -2075,6 +2075,13 @@ lighting Light Licht
Use Artboards Tekenborden gebruiken
Choose a folder Kies een map
Create Sub-folders Submappen maken
Clear Selection Selectie wissen
Export PDFs As: PDF's exporteren als:
Single File Eén bestand
Multiple Files Meerdere bestanden
Format Presets Indelingsvoorinstellingen
Grid View Rasterweergave
Selected: {n}, Total number of files to export: {m} Geselecteerd: {n}, totaal aantal te exporteren bestanden: {m}
Export Asset Asset exporteren
Export to: Exporteren naar:
Format Settings Indelingsinstellingen
@@ -4081,6 +4088,7 @@ imageTrace Outline Contour
@msg cannot split an open path at its end point kan een open pad niet splitsen op zijn eindpunt
@msg closing needs three anchors voor sluiten zijn drie ankers nodig
@msg check an asset to export vink een asset aan om te exporteren
@msg check an artboard to export vink een tekengebied aan om te exporteren
@msg choice must be "merge" or "add", not `{choice}` choice moet "merge" of "add" zijn, niet `{choice}`
@msg choose a direction to divide in kies een richting om in te verdelen
@msg choose a folder to export to kies een map om naar te exporteren
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -2174,6 +2174,13 @@ lighting Light Światło
Use Artboards Użyj obszarów kompozycji
Choose a folder Wybierz folder
Create Sub-folders Utwórz podfoldery
Clear Selection Wyczyść zaznaczenie
Export PDFs As: Eksportuj pliki PDF jako:
Single File Jeden plik
Multiple Files Wiele plików
Format Presets Ustawienia formatów
Grid View Widok siatki
Selected: {n}, Total number of files to export: {m} Zaznaczono: {n}, łączna liczba plików do eksportu: {m}
Export Asset Eksportuj zasób
Export to: Eksportuj do:
Format Settings Ustawienia formatu
@@ -4199,6 +4206,7 @@ imageTrace Outline Kontur
@msg cannot split an open path at its end point nie można podzielić otwartej ścieżki w jej punkcie końcowym
@msg closing needs three anchors zamknięcie wymaga trzech punktów
@msg check an asset to export zaznacz zasób do eksportu
@msg check an artboard to export zaznacz obszar kompozycji do eksportu
@msg check a layer to export zaznacz warstwę do eksportu
@msg select the art to export first najpierw zaznacz grafikę do eksportu
@msg choice must be "merge" or "add", not `{choice}` wybór musi mieć wartość "merge" lub "add", nie `{choice}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -585,6 +585,13 @@
Create Path Criar Caminho
Create Slice Criar Fatia
Create Sub-folders Criar Subpastas
Clear Selection Limpar Seleção
Export PDFs As: Exportar PDFs Como:
Single File Arquivo Único
Multiple Files Vários Arquivos
Format Presets Predefinições de Formato
Grid View Exibição em Grade
Selected: {n}, Total number of files to export: {m} Selecionados: {n}, número total de arquivos a exportar: {m}
Create Text Criar Texto
Create Trim Marks Criar Marcas de Corte
Create from Guides Criar a partir de Guias
@@ -3646,6 +3653,7 @@ axis Both Ambos
@msg cannot split an open path at its end point não é possível dividir um traçado aberto em sua extremidade
@msg closing needs three anchors fechar exige três âncoras
@msg check an asset to export marque um recurso para exportar
@msg check an artboard to export marque uma prancheta para exportar
@msg check a layer to export marque uma camada para exportar
@msg select the art to export first selecione primeiro a arte a exportar
@msg choice must be "merge" or "add", not `{choice}` choice deve ser "merge" ou "add", não `{choice}`
Can't render this file because it contains an unexpected character in line 6 and column 47.
+8
View File
@@ -661,6 +661,13 @@ font-technology Variable Вариативные
Create Report Создать отчёт
Create Slice Создать фрагмент
Create Sub-folders Создать вложенные папки
Clear Selection Снять выделение
Export PDFs As: Экспортировать PDF как:
Single File Один файл
Multiple Files Несколько файлов
Format Presets Наборы форматов
Grid View Сетка
Selected: {n}, Total number of files to export: {m} Выбрано: {n}, всего файлов для экспорта: {m}
Create Text Создать текст
Create Trim Marks Создать метки подрезки
Created Создано
@@ -4104,6 +4111,7 @@ axis Both Оба
@msg cannot split an open path at its end point нельзя разделить открытый контур в его конечной точке
@msg closing needs three anchors для замыкания нужны три опорные точки
@msg check an asset to export отметьте ресурс для экспорта
@msg check an artboard to export отметьте монтажную область для экспорта
@msg check a layer to export отметьте слой для экспорта
@msg select the art to export first сначала выделите объекты для экспорта
@msg choice must be "merge" or "add", not `{choice}` choice должно быть "merge" или "add", а не `{choice}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -651,6 +651,13 @@ font-technology Variable Variabla
Create Report Skapa rapport
Create Slice Skapa skiva
Create Sub-folders Skapa undermappar
Clear Selection Rensa markering
Export PDFs As: Exportera PDF-filer som:
Single File En fil
Multiple Files Flera filer
Format Presets Formatförinställningar
Grid View Rutnätsvy
Selected: {n}, Total number of files to export: {m} Markerade: {n}, totalt antal filer att exportera: {m}
Create Text Skapa text
Create Trim Marks Skapa skärmärken
Created Skapad
@@ -3979,6 +3986,7 @@ axis Both Båda
@msg cannot split an open path at its end point kan inte dela en öppen bana vid dess slutpunkt
@msg closing needs three anchors att sluta banan kräver tre ankarpunkter
@msg check an asset to export kryssa för en resurs att exportera
@msg check an artboard to export kryssa för en rityta att exportera
@msg check a layer to export markera ett lager att exportera
@msg select the art to export first markera först teckningen som ska exporteras
@msg choice must be "merge" or "add", not `{choice}` choice måste vara "merge" eller "add", inte `{choice}`
Can't render this file because it contains an unexpected character in line 5 and column 25.
+8
View File
@@ -643,6 +643,13 @@ font-technology Variable Değişken
Create Report Rapor Oluştur
Create Slice Dilim Oluştur
Create Sub-folders Alt Klasörler Oluşturun
Clear Selection Seçimi Temizle
Export PDFs As: PDF'leri Şu Şekilde Dışa Aktar:
Single File Tek Dosya
Multiple Files Birden Fazla Dosya
Format Presets Biçim Ön Ayarları
Grid View Izgara Görünümü
Selected: {n}, Total number of files to export: {m} Seçilen: {n}, dışa aktarılacak toplam dosya sayısı: {m}
Create Text Metin Oluştur
Create Trim Marks Kesim İşaretleri Oluşturun
Created Oluşturuldu
@@ -3848,6 +3855,7 @@ axis Both Her İkisi
@msg cannot split an open path at its end point açık bir yolu bitiş noktasında bölemez
@msg closing needs three anchors Kapanış üç çapaya ihtiyaç duyar
@msg check an asset to export dışa aktarılacak bir varlığı kontrol etme
@msg check an artboard to export dışa aktarılacak bir çalışma yüzeyi işaretleyin
@msg check a layer to export dışa aktarılacak katmanı kontrol edin
@msg select the art to export first önce dışa aktarılacak resmi seçin
@msg choice must be "merge" or "add", not `{choice}` seçim "{choice}" değil "birleştir" veya "ekle" olmalıdır
Can't render this file because it contains an unexpected character in line 5 and column 25.
+8
View File
@@ -657,6 +657,13 @@ font-technology Variable Варіативні
Create Report Створити звіт
Create Slice Створити фрагмент
Create Sub-folders Створити вкладені папки
Clear Selection Зняти виділення
Export PDFs As: Експортувати PDF як:
Single File Один файл
Multiple Files Кілька файлів
Format Presets Набори форматів
Grid View Сітка
Selected: {n}, Total number of files to export: {m} Вибрано: {n}, усього файлів для експорту: {m}
Create Text Створити текст
Create Trim Marks Створити мітки підрізування
Created Створено
@@ -4092,6 +4099,7 @@ axis Both Обидві
@msg cannot split an open path at its end point не можна розділити відкритий контур у його кінцевій точці
@msg closing needs three anchors для замикання потрібні три опорні точки
@msg check an asset to export позначте ресурс для експорту
@msg check an artboard to export позначте монтажну область для експорту
@msg check a layer to export позначте шар для експорту
@msg select the art to export first спочатку виділіть об’єкти для експорту
@msg choice must be "merge" or "add", not `{choice}` choice має бути "merge" або "add", а не `{choice}`
Can't render this file because it contains an unexpected character in line 178 and column 5.
+8
View File
@@ -760,6 +760,13 @@ font-technology Variable Biến thiên
Create Report Tạo báo cáo
Create Slice Tạo lát cắt
Create Sub-folders Tạo thư mục con
Clear Selection Xóa lựa chọn
Export PDFs As: Xuất PDF dưới dạng:
Single File Một tệp
Multiple Files Nhiều tệp
Format Presets Thiết lập sẵn định dạng
Grid View Chế độ lưới
Selected: {n}, Total number of files to export: {m} Đã chọn: {n}, tổng số tệp sẽ xuất: {m}
Create Text Tạo văn bản
Create Trim Marks Tạo dấu cắt xén
Create: Tạo:
@@ -4274,6 +4281,7 @@ theme Appearance Giao diện
@msg cannot read PDF: {0} không thể đọc PDF: {0}
@msg cannot split an open path at its end point không thể tách đường dẫn mở tại điểm cuối
@msg check an asset to export hãy chọn một asset để xuất
@msg check an artboard to export hãy chọn một bảng vẽ để xuất
@msg check a layer to export hãy đánh dấu một lớp để xuất
@msg select the art to export first hãy chọn hình cần xuất trước
@msg choice must be "merge" or "add", not `{choice}` lựa chọn phải là "merge" hoặc "add", không phải `{choice}`
Can't render this file because it contains an unexpected character in line 200 and column 5.
+8
View File
@@ -608,6 +608,13 @@
Create Report 创建报告
Create Slice 创建切片
Create Sub-folders 创建子文件夹
Clear Selection 清除选择
Export PDFs As: PDF 导出为:
Single File 单个文件
Multiple Files 多个文件
Format Presets 格式预设
Grid View 网格视图
Selected: {n}, Total number of files to export: {m} 已选择:{n},要导出的文件总数:{m}
Create Text 创建文字
Create Trim Marks 创建裁切标记
Created 创建时间
@@ -4148,6 +4155,7 @@ imageTrace Outline 轮廓
@msg nothing to export: the document has no visible art (or turn on Use Artboards) 没有可导出的内容:文档中没有可见的图稿 (或请打开“使用画板”)
@msg add a format to export 请添加要导出的格式
@msg check an asset to export 请勾选要导出的资源
@msg check an artboard to export 请勾选要导出的画板
@msg `{tool}` isn't a freehand tool `{tool}` 不是手绘工具
@msg bad colour `{hex}` (#rrggbb) 颜色 `{hex}` 无效 (#rrggbb)
@msg no gradient stop or freeform point is selected 未选择渐变色标或任意形状点
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -625,6 +625,13 @@ font-technology Variable 可變
Create Report 建立報告
Create Slice 建立切片
Create Sub-folders 建立子資料夾
Clear Selection 清除選取
Export PDFs As: PDF 轉存為:
Single File 單一檔案
Multiple Files 多個檔案
Format Presets 格式預設集
Grid View 格點檢視
Selected: {n}, Total number of files to export: {m} 已選取:{n},要轉存的檔案總數:{m}
Create Text 建立文字
Create Trim Marks 建立剪裁標記
Created 建立時間
@@ -2088,6 +2095,7 @@ file-filter Templates 範本
@msg nothing to export: the document has no visible art (or turn on Use Artboards) 沒有可匯出的內容:文件中沒有可見的圖稿 (或請開啟「使用工作區域」)
@msg add a format to export 請新增要匯出的格式
@msg check an asset to export 請勾選要匯出的資產
@msg check an artboard to export 請勾選要匯出的工作區域
@msg `{tool}` isn't a freehand tool `{tool}` 不是手繪工具
@msg bad colour `{hex}` (#rrggbb) 顏色 `{hex}` 無效 (#rrggbb)
@msg no gradient stop or freeform point is selected 未選取漸層色標或任意形狀點
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -285,6 +285,8 @@ fn report_exported(app: &mut VectorcraftApp, r: Value, folder: &str, open_locati
}
app.status(status);
} else if let Some(download) = app.services.download.as_mut() {
// A browser download has no folders: a file from a sub-folder (`2x/Logo@2x.png`) is named alone.
let returned: Vec<(&str, &str)> = returned.iter().map(|(name, data)| (name.rsplit('/').next().unwrap_or(name), *data)).collect();
for (name, data) in &returned {
let bytes = vectorcraft_format::base64_decode(data).ok_or("the export returned unreadable data")?;
download(name, &bytes)?;
+1 -1
View File
@@ -263,7 +263,7 @@ fn settings(ui: &mut Ui, st: &DocState) -> (Option<Settings>, usize) {
let preset = preset.unwrap_or_default().to_string();
let n = rows.len();
let mut d = Dialog::new(EXPORT_FOR_SCREENS, json!({ "formats": &rows, "preset": preset, "__settings": "" }));
screen_formats(ui, &mut d);
screen_formats(ui, &mut d, None);
let (shown, picked) = (d.str("__settings"), d.str("preset"));
let formats = d.fields.remove("formats").unwrap_or(Value::Null);
let change = if !shown.is_empty() {
@@ -209,7 +209,7 @@ fn the_rows_are_the_shared_export_settings() {
// A preset set by an agent shows as its rows.
app.run("assets.settings.set", json!({"preset": "mobile"})).unwrap();
let texts = frame(&ctx, &mut app, vec![], false);
assert!(has(&texts, "Mobile 1x/2x/3x") && has(&texts, "@3x"));
assert!(has(&texts, "iOS") && has(&texts, "@3x"));
// Export (nothing selected: every asset) downloads one zip on the web.
let texts = frame(&ctx, &mut app, vec![], false);
frame(&ctx, &mut app, click(at(&texts, "Export")), false);
+3 -2
View File
@@ -3987,8 +3987,9 @@ each file deflated, or stored when deflating does not make it smaller.
A row is `{format, scale?}` where `scale` is a factor (`2`, `"2x"`), a pixel width (`"100w"` or `width: 100`), a
height (`"100h"`) or a resolution (`"72ppi"`); raster rows name their files `@2x`, `@100w`… unless `suffix` says
otherwise. `fullDocument` writes one file per row instead (a PDF of every artboard, other formats the bounds of all
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
art), `includeBleed` grows each artboard by the document's bleed, `pdfs: "single"` (Export PDFs As › Single file)
writes the artboards a PDF row exports as the pages of one PDF named after the document, `subfolders` puts each
row's files in a sub-folder (its size or format, or its own `folder`; `folders: "format"` sorts by format instead), `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. 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
+3 -2
View File
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Artwork Cleanup add-on: Join Point Text, #4826; scores unchanged; previously: G13: Direct Selection snaps a dragged line end square to straight paths and grazing curves; Path Precision Tools add-on: Shared Edge Merge Tool; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Export for Screens laid out as Illustrator's, with Export PDFs As and sub-folders by scale or format; previously: Artwork Cleanup add-on: Join Point Text, #4826; scores unchanged; previously: G13: Direct Selection snaps a dragged line end square to straight paths and grazing curves; Path Precision Tools add-on: Shared Edge Merge Tool; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
## Feature submission evidence
@@ -19,6 +19,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| Date | Change | Summary |
|---|---|---|
| 2026-10-31 | minor | Export for Screens is laid out as Illustrator's: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix under them, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File or Multiple Files (`pdfs`), iOS and Android rows, the format presets in a ☰ menu, and a Selected / files-to-export count; the naming pattern, Layers and Selection pages and folders list stay in `document.exportForScreens` for scripts and MCP; scores unchanged. |
| 2026-10-31 | minor | Artwork Cleanup add-on: Join Point Text (`addon.cleanup.joinText`) joins stacked one-line point type into one object, a paragraph per line keeping its baseline (#4826); scores unchanged. |
| 2026-10-31 | minor | Width tool: a stroke with a width profile keeps its shape while the view zooms out; the minimum visible width goes to its widest point instead of scaling the whole profile (#995); scores unchanged. |
| 2026-10-31 | minor | Patterns: Grid Repeat Options has a Preview checkbox, as the public grid options page lists (#2651); scores unchanged. |
@@ -542,7 +543,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Batch…: Override Save and Override Export name each file with the extension of the format the step writes, however the step spells the format (`.svg` and `SVG` give `.svg`, `png8` gives `.png`), in `action.batch`.
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
- Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used.
- Export for Screens: the dialog's Example line shows the path of the file the export writes first, sub-folder included, named as the export names it, for the first chosen artboard, asset, layer or object (`document.exportForScreens`).
- Export for Screens is laid out as Illustrator'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, 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`).
- 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.
+2 -1
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Properties: Blend Options in Quick Actions for one selected blend with any tool, #4817; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Export for Screens laid out as Illustrator's; previously: Properties: Blend Options in Quick Actions for one selected blend with any tool, #4817; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
## Feature submission evidence
@@ -376,6 +376,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 2026-10-31 | minor | Export for Screens is laid out as Illustrator's: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix under them, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File or Multiple Files (`pdfs`), iOS and Android rows, the format presets in a ☰ menu, and a Selected / files-to-export count; the naming pattern, Layers and Selection pages and folders list stay in `document.exportForScreens` for scripts and MCP; scores unchanged |
| 2026-10-31 | minor | Properties: Quick Actions has Blend Options for one selected blend with any tool, not only the Blend tool, as the blending page describes (#4817); scores unchanged |
| 2026-10-31 | minor | Smart guides and snapping: Snap to Perpendicular and Snap to Tangent for an existing line's end dragged with Direct Selection (#2530), so the tangent and perpendicular row is done; scores and estimates unchanged |
| 2026-10-31 | minor | Grid Repeat Options has a Preview checkbox (#2651); scores and estimates unchanged |