diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index d479606938..7b16251b1e 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -43,6 +43,7 @@ mod steps_image; mod steps_layer; mod steps_layer_style; mod steps_make; +mod steps_type_path; mod steps_stylize; diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index b611026809..39cdce665f 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -799,6 +799,7 @@ pub(super) const EVENTS: &[Event] = &[ label: "Make", ids: &[ "type.convertToShape", + "type.createWorkPath", "layer.new.layer", "layer.layerStyle.createLayer", "layer.new.group", diff --git a/crates/engine/src/cmd/atn_cmds/steps_make.rs b/crates/engine/src/cmd/atn_cmds/steps_make.rs index b45ceddabf..eed2974b6a 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 get_ref(d, "null").and_then(ref_class).as_deref() == Some("Path") { + return super::steps_type_path::from(d); + } if let Some(mapped) = super::steps_layer_style::create_from_descriptor(d) { return mapped; } @@ -314,6 +317,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_type_path::COMMAND { + return super::steps_type_path::to(p); + } if id == super::steps_layer_style::CREATE { return super::steps_layer_style::style_to(id, p); } diff --git a/crates/engine/src/cmd/atn_cmds/steps_type_path.rs b/crates/engine/src/cmd/atn_cmds/steps_type_path.rs new file mode 100644 index 0000000000..ec99bfd2e9 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_type_path.rs @@ -0,0 +1,48 @@ +//! The measured Make Path from the target text layer, without guessing named sources. +use super::descriptors::{class_ref, desc, get_ref, jstr, target_ref}; +use super::fields::{Mapped, step}; +use super::{Descriptor, ReferenceItem, Value, json}; + +pub(super) const COMMAND: &str = "type.createWorkPath"; +const SOURCE: &str = "_atnTypePathDescriptor"; + +/// Canonical Make Path descriptor for the active text layer; it carries no path geometry. +fn descriptor() -> Descriptor { + desc("null").with("null", class_ref("Path")).with("From", target_ref("TxLr")) +} + +/// Unknown keys, duplicate keys and different reference chains stay complete kept steps. +fn supported(d: &Descriptor) -> bool { + if d.items.len() != 2 || ["null", "From"].iter().any(|key| d.items.iter().filter(|(k, _)| k.is(key)).count() != 1) { + return false; + } + matches!(get_ref(d, "null"), Some([ReferenceItem::Class(path)]) if path.class_id.is("Path")) + && matches!(get_ref(d, "From"), Some([ReferenceItem::Enumerated { class, type_id, value }]) + if class.class_id.is("TxLr") && type_id.is("Ordn") && value.is("Trgt")) +} + +/// Map only the measured active-text reference, retaining noncanonical wire identity for export. +pub(super) fn from(d: &Descriptor) -> Mapped { + if !supported(d) { + return Mapped::Unsupported { harmless: false }; + } + let mut params = json!({}); + // Descriptor/class names, key order and ID encodings are wire identity, not native options. + if *d != descriptor() { + params[SOURCE] = photocraft_paint::tile::b64_encode(&d.to_bytes()).into(); + } + step(COMMAND, params) +} + +/// Export implicit native playback or a validated original descriptor; explicit targets cannot +/// be represented by this measured form and must use the caller's unsupported-step handling. +pub(super) fn to(p: &Value) -> Option { + if p.as_object()?.keys().any(|key| key != SOURCE) { + return None; + } + if p.get(SOURCE).is_none() { + return Some(descriptor()); + } + let source = Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(jstr(p, SOURCE)?)?).ok()?; + supported(&source).then_some(source) +} diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 2b32e02220..9706756a31 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -619,6 +619,7 @@ fn every_mapped_command_exports_and_imports_back() { ("image.duplicate", json!({"mergedOnly": true})), ("layer.new.layer", json!({"name": "Ink", "opacity": 0.5, "mode": "Multiply"})), ("type.convertToShape", json!({})), + ("type.createWorkPath", json!({})), ("filter.blur.shapeBlur", json!({})), ("filter.blur.shapeBlur", json!({"radius": 10})), ("layer.new.group", json!({"name": "G"})), diff --git a/crates/engine/tests/it/atn_type_path.rs b/crates/engine/tests/it/atn_type_path.rs new file mode 100644 index 0000000000..f5b565b849 --- /dev/null +++ b/crates/engine/tests/it/atn_type_path.rs @@ -0,0 +1,183 @@ +//! Recorded Type > Create Work Path preserves its descriptor and uses native glyph outlines. +use photocraft_doc::{LayerContent, 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, ReferenceItem, UnicodeString, Value as D}, +}; +use serde_json::{Value, json}; + +const COMMAND: &str = "type.createWorkPath"; +const SOURCE: &str = "_atnTypePathDescriptor"; + +fn class(id: &str) -> Class { + Class { name: UnicodeString::new_nul(""), class_id: Id::new(id) } +} + +/// Build the measured descriptor independently of the production converter. +fn descriptor() -> Descriptor { + Descriptor::new("null") + .with("null", D::Reference(vec![ReferenceItem::Class(class("Path"))])) + .with("From", D::Reference(vec![ReferenceItem::Enumerated { class: class("TxLr"), type_id: Id::new("Ordn"), value: Id::new("Trgt") }])) +} + +fn item(d: Descriptor) -> AtnItem { + AtnItem { event: AtnEvent::Code(*b"Mk "), dictionary: b"Make".to_vec(), descriptor: Some(d), ..Default::default() } +} + +/// Exercise the public byte importer rather than invoking the converter directly. +fn load(item: AtnItem) -> photocraft_engine::atn_cmds::Imported { + let bytes = atn::write(&AtnFile { + name: UnicodeString::new_nul("Paths"), + actions: vec![AtnAction { name: UnicodeString::new_nul("Path"), items: vec![item], ..Default::default() }], + ..Default::default() + }) + .unwrap(); + import(&bytes).unwrap() +} + +/// Parse public export bytes so assertions inspect the actual saved ATN item. +fn saved(action: &Action) -> AtnItem { + let bytes = export("Paths", &[action], &[], false).unwrap().0; + atn::parse(&bytes).unwrap().actions.remove(0).items.remove(0) +} + +/// Names, key order, ID encoding and dialog flags must survive both Make event aliases. +#[test] +fn descriptor_identity_flags_and_make_alias_roundtrip() { + for named in [false, true] { + let mut d = descriptor(); + d.name = UnicodeString::new_nul("Recorded path"); + d.class_id = Id::Str(b"null".to_vec()); + d.items.reverse(); + let mut source = item(d); + if named { + source.event = AtnEvent::name("make"); + } + source.enabled = false; + source.with_dialog = true; + source.dialog_options = 2; + let loaded = load(source.clone()); + assert!(loaded.unsupported.is_empty()); + assert_eq!(loaded.actions[0].steps[0].0, COMMAND); + let result = saved(&loaded.actions[0]); + assert_eq!(result.descriptor.unwrap().to_bytes(), source.descriptor.unwrap().to_bytes()); + assert!(!result.enabled); + assert!(result.with_dialog); + assert_eq!(result.dialog_options, 2); + } +} + +/// Compare enabled canonical and retained-descriptor playback with native output at all depths. +/// Glyph bounds distinguish the older active text layer from the newest layer independently. +#[test] +fn playback_matches_native_path_and_preserves_text_with_undo_redo() { + for noncanonical in [false, true] { + for depth in [8, 16, 32] { + let mut recorded = Session::new(); + recorded.execute("file.new", json!({"width":160,"height":100,"depth":depth})).unwrap(); + let text = recorded.execute("type.create", json!({"x":90,"y":80,"text":"ill","size":30})).unwrap(); + let text = LayerId(text["layer"].as_u64().unwrap()); + let other = recorded.execute("type.create", json!({"x":8,"y":35,"text":"o","size":24})).unwrap(); + let other = LayerId(other["layer"].as_u64().unwrap()); + assert_eq!(recorded.active().unwrap().active_layer, Some(other)); + recorded.select_layer(text).unwrap(); + let before = recorded.active().unwrap().doc.clone(); + let glyphs = before.layer(text).unwrap().surface().unwrap().content_bounds(); + let other_glyphs = before.layer(other).unwrap().surface().unwrap().content_bounds(); + let past = recorded.active().unwrap().history.past_len(); + // Obtain the native result on this very document, then restore the starting state. + recorded.execute(COMMAND, json!({})).unwrap(); + let native = recorded.active().unwrap().doc.clone(); + assert!(!native.work_path.as_ref().unwrap().subpaths.is_empty()); + assert!(recorded.undo()); + assert_eq!(recorded.active().unwrap().doc.as_ref(), before.as_ref()); + let mut d = descriptor(); + if noncanonical { + d.items.reverse(); + d.name = UnicodeString::new_nul("Recorded path"); + } + recorded.actions.list = load(item(d)).actions; + if noncanonical { + assert!(recorded.actions.list[0].steps[0].1.get(SOURCE).is_some()); + } + let result = recorded.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 1); + assert!(result.get("failed").is_none(), "{result}"); + let after = recorded.active().unwrap().doc.clone(); + assert_eq!(after.as_ref(), native.as_ref()); + // Rendered glyph bounds are an independent oracle for the implicit active target. + let (x0, y0, x1, y1) = after.work_path.as_ref().unwrap().control_bounds().unwrap(); + for (path, ink) in [(x0, glyphs.x0), (y0, glyphs.y0), (x1, glyphs.x1), (y1, glyphs.y1)] { + assert!((path - f64::from(ink)).abs() < 3.0, "path {path}, active glyph bound {ink}"); + } + assert!(x0 > f64::from(other_glyphs.x1), "the other layer's glyphs must not supply the work path"); + assert!(matches!(after.layer(text).unwrap().content, LayerContent::Text(_))); + assert_eq!(recorded.active().unwrap().history.past_len(), past + 1); + assert!(recorded.undo()); + assert_eq!(recorded.active().unwrap().doc.as_ref(), before.as_ref()); + assert!(recorded.redo()); + assert_eq!(recorded.active().unwrap().doc.as_ref(), after.as_ref()); + } + } +} + +/// Unknown keys and unsupported reference chains remain verbatim kept steps, never guessed mappings. +#[test] +fn unsupported_path_forms_are_kept_and_save_without_loss() { + let original = descriptor(); + let mut malformed = vec![ + original.clone().with("extra", D::Boolean(true)), + original.clone().with("From", D::Boolean(false)), + Descriptor::new("null").with("null", D::Reference(vec![ReferenceItem::Class(class("Path"))])), + ]; + for from in [ + D::Reference(vec![ReferenceItem::Name { class: class("TxLr"), name: UnicodeString::new_nul("Text") }]), + D::Reference(vec![ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") }]), + D::Reference(vec![ReferenceItem::Enumerated { class: class("TxLr"), type_id: Id::new("Ordn"), value: Id::new("Al ") }]), + D::Reference(vec![ReferenceItem::Class(class("TxLr"))]), + ] { + let mut d = original.clone(); + d.items.iter_mut().find(|(k, _)| k.is("From")).unwrap().1 = from; + malformed.push(d); + } + for d in malformed { + let source = item(d); + let loaded = load(source.clone()); + assert_eq!(loaded.unsupported.len(), 1); + assert_eq!(loaded.actions[0].steps[0].0, "actions.photoshopStep"); + assert_eq!(saved(&loaded.actions[0]), source); + } +} + +/// A missing document or non-text receiver must fail without changing document or history. +#[test] +fn non_text_and_no_document_playback_fail_without_edits() { + for document in [false, true] { + let mut s = Session::new(); + if document { + s.execute("file.new", json!({"width":8,"height":8})).unwrap(); + } + let before = s.active().map(|st| (st.doc.clone(), st.history.past_len())); + s.actions.list = load(item(descriptor())).actions; + let result = s.execute("actions.play", json!({"action":0})).unwrap(); + assert_eq!(result["ran"], 0); + assert!(result.get("failed").is_some(), "{result}"); + assert_eq!(s.active().map(|st| (st.doc.clone(), st.history.past_len())), before); + } +} + +/// Explicit native targets and corrupt wire metadata cannot export as the implicit measured form. +#[test] +fn native_export_refuses_explicit_targets_and_damaged_metadata() { + for params in [json!({"layer":1}), json!({"layers":[1]}), json!({(SOURCE):"bad base64"}), json!({(SOURCE):Value::Null})] { + let action = Action::new("Path", vec![(COMMAND.into(), params)]); + assert!(export("Paths", &[&action], &[], false).is_err()); + } + let action = Action::new("Path", vec![(COMMAND.into(), json!({}))]); + assert_eq!(saved(&action).descriptor.unwrap(), descriptor()); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 392b7ba517..54ac07985d 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -11,6 +11,7 @@ mod atn_ripple_amount; mod atn_selective_color; mod atn_shear; mod atn_stroke_width; +mod atn_type_path; mod atn_v12; mod background_and_mask_depth; mod background_layer_consistency; diff --git a/docs/atn.md b/docs/atn.md index 64c6033bf5..e8f62e0653 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.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` | +| `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`, `type.createWorkPath` | | (string ID only) | animationFramesFromLayers | Make Frames from Layers | | | (string ID only) | matchColor | Match Color | `image.adjustments.matchColor` | | `Mxm ` | maximum | Maximum | `filter.other.maximum` | @@ -269,6 +269,19 @@ 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. +### Type Create Work Path recordings + +Make (`Mk ` / `make`) maps to `type.createWorkPath` when `null` is the `Path` +class reference and `From` is the target text layer (`TxLr.Ordn.Trgt`). Playback +uses the existing native glyph-outline command and leaves the text layer editable. +Undo restores the previous work path; Redo restores the new one. + +The mapping preserves the complete descriptor, including key order and reference +names. Unknown or duplicate keys, named sources, other source classes and other +reference chains remain complete kept Photoshop steps. Native export supports +the implicit active text layer only; explicit layer targets refuse export. +This is another implemented subset of #10723, not support for its remaining events. + ### Settings retained by mapped events - Transform keeps its recorded `FTcs` reference point. Show with `TglO` true maps to @@ -554,7 +567,6 @@ layer reference, `Ordn.Trgt`); mapping them needs only the event name. | Layer > Smart Objects > Stack Mode > None | `imageStackConvertSmartObject` | none | | Layer > Video Layers > New Blank Video Layer, Insert Blank Frame, Delete Frame, Show / Hide Altered Video | `addBlankVideoLayer`, `insertBlankFrame`, `deleteFrame`, `showAlteredVideo` / `hideAlteredVideo` | none | | Layer > Video Layers > Interpret Footage | `interpretFootage` | `Vrsn` long (1), `interpretAlpha` (`alphaInterpretation`: `Ignr` ignore, …), `colorConvertAlterations` bool | -| Type > Create Work Path | Make, `null` = class `Path`, `From` = text layer (`TxLr`) target | | | Edit > Find and Replace Text (Replace All) | `replace`, `null` = reference: property `replace` of all text layers (`TxLr` `Ordn.Al`), `Usng` = `find` text, `replace` text, `checkAll`, `Fwd `, `caseSensitive`, `wholeWord`, `ignoreAccents` bool | | | Edit > Check Spelling | `spellCheck`, `null` = property `spellCheck` of all text layers, `Usng` = `checkAll` bool | recorded with its dialog switched on; the corrections themselves are not recorded | | Image > Analysis > Set Measurement Scale > Custom | Set, `null` = property `measurementScale` of the document target, `T ` = `pixelLength` long, `logicalLength` double, `logicalUnits` text | |