M0.9: figcraft-cli export names files as the app's export does (#1154)

export into a folder replaced every character outside letters, digits,
'-', '_', space and '.' with '_', so a frame called
"Homepage - desktop (live + verbeteringen)" became
"Homepage - desktop _live _ verbeteringen_.png". The app's
file.exportLayers keeps those names and only replaces what file systems
refuse. The CLI now uses the engine's safe_file_name, and docs/cli.md
says which characters are replaced.

Also adds my own entry to contributors/people.toml.
This commit is contained in:
Gert Pellin
2026-10-22 21:22:20 +02:00
parent 9ec80bf2c2
commit ca30791fd7
5 changed files with 47 additions and 4 deletions
+10 -2
View File
@@ -207,9 +207,11 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
Ok(ExitCode::SUCCESS)
}
/// The file name a layer exports to: the app's rule ([`figcraft_engine::cmd::file::safe_file_name`],
/// so `/` becomes `_` too), without leading or trailing spaces and dots.
fn sanitize(name: &str) -> String {
let s: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | ' ' | '.') { c } else { '_' }).collect();
let s = s.trim().trim_matches('.').to_string();
let s = figcraft_engine::cmd::file::safe_file_name(name);
let s = s.trim().trim_matches('.').trim().to_string();
if s.is_empty() { "layer".into() } else { s.chars().take(100).collect() }
}
@@ -411,6 +413,12 @@ mod tests {
assert_eq!(sanitize("Home / Hero"), "Home _ Hero");
assert_eq!(sanitize("../.."), "_");
assert_eq!(sanitize(""), "layer");
// The same characters the app's export keeps (#1154): only what file systems refuse goes.
assert_eq!(sanitize("Homepage - desktop (live + verbeteringen)"), "Homepage - desktop (live + verbeteringen)");
assert_eq!(sanitize("Q&A, 50% #1 [v2] {x} ~é"), "Q&A, 50% #1 [v2] {x} ~é");
assert_eq!(sanitize("a\\b:c*d?e\"f<g>h|i\tj"), "a_b_c_d_e_f_g_h_i_j");
assert_eq!(sanitize(" .hidden. "), "hidden");
assert_eq!(sanitize(&"x".repeat(300)).chars().count(), 100);
}
#[test]
+24
View File
@@ -81,6 +81,30 @@ fn run_export_info_convert() {
std::fs::remove_dir_all(&d).ok();
}
/// `export` into a folder names the files as the app's export does (#1154): brackets, `+`, `&`,
/// `%`, `#` and commas stay, what file systems refuse becomes `_`.
#[test]
fn export_file_names_keep_what_the_app_keeps() {
let d = tmp("names");
let script = d.join("s.json");
let names = ["Homepage - desktop (live + verbeteringen)", "Q&A, 50% #1 [v2]", "a:b*c?d|e"];
let steps: Vec<serde_json::Value> = names
.iter()
.enumerate()
.map(|(i, n)| serde_json::json!({"command": "frame.create", "params": {"x": i * 200, "y": 0, "width": 20, "height": 20, "name": n}}))
.collect();
std::fs::write(&script, serde_json::to_string(&steps).unwrap()).unwrap();
let file = d.join("doc.figcraft");
assert!(bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap().status.success());
let dir = d.join("out");
let out = bin().arg("export").arg(&file).arg("-o").arg(&dir).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
for want in ["Homepage - desktop (live + verbeteringen).png", "Q&A, 50% #1 [v2].png", "a_b_c_d_e.png"] {
assert!(dir.join(want).is_file(), "{want} in {:?}", std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name()).collect::<Vec<_>>());
}
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn run_takes_the_documented_gradient_shorthand() {
// #252: only MCP used to accept it; `run` said "`fills` must be paints or colours".
+5
View File
@@ -71,3 +71,8 @@ sync_github_name = true
[people.AganzeFelicite]
sync_github_name = true
[people.switch87]
real_name = "Gert Pellin"
display_name = "Gert Pellin"
sync_github_name = false
+4 -2
View File
@@ -1169,8 +1169,10 @@ fn export_path(name: &str) -> String {
if parts.is_empty() { "Untitled".into() } else { parts.join("/") }
}
/// `name` without path separators and characters file systems reject.
fn safe_file_name(name: &str) -> String {
/// `name` without path separators and characters file systems reject (`/ \ : * ? " < > |` and
/// control characters become `_`); everything else is kept. `figcraft-cli export` names its files
/// by this rule too.
pub fn safe_file_name(name: &str) -> String {
name.chars().take(200).map(|c| if c.is_control() || "/\\:*?\"<>|".contains(c) { '_' } else { c }).collect()
}
+4
View File
@@ -51,6 +51,10 @@ page 1 "Screens": 380 nodes, 4 top-level
- `<out>` is a directory: one file per top-level frame of the page (default the first page), named
after the frame (`Home.png`, `Settings.png`…); with no frames, one file per top-level layer.
The name is kept as it is, except for the characters file systems refuse (`/ \ : * ? " < > |`
and control characters), which become `_`, and leading or trailing spaces and dots, which are
dropped. The app's export replaces the same characters (there a `/` makes a folder). Two layers
with the same name get `Name 2`, `Name 3`…
- `<out>` has an image extension: one file with the given `--node`, or the whole page composited.
- `--node` takes a layer id (`12` or `0:12`) or a layer name.
- `--format fig` (or an `<out>` ending in `.fig`) writes the whole file as `.fig`, experimental: