diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index 41078987cd..d479606938 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -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; diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index 97c64e953f..b611026809 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -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", diff --git a/crates/engine/src/cmd/atn_cmds/steps_layer_style.rs b/crates/engine/src/cmd/atn_cmds/steps_layer_style.rs new file mode 100644 index 0000000000..4b85127cf4 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_layer_style.rs @@ -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 { + 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 { + 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 { + 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 { + 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, ¶meters(id, &source)?)?; + Some(if original == canonical { source } else { canonical }) +} diff --git a/crates/engine/src/cmd/atn_cmds/steps_make.rs b/crates/engine/src/cmd/atn_cmds/steps_make.rs index 9ce3bbb8f7..b45ceddabf 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_make.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_make.rs @@ -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 { + 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()); } diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 9fc345f924..2b32e02220 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -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"})), diff --git a/crates/engine/tests/it/atn_layer_style.rs b/crates/engine/tests/it/atn_layer_style.rs new file mode 100644 index 0000000000..38db75621e --- /dev/null +++ b/crates/engine/tests/it/atn_layer_style.rs @@ -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) -> AtnFile { + AtnFile { + name: UnicodeString::new_nul("S"), + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], + ..Default::default() + } +} + +fn action(items: Vec) -> 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 { + 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 = + (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); + } +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 9b37b126bb..392b7ba517 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -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; diff --git a/docs/atn.md b/docs/atn.md index eb50b7f459..64c6033bf5 100644 --- a/docs/atn.md +++ b/docs/atn.md @@ -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) | |