From 4682c9dd755c791f06271911913fed2a522ec04e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 18:03:47 +0000 Subject: [PATCH 1/4] fix(cli,ui): escape control characters in document and plugin text sent to the terminal --- apps/figcraft-cli/src/files.rs | 22 +++++++++++++++------- crates/ui-egui/src/app/commands.rs | 3 ++- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 29f8cd5b6..c4cc7d5f6 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -199,26 +199,33 @@ pub fn info(path: &str, as_json: bool) -> CliResult { return Ok(ExitCode::SUCCESS); } for n in ¬es { - eprintln!("note: {n}"); + eprintln!("note: {}", n.escape_debug()); } println!( "{} — {} ({} nodes, {} styles, {} variables, {} images)", - path, - doc.name, + path.escape_debug(), + doc.name.escape_debug(), doc.node_count(), doc.styles.len(), doc.variables.variables.len(), doc.images.len() ); for p in &pages { - println!("page {} \"{}\": {} nodes, {} top-level", p["id"], p["name"].as_str().unwrap_or(""), p["nodes"], p["topLevel"]); + println!("page {} \"{}\": {} nodes, {} top-level", p["id"], p["name"].as_str().unwrap_or("").escape_debug(), p["nodes"], p["topLevel"]); for f in p["frames"].as_array().into_iter().flatten() { - println!(" {} {} \"{}\" {}×{}", f["id"], f["type"].as_str().unwrap_or(""), f["name"].as_str().unwrap_or(""), f["width"], f["height"]); + println!( + " {} {} \"{}\" {}×{}", + f["id"], + f["type"].as_str().unwrap_or(""), + f["name"].as_str().unwrap_or("").escape_debug(), + f["width"], + f["height"] + ); } } for m in &missing { let layers = if m.layers == 1 { "text layer" } else { "text layers" }; - println!("missing font \"{}\": {} {layers} (drawn in a fallback font)", m.family, m.layers); + println!("missing font \"{}\": {} {layers} (drawn in a fallback font)", m.family.escape_debug(), m.layers); } Ok(ExitCode::SUCCESS) } @@ -438,7 +445,8 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe let cmd = steps.get(failed_at).map_or("", |step| step.command.as_str()); let i = failed_at; println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true})); - eprintln!("error: step {i} ({cmd}) failed: {e}"); + // Plugin-supplied text: escape terminal controls and line breaks. + eprintln!("error: step {i} ({}) failed: {}", cmd.escape_debug(), e.to_string().escape_debug()); eprintln!("the script's document changes were rolled back"); return Ok(ExitCode::from(1)); } diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 93762e579..960291382 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -55,7 +55,8 @@ impl FigApp { /// Show command `id`'s error. pub(crate) fn failed(&mut self, id: &str, e: &figcraft_engine::EngineError) { let msg = e.to_string(); - log::warn!("{id}: {msg}"); + // A plugin's failure text may carry CR or terminal controls: log it escaped. + log::warn!("{id}: {}", msg.escape_debug()); self.show_error(msg); } From 680ec56c89cae41fd83c5b29e085a51563172f5b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 7 Nov 2026 08:02:06 +0700 Subject: [PATCH 2/4] CLI: escape control characters only, so quotes and Windows paths print as they are --- apps/figcraft-cli/src/files.rs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index c4cc7d5f6..100ce154b 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -10,6 +10,12 @@ use serde_json::{Value, json}; use crate::{CliError, CliResult}; +/// A document's or plugin's text as a terminal should show it: control characters escaped, and +/// everything else (quotes, backslashes in paths) left as it is. +fn plain(text: &str) -> String { + text.chars().map(|c| if c.is_control() { c.escape_default().to_string() } else { c.to_string() }).collect() +} + pub fn load(path: &str) -> CliResult { let (doc, notes) = load_noted(path)?; if ext(path) == "penpot" { @@ -199,25 +205,25 @@ pub fn info(path: &str, as_json: bool) -> CliResult { return Ok(ExitCode::SUCCESS); } for n in ¬es { - eprintln!("note: {}", n.escape_debug()); + eprintln!("note: {}", plain(&n.to_string())); } println!( "{} — {} ({} nodes, {} styles, {} variables, {} images)", - path.escape_debug(), - doc.name.escape_debug(), + plain(path), + plain(&doc.name), doc.node_count(), doc.styles.len(), doc.variables.variables.len(), doc.images.len() ); for p in &pages { - println!("page {} \"{}\": {} nodes, {} top-level", p["id"], p["name"].as_str().unwrap_or("").escape_debug(), p["nodes"], p["topLevel"]); + println!("page {} \"{}\": {} nodes, {} top-level", p["id"], plain(p["name"].as_str().unwrap_or("")), p["nodes"], p["topLevel"]); for f in p["frames"].as_array().into_iter().flatten() { println!( " {} {} \"{}\" {}×{}", f["id"], f["type"].as_str().unwrap_or(""), - f["name"].as_str().unwrap_or("").escape_debug(), + plain(f["name"].as_str().unwrap_or("")), f["width"], f["height"] ); @@ -225,7 +231,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult { } for m in &missing { let layers = if m.layers == 1 { "text layer" } else { "text layers" }; - println!("missing font \"{}\": {} {layers} (drawn in a fallback font)", m.family.escape_debug(), m.layers); + println!("missing font \"{}\": {} {layers} (drawn in a fallback font)", plain(&m.family), m.layers); } Ok(ExitCode::SUCCESS) } @@ -446,7 +452,7 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe let i = failed_at; println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true})); // Plugin-supplied text: escape terminal controls and line breaks. - eprintln!("error: step {i} ({}) failed: {}", cmd.escape_debug(), e.to_string().escape_debug()); + eprintln!("error: step {i} ({}) failed: {}", plain(cmd), plain(&e.to_string())); eprintln!("the script's document changes were rolled back"); return Ok(ExitCode::from(1)); } From 8802f3fd49aacb38953c18162bd7441f4b9b11fe Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 7 Nov 2026 08:56:32 +0700 Subject: [PATCH 3/4] CLI: test that plain escapes controls only --- apps/figcraft-cli/src/files.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 100ce154b..b2d9cbd45 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -505,6 +505,12 @@ pub fn connect_publish(file: &str, mappings: &str, dry_run: bool) -> CliResult Date: Sat, 7 Nov 2026 11:39:17 +0700 Subject: [PATCH 4/4] CLI: test what info and a failed script print --- apps/figcraft-cli/src/files.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index b2d9cbd45..a7bddeb3b 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -505,6 +505,19 @@ pub fn connect_publish(file: &str, mappings: &str, dry_run: bool) -> CliResult"#).unwrap(); + std::fs::write(&script, r#"[{"command":"no.such\u001b"}]"#).unwrap(); + assert_eq!(info(svg.to_str().unwrap(), false).unwrap(), ExitCode::SUCCESS); + assert_eq!(run_script(script.to_str().unwrap(), None, None).unwrap(), ExitCode::from(1)); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn plain_escapes_controls_and_leaves_quotes_and_paths() { assert_eq!(plain("a\u{1b}[31m\nb"), r"a\u{1b}[31m\nb");