Merge PR #10998: Actions: map recorded Scale Effects and Create Layers

This commit is contained in:
David Mann committed 2026-11-04 00:51:01 +01:00
commit 545d447c10
8 files changed
+671 -3

No files matched your search

+1
View File
@@ -41,6 +41,7 @@ mod steps_doc;
mod steps_gallery;
mod steps_image;
mod steps_layer;
mod steps_layer_style;
mod steps_make;
mod steps_stylize;
+3
View File
@@ -23,6 +23,7 @@ use super::steps_layer::{
move_from, move_to, paste_effects_from, paste_effects_to, play_from, play_to, rotate_from, rotate_to, select_from, select_to, stroke_to, visibility_from,
visibility_to,
};
use super::steps_layer_style::{scale_from, style_to};
use super::steps_make::{locking_from, locking_to, make_from, make_to, set_from, set_to};
use super::steps_other::{DE_INTERLACE, HSB_HSL, bare_from, bare_to, custom_from, custom_to, enums_from, enums_to};
use super::tables::{NOISE, OFFSET_FILL, SMART_BLUR_MODE, SMART_BLUR_QUALITY, TRIM};
@@ -764,6 +765,7 @@ pub(super) const EVENTS: &[Event] = &[
},
Event { code: "Dplc", name: "duplicate", label: "Duplicate", ids: &["layer.duplicate", "image.duplicate"], from: duplicate_from, to: duplicate_to },
// Layers.
Event { code: "", name: "scaleEffectsEvent", label: "Scale Effects", ids: &["layer.layerStyle.scaleEffects"], from: scale_from, to: style_to },
target_event!("reverse", "Reverse", "layer.arrange.reverse"),
target_event!("selectLinkedLayers", "Select Linked Layers", "layer.selectLinkedLayers"),
target_event!("disableLayerStyle", "Clear Layer Style", "layer.layerStyle.clear"),
@@ -798,6 +800,7 @@ pub(super) const EVENTS: &[Event] = &[
ids: &[
"type.convertToShape",
"layer.new.layer",
"layer.layerStyle.createLayer",
"layer.new.group",
"layer.groupLayers",
"layer.duplicate",
@@ -0,0 +1,130 @@
//! The measured Scale Effects event and Make-from-layer-effects form.
use super::descriptors::{PCT, class, class_ref, desc, get_ref, jstr, pct, ref_class};
use super::fields::{Mapped, step};
use super::{D, Descriptor, Id, ReferenceItem, Value, json};
const SCALE: &str = "layer.layerStyle.scaleEffects";
pub(super) const CREATE: &str = "layer.layerStyle.createLayer";
const SOURCE: &str = "_atnLayerStyleDescriptor";
/// Only documented keys, once each: unknown or repeated settings cannot be discarded.
fn keys(d: &Descriptor, expected: &[&str]) -> bool {
d.items.len() == expected.len() && expected.iter().all(|key| d.items.iter().filter(|(k, _)| k.is(key)).count() == 1)
}
/// The dialog records whole percentages; native fractional/clamped values are not guessed.
fn whole_percent(value: f64) -> bool {
value.is_finite() && (1.0..=1000.0).contains(&value) && value.fract() == 0.0
}
/// The native Create Layers operation has only the measured implicit active-layer target.
fn create_descriptor() -> Descriptor {
desc("null").with("null", class_ref("Lyr ")).with(
"Usng",
D::Reference(vec![
ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") },
ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") },
]),
)
}
/// Parse the exact recorded values and reference chain, keeping unsupported forms complete.
fn parameters(id: &str, d: &Descriptor) -> Option<Value> {
if id == SCALE {
if !keys(d, &["Scl "]) {
return None;
}
let D::UnitFloat { unit, value } = d.get("Scl ")? else { return None };
return (*unit == PCT && whole_percent(*value)).then(|| json!({"scale":value}));
}
if id != CREATE || !keys(d, &["null", "Usng"]) {
return None;
}
let [ReferenceItem::Class(layer)] = get_ref(d, "null")? else { return None };
let [ReferenceItem::Property { class: property, key }, ReferenceItem::Enumerated { class: target, type_id, value }] = get_ref(d, "Usng")? else {
return None;
};
(layer.class_id.is("Lyr ") && property.class_id.is("Prpr") && key.is("Lefx") && target.class_id.is("Lyr ") && type_id.is("Ordn") && value.is("Trgt"))
.then(|| json!({}))
}
/// Build supported native values explicitly; an absent scale uses the native default of 100.
fn descriptor(id: &str, p: &Value) -> Option<Descriptor> {
let object = p.as_object()?;
if object.keys().any(|key| key != SOURCE && !(id == SCALE && key == "scale")) {
return None;
}
match id {
SCALE => {
let value = match p.get("scale") {
None => 100.0,
Some(v) => v.as_f64()?,
};
whole_percent(value).then(|| desc("null").with("Scl ", pct(value)))
}
CREATE => Some(create_descriptor()),
_ => None,
}
}
/// Preserve wire identity/order while unchanged, without making it a native option.
fn from(id: &str, d: &Descriptor) -> Mapped {
let Some(mut p) = parameters(id, d) else { return Mapped::Unsupported { harmless: false } };
if descriptor(id, &p).as_ref() != Some(d) {
p[SOURCE] = photocraft_paint::tile::b64_encode(&d.to_bytes()).into();
}
step(id, p)
}
/// Import Scale Effects only when the whole recorded percentage is represented.
pub(super) fn scale_from(d: &Descriptor) -> Mapped {
from(SCALE, d)
}
/// Find typed effects references in parsed values; opaque data and class names are not references.
fn has_style_reference(value: &D) -> bool {
let mut pending = vec![value];
while let Some(value) = pending.pop() {
match value {
D::Reference(parts) => {
if parts.iter().any(|part| matches!(part, ReferenceItem::Property { key, .. } if key.is("Lefx"))) {
return true;
}
}
D::Descriptor(d) | D::GlobalObject(d) => pending.extend(d.items.iter().map(|(_, value)| value)),
D::List(values) => pending.extend(values),
D::ObjectArray(array) => pending.extend(array.body.items.iter().map(|(_, value)| value)),
_ => {}
}
}
false
}
/// Intercept layer-style Make intent before generic Make can turn it into a blank layer.
pub(super) fn create_from_descriptor(d: &Descriptor) -> Option<Mapped> {
let layer_make = get_ref(d, "null").and_then(ref_class).as_deref() == Some("Lyr");
let style_intent = d.items.iter().any(|(item_key, value)| {
// Deep inspection belongs to Layer Make; other Make classes retain direct-reference handling.
let style_reference = (layer_make || matches!(value, D::Reference(_))) && has_style_reference(value);
if item_key.is("Usng") {
// A layer Make may omit Usng, or use the ordinary layer-options object. Other
// shapes must not be discarded as if they were an empty layer-options object.
style_reference || (layer_make && !matches!(value, D::Descriptor(u) if u.class_id.is("Lyr ")))
} else {
// A miskeyed effects reference is still style intent, not an ordinary blank layer.
layer_make && style_reference
}
});
style_intent.then(|| from(CREATE, d))
}
/// Validate preserved data before fallback; supported edits export their current canonical values.
pub(super) fn style_to(id: &str, p: &Value) -> Option<Descriptor> {
let canonical = descriptor(id, p)?;
if p.get(SOURCE).is_none() {
return Some(canonical);
}
let source = Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(jstr(p, SOURCE)?)?).ok()?;
let original = descriptor(id, &parameters(id, &source)?)?;
Some(if original == canonical { source } else { canonical })
}
@@ -179,6 +179,9 @@ fn type_shape_descriptor() -> Descriptor {
/// Import Make events only when their geometry and options can be represented faithfully,
/// including the exact target-text-to-content-layer references.
pub(super) fn make_from(d: &Descriptor) -> Mapped {
if let Some(mapped) = super::steps_layer_style::create_from_descriptor(d) {
return mapped;
}
if d.items == type_shape_descriptor().items {
return none("type.convertToShape");
}
@@ -311,6 +314,9 @@ fn recorded_percent(opacity: f64) -> f64 {
/// Write a creation step, refusing native options the target descriptor cannot carry; Type to
/// Shape exports only without native options.
pub(super) fn make_to(id: &str, p: &Value) -> Option<Descriptor> {
if id == super::steps_layer_style::CREATE {
return super::steps_layer_style::style_to(id, p);
}
if id == "type.convertToShape" {
return p.as_object().filter(|p| p.is_empty()).map(|_| type_shape_descriptor());
}
+2
View File
@@ -755,6 +755,8 @@ fn every_mapped_command_exports_and_imports_back() {
("layer.createClippingMask", json!({})),
("layer.ungroupLayers", json!({})),
("layer.layerStyle.copyLayerStyle", json!({})),
("layer.layerStyle.scaleEffects", json!({"scale": 200})),
("layer.layerStyle.createLayer", json!({})),
("layer.layerStyle.pasteLayerStyle", json!({"allowGroups": true})),
("layer.setExpanded", json!({"all": true, "expanded": false})),
("layerComp.apply", json!({"comp": "Original"})),
+505
View File
@@ -0,0 +1,505 @@
//! Measured layer-style action steps retain their settings and use the existing native commands.
use photocraft_doc::{Document, Effect, LayerId};
use photocraft_engine::{
Session,
actions_cmds::Action,
atn_cmds::{export, import},
};
use photocraft_psd::{
atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem},
descriptor::{Class, Descriptor, Id, MAX_DEPTH, ObjectArray, ReferenceItem, UnicodeString, Value as D},
};
use serde_json::{Value, json};
const SCALE: &str = "layer.layerStyle.scaleEffects";
const CREATE: &str = "layer.layerStyle.createLayer";
fn class(id: &str) -> Class {
Class { name: UnicodeString::default(), class_id: Id::new(id) }
}
fn scale(value: f64) -> Descriptor {
Descriptor::new("null").with("Scl ", D::UnitFloat { unit: *b"#Prc", value })
}
fn create() -> Descriptor {
Descriptor::new("null").with("null", D::Reference(vec![ReferenceItem::Class(class("Lyr "))])).with(
"Usng",
D::Reference(vec![
ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") },
ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") },
]),
)
}
fn item(id: &str, descriptor: Descriptor) -> AtnItem {
let (event, name) = if id == SCALE { (AtnEvent::name("scaleEffectsEvent"), "Scale Effects") } else { (AtnEvent::Code(*b"Mk "), "Make") };
AtnItem { event, dictionary: name.as_bytes().to_vec(), descriptor: Some(descriptor), ..Default::default() }
}
fn file(items: Vec<AtnItem>) -> AtnFile {
AtnFile {
name: UnicodeString::new_nul("S"),
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }],
..Default::default()
}
}
fn action(items: Vec<AtnItem>) -> Action {
let loaded = import(&atn::write(&file(items)).unwrap()).unwrap();
assert!(loaded.unsupported.is_empty());
loaded.actions.into_iter().next().unwrap()
}
fn exported(id: &str, params: Value) -> Option<AtnItem> {
let a = Action::new("A", vec![(id.into(), params)]);
let bytes = export("S", &[&a], &[], false).ok()?.0;
atn::parse(&bytes).ok()?.actions.into_iter().next()?.items.into_iter().next()
}
/// Wire bytes also compare preserved NaN payloads, whose floating values are not reflexively equal.
fn same_item_wire(mut actual: AtnItem, mut expected: AtnItem) {
assert_eq!(actual.descriptor.take().map(|d| d.to_bytes()), expected.descriptor.take().map(|d| d.to_bytes()));
assert_eq!(actual, expected);
}
/// The direct control must actually scale the known one-pixel shadow, not merely accept the call.
fn scaled_shadow(s: &Session, source: LayerId, percent: f64) {
let shadow = s
.active()
.unwrap()
.doc
.layer(source)
.unwrap()
.effects
.items
.iter()
.find_map(|effect| match effect {
Effect::DropShadow(shadow) => Some(shadow),
_ => None,
})
.unwrap();
let factor = percent as f32 / 100.0;
assert_eq!((shadow.distance, shadow.size), (factor, factor));
}
/// A small painted source with two effects makes a real conversion differ from a blank layer.
fn fixture(depth: u32) -> (Session, LayerId) {
let mut s = Session::new();
s.execute("file.new", json!({"width":8,"height":8,"depth":depth,"background":"transparent"})).unwrap();
s.execute("layer.new.layer", json!({"name":"Source"})).unwrap();
let source = s.active().unwrap().active_layer.unwrap();
s.edit("Synthetic pixels", |doc, _| {
let pixels: Vec<f32> =
(0..8).flat_map(|y| (0..8).flat_map(move |x| if (2..6).contains(&x) && (2..6).contains(&y) { [0.2, 0.5, 0.9, 1.0] } else { [0.0; 4] })).collect();
let bounds = doc.bounds();
doc.layer_mut(source).unwrap().surface_mut().unwrap().write_region(bounds, &pixels);
Ok(())
})
.unwrap();
s.execute("layer.layerStyle.dropShadow", json!({"blend":"normal","distance":1,"size":1,"opacity":60})).unwrap();
s.execute("layer.layerStyle.colorOverlay", json!({"color":"#ff8000","opacity":50,"add":true})).unwrap();
(s, source)
}
/// Compare each complete raster and its style/stack state without assuming cross-session IDs.
fn equivalent(actual: &Session, expected: &Session) {
let a = &actual.active().unwrap().doc;
let b = &expected.active().unwrap().doc;
assert_eq!(a.layers.len(), b.layers.len());
assert_eq!(a.selection, b.selection);
for (a, b) in a.layers.iter().zip(&b.layers) {
assert_eq!((&a.name, a.clipped, a.blend, a.opacity), (&b.name, b.clipped, b.blend, b.opacity));
assert_eq!(a.effects, b.effects);
let (a, b) = (a.surface().unwrap(), b.surface().unwrap());
assert_eq!(a.content_bounds(), b.content_bounds());
let bounds = a.content_bounds().union(&b.content_bounds());
assert_eq!(a.read_region(bounds), b.read_region(bounds));
}
}
/// Undo exactly the native control's history edits, then redo the complete document state.
fn undo_redo(s: &mut Session, before: &Document, after: &Document, edits: usize) {
for _ in 0..edits {
assert!(s.undo());
}
assert_eq!(s.active().unwrap().doc.as_ref(), before);
for _ in 0..edits {
assert!(s.redo());
}
assert_eq!(s.active().unwrap().doc.as_ref(), after);
}
/// Playback must produce actual effect layers, not the blank layer the old Make branch creates.
#[test]
fn create_layers_playback_matches_native_effect_layers_and_undo_at_each_depth() {
for depth in [8, 16, 32] {
let (mut native, native_source) = fixture(depth);
let native_before = native.active().unwrap().doc.clone();
native.execute(CREATE, json!({})).unwrap();
let native_after = native.active().unwrap().doc.clone();
assert_eq!(native_after.layers.len(), native_before.layers.len() + 2);
assert!(native_after.layer(native_source).unwrap().effects.items.is_empty());
undo_redo(&mut native, &native_before, &native_after, 1);
let (mut recorded, source) = fixture(depth);
let before = recorded.active().unwrap().doc.clone();
let past = recorded.active().unwrap().history.past_len();
recorded.actions.list.push(action(vec![item(CREATE, create())]));
let result = recorded.execute("actions.play", json!({"action":0})).unwrap();
assert_eq!(result["ran"], 1);
assert!(result.get("failed").is_none(), "{depth}: {result}");
let after = recorded.active().unwrap().doc.clone();
assert_eq!(after.layers.len(), before.layers.len() + 2, "{depth}: Create Layers must split two effects, not create one blank layer");
assert!(after.layer(source).unwrap().effects.items.is_empty());
let at = after.layers.iter().position(|l| l.id == source).unwrap();
assert!(after.layers[at - 1].name.ends_with("Drop Shadow") && !after.layers[at - 1].clipped);
assert!(after.layers[at + 1].name.ends_with("Color Overlay") && after.layers[at + 1].clipped);
assert_eq!(recorded.active().unwrap().history.past_len(), past + 1);
equivalent(&recorded, &native);
undo_redo(&mut recorded, &before, &after, 1);
}
}
/// The recorded whole-percent limits and interior values match native effect scaling at all depths.
#[test]
fn scale_effects_playback_matches_native_settings_and_undo_at_each_depth() {
for depth in [8, 16, 32] {
for percent in [1.0, 100.0, 200.0, 1000.0] {
let (mut native, native_source) = fixture(depth);
let native_past = native.active().unwrap().history.past_len();
native.execute(SCALE, json!({"scale":percent})).unwrap();
scaled_shadow(&native, native_source, percent);
let edits = native.active().unwrap().history.past_len() - native_past;
let (mut recorded, _) = fixture(depth);
let before = recorded.active().unwrap().doc.clone();
let past = recorded.active().unwrap().history.past_len();
recorded.actions.list.push(action(vec![item(SCALE, scale(percent))]));
let result = recorded.execute("actions.play", json!({"action":0})).unwrap();
assert_eq!(result["ran"], 1);
assert!(result.get("failed").is_none(), "{depth}/{percent}: {result}");
let after = recorded.active().unwrap().doc.clone();
assert_eq!(recorded.active().unwrap().history.past_len(), past + edits);
equivalent(&recorded, &native);
undo_redo(&mut recorded, &before, &after, edits);
}
}
}
/// Scaling then splitting keeps the same native result and two separately reversible edits.
#[test]
fn scale_then_create_layers_matches_native_sequence() {
let (mut native, _) = fixture(16);
native.execute(SCALE, json!({"scale":200})).unwrap();
native.execute(CREATE, json!({})).unwrap();
let (mut recorded, _) = fixture(16);
let before = recorded.active().unwrap().doc.clone();
let past = recorded.active().unwrap().history.past_len();
recorded.actions.list.push(action(vec![item(SCALE, scale(200.0)), item(CREATE, create())]));
let result = recorded.execute("actions.play", json!({"action":0})).unwrap();
assert_eq!(result["ran"], 2);
assert!(result.get("failed").is_none());
assert_eq!(recorded.active().unwrap().history.past_len(), past + 2);
let after = recorded.active().unwrap().doc.clone();
equivalent(&recorded, &native);
undo_redo(&mut recorded, &before, &after, 2);
}
/// Unedited wire identity/order/flags survive; a supported scale edit exports its new value.
#[test]
fn layer_style_descriptors_roundtrip_and_edited_scale_does_not_revive_source() {
for (id, mut d) in [(SCALE, scale(100.0)), (CREATE, create())] {
d.name = UnicodeString::new_nul("Recorded style step");
d.class_id = Id::Str(b"null".to_vec());
d.items.reverse();
let mut source = item(id, d);
source.enabled = false;
source.with_dialog = true;
source.dialog_options = 2;
let a = action(vec![source.clone()]);
assert_eq!(a.steps[0].0, id);
let bytes = export("S", &[&a], &[], false).unwrap().0;
let saved = atn::parse(&bytes).unwrap().actions.into_iter().next().unwrap().items;
assert_eq!(saved.len(), 1);
same_item_wire(saved.into_iter().next().unwrap(), source);
let mut damaged = a.steps[0].1.clone();
damaged["_atnLayerStyleDescriptor"] = json!("not base64");
assert!(exported(id, damaged).is_none());
}
let mut d = scale(100.0);
d.name = UnicodeString::new_nul("Original");
let mut a = action(vec![item(SCALE, d)]);
a.steps[0].1["scale"] = json!(200);
for skip in [false, true] {
let (bytes, skipped) = export("S", &[&a], &[], skip).unwrap();
assert!(skipped.is_empty());
assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor, Some(scale(200.0)));
}
}
/// Native defaults are explicit; unrepresented targets, options and damaged metadata cannot vanish.
#[test]
fn layer_style_native_exports_are_strict_and_make_defaults_explicit() {
assert_eq!(exported(SCALE, json!({})).unwrap().descriptor, Some(scale(100.0)));
let mut canonical_create = create();
// The shared canonical class constructor writes nul-terminated display names;
// imported empty names remain separate wire data in the round-trip fixtures.
for (_, value) in &mut canonical_create.items {
let D::Reference(parts) = value else { panic!("fixture must contain references") };
for part in parts {
let class = match part {
ReferenceItem::Class(class) | ReferenceItem::Property { class, .. } | ReferenceItem::Enumerated { class, .. } => class,
_ => panic!("fixture must contain class/property/target references"),
};
class.name = UnicodeString::new_nul("");
}
}
assert_eq!(exported(CREATE, json!({})).unwrap().descriptor, Some(canonical_create));
for percent in [1, 100, 200, 1000] {
assert_eq!(exported(SCALE, json!({"scale":percent})).unwrap().descriptor, Some(scale(f64::from(percent))));
}
for value in [json!(0), json!(1001), json!(1.5), Value::Null, json!("100"), json!(true)] {
assert!(exported(SCALE, json!({"scale":value})).is_none());
}
for id in [SCALE, CREATE] {
for key in ["layer", "layers", "target", "unknown"] {
let mut p = json!({});
p[key] = json!(1);
assert!(exported(id, p).is_none(), "{id}/{key}");
}
let invalid = if id == SCALE { scale(100.0) } else { create() }.with("extra", D::Boolean(true));
let mut p = if id == SCALE { json!({"scale":200}) } else { json!({}) };
p["_atnLayerStyleDescriptor"] = json!(photocraft_paint::tile::b64_encode(&invalid.to_bytes()));
assert!(exported(id, p).is_none());
}
assert!(exported(CREATE, json!({"scale":100})).is_none());
}
/// Malformed style references remain whole and cannot fall through to ordinary blank-layer creation.
#[test]
fn layer_style_malformed_records_stay_kept_without_an_edit() {
let mut cases = vec![(SCALE, Descriptor::new("null")), (CREATE, Descriptor::new("null").with("Usng", create().items[1].1.clone()))];
for value in [D::Integer(100), D::Double(100.0), D::Boolean(true), D::UnitFloat { unit: *b"#Pxl", value: 100.0 }] {
cases.push((SCALE, Descriptor::new("null").with("Scl ", value)));
}
for value in [0.0, 1001.0, 1.5, f64::NAN, f64::INFINITY] {
cases.push((SCALE, scale(value)));
}
for (id, d) in [(SCALE, scale(100.0)), (CREATE, create())] {
cases.push((id, d.clone().with("extra", D::Boolean(true))));
let mut duplicate = d.clone();
duplicate.items.push(duplicate.items[0].clone());
cases.push((id, duplicate));
}
for null in [
D::Integer(1),
D::Reference(vec![ReferenceItem::Class(class("Chnl"))]),
D::Reference(vec![ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") }]),
] {
let mut d = create();
d.items[0].1 = null;
cases.push((CREATE, d));
}
for using in [
D::Integer(1),
D::Boolean(false),
D::Descriptor(Descriptor::new("unknown")),
D::Reference(vec![]),
D::Reference(vec![
ReferenceItem::Property { class: class("Prpr"), key: Id::new("wrong") },
ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") },
]),
D::Reference(vec![
ReferenceItem::Property { class: class("wrong"), key: Id::new("Lefx") },
ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") },
]),
D::Reference(vec![
ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") },
ReferenceItem::Enumerated { class: class("Dcmn"), type_id: Id::new("Ordn"), value: Id::new("Trgt") },
]),
D::Reference(vec![
ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") },
ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Al ") },
]),
D::Reference(vec![
ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") },
ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("wrong"), value: Id::new("Trgt") },
]),
D::Reference(vec![
ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") },
ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul("Source") },
]),
D::Reference(vec![
ReferenceItem::Property { class: class("Prpr"), key: Id::new("Lefx") },
ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") },
ReferenceItem::Class(class("Lyr ")),
]),
] {
let mut d = create();
d.items[1].1 = using;
cases.push((CREATE, d));
}
for (id, d) in cases {
let source = item(id, d);
let loaded = import(&atn::write(&file(vec![source.clone()])).unwrap()).unwrap();
assert_eq!(loaded.unsupported.len(), 1, "{id}: {source:?}");
assert_eq!(loaded.actions[0].steps[0].0, "actions.photoshopStep");
let bytes = export("S", &[&loaded.actions[0]], &[], false).unwrap().0;
let saved = atn::parse(&bytes).unwrap().actions.into_iter().next().unwrap().items;
assert_eq!(saved.len(), 1);
same_item_wire(saved.into_iter().next().unwrap(), source);
let (mut s, _) = fixture(8);
let before = s.active().unwrap().doc.clone();
let past = s.active().unwrap().history.past_len();
s.actions.list.extend(loaded.actions);
let result = s.execute("actions.play", json!({"action":0})).unwrap();
assert_eq!(result["ran"], 0);
assert!(result.get("failed").is_some());
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(s.active().unwrap().history.past_len(), past);
}
}
/// A miskeyed effects reference must be kept, rather than playing as an ordinary blank layer.
#[test]
fn miskeyed_effects_reference_stays_whole_without_a_blank_layer_edit() {
let mut d = create();
d.items[1].0 = Id::new("Usin");
let source = item(CREATE, d);
let loaded = import(&atn::write(&file(vec![source.clone()])).unwrap()).unwrap();
let (mut s, _) = fixture(8);
let before = s.active().unwrap().doc.clone();
let past = s.active().unwrap().history.past_len();
s.actions.list.extend(loaded.actions.clone());
let result = s.execute("actions.play", json!({"action": 0})).unwrap();
// Play before checking import classification: the old path actually creates a blank layer.
assert!(
s.active().unwrap().doc == before,
"miskeyed Lefx reference changed the document: layers {} -> {}, history {} -> {}",
before.layers.len(),
s.active().unwrap().doc.layers.len(),
past,
s.active().unwrap().history.past_len()
);
assert_eq!(s.active().unwrap().history.past_len(), past);
assert_eq!(result["ran"], 0);
assert!(result.get("failed").is_some());
assert_eq!(loaded.unsupported.len(), 1);
assert_eq!(loaded.actions[0].steps[0].0, "actions.photoshopStep");
let (bytes, skipped) = export("S", &[&loaded.actions[0]], &[], false).unwrap();
assert!(skipped.is_empty());
let saved = atn::parse(&bytes).unwrap().actions.into_iter().next().unwrap().items;
assert_eq!(saved.len(), 1);
same_item_wire(saved.into_iter().next().unwrap(), source);
}
/// A normal layer Make, including class-only Make, still creates a blank layer without splitting fx.
#[test]
fn ordinary_layer_make_remains_a_legitimate_control() {
for using in [None, Some(D::Descriptor(Descriptor::new("Lyr ").with("Nm ", D::Text(UnicodeString::new_nul("Ordinary")))))] {
let mut d = Descriptor::new("null").with("null", D::Reference(vec![ReferenceItem::Class(class("Lyr "))]));
if let Some(u) = using {
d = d.with("Usng", u);
}
let a = action(vec![item(CREATE, d)]);
assert_eq!(a.steps[0].0, "layer.new.layer");
let (mut s, source) = fixture(8);
let before = s.active().unwrap().doc.clone();
s.actions.list.push(a);
let result = s.execute("actions.play", json!({"action":0})).unwrap();
assert_eq!(result["ran"], 1);
assert!(result.get("failed").is_none());
let after = s.active().unwrap().doc.clone();
assert_eq!(after.layers.len(), before.layers.len() + 1);
assert_eq!(after.layer(source), before.layer(source));
undo_redo(&mut s, &before, &after, 1);
}
}
/// Every typed container must retain nested effects intent without changing the document or history.
#[test]
fn nested_effects_references_stay_whole_without_a_blank_layer_edit() {
let reference = create().items.pop().unwrap().1;
let nested = Descriptor::new("Lyr ").with("Nm ", D::Text(UnicodeString::new_nul("Before"))).with("data", reference.clone());
let mut deepest = reference.clone();
// Root descriptor depth0, Usng value depth1, options descriptor depth2, data value depth3.
for _ in 3..MAX_DEPTH {
deepest = D::List(vec![D::Boolean(false), deepest]);
}
let cases = [
("reference", reference.clone()),
("descriptor", D::Descriptor(nested.clone())),
("global object", D::GlobalObject(nested.clone())),
("list", D::List(vec![D::Boolean(false), reference])),
("object array", D::ObjectArray(ObjectArray { prefix: 16, body: nested.clone() })),
(
"mixed containers",
D::List(vec![D::Boolean(false), D::GlobalObject(Descriptor::new("Lyr ").with("data", D::ObjectArray(ObjectArray { prefix: 16, body: nested })))]),
),
("parser depth limit", deepest),
];
for (label, value) in cases {
for key in ["Usng", "Usin"] {
let d = Descriptor::new("null")
.with("null", D::Reference(vec![ReferenceItem::Class(class("Lyr "))]))
.with(key, D::Descriptor(Descriptor::new("Lyr ").with("Nm ", D::Text(UnicodeString::new_nul("Ordinary"))).with("data", value.clone())));
let source = item(CREATE, d);
let loaded = import(&atn::write(&file(vec![source.clone()])).unwrap()).unwrap();
let (mut s, _) = fixture(8);
let before = s.active().unwrap().doc.clone();
let past = s.active().unwrap().history.past_len();
s.actions.list.extend(loaded.actions.clone());
let result = s.execute("actions.play", json!({"action": 0})).unwrap();
// Playback precedes classification so the baseline exposes the actual blank-layer edit.
assert!(
s.active().unwrap().doc == before,
"{label}/{key}: nested Lefx changed the document: layers {} -> {}, history {} -> {}",
before.layers.len(),
s.active().unwrap().doc.layers.len(),
past,
s.active().unwrap().history.past_len()
);
assert_eq!(s.active().unwrap().history.past_len(), past, "{label}/{key}");
assert_eq!(result["ran"], 0, "{label}/{key}");
assert!(result.get("failed").is_some(), "{label}/{key}");
assert_eq!(loaded.unsupported.len(), 1, "{label}/{key}");
assert_eq!(loaded.actions[0].steps[0].0, "actions.photoshopStep", "{label}/{key}");
let (bytes, skipped) = export("S", &[&loaded.actions[0]], &[], false).unwrap();
assert!(skipped.is_empty(), "{label}/{key}");
let saved = atn::parse(&bytes).unwrap().actions.into_iter().next().unwrap().items;
assert_eq!(saved.len(), 1, "{label}/{key}");
same_item_wire(saved.into_iter().next().unwrap(), source);
}
}
}
/// Native layer options still play with Lefx as a name or in synthetic opaque metadata.
#[test]
fn ordinary_layer_make_native_export_matches_native_playback() {
let params = json!({"name": "Lefx", "opacity": 0.375, "mode": "Multiply", "clipToPrevious": true});
let opaque = D::List(vec![D::RawData(create().to_bytes()), D::Alias(b"Lefx".to_vec()), D::Path(b"Lefx".to_vec()), D::Text(UnicodeString::new_nul("Lefx"))]);
for metadata in [None, Some(opaque)] {
let mut source = exported("layer.new.layer", params.clone()).unwrap();
if let Some(value) = metadata {
let D::Descriptor(options) = &mut source.descriptor.as_mut().unwrap().items[1].1 else { panic!("native layer options") };
options.items.push((Id::new("data"), value));
}
let a = action(vec![source]);
assert_eq!(a.steps[0], ("layer.new.layer".into(), params.clone()));
let (mut native, _) = fixture(8);
native.execute("layer.new.layer", params.clone()).unwrap();
let (mut recorded, source) = fixture(8);
let before = recorded.active().unwrap().doc.clone();
let past = recorded.active().unwrap().history.past_len();
recorded.actions.list.push(a);
let result = recorded.execute("actions.play", json!({"action": 0})).unwrap();
assert_eq!(result["ran"], 1);
assert!(result.get("failed").is_none());
let after = recorded.active().unwrap().doc.clone();
assert_eq!(after.layers.len(), before.layers.len() + 1);
assert_eq!(after.layer(source), before.layer(source));
assert_eq!(recorded.active().unwrap().history.past_len(), past + 1);
equivalent(&recorded, &native);
undo_redo(&mut recorded, &before, &after, 1);
}
}
+1
View File
@@ -4,6 +4,7 @@ mod affinity;
mod artboard_export_names;
mod atn_crystallize_cell_size;
mod atn_direct_adjustments;
mod atn_layer_style;
mod atn_newer_events;
mod atn_reference_forms;
mod atn_ripple_amount;
+23 -3
View File
@@ -99,7 +99,7 @@ the reference application.
| `CtTL` | cutToLayer | Layer Via Cut | `layer.new.layerViaCut` |
| `LnsF` | lensFlare | Lens Flare | `filter.render.lensFlare` |
| `Lvls` | levels | Levels | `image.adjustments.levels`, `image.autoTone`, `image.autoContrast` |
| `Mk ` | make | Make | `layer.new.layer`, `layer.new.group`, `layer.groupLayers`, `layer.duplicate`, `layer.new.layerViaCopy`, `layer.new.layerViaCut`, `layer.newFillLayer.solidColor`, `layer.newFillLayer.pattern`, `layer.newAdjustmentLayer.levels`, `layer.newAdjustmentLayer.curves`, `layer.newAdjustmentLayer.hueSaturation`, `layer.newAdjustmentLayer.brightnessContrast`, `layer.newAdjustmentLayer.threshold`, `layer.newAdjustmentLayer.posterize`, `layer.newAdjustmentLayer.invert`, `layer.newAdjustmentLayer.colorBalance`, `layer.newAdjustmentLayer.vibrance`, `layer.newAdjustmentLayer.exposure`, `layer.newAdjustmentLayer.photoFilter`, `layer.newAdjustmentLayer.blackWhite`, `layer.newAdjustmentLayer.channelMixer`, `layer.newAdjustmentLayer.selectiveColor`, `layer.newAdjustmentLayer.gradientMap`, `layer.layerMask.revealAll`, `layer.layerMask.hideAll`, `layer.layerMask.revealSelection`, `layer.layerMask.hideSelection`, `file.new`, `file.newFromClipboard`, `history.newSnapshot`, `type.convertToShape` |
| `Mk ` | make | Make | `layer.layerStyle.createLayer`, `layer.new.layer`, `layer.new.group`, `layer.groupLayers`, `layer.duplicate`, `layer.new.layerViaCopy`, `layer.new.layerViaCut`, `layer.newFillLayer.solidColor`, `layer.newFillLayer.pattern`, `layer.newAdjustmentLayer.levels`, `layer.newAdjustmentLayer.curves`, `layer.newAdjustmentLayer.hueSaturation`, `layer.newAdjustmentLayer.brightnessContrast`, `layer.newAdjustmentLayer.threshold`, `layer.newAdjustmentLayer.posterize`, `layer.newAdjustmentLayer.invert`, `layer.newAdjustmentLayer.colorBalance`, `layer.newAdjustmentLayer.vibrance`, `layer.newAdjustmentLayer.exposure`, `layer.newAdjustmentLayer.photoFilter`, `layer.newAdjustmentLayer.blackWhite`, `layer.newAdjustmentLayer.channelMixer`, `layer.newAdjustmentLayer.selectiveColor`, `layer.newAdjustmentLayer.gradientMap`, `layer.layerMask.revealAll`, `layer.layerMask.hideAll`, `layer.layerMask.revealSelection`, `layer.layerMask.hideSelection`, `file.new`, `file.newFromClipboard`, `history.newSnapshot`, `type.convertToShape` |
| (string ID only) | animationFramesFromLayers | Make Frames from Layers | |
| (string ID only) | matchColor | Match Color | `image.adjustments.matchColor` |
| `Mxm ` | maximum | Maximum | `filter.other.maximum` |
@@ -150,6 +150,7 @@ the reference application.
| `Shw ` | show | Show | `layer.showLayers`, `layer.showOnly` |
| `Smlr` | similar | Similar | `select.similar` |
| (string ID only) | selectSky | Sky | `select.sky` |
| (string ID only) | scaleEffectsEvent | Scale Effects | `layer.layerStyle.scaleEffects` |
| `SmrB` | smartBlur | Smart Blur | `filter.blur.smartBlur` |
| (string ID only) | smartSharpen | Smart Sharpen | `filter.sharpen.smartSharpen` |
| `Smth` | smoothness | Smooth | `select.modify.smooth` |
@@ -247,6 +248,27 @@ The separate `CropPhotos0001` helper remains a complete kept Photoshop step.
Playback stops at that enabled helper before subsequent duplicate/crop steps can
run. This covers the parent event, not complete recordings with generated helpers.
### Layer Style menu recordings
`scaleEffectsEvent` maps to `layer.layerStyle.scaleEffects` with the recorded `Scl `
whole percentage in `#Prc`, from 1 through 1000. A missing recorded value is not
given a guessed default. Native export makes its effective default of 100 explicit;
fractional, out-of-range and unrepresented options refuse export.
Create Layer(s) maps Make (`Mk ` / `make`) to `layer.layerStyle.createLayer` when
`null` is the `Lyr ` class reference and `Usng` is the `Lefx` property of the target
layer (`Prpr.Lefx`, `Lyr .Ordn.Trgt`). It has no recorded operation settings.
The existing native command creates the effect layers. The warning dialog is not
an extra action parameter. Explicit native layer IDs have no measured export form.
Malformed references stay complete kept steps, rather than creating a blank layer;
ordinary layer creation still uses its layer-options object or class-only form.
Both mappings retain descriptor identity, order and step flags while unchanged.
A supported scale edit exports its new value canonically; damaged preservation
metadata remains refused. These two rows are the implemented subset of #10723;
the other menu recordings remain separate work. Playback reuses native history
and rendering; this does not claim Photoshop pixel equivalence.
### Settings retained by mapped events
- Transform keeps its recorded `FTcs` reference point. Show with `TglO` true maps to
@@ -524,8 +546,6 @@ layer reference, `Ordn.Trgt`); mapping them needs only the event name.
| Menu | Recorded step | Keys |
|---|---|---|
| Layer > Layer Style > Scale Effects | `scaleEffectsEvent` | `Scl ` percent, whole number 1..1000 (the dialog clamps) |
| Layer > Layer Style > Create Layer(s) | Make, `null` = class `Lyr `, `Usng` = reference to the `Lefx` property of the target layer | none; the dialog first warns that some effects cannot be reproduced with layers (OK / Cancel) |
| Layer > New > Frame from Layers | Make, `null` = class `framedGroupSection`, `From` = layer **by name**, `layerSectionStart`, `layerSectionEnd` long, `Nm ` text | the dialog also shows width and height, which are not recorded |
| Layer > New > Artboard from Layers | Make, `null` = class `artboardSection`, `From` = layer target, `layerSectionStart`, `layerSectionEnd`, `Nm ` | |
| Layer > New > Artboard | Make, `null` = class `artboardSection`, `layerSectionStart`, `layerSectionEnd`, `Nm `, `artboardRect` (`Top `, `Left`, `Btom`, `Rght` doubles, pixels) | |