Integration: Group F steps recorded by the reference application map (#10628)

The Group F loader only mapped descriptors with an empty name and class
`null`, and nested shapes with an empty name. The reference application
names every step descriptor after the localized step (plus NUL), uses
the event code as its class and names nested shapes too, so every real
recorded Add To / Fade / Merge Layers ... step stayed a kept step and
never played (e.g. `addTo` with name "Add To\0", class `AddT` and a
nested `Rctn` named "\0"). The header is no longer checked; the shape
is matched by class only; key, order and enum checks stay strict.

IntW, Dstr and MrgL were registered with the string IDs
`interfaceWhite`, `distort` and none; the real ones are
`intersectWith`, `distribute` and `mergeLayers`, so steps written with
them stayed kept.

Co-authored-by: yoriswind <164435053+yoriswind@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed 2026-11-02 02:50:33 +01:00
1 parent 036d37f90d
commit 0e41091687
4 files changed
+88 -30

No files matched your search

+4 -4
View File
@@ -182,7 +182,7 @@ macro_rules! clipping_event {
};
}
/// Group F validates the outer descriptor before every converter, including empty events.
/// Group F routes every event through `steps_selection::from`; the outer header (name, class) is not checked.
macro_rules! selection_event {
($code:literal, $name:literal, $label:literal, $ids:expr, $from:expr, $to:expr) => {
Event { code: $code, name: $name, label: $label, ids: $ids, from: |d| super::steps_selection::from(d, $from), to: $to }
@@ -199,7 +199,7 @@ pub(super) const EVENTS: &[Event] = &[
}),
selection_event!(
"IntW",
"interfaceWhite",
"intersectWith",
"Intersect with Selection",
&["select.rect"],
|d| super::steps_selection::selection_from(d, "intersect"),
@@ -225,7 +225,7 @@ pub(super) const EVENTS: &[Event] = &[
selection_event!("", "linkSelectedLayers", "Link Layers", &["layer.linkLayers"], super::steps_selection::link_from, super::steps_selection::link_to),
selection_event!(
"Dstr",
"distort",
"distribute",
"Distribute",
&[
"layer.distribute.leftEdges",
@@ -256,7 +256,7 @@ pub(super) const EVENTS: &[Event] = &[
),
selection_event!(
"MrgL",
"",
"mergeLayers",
"Merge Layers",
&["layer.mergeLayers"],
|d| super::steps_selection::empty_from(d, "layer.mergeLayers", Some("MrgL")),
@@ -38,15 +38,15 @@ fn fixtures() -> Vec<(&'static str, Option<&'static str>, Descriptor)> {
("AddT", Some("addTo"), rectangle([30.0, 30.0, 90.0, 90.0], false)),
("AddT", Some("addTo"), rectangle([30.0, 30.0, 90.0, 90.0], true)),
("SbtF", Some("subtractFrom"), rectangle([30.0, 30.0, 60.0, 60.0], false)),
("IntW", Some("interfaceWhite"), rectangle([30.0, 30.0, 120.0, 120.0], false)),
("IntW", Some("intersectWith"), rectangle([30.0, 30.0, 120.0, 120.0], false)),
("PstI", Some("pasteInto"), desc("null").with("AntA", enm("Annt", "Anno")).with("As ", D::Class(class("Pxel")))),
("PstO", Some("pasteOutside"), desc("null").with("AntA", enm("Annt", "Anno")).with("As ", D::Class(class("Pxel")))),
("Fade", Some("fade"), desc("null").with("Opct", pct(50.0)).with("Md ", enm("BlnM", "Mltp"))),
("", Some("linkSelectedLayers"), desc("null").with("null", target_ref("Lyr "))),
("Dstr", Some("distort"), desc("null").with("null", target_ref("Lyr ")).with("Usng", enm("ADSt", "AdLf"))),
("Dstr", Some("distribute"), desc("null").with("null", target_ref("Lyr ")).with("Usng", enm("ADSt", "AdLf"))),
("RmvL", Some("removeLayerMask"), desc("null")),
("DfnP", Some("definePattern"), desc("null")),
("MrgL", None, desc("null")),
("MrgL", Some("mergeLayers"), desc("null")),
];
// Opposite-signed fractions can lose an endpoint through x+(right-x); preserve it explicitly.
for mode in ["AddT", "SbtF", "IntW"] {
@@ -59,7 +59,7 @@ fn fixtures() -> Vec<(&'static str, Option<&'static str>, Descriptor)> {
cases.push(("Fade", Some("fade"), desc("null").with("Opct", pct(25.5)).with("Md ", enm("BlnM", mode))));
}
for edge in ["AdCH", "AdRg", "AdTp", "AdCV", "AdBt"] {
cases.push(("Dstr", Some("distort"), desc("null").with("null", target_ref("Lyr ")).with("Usng", enm("ADSt", edge))));
cases.push(("Dstr", Some("distribute"), desc("null").with("null", target_ref("Lyr ")).with("Usng", enm("ADSt", edge))));
}
cases
}
@@ -90,8 +90,7 @@ fn group_f_load_save_preserves_recorded_descriptors() {
/// A kept step retains its descriptor bytes, event identity and playback flags.
fn assert_kept(id: &str, name: Option<&str>, descriptor: Descriptor) {
let event = if id.is_empty() { AtnEvent::name(name.unwrap()) } else { AtnEvent::Code(id.as_bytes().try_into().unwrap()) };
let mut original = atn::parse(&file(event, descriptor.clone())).unwrap();
let mut original = atn::parse(&file(fixture_event(id, name), descriptor.clone())).unwrap();
original.actions[0].items[0].enabled = false;
original.actions[0].items[0].with_dialog = true;
original.actions[0].items[0].dialog_options = 2;
@@ -112,10 +111,19 @@ fn assert_kept(id: &str, name: Option<&str>, descriptor: Descriptor) {
assert_eq!(saved.descriptor.as_ref().unwrap().to_bytes(), descriptor.to_bytes());
}
/// Outer and nested shape metadata stay kept, including headers on empty Group F events.
/// The event of a fixture, in its code form where it has one.
fn fixture_event(id: &str, name: Option<&str>) -> AtnEvent {
if id.is_empty() { AtnEvent::name(name.unwrap()) } else { AtnEvent::Code(id.as_bytes().try_into().unwrap()) }
}
/// The outer name and class and the nested shape's name and class encoding do not gate mapping:
/// such a step maps to the same command and params as the empty-name `null` header.
#[test]
fn noncanonical_group_f_headers_keep_their_bytes_and_flags() {
fn noncanonical_group_f_headers_map_like_the_canonical_form() {
for (id, name, d) in fixtures() {
let event = fixture_event(id, name);
let canonical = import(&file(event.clone(), d.clone())).unwrap();
assert!(canonical.unsupported.is_empty(), "{id}");
for variant in 0..5 {
let mut descriptor = d.clone();
match variant {
@@ -131,11 +139,68 @@ fn noncanonical_group_f_headers_keep_their_bytes_and_flags() {
}
}
}
assert_kept(id, name, descriptor);
let loaded = import(&file(event.clone(), descriptor.clone())).unwrap();
assert!(loaded.unsupported.is_empty(), "{id}/{name:?}/{variant}");
assert_eq!(loaded.actions[0].steps, canonical.actions[0].steps, "{id}/{name:?}/{variant}");
}
}
}
/// The reference application names each step descriptor after the localized step (with a NUL),
/// gives it the event code as its class and names nested shapes too (synthetic descriptors).
#[test]
fn group_f_maps_steps_recorded_with_the_application_header() {
let mut shape = Descriptor { name: UnicodeString::new_nul(""), class_id: code("Rctn"), items: Vec::new() };
for (key, value) in ["Top ", "Left", "Btom", "Rght"].into_iter().zip([30.0, 30.0, 90.0, 90.0]) {
shape = shape.with(key, px(value));
}
let cases = [
(
AtnEvent::name("fade"),
Descriptor {
name: UnicodeString::new_nul("Fade"),
class_id: code("Fade"),
items: vec![(code("Opct"), pct(50.0)), (code("Md "), enm("BlnM", "Nrml"))],
},
("edit.fade", json!({"opacity":50,"mode":"Normal"})),
),
(
AtnEvent::name("addTo"),
Descriptor {
name: UnicodeString::new_nul("Add To"),
class_id: code("AddT"),
items: vec![(code("null"), selection()), (code("T "), D::Descriptor(shape))],
},
("select.rect", json!({"x":30,"y":30,"width":60,"height":60,"mode":"add"})),
),
(
AtnEvent::Code(*b"MrgL"),
Descriptor { name: UnicodeString::new_nul("Merge Layers"), class_id: code("MrgL"), items: Vec::new() },
("layer.mergeLayers", json!({"_atnEvent":"MrgL"})),
),
];
for (event, d, (command, params)) in cases {
let loaded = import(&file(event, d)).unwrap();
assert!(loaded.unsupported.is_empty(), "{command}");
assert_eq!(loaded.actions[0].steps, vec![(command.to_string(), params)]);
}
}
/// Steps recorded with the reference application's string IDs map like their four-character codes.
#[test]
fn group_f_string_ids_map_like_their_codes() {
for (id, name, d) in [
("IntW", "intersectWith", rectangle([30.0, 30.0, 120.0, 120.0], false)),
("Dstr", "distribute", desc("null").with("null", target_ref("Lyr ")).with("Usng", enm("ADSt", "AdLf"))),
("MrgL", "mergeLayers", desc("null")),
] {
let by_code = import(&file(AtnEvent::Code(id.as_bytes().try_into().unwrap()), d.clone())).unwrap();
let by_name = import(&file(AtnEvent::name(name), d)).unwrap();
assert!(by_code.unsupported.is_empty() && by_name.unsupported.is_empty(), "{name}");
assert_eq!(by_name.actions[0].steps, by_code.actions[0].steps, "{name}");
}
}
/// Every keyed converter rejects alternate key encodings and adjacent order changes.
#[test]
fn noncanonical_group_f_keys_keep_their_bytes_and_flags() {
@@ -5,16 +5,9 @@ use super::fields::{Mapped, step};
use super::tables::{ALIGN, blend_label, blend_ps, lookup, lookup_back};
use super::{D, Descriptor, ReferenceItem, Value, json};
/// Exported headers use an empty name and the four-byte class identifier.
fn canonical_header(d: &Descriptor, class: &str) -> bool {
d.name == Default::default() && d.class_id == code(class)
}
/// Group F checks the outer header before every converter, including empty events.
/// Group F converters ignore the outer header, like every other event: the reference application
/// records the localized step name (with its NUL) as the name and the event code as the class.
pub(super) fn from(d: &Descriptor, convert: fn(&Descriptor) -> Mapped) -> Mapped {
if !canonical_header(d, "null") {
return Mapped::Unsupported { harmless: false };
}
convert(d)
}
@@ -69,7 +62,7 @@ pub(super) fn selection_from(d: &Descriptor, mode: &str) -> Mapped {
}
let D::Descriptor(shape) = d.get("T ")? else { return None };
let ellipse = shape.class_id.is("Elps");
if !canonical_header(shape, if ellipse { "Elps" } else { "Rctn" }) || (ellipse && mode != "add") || !keys(shape, &["Top ", "Left", "Btom", "Rght"]) {
if (!ellipse && !shape.class_id.is("Rctn")) || (ellipse && mode != "add") || !keys(shape, &["Top ", "Left", "Btom", "Rght"]) {
return None;
}
let [top, left, bottom, right] =
+7 -7
View File
@@ -22,15 +22,15 @@ does not map yet, with everything needed to implement them without the reference
|---|---|---|---|
| `AddT` | addTo | Add to Selection | `select.rect` (add rectangle or ellipse) |
| `SbtF` | subtractFrom | Subtract from Selection | `select.rect` (subtract rectangle) |
| `IntW` | interfaceWhite | Intersect with Selection | `select.rect` (intersect rectangle) |
| `IntW` | intersectWith | Intersect with Selection | `select.rect` (intersect rectangle) |
| `PstI` | pasteInto | Paste Into | `edit.pasteSpecial.pasteInto` |
| `PstO` | pasteOutside | Paste Outside | `edit.pasteSpecial.pasteOutside` |
| `Fade` | fade | Fade | `edit.fade` |
| (string ID only) | linkSelectedLayers | Link Layers | `layer.linkLayers` |
| `Dstr` | distort | Distribute | `layer.distribute.leftEdges`, `layer.distribute.horizontalCenters`, `layer.distribute.rightEdges`, `layer.distribute.topEdges`, `layer.distribute.verticalCenters`, `layer.distribute.bottomEdges` |
| `Dstr` | distribute | Distribute | `layer.distribute.leftEdges`, `layer.distribute.horizontalCenters`, `layer.distribute.rightEdges`, `layer.distribute.topEdges`, `layer.distribute.verticalCenters`, `layer.distribute.bottomEdges` |
| `RmvL` | removeLayerMask | Remove Layer Mask (older files) | `layer.layerMask.delete` |
| `DfnP` | definePattern | Define Pattern | `edit.definePattern` |
| `MrgL` | (code only) | Merge Layers (older files) | `layer.mergeLayers` |
| `MrgL` | mergeLayers | Merge Layers (older files) | `layer.mergeLayers` |
| `AdNs` | addNoise | Add Noise | `filter.noise.addNoise` |
| `Algn` | align | Align | `layer.align.leftEdges`, `layer.align.horizontalCenters`, `layer.align.rightEdges`, `layer.align.topEdges`, `layer.align.verticalCenters`, `layer.align.bottomEdges` |
| `AppI` | applyImageEvent | Apply Image | `image.applyImage` |
@@ -214,7 +214,7 @@ Nested values as recorded (example values):
#### Intersect with Selection
- Event: `IntW` / `interfaceWhite`
- Event: `IntW` / `intersectWith`
- PhotoCraft command: `select.rect {"mode":"intersect"}`
- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set).
@@ -277,7 +277,7 @@ Nested values as recorded (example values):
#### Distribute
- Event: `Dstr` / `distort`
- Event: `Dstr` / `distribute`
- PhotoCraft command: `layer.distribute.leftEdges (one command per edge)`
- `Usng` selects the edge or centre; one enum value per Distribute menu item.
- Measured with example values in a prepared document: the Value column shows what was sent and recorded, not a factory default (a step always carries the values the user set).
@@ -305,12 +305,12 @@ No parameters: the step carries an empty descriptor.
#### Merge Layers (older files)
- Event: `MrgL` (no string ID)
- Event: `MrgL` / `mergeLayers`
- PhotoCraft command: `layer.mergeLayers`
No parameters: the step carries an empty descriptor.
The prepared-document values in this group are required inputs, rather than factory defaults. Unknown settings stay kept steps. Paste uses the current clipboard and selection; Fade uses the preceding edit. Link Layers uses the existing selected-layer toggle. Native exports retain their current Delete Mask and Merge Layers events; imported older events retain their original IDs.
The prepared-document values in this group are required inputs, rather than factory defaults. Unknown settings stay kept steps; the descriptor name and class (the reference application records the localized step name and the event code there) do not matter. Paste uses the current clipboard and selection; Fade uses the preceding edit. Link Layers uses the existing selected-layer toggle. Native exports retain their current Delete Mask and Merge Layers events; imported older events retain their original IDs.
## Recognized steps kept disabled