mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-01 05:47:31 -06:00
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:
co-authored by
VishnuByrraju
Claude Opus 5.5
parent
aa87afc886
commit
d921fedcca
@@ -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) }),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user