From d921fedcca2b2fce76fb44b7bc3fbb75cd63ada6 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 22 Oct 2026 21:51:51 +0200 Subject: [PATCH] Integration: export dialog-recorded Apply Image channels, refuse numeric selectors (#8180) The Apply Image dialog sends Channels-panel names in sourceChannel ("RGB" by default, "Red", "Alpha 1"), but the export matched only lowercase keys, so a dialog-recorded Apply Image failed strict .atn export. Channel names now map case-insensitively (RGB/CMYK/Lab to the composite), an alpha channel is written as a Chnl name reference and read back on import, and a numeric layer or channel selector is refused instead of becoming the merged composite. Tests: apply_image_dialog_channel_names_survive_a_strict_export (the dialog's exact params) and apply_image_numeric_source_selectors_are_refused; both fail without the fix. Co-authored-by: VishnuByrraju <112673081+VishnuByrraju@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/atn_cmds.rs | 33 +++++++++++++++++-- crates/engine/src/atn_cmds/tests.rs | 49 +++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 1a0fa60adb..bd4183833e 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -2017,6 +2017,10 @@ fn apply_image_from(d: &Descriptor) -> Mapped { let Some(channel) = lookup(SOURCE_CHANNELS, &v).or_else(|| lookup(CHANNELS, &v)) else { return unsupported }; source.insert("channel".into(), channel.into()); } + // A named (alpha) channel. + ReferenceItem::Name { class, name } if class.class_id.is("Chnl") => { + source.insert("channel".into(), name.to_string_lossy().into()); + } ReferenceItem::Name { class, name } if class.class_id.is("Lyr ") => { source.insert("layer".into(), name.to_string_lossy().into()); } @@ -2047,6 +2051,26 @@ fn apply_image_from(d: &Descriptor) -> Mapped { step("image.applyImage", p) } +/// Apply Image's source channel as a `Chnl` reference item. The dialog sends Channels-panel names +/// ("RGB", "Red", "Alpha 1"), which the command reads case-insensitively: the composite names map to +/// the composite, colour names to their enumeration, and any other name to a named (alpha) channel, +/// as Photoshop records it. +fn apply_image_channel(channel: &str) -> Option { + let l = channel.to_ascii_lowercase(); + let key = match l.as_str() { + "rgb" | "cmyk" | "lab" | "merged" => "composite", + "layermask" => "mask", + // Sources the command knows by keyword but Photoshop has no channel for. + "quickmask" | "quick mask" | "vectormask" | "vector mask" => return None, + other => other, + }; + Some(match lookup_back(SOURCE_CHANNELS, key).or_else(|| lookup_back(CHANNELS, key)) { + Some(chan) => ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code(chan) }, + None if !channel.trim().is_empty() => ReferenceItem::Name { class: class("Chnl"), name: UnicodeString::new_nul(channel) }, + None => return None, + }) +} + fn apply_image_to(_: &str, p: &Value) -> Option { // The optional blending mask has no descriptor mapping: refuse it rather than drop it, // which would widen the edit to the whole layer. @@ -2064,9 +2088,12 @@ fn apply_image_to(_: &str, p: &Value) -> Option { if source.get("document").is_some_and(|d| !d.is_null()) { return None; } - let channel = jstr(&source, "channel").unwrap_or("composite"); - let chan = lookup_back(SOURCE_CHANNELS, channel).or_else(|| lookup_back(CHANNELS, channel))?; - let mut items = vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code(chan) }]; + // A layer id or channel index has no name to record: refuse it rather than write the composite. + let not_name = |k: &str| source.get(k).is_some_and(|v| !(v.is_null() || v.is_string())); + if !source.is_object() || not_name("layer") || not_name("channel") { + return None; + } + let mut items = vec![apply_image_channel(jstr(&source, "channel").unwrap_or("composite"))?]; match jstr(&source, "layer") { Some("merged") | None => items.push(ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code("Mrgd") }), Some(name) => items.push(ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul(name) }), diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index d7d8e87f88..b22b229c4f 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -2336,6 +2336,55 @@ fn apply_image_flat_source_fields_survive_a_strict_export() { assert!(export_item("image.applyImage", &json!({"sourceDocument": "Other"}), "S").is_none()); } +/// What the Apply Image dialog records survives a strict `.atn` export and comes back: its source +/// channel is a Channels-panel name ("RGB", "Red", "Alpha 1"), not a lowercase key. +#[test] +fn apply_image_dialog_channel_names_survive_a_strict_export() { + let dialog = |channel: &str| json!({"sourceLayer": "merged", "sourceChannel": channel, "sourceInvert": false, "blending": "multiply", "opacity": 100.0, "scale": 1.0, "offset": 0, "preserveTransparency": false, "maskChannel": "none"}); + for (channel, back) in [ + ("RGB", "composite"), + ("CMYK", "composite"), + ("Lab", "composite"), + ("Red", "red"), + ("Gray", "gray"), + ("Alpha 1", "Alpha 1"), + ("transparency", "transparency"), + ] { + let a = Action::new("A", vec![("image.applyImage".into(), dialog(channel))]); + let (bytes, skipped) = export("S", &[&a], &[], false).unwrap_or_else(|e| panic!("{channel}: {e}")); + assert!(skipped.is_empty(), "{channel}"); + let file = atn::parse(&bytes).unwrap(); + assert_eq!(file.actions[0].items[0].event.id(), "AppI"); + let back_file = import(&bytes).unwrap(); + assert!(back_file.unsupported.is_empty(), "{channel}"); + let p = &back_file.actions[0].steps[0].1; + assert_eq!(p["source"], json!({"layer": "merged", "channel": back, "invert": false}), "{channel}"); + } + // A named channel is written as Photoshop records it: a `Chnl` name reference. + let item = export_item("image.applyImage", &dialog("Alpha 1"), "S").unwrap(); + let with = get_obj(item.descriptor.as_ref().unwrap(), "With").unwrap(); + let r = get_ref(with, "T ").unwrap(); + assert!(matches!(&r[0], ReferenceItem::Name { class, name } if class.class_id.is("Chnl") && name.to_string_lossy() == "Alpha 1")); +} + +/// A numeric layer or channel selector has no name to record: the step is refused, not exported as +/// the merged composite. +#[test] +fn apply_image_numeric_source_selectors_are_refused() { + for p in [ + json!({"sourceLayer": 3}), + json!({"sourceChannel": 2}), + json!({"source": {"channel": 2}}), + json!({"source": {"layer": 7, "channel": "red"}}), + json!({"source": "red"}), + json!({"sourceChannel": "quickMask"}), + ] { + assert!(export_item("image.applyImage", &p, "S").is_none(), "{p}"); + } + let a = Action::new("A", vec![("image.applyImage".into(), json!({"sourceLayer": 3}))]); + assert!(export("S", &[&a], &[], false).is_err()); +} + /// A param a PhotoCraft step leaves out exports as PhotoCraft's default wherever Photoshop plays /// the missing key differently (checked against Photoshop): Canvas Size anchors top left, /// Apply Image blends Normal, Color Balance turns preserve luminosity off, and a Paint Bucket