mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-10 12:22:22 +00:00
Merge PR #11222: Actions: support recorded Type Create Work Path
This commit is contained in:
commit
a7f34dfa9f
8 files changed
+255
-2
No files matched your search
@@ -43,6 +43,7 @@ mod steps_image;
|
||||
mod steps_layer;
|
||||
mod steps_layer_style;
|
||||
mod steps_make;
|
||||
mod steps_type_path;
|
||||
|
||||
mod steps_stylize;
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"})),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
@@ -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 | |
|
||||
|
||||
Reference in new issue
Block a user