diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index c453fecd7..13ad41667 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -129,7 +129,9 @@ pub(crate) use access::{drive_share_path, share_path, touched}; pub(crate) use access::{may_read, may_write}; pub use commands::specs; pub use export_names::artboard_file_names; -pub(crate) use export_names::{default_name, merge, unique_file_names, with_black_output, write_or_return}; +pub(crate) use export_names::{ + artboard_file_names_keeping_dots, default_name, merge, unique_file_names_keeping_dots, with_black_output, write_or_return, +}; use export_names::{distinct_names_by, write_encoded}; use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, import_name, writable}; pub use formats::{ diff --git a/crates/engine/src/cmd/fileio/export_names.rs b/crates/engine/src/cmd/fileio/export_names.rs index af0e49563..5ff0add25 100644 --- a/crates/engine/src/cmd/fileio/export_names.rs +++ b/crates/engine/src/cmd/fileio/export_names.rs @@ -5,19 +5,41 @@ use super::{Encoded, EngineError, Result, Session, Value, json}; /// `-`, `Artboard-N` when unnamed, and `-2`, `-3`… after a name already used (compared without /// case: `Icon` and `icon` are one file on most desktop file systems). pub fn artboard_file_names(doc: &vectorcraft_doc::Document, boards: &[usize]) -> Vec { - unique_file_names(boards.iter().map(|&b| (doc.artboards.get(b).map_or("", |a| a.name.as_str()), format!("Artboard-{}", b + 1)))) + unique_file_names(artboard_names(doc, boards)) +} + +/// [`artboard_file_names`] with the dots of a name kept (`example.artboard`), for Export for Screens, +/// which puts every stem through `safe_file_name` after this (trailing periods, device names). +pub(crate) fn artboard_file_names_keeping_dots(doc: &vectorcraft_doc::Document, boards: &[usize]) -> Vec { + unique_file_names_keeping_dots(artboard_names(doc, boards)) +} + +/// Each of `boards`' name, with the file name to use when it has none. +fn artboard_names<'a>(doc: &'a vectorcraft_doc::Document, boards: &'a [usize]) -> impl Iterator { + boards.iter().map(|&b| (doc.artboards.get(b).map_or("", |a| a.name.as_str()), format!("Artboard-{}", b + 1))) } /// File-name parts for `(name, fallback)` pairs (artboards, assets): the name with unsafe /// characters as `-`, the fallback when it is empty, and `-2`, `-3`… after a name already used /// (compared without case). -pub(crate) fn unique_file_names<'a>(names: impl IntoIterator) -> Vec { +fn unique_file_names<'a>(names: impl IntoIterator) -> Vec { distinct_names(names.into_iter().map(|(name, fallback)| { let base: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect(); if base.is_empty() { fallback } else { base } })) } +/// [`unique_file_names`] keeping `.` in a name (an artboard called `example.artboard` exports as +/// `example.artboard.png`, #6143). A name of only dots (`.` and `..` name other folders) takes the +/// fallback; the caller still runs each stem through `safe_file_name`, which drops trailing periods +/// and guards Windows device names. +pub(crate) fn unique_file_names_keeping_dots<'a>(names: impl IntoIterator) -> Vec { + distinct_names(names.into_iter().map(|(name, fallback)| { + let base: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '-' }).collect(); + if base.trim_matches('.').is_empty() { fallback } else { base } + })) +} + /// `names` with `-2`, `-3`… after a name already used (compared without case). pub(super) fn distinct_names(names: impl IntoIterator) -> Vec { distinct_names_by(names, str::to_lowercase) diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index b69cfd177..803109943 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -21,8 +21,8 @@ use super::encode::single_artboard; use super::export::{export_crop, isolated, selected_artwork, without_artboards}; use super::naming::{self, NameParts}; use super::{ - ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, distinct_names_by, encode_with_warnings, merge, unique_file_names, - with_black_output, writable, write_file, + ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names_keeping_dots, create_dir, distinct_names_by, encode_with_warnings, merge, + unique_file_names_keeping_dots, with_black_output, writable, write_file, }; const C: &str = "document.exportForScreens"; @@ -1184,7 +1184,7 @@ fn items( // the item folders are (`fold`). let safe = |stems: Vec| distinct_names_by(stems.iter().map(|s| safe_file_name(s)), fold); let named = |pieces: Vec<(String, Arc, Option, Rect)>, fallback: &str| -> Vec { - let stems = safe(unique_file_names(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string())))); + let stems = safe(unique_file_names_keeping_dots(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string())))); pieces .into_iter() .zip(stems) @@ -1261,7 +1261,7 @@ fn items( if boards.is_empty() { return Err(bad(C, "the document has no artboard")); } - let stems = safe(artboard_file_names(doc, &boards)); + let stems = safe(artboard_file_names_keeping_dots(doc, &boards)); Ok(boards .into_iter() .zip(stems) diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs index 64dd14dcf..ff2358aa0 100644 --- a/crates/engine/src/cmd/fileio/tests_screens.rs +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -148,6 +148,18 @@ fn device_names_and_trailing_periods_follow_windows_naming_rules() { assert_eq!(r["name"], "nul-.zip"); } +/// A dot in an artboard's name stays a dot (`example.artboard.png`, #6143); a name of only dots, or +/// ending in one, still can't name another folder or end in a period, and `/` and `:` are dashes. +#[test] +fn an_artboard_name_keeps_its_dots() { + let mut s = session(5); + for (index, name) in ["example.artboard", "v1.2.final", "..", "a/b:c", "trail."].iter().enumerate() { + s.execute("artboard.setProps", &json!({"index": index, "name": name})).unwrap(); + } + let r = export(&mut s, json!({"formats": [{"format": "png"}]})); + assert_eq!(names(&r), ["example.artboard.png", "v1.2.final.png", "Artboard-3.png", "a-b-c.png", "trail.png"]); +} + /// `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