Merge PR #11222: Actions: support recorded Type Create Work Path

This commit is contained in:
David Mann committed 2026-11-04 14:50:47 +01:00
commit a7f34dfa9f
8 files changed
+255 -2

No files matched your search

+1
View File
@@ -43,6 +43,7 @@ mod steps_image;
mod steps_layer;
mod steps_layer_style;
mod steps_make;
mod steps_type_path;
mod steps_stylize;
+1
View File
@@ -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",
@@ -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<Descriptor> {
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);
}
@@ -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<Descriptor> {
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)
}
+1
View File
@@ -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"})),
+183
View File
@@ -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());
}
+1
View File
@@ -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;
+14 -2
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.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 | |