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) <noreply@anthropic.com>
This commit is contained in:
David Mann
2026-10-22 21:51:51 +02:00
co-authored by VishnuByrraju Claude Opus 5.5
parent aa87afc886
commit d921fedcca
2 changed files with 79 additions and 3 deletions
+30 -3
View File
@@ -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<ReferenceItem> {
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<Descriptor> {
// 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<Descriptor> {
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) }),
+49
View File
@@ -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