From 6d2c6cc0174c05ee19d00f28f645f81814f62a2c Mon Sep 17 00:00:00 2001 From: wwewtech Date: Tue, 20 Oct 2026 16:57:48 +0400 Subject: [PATCH] fix(atn): a Group Layers step keeps its explicit layer through .atn (#7236) --- crates/engine/src/atn_cmds.rs | 18 +++++++++-- crates/engine/src/atn_cmds/tests.rs | 48 +++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 9e8eb6cb7c..a2b70d13f8 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -1096,8 +1096,18 @@ fn make_from(d: &Descriptor) -> Mapped { if let Some(n) = using.and_then(|u| get_text(u, "Nm ")) { p.insert("name".into(), n.into()); } - let id = if get_ref(d, "From").is_some() { "layer.groupLayers" } else { "layer.new.group" }; - step(id, Value::Object(p)) + match get_ref(d, "From") { + // A named source layer stays the source (#7236); a target reference and an + // index/id source (our exporter never writes those) keep the old implicit + // import: the group lands on the current selection at playback. + Some(r) => { + if let Some(t) = layer_target(r) { + p.extend(t); + } + step("layer.groupLayers", Value::Object(p)) + } + None => step("layer.new.group", Value::Object(p)), + } } _ => Mapped::Unsupported { harmless: false }, } @@ -1124,7 +1134,9 @@ fn make_to(id: &str, p: &Value) -> Option { } "layer.new.group" => desc("null").with("null", class_ref("layerSection")).with("Usng", D::Descriptor(with_name("layerSection"))), "layer.groupLayers" => { - desc("null").with("null", class_ref("layerSection")).with("From", target_ref("Lyr ")).with("Usng", D::Descriptor(with_name("layerSection"))) + // An explicit source layer goes in by name like every other layer reference; a + // step without one keeps the target-layer form (#7236). + desc("null").with("null", class_ref("layerSection")).with("From", layer_ref(p)).with("Usng", D::Descriptor(with_name("layerSection"))) } "layer.new.layerViaCopy" | "layer.new.layerViaCut" => { let mut d = desc("null").with("Nw ", D::Descriptor(desc("Lyr "))).with("Usng", enm("ArSl", "Slct")); diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 26865d4060..da73404ad1 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -518,6 +518,54 @@ fn bucket_keeps_an_explicit_color_through_atn() { } } +/// A `Mk` layerSection step exported to .atn and imported back → (id, params), or None. +fn group_round_trip(id: &str, params: &Value) -> Option<(String, Value)> { + let item = export_item(id, params, "S")?; + let e = event_for(&item.event)?; + match (e.from)(item.descriptor.as_ref()?) { + Mapped::Steps(steps) => steps.first().map(|(i, p)| (i.clone(), p.clone())), + _ => None, + } +} + +#[test] +fn group_layers_keeps_its_explicit_target_through_atn() { + // #7236: a recorded step with an explicit layer groups that layer, not the current + // selection. The source goes into the `From` reference by name and comes back the same + // way; a step with no source layer keeps the implicit form, imported as before. + let (id, p) = group_round_trip("layer.groupLayers", &json!({"layer": "A", "name": "Group"})).unwrap(); + assert_eq!(id, "layer.groupLayers"); + assert_eq!(p, json!({"layer": "A", "name": "Group"})); + // The implicit form survives unchanged: the reference stays the target layer. + let (id, p) = group_round_trip("layer.groupLayers", &json!({"name": "Group"})).unwrap(); + assert_eq!(id, "layer.groupLayers"); + assert_eq!(p, json!({"name": "Group"})); + let (id, p) = group_round_trip("layer.groupLayers", &json!({})).unwrap(); + assert_eq!(id, "layer.groupLayers"); + assert_eq!(p, json!({})); + // Group Without Layers: no `From` at all, imported as a plain new group. + let (id, p) = group_round_trip("layer.new.group", &json!({"name": "G"})).unwrap(); + assert_eq!(id, "layer.new.group"); + assert_eq!(p, json!({"name": "G"})); +} + +#[test] +fn group_layers_from_index_reference_stays_implicit() { + // A Photoshop-authored `From` that names nothing portable (a zero-based index) keeps the + // old implicit import instead of inventing a wrong explicit target (#7236, edge). + let d = desc("null") + .with("null", class_ref("layerSection")) + .with("From", D::Reference(vec![ReferenceItem::Index { class: class("Lyr "), index: 2 }])) + .with("Usng", D::Descriptor(desc("layerSection").with("Nm ", text("Group")))); + match make_from(&d) { + Mapped::Steps(steps) => { + assert_eq!(steps[0].0, "layer.groupLayers"); + assert_eq!(steps[0].1, json!({"name": "Group"})); + } + _ => panic!("an index source imports as the implicit form"), + } +} + #[test] fn unsupported_shapes_become_photoshop_steps_not_errors() { // Image size in two different percentages, a Paint Bucket click in percent of the canvas, a