From 6fdbed2348b875840fd41e52aa1b10a3bf94d6a6 Mon Sep 17 00:00:00 2001 From: Jason White Date: Mon, 19 Oct 2026 10:48:31 +0100 Subject: [PATCH] fix(atn): keep a Save with a destination but no format A recorded Save with an In path and no As format was still imported as a Save As going by the file extension alone. Without the format there's nothing to check the destination against, so keep it as a Photoshop step and let playback stop there. --- crates/engine/src/atn_cmds.rs | 32 ++++++++++++----------------- crates/engine/src/atn_cmds/tests.rs | 4 +++- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 66cc99a71d..726aa93bf5 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -1571,32 +1571,26 @@ fn path_extension(path: &str) -> Option { } /// `save`: a plain Save, or a Save As (`As` format, `In` destination, `Cpy` As a Copy). -/// Only a destination that is a file of the recorded format imports: a folder (#5430), an -/// unknown format or a mismatched extension stays a kept Photoshop step, so playback stops +/// Only a destination that is a file of the recorded format imports: a folder (#5430), a missing +/// or unknown format or a mismatched extension stays a kept Photoshop step, so playback stops /// before writing rather than saving over the played file (#7102). fn save_from(d: &Descriptor) -> Mapped { let unsupported = Mapped::Unsupported { harmless: false }; - let dest = d.get("In "); - let format = match d.get("As ") { - None if dest.is_none() => return step("file.save", json!({})), - None => None, - // A format that isn't a descriptor can't be checked against the destination. - Some(_) => match get_obj(d, "As ") { - Some(f) => Some(f), - None => return unsupported, - }, - }; - let Some(path) = dest.and_then(path_text) else { return unsupported }; + if d.get("As ").is_none() && d.get("In ").is_none() { + return step("file.save", json!({})); + } + // A Save As needs a format descriptor to check the destination against: without one (or + // with a format that isn't a descriptor) the step is kept. + let Some(format) = get_obj(d, "As ") else { return unsupported }; + let Some(path) = d.get("In ").and_then(path_text) else { return unsupported }; if get_bool(d, "DIDr") == Some(true) { return unsupported; } let Some(ext) = path_extension(&path) else { return unsupported }; - if let Some(f) = format { - let class = trimmed(&f.class_id); - let Some((_, exts)) = SAVE_FORMATS.iter().find(|(c, _)| *c == class) else { return unsupported }; - if !exts.contains(&ext.as_str()) { - return unsupported; - } + let class = trimmed(&format.class_id); + let Some((_, exts)) = SAVE_FORMATS.iter().find(|(c, _)| *c == class) else { return unsupported }; + if !exts.contains(&ext.as_str()) { + return unsupported; } step(if get_bool(d, "Cpy ") == Some(true) { "file.saveACopy" } else { "file.saveAs" }, json!({"path": path})) } diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index ff42da5e38..a9a9076d4c 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1455,10 +1455,12 @@ fn save_as_that_cannot_be_mapped_stops_playback_instead_of_saving_over_the_file( // A format that isn't a descriptor, with and without a destination. item("save", "Save", Some(desc("null").with("As ", long(1.0)))), item("save", "Save", Some(desc("null").with("As ", long(1.0)).with("In ", path_value("/out/result.png")))), + // A destination with no format at all. + item("save", "Save", Some(desc("null").with("In ", path_value("/out/result.png")))), ]); let imp = import(&atn::write(&file).unwrap()).unwrap(); let ids: Vec<&str> = imp.actions[0].steps.iter().map(|(id, _)| id.as_str()).collect(); - assert_eq!(ids, [PHOTOSHOP_STEP; 7]); + assert_eq!(ids, [PHOTOSHOP_STEP; 8]); assert!(imp.actions[0].disabled.is_empty(), "kept saves stay on, so playback stops on them"); // The kept steps are written back byte for byte. let a = &imp.actions[0];