mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 18:27:02 -06:00
Merge pull request #2839 from dotCooCoo/fix/export-for-screens-example-name
Export for Screens: the dialog's example names the file the export writes
This commit is contained in:
@@ -172,7 +172,7 @@ fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom::
|
||||
|
||||
/// The selected objects (their top-most selected ancestors) that are artwork: those on template
|
||||
/// layers are guides and are left out.
|
||||
pub(crate) fn selected_artwork(s: &mut Session) -> Result<Vec<NodeId>> {
|
||||
pub(crate) fn selected_artwork(s: &Session) -> Result<Vec<NodeId>> {
|
||||
let ids = edit::selected_roots(s)?;
|
||||
let st = s.doc()?;
|
||||
let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. }));
|
||||
|
||||
@@ -75,7 +75,7 @@ pub use save::{
|
||||
};
|
||||
pub use screens::{
|
||||
PRESET_KEYS as SCREEN_EXPORT_PRESET_KEYS, PRESETS as SCREEN_PRESETS, ScreenExportPreset, ScreenSize,
|
||||
builtin_presets as screen_export_builtin_presets, preset_rows as screen_preset_rows,
|
||||
builtin_presets as screen_export_builtin_presets, first_file as screen_first_file, preset_rows as screen_preset_rows,
|
||||
};
|
||||
pub(crate) use screens::{
|
||||
SHARED_KEYS as SCREEN_SETTINGS_KEYS, check_settings as check_screen_settings, export as export_screens, safe_file_name,
|
||||
|
||||
@@ -18,7 +18,7 @@ use vectorcraft_geom::Rect;
|
||||
|
||||
use super::super::*;
|
||||
use super::encode::single_artboard;
|
||||
use super::export::{isolated, selected_artwork, without_artboards};
|
||||
use super::export::{export_crop, isolated, selected_artwork, without_artboards};
|
||||
use super::naming::{self, NameParts};
|
||||
use super::{
|
||||
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, distinct_names, encode_with_warnings, unique_file_names,
|
||||
@@ -342,6 +342,97 @@ impl ScreenParams {
|
||||
fn of(p: &Value) -> Result<Self> {
|
||||
if p.is_object() { Self::deserialize(p).map_err(|e| bad(C, e.to_string())) } else { Ok(Self::default()) }
|
||||
}
|
||||
|
||||
/// An error when more than one of `assets`, `layers`, `selection` and `fullDocument` is given.
|
||||
fn one_source(&self) -> Result<()> {
|
||||
let sources = [self.full_document, self.assets.is_some(), self.layers.is_some(), self.selection.is_some()].iter().filter(|b| **b).count();
|
||||
if sources > 1 {
|
||||
return Err(bad(C, "give one of assets, layers, selection or fullDocument"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `selection`: `None` without it, `Some(false)` for `one` file, `Some(true)` for `each` object.
|
||||
fn selection_mode(&self) -> Result<Option<bool>> {
|
||||
match self.selection.as_deref() {
|
||||
None => Ok(None),
|
||||
Some("one") => Ok(Some(false)),
|
||||
Some("each") => Ok(Some(true)),
|
||||
Some(m) => Err(bad(C, format!("selection `{m}`: one or each"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a batch export names its files and sorts them into folders.
|
||||
struct Naming<'a> {
|
||||
/// The `name` pattern, when it holds more than spaces.
|
||||
pattern: Option<&'a str>,
|
||||
/// The `prefix`, through [`file_part`].
|
||||
prefix: String,
|
||||
/// The document's name, safe as a file name (`<filename>`, the Full Document's item).
|
||||
filename: String,
|
||||
/// The date and time the pattern's tokens use.
|
||||
when: [i64; 6],
|
||||
counter_start: u64,
|
||||
/// The counter counts files rather than items.
|
||||
per_file: bool,
|
||||
folders: Folders,
|
||||
}
|
||||
|
||||
impl<'a> Naming<'a> {
|
||||
/// The naming of params `o` for a document titled `title`.
|
||||
fn of(o: &'a ScreenParams, title: &str) -> Result<Self> {
|
||||
let folders = Folders::of(o)?;
|
||||
// A prefix names files, not folders.
|
||||
let prefix = file_part(o.prefix.as_deref().unwrap_or(""));
|
||||
let when = match o.date.as_deref() {
|
||||
Some(d) => naming::parse_date(d).ok_or_else(|| bad(C, format!("date `{d}`: YYYY-MM-DD")))?,
|
||||
None => vectorcraft_doc::metadata::now_unix().map_or([1970, 1, 1, 0, 0, 0], vectorcraft_doc::metadata::civil),
|
||||
};
|
||||
let per_file = match o.counter_per.as_deref() {
|
||||
None | Some("item") => false,
|
||||
Some("file") => true,
|
||||
Some(c) => return Err(bad(C, format!("counterPer `{c}`: item or file"))),
|
||||
};
|
||||
let filename = safe_name(&super::file_stem(title)).unwrap_or_else(|| "Untitled".into());
|
||||
let pattern = o.name.as_deref().filter(|n| !n.trim().is_empty());
|
||||
Ok(Self { pattern, prefix, filename, when, counter_start: o.counter_start.unwrap_or(1), per_file, folders })
|
||||
}
|
||||
|
||||
/// The sub-folder (`None` for the export folder itself) and the file name of the file row `r`
|
||||
/// writes of `item`, the `index`th item (from 0), when `files` files came before it.
|
||||
fn file(&self, item: &Item, index: usize, files: u64, r: &Row) -> (Option<String>, String) {
|
||||
let counter = self.counter_start.saturating_add(if self.per_file { files } else { index as u64 });
|
||||
let ext = r.format.extensions.first().copied().unwrap_or(r.format.id);
|
||||
let prefix = &self.prefix;
|
||||
let stem = match self.pattern {
|
||||
Some(pattern) => {
|
||||
let parts = NameParts {
|
||||
filename: &self.filename,
|
||||
name: &item.name,
|
||||
index: index + 1,
|
||||
counter,
|
||||
format: ext,
|
||||
scale: &if r.format.raster { r.size.label() } else { String::new() },
|
||||
suffix: &r.suffix,
|
||||
when: self.when,
|
||||
};
|
||||
let named = naming::render(pattern, &parts);
|
||||
let named = if named.is_empty() { item.stem.clone() } else { named };
|
||||
if naming::has_suffix_token(pattern) { format!("{prefix}{named}") } else { format!("{prefix}{named}{}", r.suffix) }
|
||||
}
|
||||
None => format!("{prefix}{}{}", item.stem, r.suffix),
|
||||
};
|
||||
let file = safe_file_name(&format!("{stem}.{ext}"));
|
||||
let sub = match self.folders {
|
||||
Folders::None => None,
|
||||
Folders::Rows => Some(safe_file_name(&r.folder)),
|
||||
Folders::Format => Some(ext.to_string()),
|
||||
Folders::Item => Some(item.stem.clone()),
|
||||
Folders::ItemFormat => Some(format!("{}/{ext}", item.stem)),
|
||||
};
|
||||
(sub, file)
|
||||
}
|
||||
}
|
||||
|
||||
/// The format rows `p` asks for (`formats`, else `preset`'s, else one PNG at 1x), each with the
|
||||
@@ -566,36 +657,15 @@ pub(super) fn presets_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let o = ScreenParams::of(p)?;
|
||||
let rows = rows_of(s, p, o.preset.as_deref())?;
|
||||
let sources = [o.full_document, o.assets.is_some(), o.layers.is_some(), o.selection.is_some()].iter().filter(|b| **b).count();
|
||||
if sources > 1 {
|
||||
return Err(bad(C, "give one of assets, layers, selection or fullDocument"));
|
||||
}
|
||||
let folders = Folders::of(&o)?;
|
||||
// A prefix names files, not folders.
|
||||
let prefix = file_part(o.prefix.as_deref().unwrap_or(""));
|
||||
o.one_source()?;
|
||||
let naming = Naming::of(&o, &s.doc()?.doc.title)?;
|
||||
let folder = o.folder.as_deref().filter(|f| !f.is_empty());
|
||||
let when = match o.date.as_deref() {
|
||||
Some(d) => naming::parse_date(d).ok_or_else(|| bad(C, format!("date `{d}`: YYYY-MM-DD")))?,
|
||||
None => vectorcraft_doc::metadata::now_unix().map_or([1970, 1, 1, 0, 0, 0], vectorcraft_doc::metadata::civil),
|
||||
};
|
||||
let per_file = match o.counter_per.as_deref() {
|
||||
None | Some("item") => false,
|
||||
Some("file") => true,
|
||||
Some(c) => return Err(bad(C, format!("counterPer `{c}`: item or file"))),
|
||||
};
|
||||
let counter_start = o.counter_start.unwrap_or(1);
|
||||
let selection = match o.selection.as_deref() {
|
||||
None => None,
|
||||
Some("one") => Some(false),
|
||||
Some("each") => Some(true),
|
||||
Some(m) => return Err(bad(C, format!("selection `{m}`: one or each"))),
|
||||
};
|
||||
let selection = o.selection_mode()?;
|
||||
let selected = if selection.is_some() { selected_artwork(s)? } else { vec![] };
|
||||
if let Some(dir) = folder {
|
||||
create_dir(dir)?;
|
||||
}
|
||||
let doc = s.doc()?.doc.clone();
|
||||
let filename = safe_name(&super::file_stem(&doc.title)).unwrap_or_else(|| "Untitled".into());
|
||||
// Include Bleed: each artboard grown by the document's bleed.
|
||||
let doc = if o.include_bleed && doc.setup.has_bleed() {
|
||||
let mut d = (*doc).clone();
|
||||
@@ -607,7 +677,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
doc
|
||||
};
|
||||
let crop = rows.iter().any(|r| r.format.id != "pdf");
|
||||
let items = items(&doc, &o, selection, &selected, &filename, crop)?;
|
||||
let items = items(&doc, &o, selection, &selected, &naming.filename, crop, true)?;
|
||||
let mut out = Output::default();
|
||||
// The encoders' warnings for every file, each once.
|
||||
let mut warnings: Vec<String> = Vec::new();
|
||||
@@ -626,34 +696,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut files = 0u64;
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
for r in &rows {
|
||||
let counter = counter_start.saturating_add(if per_file { files } else { index as u64 });
|
||||
let ext = r.format.extensions.first().copied().unwrap_or(r.format.id);
|
||||
let stem = match o.name.as_deref().filter(|n| !n.trim().is_empty()) {
|
||||
Some(pattern) => {
|
||||
let parts = NameParts {
|
||||
filename: &filename,
|
||||
name: &item.name,
|
||||
index: index + 1,
|
||||
counter,
|
||||
format: ext,
|
||||
scale: &if r.format.raster { r.size.label() } else { String::new() },
|
||||
suffix: &r.suffix,
|
||||
when,
|
||||
};
|
||||
let named = naming::render(pattern, &parts);
|
||||
let named = if named.is_empty() { item.stem.clone() } else { named };
|
||||
if naming::has_suffix_token(pattern) { format!("{prefix}{named}") } else { format!("{prefix}{named}{}", r.suffix) }
|
||||
}
|
||||
None => format!("{prefix}{}{}", item.stem, r.suffix),
|
||||
};
|
||||
let file = safe_file_name(&format!("{stem}.{ext}"));
|
||||
let sub = match folders {
|
||||
Folders::None => None,
|
||||
Folders::Rows => Some(safe_file_name(&r.folder)),
|
||||
Folders::Format => Some(ext.to_string()),
|
||||
Folders::Item => Some(item.stem.clone()),
|
||||
Folders::ItemFormat => Some(format!("{}/{ext}", item.stem)),
|
||||
};
|
||||
let (sub, file) = naming.file(item, index, files, r);
|
||||
let name = sub.map_or_else(|| file.clone(), |d| format!("{d}/{file}"));
|
||||
let Some(name) = out.claim(&name, &format!("{}|{}", item.stem, r.key), o.name.is_some()) else { continue };
|
||||
let bytes = if let (Some(all), "pdf") = (&item.every_artboard, r.format.id) {
|
||||
@@ -675,7 +718,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let names: Vec<&str> = out.kept.iter().map(|(n, _)| n.as_str()).collect();
|
||||
if o.zip {
|
||||
let zip = super::zip::store(&out.kept).map_err(|e| bad(C, e))?;
|
||||
let zip_name = safe_file_name(&format!("{prefix}{filename}.zip"));
|
||||
let zip_name = safe_file_name(&format!("{}{}.zip", naming.prefix, naming.filename));
|
||||
return match folder {
|
||||
Some(dir) => {
|
||||
let path = format!("{dir}/{zip_name}");
|
||||
@@ -697,12 +740,52 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The path, relative to the export folder, of the first file `document.exportForScreens` with
|
||||
/// params `p` writes (sub-folders included: `2x/Logo@2x.png`): the first format row's file of the
|
||||
/// first artboard, asset, layer or selected object, or of the whole document, named as [`export`]
|
||||
/// names it. It encodes and writes nothing and cuts no art out of the document: of the objects
|
||||
/// selected for a file each, it measures only the first ones, up to the first that draws something.
|
||||
/// It does not refuse what the export refuses for drawing nothing: a checked asset or layer, the
|
||||
/// selection as one file, or the Full Document with no visible art.
|
||||
/// The Export for Screens dialog shows it on its Example line.
|
||||
pub fn first_file(s: &Session, p: &Value) -> Result<String> {
|
||||
let p = with_preset(s, p)?;
|
||||
let o = ScreenParams::of(&p)?;
|
||||
let rows = rows_of(s, &p, o.preset.as_deref())?;
|
||||
o.one_source()?;
|
||||
let selection = o.selection_mode()?;
|
||||
let doc = &s.doc()?.doc;
|
||||
let mut selected = if selection.is_some() { selected_artwork(s)? } else { vec![] };
|
||||
// A file per object: objects that draw nothing write no file.
|
||||
if selection == Some(true) {
|
||||
selected = selected.into_iter().find(|id| export_crop(doc, &[*id]).is_some()).into_iter().collect();
|
||||
}
|
||||
let naming = Naming::of(&o, &doc.title)?;
|
||||
let items = items(doc, &o, selection, &selected, &naming.filename, false, false)?;
|
||||
let (Some(item), Some(r)) = (items.first(), rows.first()) else { return Err(bad(C, "nothing to export")) };
|
||||
let (sub, file) = naming.file(item, 0, 0, r);
|
||||
Ok(sub.map_or_else(|| file.clone(), |d| format!("{d}/{file}")))
|
||||
}
|
||||
|
||||
/// What `o` exports a file of per row ([`Item`]): the whole document, the assets, the layers, the
|
||||
/// selection (one item, or one per object), else the chosen artboards.
|
||||
/// `crop`: some row needs the Full Document's art bounds (a format other than PDF).
|
||||
fn items(doc: &Arc<Document>, o: &ScreenParams, selection: Option<bool>, selected: &[NodeId], filename: &str, crop: bool) -> Result<Vec<Item>> {
|
||||
/// `crop`: some row needs the Full Document's art bounds (a format other than PDF). `art`: each
|
||||
/// asset, layer and selected object is cut out of the document; without it, only the items' names
|
||||
/// are made, every item holds the whole document, and none is left out for drawing nothing.
|
||||
fn items(
|
||||
doc: &Arc<Document>,
|
||||
o: &ScreenParams,
|
||||
selection: Option<bool>,
|
||||
selected: &[NodeId],
|
||||
filename: &str,
|
||||
crop: bool,
|
||||
art: bool,
|
||||
) -> Result<Vec<Item>> {
|
||||
// The art of `ids` alone, as an item named `name`.
|
||||
let alone = |ids: &[NodeId], name: &str| -> Option<(Arc<Document>, Rect)> {
|
||||
if !art {
|
||||
return Some((doc.clone(), Rect::ZERO));
|
||||
}
|
||||
isolated(doc, &doc.paint_order(ids.iter().copied()), name).map(|(d, r)| (Arc::new(d), r))
|
||||
};
|
||||
// Each item's stem follows Windows' file-naming rules, and no two items share one.
|
||||
|
||||
@@ -146,6 +146,63 @@ fn device_names_and_trailing_periods_follow_windows_naming_rules() {
|
||||
assert_eq!(r["name"], "nul-.zip");
|
||||
}
|
||||
|
||||
/// `first_file` names the file the export writes first, for the same params: the item's file name,
|
||||
/// a prefix and a suffix with characters Windows does not allow, a range, a Windows device name,
|
||||
/// every kind of folder, presets, size suffixes, a name pattern with its counter and date, an
|
||||
/// export preset, a ZIP, Full Document, Include Bleed, assets, layers and the selection; and it
|
||||
/// refuses what the export refuses.
|
||||
#[test]
|
||||
fn the_first_file_is_the_file_the_export_writes_first() {
|
||||
let mut s = session(3);
|
||||
s.execute("artboard.setProps", &json!({"index": 1, "name": "CON"})).unwrap();
|
||||
let layer = s.doc().unwrap().doc.layers[0].id.0;
|
||||
let square = s.doc().unwrap().doc.layers[0].children().unwrap()[0].id.0;
|
||||
let asset = s.execute("assets.add", &json!({"ids": [square], "name": "Logo?"})).unwrap()["assets"][0].as_u64().unwrap();
|
||||
s.execute("shape.ellipse", &json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let written = |r: &Value| r["files"][0].as_str().or_else(|| r["files"][0]["name"].as_str()).unwrap_or_default().to_string();
|
||||
let cases = [
|
||||
json!({}),
|
||||
json!({"prefix": "a?b/", "formats": [{"format": "png", "suffix": "<x>"}]}),
|
||||
json!({"range": "2-3", "folders": "item"}),
|
||||
json!({"range": "2", "folders": "itemFormat", "formats": [{"format": "svg", "scale": "2x", "suffix": "@2x"}]}),
|
||||
json!({"preset": "mobile"}),
|
||||
json!({"preset": "density"}),
|
||||
json!({"subfolders": true, "formats": [{"format": "png", "scale": "144ppi", "folder": "a b"}]}),
|
||||
json!({"folders": "format", "formats": [{"format": "jpg", "quality": 50}]}),
|
||||
json!({
|
||||
"name": "<filename>-<name>-<scale>-<index>-###-YYYYMMDD",
|
||||
"counterStart": 7,
|
||||
"counterPer": "file",
|
||||
"date": "2026-10-17",
|
||||
"formats": [{"format": "png", "scale": "2x"}]
|
||||
}),
|
||||
json!({"name": "<suffix>", "formats": [{"format": "png", "suffix": ""}]}),
|
||||
json!({"exportPreset": "Logo Package"}),
|
||||
json!({"zip": true, "formats": [{"format": "svg"}, {"format": "png"}]}),
|
||||
json!({"fullDocument": true, "folders": "item", "formats": [{"format": "pdf"}]}),
|
||||
json!({"fullDocument": true, "formats": [{"format": "png"}]}),
|
||||
json!({"includeBleed": true}),
|
||||
json!({"assets": [asset], "formats": [{"format": "svg"}]}),
|
||||
json!({"layers": [layer], "folders": "item"}),
|
||||
json!({"selection": "one"}),
|
||||
json!({"selection": "each", "name": "<name>"}),
|
||||
];
|
||||
for p in cases {
|
||||
let first = super::screens::first_file(&s, &p).unwrap();
|
||||
assert_eq!(first, written(&export(&mut s, p.clone())), "{p}");
|
||||
}
|
||||
for p in [json!({"layers": [layer], "assets": [asset]}), json!({"selection": "all"}), json!({"counterPer": "row"})] {
|
||||
assert!(super::screens::first_file(&s, &p).is_err() && s.execute("document.exportForScreens", &p).is_err(), "{p}");
|
||||
}
|
||||
// A document named after a Windows device.
|
||||
s.execute("file.new", &json!({"width": 40, "height": 30, "name": "nul"})).unwrap();
|
||||
for p in [json!({"fullDocument": true, "folders": "item", "formats": [{"format": "pdf"}]}), json!({"name": "<filename>"})] {
|
||||
let first = super::screens::first_file(&s, &p).unwrap();
|
||||
assert_eq!(first, written(&export(&mut s, p.clone())), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn sub_folders_are_made_in_the_export_folder() {
|
||||
|
||||
@@ -296,6 +296,28 @@ fn checked_layers(app: &VectorcraftApp, d: &Dialog) -> Vec<u64> {
|
||||
/// 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> {
|
||||
let (mut p, files) = settings_params(app, d)?;
|
||||
if io::is_web(app) {
|
||||
p.insert("zip".into(), json!(files > 1));
|
||||
} else {
|
||||
let folder = d.str("folder");
|
||||
if folder.trim().is_empty() {
|
||||
return Err("choose a folder to export to".into());
|
||||
}
|
||||
p.insert("folder".into(), json!(folder.trim()));
|
||||
p.insert("openLocation".into(), json!(d.bool("openLocation")));
|
||||
}
|
||||
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 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() {
|
||||
@@ -363,21 +385,10 @@ fn params(app: &VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
if !name.trim().is_empty() {
|
||||
p.insert("name".into(), json!(name.trim()));
|
||||
}
|
||||
let start = 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);
|
||||
p.insert("counterStart".into(), json!(start));
|
||||
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!({})));
|
||||
if io::is_web(app) {
|
||||
p.insert("zip".into(), json!(files > 1));
|
||||
} else {
|
||||
let folder = d.str("folder");
|
||||
if folder.trim().is_empty() {
|
||||
return Err("choose a folder to export to".into());
|
||||
}
|
||||
p.insert("folder".into(), json!(folder.trim()));
|
||||
p.insert("openLocation".into(), json!(d.bool("openLocation")));
|
||||
}
|
||||
Ok(Value::Object(p))
|
||||
Ok((p, files))
|
||||
}
|
||||
|
||||
/// Export; the dialog stays open when the export fails.
|
||||
@@ -684,51 +695,21 @@ fn naming(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
widgets::dim_label(ui, tl!("Prefix:"));
|
||||
form::text(ui, d, "prefix", 110.0);
|
||||
widgets::dim_label(ui, tl!("Start at:"));
|
||||
let start = d.fields.get("counterStart").and_then(Value::as_u64).unwrap_or(1) as f64;
|
||||
if let Some(v) = widgets::range_field(ui, "efs-counter-start", start, 0.0..=999_999.0, "", 0, 64.0) {
|
||||
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);
|
||||
widgets::dim_label(ui, &crate::i18n::fmt(tl!("Example: {name}"), &[("name", &example)]));
|
||||
if !example.is_empty() {
|
||||
widgets::dim_label(ui, &crate::i18n::fmt(tl!("Example: {name}"), &[("name", &example)]));
|
||||
}
|
||||
}
|
||||
|
||||
/// The first file the export would write, as the folders and pattern name it.
|
||||
/// 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 {
|
||||
let Some(st) = app.session.active() else { return String::new() };
|
||||
let row = d.fields.get("formats").and_then(Value::as_array).and_then(|r| r.first()).cloned().unwrap_or(json!({}));
|
||||
let format = row["format"].as_str().and_then(fileio::format);
|
||||
let ext = format.and_then(|f| f.extensions.first().copied()).unwrap_or("png");
|
||||
let raster = format.is_none_or(|f| f.raster);
|
||||
let scale = if raster { row["scale"].as_str().unwrap_or("1x").to_string() } else { String::new() };
|
||||
let suffix = row["suffix"].as_str().unwrap_or_default().to_string();
|
||||
let item = match d.str("tab").as_str() {
|
||||
"assets" => checked_assets(app, d).first().and_then(|id| st.doc.asset(*id)).map(|a| a.name.clone()),
|
||||
"layers" => checked_layers(app, d).first().and_then(|id| st.doc.node(NodeId(*id))).map(|n| n.display_name()),
|
||||
"selection" => Some("Selection".to_string()),
|
||||
_ => st.doc.artboards.first().map(|a| a.name.clone()),
|
||||
}
|
||||
.unwrap_or_else(|| "Artboard 1".into());
|
||||
let filename = fileio::file_stem(&st.doc.title);
|
||||
let when = vectorcraft_doc::metadata::now_unix().map_or([1970, 1, 1, 0, 0, 0], vectorcraft_doc::metadata::civil);
|
||||
let start = d.fields.get("counterStart").and_then(Value::as_u64).unwrap_or(1);
|
||||
let pattern = d.str("name");
|
||||
let stem = if pattern.trim().is_empty() {
|
||||
format!("{item}{suffix}")
|
||||
} else {
|
||||
let parts =
|
||||
fileio::FileNameParts { filename: &filename, name: &item, index: 1, counter: start, format: ext, scale: &scale, suffix: &suffix, when };
|
||||
let named = fileio::render_file_name(&pattern, &parts);
|
||||
if pattern.contains("<suffix>") { named } else { format!("{named}{suffix}") }
|
||||
};
|
||||
let file = format!("{}{stem}.{ext}", d.str("prefix"));
|
||||
match d.str("folders").as_str() {
|
||||
"rows" => format!("{}/{file}", if raster { scale } else { ext.to_uppercase() }),
|
||||
"format" => format!("{ext}/{file}"),
|
||||
"item" => format!("{item}/{file}"),
|
||||
"itemFormat" => format!("{item}/{ext}/{file}"),
|
||||
_ => file,
|
||||
}
|
||||
settings_params(app, d).ok().and_then(|(p, _)| fileio::screen_first_file(&app.session, &Value::Object(p)).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The Layers tab: each top-level layer's thumbnail and checkbox (a file of its art alone),
|
||||
|
||||
@@ -567,6 +567,80 @@ fn tiff_dropdown_exports_scaled_artboards_with_options_and_reopens() {
|
||||
assert_eq!(field(&app, "formats")[0]["format"], "png");
|
||||
}
|
||||
|
||||
/// The Example line names the first file the export writes for the same settings: the item's file
|
||||
/// 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.
|
||||
#[test]
|
||||
fn the_example_names_the_first_file_the_export_writes() {
|
||||
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:?}"));
|
||||
}
|
||||
}
|
||||
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.
|
||||
#[test]
|
||||
fn the_example_needs_no_folder_and_start_at_shows_the_counter_used() {
|
||||
let (mut app, _) = app(1, false);
|
||||
open(&mut app);
|
||||
for (k, v) in [("folder", json!("")), ("name", json!("####")), ("counterStart", json!("7"))] {
|
||||
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]
|
||||
|
||||
@@ -433,6 +433,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, Save for Web (slice images, and files named after the document's title) and the Libraries panel name files with the characters Windows does not allow written as `-` and with a `-` after a Windows device name such as `CON`, on every platform (`document.exportForScreens`, `document.exportForWeb`, `library.create`).
|
||||
- Variables (data merge): text, visibility and image variables bound to objects (an object may carry several), dataset rows applied in one undo step (`variable.*`, `dataset.*`) from a Variables panel and Window › Variables, with dialogs to define, bind and edit, Make Text/Visibility/Image Dynamic, Capture/Update Data Set, CSV import/export (`dataset.import` / `dataset.export`), and a row in italics when an edit leaves the art not matching it; an image dataset value is a file path and apply keeps the object's bounds; bindings let go of deleted art and locked art is left alone; every command, like the rest, is drivable over MCP and the CLI.
|
||||
- Variables: Import Data Sets… (`dataset.import`) reads tab-separated files as well as comma-separated ones, and files without a `name` column, whose rows take the first free names Data Set 1, Data Set 2 and so on. An import error about a row's values or name gives the file line the row starts on.
|
||||
|
||||
Reference in New Issue
Block a user