Files
photocraft/crates/engine/src/cmd/atn_cmds/tests.rs
T
2026-11-09 19:27:57 +01:00

6996 lines
392 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
use photocraft_psd::atn::{AtnAction, AtnEvent, AtnFile, AtnItem};
use photocraft_psd::descriptor::{Descriptor, Id, ReferenceItem, UnicodeString, Value as D};
use serde_json::json;
/// A wire key has one mapper; distinct wire aliases may still name the same command.
#[test]
fn event_wire_keys_are_unique() {
let mut codes = std::collections::BTreeSet::new();
let mut names = std::collections::BTreeSet::new();
for event in EVENTS {
assert!(event.code.is_empty() || codes.insert(event.code), "duplicate ATN code {}", event.code);
assert!(event.name.is_empty() || names.insert(event.name), "duplicate ATN name {}", event.name);
}
}
#[test]
fn blend_modes_accept_photoshop_string_ids_and_export_canonical_ids() {
for (string_id, char_id, label) in [
("normal", "Nrml", "Normal"),
("dissolve", "Dslv", "Dissolve"),
("darken", "Drkn", "Darken"),
("multiply", "Mltp", "Multiply"),
("colorBurn", "CBrn", "Color Burn"),
("lighten", "Lghn", "Lighten"),
("screen", "Scrn", "Screen"),
("colorDodge", "CDdg", "Color Dodge"),
("overlay", "Ovrl", "Overlay"),
("softLight", "SftL", "Soft Light"),
("hardLight", "HrdL", "Hard Light"),
("difference", "Dfrn", "Difference"),
("exclusion", "Xclu", "Exclusion"),
("hue", "H", "Hue"),
("saturation", "Strt", "Saturation"),
("color", "Clr", "Color"),
("luminosity", "Lmns", "Luminosity"),
("passThrough", "passThrough", "Pass Through"),
] {
assert_eq!(blend_label(string_id), Some(label), "{string_id}");
assert_eq!(blend_ps(label), Some(char_id), "{label} remains serialized canonically");
}
}
/// #8454: a Set and a Make layer step whose blend mode is stored as a Photoshop stringID import
/// with that mode (a Set was unsupported, a Make silently fell back to Normal).
#[test]
fn layer_steps_with_string_id_blend_modes_import_with_their_mode() {
let set = desc("null").with("null", target_ref("Lyr ")).with("T ", D::Descriptor(desc("Lyr ").with("Md ", enm("BlnM", "softLight"))));
let make = desc("null").with("null", class_ref("Lyr ")).with("Usng", D::Descriptor(desc("Lyr ").with("Md ", enm("BlnM", "luminosity"))));
let file = AtnFile {
name: UnicodeString::new_nul("Blend"),
expanded: false,
actions: vec![AtnAction {
name: UnicodeString::new_nul("A"),
items: vec![item("setd", "Set", Some(set)), item("Mk ", "Make", Some(make))],
..Default::default()
}],
warnings: vec![],
..Default::default()
};
let imp = import(&atn::write(&file).unwrap()).unwrap();
assert!(imp.unsupported.is_empty(), "{:?}", imp.unsupported.iter().map(|u| &u.event).collect::<Vec<_>>());
let steps = &imp.actions[0].steps;
assert_eq!(steps.len(), 2);
assert_eq!(steps[0].0, "layer.setProps");
assert_eq!(steps[0].1["blend"], json!("Soft Light"));
assert_eq!(steps[1].0, "layer.new.layer");
assert_eq!(steps[1].1["mode"], json!("Luminosity"));
}
fn item(event: &str, label: &str, d: Option<Descriptor>) -> AtnItem {
let event = match event.as_bytes() {
[a, b, c, dd] => AtnEvent::Code([*a, *b, *c, *dd]),
_ => AtnEvent::name(event),
};
AtnItem { event, dictionary: label.as_bytes().to_vec(), descriptor: d, ..Default::default() }
}
fn b64(bytes: &[u8]) -> String {
photocraft_paint::tile::b64_encode(bytes)
}
fn color_lookup_case() -> Value {
let bytes = b"LUT_3D_SIZE 2\n0 0 0\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n";
let d = desc("null")
.with("lookupType", enm("colorLookupType", "3DLUT"))
.with("Nm ", text("identity.cube"))
.with("Dthr", D::Boolean(false))
.with("profile", D::RawData(Vec::new()))
.with("LUTFormat", enm("LUTFormatType", "LUTFormatCUBE"))
.with("LUT3DFileData", D::RawData(Vec::new()))
.with("LUT3DFileName", text("identity.cube"));
json!({"data":b64(bytes),"dataEncoding":"base64","lutFormat":"cube","fileName":"identity.cube","dither":false,"__atnColorLookup":b64(&d.to_bytes())})
}
/// Keep the starting counter nested in naming rules, as recorded by the Batch dialog.
fn batch_descriptor(component: &str) -> Descriptor {
let rules = desc("fileNamingRules")
.with("fileNamingComponents", D::List(vec![D::Descriptor(desc("fileNamingComponent").with("component", enm("fileNamingComponent", component)))]))
.with("Strt", D::Integer(7))
.with("Mcnt", D::Boolean(true))
.with("Win ", D::Boolean(false))
.with("unix", D::Boolean(true));
desc("Btch")
.with("null", path_value("/input"))
.with("Fldr", D::Boolean(false))
.with(
"Usng",
D::Reference(vec![
ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul("Child") },
ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul("S") },
]),
)
.with("T ", path_value("/output"))
.with("Ovrd", D::Boolean(false))
.with("fileNamingRules", D::Descriptor(rules))
}
/// Preserve the recorded template in the generic registry round-trip witness.
fn batch_case() -> Value {
json!({"input":"/input","output":"/output","action":"Child","set":"S","flatOutput":true,"includeSubfolders":false,"overrideSaveAs":false,"stopOnError":true,"includeNonImages":true,
"namingComponents":[{"component":"serial3"}],"startingSerial":7,"compatibility":{"macintosh":true,"windows":false,"unix":true},
"__atnBatch":b64(&batch_descriptor("threeDigit").to_bytes())})
}
/// Save and Close replaces the folder path while retaining its recorded naming metadata.
fn batch_save_and_close_case() -> Value {
let mut d = batch_descriptor("threeDigit");
d.items.retain(|(key, _)| !key.is("T "));
let d = d.with("SvAn", D::Boolean(true));
let mut p = batch_case();
p.as_object_mut().unwrap().remove("output");
p.as_object_mut().unwrap().remove("flatOutput");
p["destination"] = json!("saveAndClose");
p["__atnBatch"] = json!(b64(&d.to_bytes()));
p
}
/// Destination changes retain exact descriptors and reject ambiguous output/override settings.
#[test]
fn batch_save_and_close_round_trips_without_folder_defaults() {
let p = batch_save_and_close_case();
let d = super::steps_batch::batch_to("file.automate.batch", &p).unwrap();
let bytes = photocraft_paint::tile::b64_decode(&set_of("S", vec![("Driver", vec![item("Btch", "Batch", Some(d.clone()))])])).unwrap();
let imported = import(&bytes).unwrap();
assert!(imported.unsupported.is_empty());
assert_eq!(imported.actions[0].steps[0].1, p);
let (out, skipped) = export("S", &[&imported.actions[0]], &[], false).unwrap();
assert!(skipped.is_empty());
assert_eq!(atn::parse(&out).unwrap().actions[0].items[0].descriptor, Some(d.clone()));
for bad in [d.clone().with("T ", path_value("/other")), {
let mut bad = d.clone();
bad.items.retain(|(key, _)| !key.is("Ovrd"));
bad.with("Ovrd", D::Boolean(true))
}] {
let bytes = photocraft_paint::tile::b64_decode(&set_of("S", vec![("Driver", vec![item("Btch", "Batch", Some(bad.clone()))])])).unwrap();
let imported = import(&bytes).unwrap();
assert_eq!(imported.actions[0].steps[0].0, PHOTOSHOP_STEP);
let (out, _) = export("S", &[&imported.actions[0]], &[], false).unwrap();
assert_eq!(atn::parse(&out).unwrap().actions[0].items[0].descriptor, Some(bad));
}
}
/// Each documented component and portability flag must survive native conversion and save.
#[test]
fn batch_all_documented_components_and_compatibility_flags_round_trip() {
for &(wire, native) in crate::file_cmds::BATCH_NAMING_COMPONENTS {
let d = batch_descriptor(wire);
let bytes = photocraft_paint::tile::b64_decode(&set_of("S", vec![("Driver", vec![item("Btch", "Batch", Some(d.clone()))])])).unwrap();
let imported = import(&bytes).unwrap();
assert!(imported.unsupported.is_empty(), "{wire}");
let p = &imported.actions[0].steps[0].1;
assert_eq!(p["namingComponents"], json!([{"component":native}]));
assert_eq!(p["startingSerial"], 7);
assert_eq!(p["stopOnError"], true);
assert_eq!(p["includeNonImages"], true);
assert_eq!(p["compatibility"], json!({"macintosh":true,"windows":false,"unix":true}));
let (out, skipped) = export("S", &[&imported.actions[0]], &[], false).unwrap();
assert!(skipped.is_empty());
assert_eq!(atn::parse(&out).unwrap().actions[0].items[0].descriptor, Some(d));
}
}
/// Batch's Open binding round-trips, but richer Open descriptors must remain whole.
#[test]
fn batch_override_open_and_unrepresented_open_settings_round_trip() {
for enabled in [false, true] {
let mut p = batch_case();
p.as_object_mut().unwrap().remove("__atnBatch");
p["overrideOpen"] = json!(enabled);
p["suppressWarnings"] = json!(enabled);
p["suppressOpenOptions"] = json!(enabled);
let a = Action::new("Driver", vec![("file.automate.batch".into(), p)]);
let (bytes, skipped) = export("S", &[&a], &[], false).unwrap();
assert!(skipped.is_empty());
let loaded = import(&bytes).unwrap();
assert_eq!(loaded.actions[0].steps[0].1["overrideOpen"], enabled);
assert_eq!(loaded.actions[0].steps[0].1["suppressWarnings"], enabled);
assert_eq!(loaded.actions[0].steps[0].1["suppressOpenOptions"], enabled);
let (again, skipped) = export("S", &[&loaded.actions[0]], &[], false).unwrap();
assert!(skipped.is_empty());
assert_eq!(atn::parse(&again).unwrap().actions[0].items[0].descriptor, atn::parse(&bytes).unwrap().actions[0].items[0].descriptor);
}
let rich = desc("null").with("null", path_value("/recorded.png")).with("unrepresentedOpenSetting", D::Boolean(true));
let bytes = photocraft_paint::tile::b64_decode(&set_of("S", vec![("Driver", vec![item("Opn ", "Open", Some(rich.clone()))])])).unwrap();
let loaded = import(&bytes).unwrap();
assert_eq!(loaded.actions[0].steps[0].0, PHOTOSHOP_STEP);
let (again, _) = export("S", &[&loaded.actions[0]], &[], false).unwrap();
assert_eq!(atn::parse(&again).unwrap().actions[0].items[0].descriptor, Some(rich));
assert!(super::steps_doc::open_to("file.openAs", &json!({"path":"/recorded.png","as":"png"})).is_none());
}
/// Unsupported modes and misplaced fields must not lose data through a partial conversion.
#[test]
fn batch_unknown_components_wrong_start_placement_and_unsupported_modes_stay_whole() {
let mut cases = vec![batch_descriptor("unknownComponent")];
for (key, value) in [("Strt", D::Integer(3)), ("OvrO", D::Integer(1)), ("SvAn", D::Boolean(true)), ("Log ", path_value("/log"))] {
cases.push(batch_descriptor("Nm ").with(key, value));
}
let mut odd = batch_descriptor("Nm ");
odd.items.push((Id::new("Fldr"), D::Boolean(true))); // Duplicate keys cannot disappear on save.
cases.push(odd);
for d in cases {
let bytes = photocraft_paint::tile::b64_decode(&set_of("S", vec![("Driver", vec![item("Btch", "Batch", Some(d.clone()))])])).unwrap();
let imported = import(&bytes).unwrap();
assert_eq!(imported.actions[0].steps[0].0, PHOTOSHOP_STEP);
let (out, _) = export("S", &[&imported.actions[0]], &[], false).unwrap();
assert_eq!(atn::parse(&out).unwrap().actions[0].items[0].descriptor, Some(d));
}
let mut p = batch_case();
p["target"] = json!("mask");
assert!(super::steps_batch::batch_to("file.automate.batch", &p).is_none());
for key in ["stopOnError", "includeNonImages"] {
for value in [Value::Null, json!(false), json!("yes")] {
let mut p = batch_case();
p[key] = value;
assert!(super::steps_batch::batch_to("file.automate.batch", &p).is_none());
}
let mut p = batch_case();
p.as_object_mut().unwrap().remove(key);
assert!(super::steps_batch::batch_to("file.automate.batch", &p).is_none());
}
}
/// A set with a mapped filter, an adjustment layer, selection steps, a layer by name, a Stop,
/// a Play, a tool selection (no document effect) and an event PhotoCraft has no command for.
fn sample() -> AtnFile {
let levels = desc("Lvls").with(
"Adjs",
D::List(vec![D::Descriptor(
desc("LvlA")
.with("Chnl", channel_ref(None))
.with("Inpt", D::List(vec![long(10.0), long(240.0)]))
.with("Gmm ", D::Double(1.2))
.with("Otpt", D::List(vec![long(0.0), long(250.0)])),
)]),
);
let make_levels = desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(levels))));
let select_all =
desc("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }])).with("T ", enm("Ordn", "Al "));
let select_layer = desc("null").with("null", name_ref("Lyr ", "Shadow")).with("MkVs", D::Boolean(false));
let tool = desc("null").with("null", class_ref("MvTl"));
let play = desc("null").with("null", D::Reference(vec![ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul("Finish") }]));
let stop = desc("null").with("Msge", text("Check the result")).with("Cntn", D::Boolean(true));
let fancy = desc("null").with("prompt", text("a cat")).with("DocI", long(5.0));
let mut blur = item("GsnB", "Gaussian Blur", Some(desc("null").with("Rds ", px(2.5))));
blur.with_dialog = true;
let mut off = item("Fltn", "Flatten Image", None);
off.enabled = false;
AtnFile {
name: UnicodeString::new_nul("Test Set"),
expanded: true,
actions: vec![
AtnAction {
name: UnicodeString::new_nul("Soften"),
function_key: 3,
items: vec![
blur,
item("Mk ", "Make", Some(make_levels)),
item("setd", "Set", Some(select_all)),
item("select", "Select", Some(select_layer)),
item("slct", "Select", Some(tool)),
item("Stop", "Stop", Some(stop)),
item("syntheticFill", "Generative Fill", Some(fancy)),
item("Ply ", "Play", Some(play)),
off,
],
..Default::default()
},
AtnAction { name: UnicodeString::new_nul("Finish"), items: vec![item("Invs", "Inverse", None)], ..Default::default() },
],
warnings: Vec::new(),
..Default::default()
}
}
#[test]
fn layer_comp_apply_imports_plays_and_undoes() {
let name = "合成 𠮷";
let data = set_of("Comps", vec![("Apply", vec![item("applyComp", "Apply Layer Comp", Some(desc("null").with("null", name_ref("compsClass", name))))])]);
for depth in [8, 16, 32] {
let mut s = Session::new();
let loaded = s.execute("actions.load", json!({"data": data})).unwrap();
assert_eq!(loaded["unsupported"], json!([]), "{loaded}");
assert_eq!(s.actions.list[0].steps, [("layerComp.apply".into(), json!({"comp": name}))]);
s.execute("file.new", json!({"width": 8, "height": 8, "depth": depth})).unwrap();
s.execute("layerComp.new", json!({"name": "Other"})).unwrap();
let before = s.active().unwrap().doc.clone();
let played = s.execute("actions.play", json!({"action": "Apply"})).unwrap();
assert_eq!(played["ran"], 0, "{played}");
assert!(played["failed"]["error"].as_str().unwrap().contains(name), "{played}");
assert_eq!(*s.active().unwrap().doc, *before, "a missing comp must not apply another comp");
let comp = s.execute("layerComp.new", json!({"name": name})).unwrap()["comp"].clone();
let layer = s.active().unwrap().active_layer.unwrap();
s.execute("layer.setProps", json!({"visible": false})).unwrap();
s.execute("layerComp.new", json!({"name": "Hidden"})).unwrap();
let played = s.execute("actions.play", json!({"action": "Apply"})).unwrap();
assert_eq!(played["ran"], 1, "{played}");
assert!(played.get("failed").is_none(), "{played}");
assert!(s.active().unwrap().doc.layer(layer).unwrap().visible);
assert_eq!(json!(s.active().unwrap().doc.last_applied_comp), comp);
assert!(s.undo());
assert!(!s.active().unwrap().doc.layer(layer).unwrap().visible);
assert!(s.redo());
assert!(s.active().unwrap().doc.layer(layer).unwrap().visible);
}
}
#[test]
fn layer_comp_apply_round_trips_by_name() {
for name in ["Comp 1", "合成 𠮷"] {
let descriptor = desc("null").with("null", name_ref("compsClass", name));
let data = set_of("Comps", vec![("Apply", vec![item("applyComp", "Apply Layer Comp", Some(descriptor.clone()))])]);
let mut s = Session::new();
let loaded = s.execute("actions.load", json!({"data": data})).unwrap();
assert_eq!(loaded["unsupported"], json!([]), "{loaded}");
let saved = s.execute("actions.save", json!({"set": "Comps"})).unwrap();
assert_eq!(saved["skipped"], json!([]));
let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap();
let file = atn::parse(&bytes).unwrap();
assert_eq!(file.actions[0].items[0].event, AtnEvent::Name("applyComp".into()));
assert_eq!(file.actions[0].items[0].descriptor, Some(descriptor));
let mut reloaded = Session::new();
reloaded.execute("actions.load", json!({"data": saved["data"]})).unwrap();
assert_eq!(reloaded.actions.list, s.actions.list);
}
// Local comp ids and the default comp cannot be resolved to a portable name at export.
for params in [json!({}), json!({"comp": 1}), json!({"comp": null}), json!({"comp": true}), json!({"comp": []})] {
assert!(export_item("layerComp.apply", &params, "Comps").is_none(), "{params}");
}
}
#[test]
fn layer_comp_apply_unknown_references_are_kept_and_stop_playback() {
let references = [
None,
Some(D::Integer(1)),
Some(D::Reference(vec![])),
Some(name_ref("Lyr ", "Comp 1")),
Some(class_ref("compsClass")),
Some(D::Reference(vec![ReferenceItem::Offset { class: class("compsClass"), offset: 1 }])),
Some(D::Reference(vec![ReferenceItem::Enumerated { class: class("compsClass"), type_id: code("Ordn"), value: code("unsupported") }])),
Some(D::Reference(vec![
ReferenceItem::Name { class: class("compsClass"), name: UnicodeString::new_nul("Comp 1") },
ReferenceItem::Name { class: class("Dcmn"), name: UnicodeString::new_nul("Other") },
])),
];
let items: Vec<_> =
references.into_iter().map(|reference| item("applyComp", "Apply Layer Comp", reference.map(|r| desc("null").with("null", r)))).collect();
let data = set_of("Comps", vec![("Unknown", items.clone())]);
let mut s = Session::new();
let loaded = s.execute("actions.load", json!({"data": data})).unwrap();
assert_eq!(loaded["unsupported"].as_array().unwrap().len(), items.len());
assert!(s.actions.list[0].steps.iter().all(|(id, _)| id == PHOTOSHOP_STEP));
assert!(s.actions.list[0].disabled.is_empty());
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("layerComp.new", json!({"name": "Comp 1"})).unwrap();
let before = s.active().unwrap().doc.clone();
let played = s.execute("actions.play", json!({"action": "Unknown"})).unwrap();
assert_eq!(played["ran"], 0, "{played}");
assert!(played["failed"]["error"].as_str().unwrap().contains("Apply Layer Comp"), "{played}");
assert_eq!(*s.active().unwrap().doc, *before);
let saved = s.execute("actions.save", json!({"set": "Comps"})).unwrap();
let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap();
let file = atn::parse(&bytes).unwrap();
assert_eq!(file.actions[0].items.len(), items.len());
for (saved, original) in file.actions[0].items.iter().zip(&items) {
assert_eq!(saved.event, original.event);
assert_eq!(saved.descriptor, original.descriptor);
}
}
#[test]
fn load_maps_events_keeps_flags_and_plays() {
let mut s = Session::new();
let bytes = atn::write(&sample()).unwrap();
let r = s.execute("actions.load", json!({"data": b64(&bytes)})).unwrap();
assert_eq!(r["set"], "Test Set");
assert_eq!(r["actions"], json!(["Soften", "Finish"]));
assert_eq!(r["steps"], 10);
assert_eq!(r["functionKeys"], json!([{"action": "Soften", "key": "F3"}]));
assert_eq!(s.prefs().shortcuts.get("actions.play:Soften").map(String::as_str), Some("F3"));
let unsupported: Vec<(String, String)> =
r["unsupported"].as_array().unwrap().iter().map(|u| (u["event"].as_str().unwrap().into(), u["name"].as_str().unwrap().into())).collect();
assert_eq!(unsupported, [("slct".to_string(), "Select".to_string()), ("syntheticFill".to_string(), "Generative Fill".to_string())]);
let a = &s.actions.list[0];
assert_eq!(a.set.as_deref(), Some("Test Set"));
let ids: Vec<&str> = a.steps.iter().map(|(id, _)| id.as_str()).collect();
assert_eq!(
ids,
[
"filter.blur.gaussianBlur",
"layer.newAdjustmentLayer.levels",
"select.all",
"layer.select",
PHOTOSHOP_STEP,
STOP_MESSAGE,
PHOTOSHOP_STEP,
"actions.play",
"layer.flattenImage"
]
);
assert_eq!(a.steps[0].1, json!({"radius": 2.5}));
assert_eq!(a.steps[1].1, json!({"inBlack": 10, "inWhite": 240, "gamma": 1.2, "outBlack": 0, "outWhite": 250}));
assert_eq!(a.steps[3].1, json!({"layer": "Shadow"}));
assert_eq!(a.steps[5].1, json!({"message": "Check the result", "allowContinue": true}));
assert_eq!(a.steps[6].1["event"], "syntheticFill");
assert_eq!(a.steps[7].1, json!({"action": "Finish"}));
assert_eq!(a.dialogs.iter().copied().collect::<Vec<_>>(), [0], "the dialog toggle is kept");
assert_eq!(a.disabled.iter().copied().collect::<Vec<_>>(), [4, 8], "tool selections are switched off, as is the step the file had off");
assert_eq!(s.actions.list[1].steps, [("select.inverse".to_string(), json!({}))]);
// Playback: the mapped steps run, the layer is found by name, and the kept Photoshop step
// stops the action with its name.
s.execute("file.new", json!({"width": 16, "height": 16})).unwrap();
s.execute("layer.new.layer", json!({"name": "Shadow"})).unwrap();
s.execute("layer.new.layer", json!({"name": "Top"})).unwrap();
let played = s.execute("actions.play", json!({"action": "Soften"})).unwrap();
assert_eq!(played["ran"], 5, "{played}");
assert_eq!(played["failed"]["step"], 6);
assert!(played["failed"]["error"].as_str().unwrap().contains("Generative Fill"), "{played}");
let d = s.active().unwrap();
assert_eq!(d.doc.layer(d.active_layer.unwrap()).unwrap().name, "Shadow");
assert!(d.doc.walk().iter().any(|(_, _, l)| l.name.starts_with("Levels")));
// Loading the same set again replaces it; a different set with the same action names
// gets unique names and its Play steps follow.
s.execute("actions.load", json!({"data": b64(&bytes)})).unwrap();
assert_eq!(s.actions.list.len(), 2);
let r = s.execute("actions.load", json!({"data": b64(&bytes), "set": "Other"})).unwrap();
assert_eq!(r["actions"], json!(["Soften 2", "Finish 2"]));
assert_eq!(s.actions.list[2].steps[7].1, json!({"action": "Finish 2"}));
assert_eq!(s.actions.list[2].set.as_deref(), Some("Other"));
}
/// Renames that chain ("A" becomes "A 2" while the file's own "A 2" becomes "A 2 2") rewrite
/// each Play and Conditional target once, to the renamed copy of the action it named.
#[test]
fn load_renames_each_play_target_once() {
let mut s = Session::new();
let mut mine = Action::new("A", vec![]);
mine.set = Some("Mine".into());
s.actions.list.push(mine);
let a = Action::new("A", vec![("actions.conditional".into(), json!({"if": "always", "then": "A", "else": "A 2"}))]);
let a2 = Action::new("A 2", vec![("actions.play".into(), json!({"action": "A"}))]);
let keys = [("A".to_string(), "F4".to_string())];
let (bytes, _) = export("File", &[&a, &a2], &keys, false).unwrap();
let r = s.execute("actions.load", json!({"data": b64(&bytes)})).unwrap();
assert_eq!(r["actions"], json!(["A 2", "A 2 2"]));
let get = |n: &str| s.actions.list.iter().find(|a| a.name == n && a.set.as_deref() == Some("File")).unwrap();
assert_eq!(get("A 2 2").steps[0].1["action"], "A 2", "plays the file's A, not its A 2");
let cond = &get("A 2").steps[0].1;
assert_eq!((&cond["then"], &cond["else"]), (&json!("A 2"), &json!("A 2 2")), "{cond}");
assert_eq!(r["functionKeys"], json!([{"action": "A 2", "key": "F4"}]));
// Adding to a set of the same name leaves the Play steps already in it alone.
let mut s = Session::new();
for (name, steps) in [("A", vec![]), ("B", vec![("actions.play".to_string(), json!({"action": "A"}))])] {
let mut a = Action::new(name, steps);
a.set = Some("File".into());
s.actions.list.push(a);
}
let (bytes, _) = export("File", &[&Action::new("A", vec![])], &[], false).unwrap();
let r = s.execute("actions.load", json!({"data": b64(&bytes), "replace": false})).unwrap();
assert_eq!(r["actions"], json!(["A 2"]));
assert_eq!(s.actions.list[1].steps[0].1, json!({"action": "A"}), "B still plays the A it had");
}
/// A name over the 1,024-character limit is clipped for the action and for the steps that call it,
/// so a rename still finds its Play target.
#[test]
fn load_renames_play_targets_longer_than_the_name_limit() {
let mut s = Session::new();
let long = "x".repeat(2000);
let clipped = "x".repeat(1024);
let mut mine = Action::new(clipped.clone(), vec![]);
mine.set = Some("Mine".into());
s.actions.list.push(mine);
let a = Action::new(long.clone(), vec![]);
let caller = Action::new("Caller", vec![("actions.play".into(), json!({"action": long}))]);
let (bytes, _) = export("File", &[&a, &caller], &[], false).unwrap();
s.execute("actions.load", json!({"data": b64(&bytes)})).unwrap();
let renamed = format!("{clipped} 2");
let caller = s.actions.list.iter().find(|a| a.name == "Caller").unwrap();
assert_eq!(caller.steps[0].1["action"], renamed.as_str());
}
#[test]
fn load_many_duplicate_names_get_sequential_suffixes() {
// Several actions share one base name. Deduplication must stay O(n) overall and produce
// the sequence A, A 2, A 3, A 4 (the next-suffix cache cannot skip or repeat a number).
let mut s = Session::new();
let dupes: Vec<Action> = (0..4).map(|_| Action::new("A", vec![])).collect();
let refs: Vec<&Action> = dupes.iter().collect();
let (bytes, _) = export("File", &refs, &[], false).unwrap();
let r = s.execute("actions.load", json!({"data": b64(&bytes)})).unwrap();
assert_eq!(r["actions"], json!(["A", "A 2", "A 3", "A 4"]));
}
#[test]
fn save_round_trips_through_photoshop_events() {
let mut s = Session::new();
let bytes = atn::write(&sample()).unwrap();
s.execute("actions.load", json!({"data": b64(&bytes)})).unwrap();
let first = s.actions.list.clone();
let r = s.execute("actions.save", json!({"set": "Test Set"})).unwrap();
assert_eq!(r["actions"], json!(["Soften", "Finish"]));
assert_eq!(r["skipped"], json!([]));
let saved = photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap();
let file = atn::parse(&saved).unwrap();
assert_eq!(file.name.to_string_lossy(), "Test Set");
assert_eq!(file.actions[0].function_key, 3);
let events: Vec<String> = file.actions[0].items.iter().map(|i| i.event.id()).collect();
assert_eq!(events, ["GsnB", "Mk ", "setd", "slct", "slct", "Stop", "syntheticFill", "Ply ", "FltI"]);
assert!(file.actions[0].items[0].with_dialog);
assert!(!file.actions[0].items[8].enabled);
// The kept Photoshop step is written back byte for byte.
assert_eq!(file.actions[0].items[6].descriptor, sample().actions[0].items[6].descriptor);
// Play names the set too, as Photoshop does.
assert_eq!(get_ref(file.actions[0].items[7].descriptor.as_ref().unwrap(), "null").unwrap().len(), 2);
// Loading what was saved gives the same actions.
let mut t = Session::new();
t.execute("actions.load", json!({"data": b64(&saved)})).unwrap();
assert_eq!(t.actions.list, first);
// A step with no Photoshop equivalent fails unless skipped.
s.actions.list.push(Action::new("Ours", vec![("unsupported.forTest".into(), json!({}))]));
let e = s.execute("actions.save", json!({"action": "Ours"})).unwrap_err().to_string();
assert!(e.contains("unsupported.forTest"), "{e}");
let r = s.execute("actions.save", json!({"action": "Ours", "skipUnsupported": true})).unwrap();
assert_eq!(r["skipped"], json!([{"action": "Ours", "step": 0, "id": "unsupported.forTest"}]));
assert_eq!(r["set"], "Ours");
}
/// `image.adjustments.selectiveColor` takes a range as an array (`"reds": [c, m, y, k]`) or as
/// the scalar form that names one range (`"colors": "reds", "cyan": 35`). Only the arrays were
/// written, so a step recorded the other way exported as an adjustment that changes nothing, with
/// `skipped` empty (#7672).
#[test]
fn a_selective_color_step_in_the_scalar_form_keeps_its_values() {
let back = |params: Value| -> Value {
let mut s = Session::new();
s.actions.list.push(Action::new("A", vec![("image.adjustments.selectiveColor".into(), params)]));
let r = s.execute("actions.save", json!({"action": "A"})).unwrap();
assert_eq!(r["skipped"], json!([]));
let imported = import(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap();
imported.actions[0].steps[0].1.clone()
};
// The numbers are read back as numbers; a percentage may arrive as 35 or 35.0.
let range =
|step: &Value, name: &str| -> Vec<f64> { step[name].as_array().unwrap_or(&Vec::new()).iter().map(|v| v.as_f64().unwrap_or(f64::NAN)).collect() };
// The reported step: one range, one channel.
let scalar = back(json!({"method": "absolute", "colors": "reds", "cyan": 35}));
assert_eq!(scalar["method"], "absolute");
assert_eq!(range(&scalar, "reds"), [35.0, 0.0, 0.0, 0.0], "{scalar}");
// The scalar form over an array keeps both, as playing the step would.
let both = back(json!({"reds": [10, 20, 30, 40], "colors": "reds", "cyan": 35}));
assert_eq!(range(&both, "reds"), [35.0, 20.0, 30.0, 40.0], "{both}");
// The array form is unchanged.
let array = back(json!({"method": "relative", "yellows": [0, 0, -15, 5]}));
assert_eq!(range(&array, "yellows"), [0.0, 0.0, -15.0, 5.0], "{array}");
// A channel the command would refuse is not quietly dropped: the step is refused too, since
// writing it would export an adjustment that is not the one recorded. `null` is absent, as
// the command reads it.
let mut s = Session::new();
s.actions.list.push(Action::new("A", vec![("image.adjustments.selectiveColor".into(), json!({"colors": "reds", "cyan": "35"}))]));
let e = s.execute("actions.save", json!({"action": "A"})).unwrap_err().to_string();
assert!(e.contains("image.adjustments.selectiveColor"), "{e}");
let nulled = back(json!({"colors": "reds", "cyan": 35, "magenta": null}));
assert_eq!(range(&nulled, "reds"), [35.0, 0.0, 0.0, 0.0], "{nulled}");
}
/// A setting the exported event has no field for is dropped by export, and the step then plays back
/// as a different edit: Crystallize's `seed` (#7668), Radial Blur's centre, Minimum's and
/// Maximum's `preserve`, Equalize's `selectedAreaOnly`. Such a step is refused, as one with no
/// equivalent event already is. So is an adjustment edit that names its layer: the event edits the
/// active one (#8532).
#[test]
fn a_setting_the_event_cannot_hold_is_refused() {
for (id, params) in [
("filter.pixelate.crystallize", json!({"cellSize": 5, "seed": 37})),
("filter.blur.radialBlur", json!({"amount": 10, "centerX": 0.25, "centerY": 0.5})),
("filter.other.minimum", json!({"radius": 4, "preserve": "roundness"})),
("filter.other.maximum", json!({"radius": 4, "preserve": "roundness"})),
("image.adjustments.equalize", json!({"selectedAreaOnly": false})),
// Equalize reads `selectedAreaOnly` as a boolean only and refuses the string and null.
("image.adjustments.equalize", json!({"selectedAreaOnly": "true"})),
("image.adjustments.equalize", json!({"selectedAreaOnly": null})),
("layer.setAdjustment", json!({"kind": "exposure", "layer": "Target_existing", "exposure": -0.75, "offset": 0.035, "gamma": 1.37})),
("layer.setAdjustment", json!({"kind": "exposure", "document": 2, "exposure": -0.75})),
] {
let mut s = Session::new();
s.actions.list.push(Action::new("A", vec![(id.into(), params)]));
let before = s.actions.list[0].steps.clone();
let e = s.execute("actions.save", json!({"action": "A"})).unwrap_err().to_string();
assert!(e.contains(id), "{id}: {e}");
// A refused save leaves the action as it was: the step is still there to be played.
assert_eq!(s.actions.list[0].steps, before, "{id}");
let r = s.execute("actions.save", json!({"action": "A", "skipUnsupported": true})).unwrap();
assert_eq!(r["skipped"], json!([{"action": "A", "step": 0, "id": id}]), "{id}");
assert_eq!(s.actions.list[0].steps, before, "{id}: skipping a step does not drop it");
}
}
/// The same steps at the value the event plays as, and with the setting absent: written as before.
#[test]
fn settings_at_the_value_the_event_plays_still_export() {
for (id, params) in [
("filter.pixelate.crystallize", json!({"cellSize": 5, "seed": 0})),
("filter.pixelate.crystallize", json!({"cellSize": 5})),
("filter.blur.radialBlur", json!({"amount": 10, "centerX": 0.5, "centerY": 0.5})),
("filter.blur.radialBlur", json!({"amount": 10})),
("filter.other.minimum", json!({"radius": 4, "preserve": "squareness"})),
("filter.other.maximum", json!({"radius": 4})),
("layer.setAdjustment", json!({"kind": "exposure", "exposure": -0.75, "offset": 0.035, "gamma": 1.37})),
("layer.setAdjustment", json!({"kind": "exposure", "layer": null, "exposure": -0.75})),
("layer.setAdjustment", json!({"kind": "exposure", "document": null, "exposure": -0.75})),
// `preserve` reaches the filter through a comparison with the exact string "roundness",
// so these play as squareness -- replay's own value -- and lose nothing by export (#7759
// review).
("filter.other.minimum", json!({"radius": 4, "preserve": "Squareness"})),
("filter.other.maximum", json!({"radius": 4, "preserve": "Roundness"})),
("image.adjustments.equalize", json!({"selectedAreaOnly": true})),
("image.adjustments.equalize", json!({})),
] {
let mut s = Session::new();
s.actions.list.push(Action::new("A", vec![(id.into(), params.clone())]));
let r = s.execute("actions.save", json!({"action": "A"})).unwrap_or_else(|e| panic!("{id} {params}: {e}"));
assert_eq!(r["skipped"], json!([]), "{id} {params}");
}
}
/// Every exported command has a canonical import, including recorded full-stack navigation.
#[test]
/// Each mapped command retains its parameters through its corresponding ATN descriptor.
fn every_mapped_command_exports_and_imports_back() {
let cases: Vec<(&str, Value)> = vec![
(
"edit.perspectiveWarp",
json!({"planes":[{"src":[[4,4],[16,4],[16,20],[4,20]],"dst":[[5,5],[17,4],[18,20],[4,19]]}],
"target":"pixels","__atnPerspectiveWarpRect":[0,0,32,24]}),
),
(
"paint.gradient",
json!({"from":[1,1],"to":[6,2],"style":"linear","method":"classic","smoothness":100,
"stops":[[0,"#0020ff"],[1,"#ffe000"]],"transparency":[],"opacity":100,"mode":"Normal","reverse":false,"dither":false}),
),
("filter.render.clouds", json!({})),
("filter.render.differenceClouds", json!({})),
("filter.render.fibers", json!({"variance": 20, "strength": 7, "seed": -123})),
("filter.render.lensFlare", json!({"brightness": 180, "centerX": 5, "centerY": -5, "lens": "moviePrime"})),
("select.rect", json!({"x":1,"y":2,"width":3,"height":4,"mode":"add"})),
("select.rect", json!({"x":1,"y":2,"width":3,"height":4,"mode":"add","ellipse":true,"antiAlias":false})),
("select.rect", json!({"x":1,"y":2,"width":3,"height":4,"mode":"subtract"})),
("select.rect", json!({"x":1,"y":2,"width":3,"height":4,"mode":"intersect"})),
("edit.pasteSpecial.pasteInto", json!({})),
("edit.pasteSpecial.pasteOutside", json!({})),
(
"edit.contentAwareFill",
json!({"sampling":"rectangular","sampleAllLayers":true,"colorAdaptation":"default",
"rotationAdaptation":"none","scale":true,"mirror":false,"output":"new"}),
),
("edit.fade", json!({"opacity":50,"mode":"Multiply"})),
("layer.linkLayers", json!({})),
("layer.smartObjects.stackMode.range", json!({})),
("layer.smartObjects.stackMode.entropy", json!({})),
("layer.smartObjects.stackMode.kurtosis", json!({})),
("layer.smartObjects.stackMode.maximum", json!({})),
("layer.smartObjects.stackMode.median", json!({})),
("layer.smartObjects.stackMode.minimum", json!({})),
("layer.smartObjects.stackMode.mean", json!({})),
("layer.smartObjects.stackMode.skewness", json!({})),
("layer.smartObjects.stackMode.standardDeviation", json!({})),
("layer.smartObjects.stackMode.summation", json!({})),
("layer.smartObjects.stackMode.variance", json!({})),
("layer.smartObjects.stackMode.none", json!({})),
("layer.distribute.leftEdges", json!({})),
("layer.distribute.horizontalCenters", json!({})),
("layer.distribute.rightEdges", json!({})),
("layer.distribute.topEdges", json!({})),
("layer.distribute.verticalCenters", json!({})),
("layer.distribute.bottomEdges", json!({})),
("edit.definePattern", json!({})),
("layer.layerMask.delete", json!({"_atnEvent":"RmvL"})),
("layer.mergeLayers", json!({"_atnEvent":"MrgL"})),
("filter.stylize.findEdges", json!({})),
("filter.stylize.solarize", json!({})),
("filter.pixelate.facet", json!({})),
("filter.pixelate.fragment", json!({})),
("filter.stylize.extrude", json!({"size":42,"depth":91,"solidFrontFaces":true,"maskIncompleteBlocks":true,"type":"pyramids","depthMode":"levelBased"})),
("filter.stylize.traceContour", json!({"level":43,"edge":"lower"})),
("filter.stylize.wind", json!({"method":"stagger","direction":"fromLeft"})),
("filter.stylize.tiles", json!({"count":17,"maxOffset":23,"fill":"foreground"})),
("filter.pixelate.colorHalftone", json!({"maxRadius":12,"channel1":-10000,"channel2":10000,"channel3":-721,"channel4":1001})),
("filter.pixelate.mezzotint", json!({"type":"longStrokes"})),
("filter.pixelate.mosaic", json!({"cellSize":31.5})),
("filter.pixelate.pointillize", json!({"cellSize":19})),
("filter.blur.gaussianBlur", json!({"radius": 3.5})),
("filter.sharpen.unsharpMask", json!({"amount": 120, "radius": 1.5, "threshold": 3})),
("filter.noise.addNoise", json!({"amount": 12.5, "distribution": "gaussian", "monochromatic": true, "seed": 7})),
("filter.noise.median", json!({"radius": 2})),
("filter.stylize.emboss", json!({"angle": 135, "height": 3, "amount": 100})),
("image.calculations", json!({"source1":{"channel":{"color":"red"}},"source2":{"channel":{"color":"green"}},"blending":"add","scale":1,"offset":0})),
("image.adjustments.colorLookup", color_lookup_case()),
("file.automate.batch", batch_case()),
("file.automate.batch", batch_save_and_close_case()),
("filter.other.custom", json!({"kernel":[0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0],"scale":1,"offset":0})),
("filter.render.fibers", json!({"variance":64,"strength":1,"seed":-1})),
("filter.blur.shapeBlur", json!({"radius":5,"shapeName":"Recorded Square","shapeId":"original-fixture-square","shapeOriginType":9})),
("filter.blur.motionBlur", json!({"angle": 45, "distance": 20})),
("filter.blur.smartBlur", json!({"radius": 2.5, "threshold": 40, "quality": "medium", "mode": "overlayEdge"})),
("filter.other.offset", json!({"horizontal": 10, "vertical": -5, "undefinedAreas": "wrap"})),
("filter.blur.blur", json!({})),
("file.automate.createDroplet", json!({"path": "/d/Sharpen.pcdroplet", "action": "Sharpen", "set": "Tools", "output": "/d/out", "overrideOpen": true})),
("filter.distort.displace", json!({"horizontal":-999,"vertical":999,"fit":"stretch","undefinedAreas":"repeat","mapPath":LOCAL_MAP})),
("filter.distort.shear", json!({"points": [[0,0], [0.5,0.25], [1,0]], "undefinedAreas": "repeat"})),
("filter.distort.pinch", json!({"amount": -30})),
("filter.distort.polarCoordinates", json!({"mode": "polarToRectangular"})),
("filter.distort.spherize", json!({"amount": -50, "mode": "verticalOnly"})),
("filter.distort.twirl", json!({"angle": -200})),
(
"filter.distort.wave",
json!({"type": "square", "generators": 3, "wavelengthMin": 20, "wavelengthMax": 200, "amplitudeMin": 4, "amplitudeMax": 40,
"undefinedAreas": "wrap", "seed": 4_000_000_000_u32}),
),
("filter.distort.zigZag", json!({"amount": -20, "ridges": 12, "style": "aroundCenter"})),
("filter.video.deInterlace", json!({"eliminate": "evenFields", "createBy": "duplication"})),
("filter.video.ntscColors", json!({})),
("filter.other.hsbHsl", json!({"inputMode": "hsl", "rowOrder": "rgb"})),
(
"filter.other.custom",
json!({"kernel": [0, 0, 0, 0, 0, 0, -1, -1, -1, 0, 0, -1, 9, -1, 0, 0, -1, -1, -1, 0, 0, 0, 0, 0, 0], "scale": 3, "offset": -10}),
),
("filter.blur.average", json!({})),
("layer.matting.removeBlackMatte", json!({})),
("layer.matting.removeWhiteMatte", json!({})),
("filter.stylize.oilPaint", json!({"lighting": false, "stylization": 6.5, "cleanliness": 3, "scale": 2, "bristleDetail": 7, "angle": 45, "shine": 2})),
("filter.sharpen.smartSharpen", json!({"amount": 150, "radius": 2.5, "reduceNoise": 0})),
("select.focusArea", json!({})),
("select.sky", json!({"sampleAllLayers": false})),
("layer.removeBackground", json!({})),
("channel.split", json!({})),
("channel.mergeSpot", json!({})),
("image.trap", json!({"width": 3})),
("image.adjustments.matchColor", json!({"luminance": 120, "intensity": 80, "fade": 25})),
(
"image.adjustments.levels",
json!({"inBlack": 5, "inWhite": 250, "gamma": 0.9, "outBlack": 0, "outWhite": 255, "red": {"inBlack": 2, "inWhite": 255, "gamma": 1, "outBlack": 0, "outWhite": 255}}),
),
("image.adjustments.curves", json!({"points": [[0, 0], [128, 140], [255, 255]], "blue": [[0, 10], [255, 245]]})),
(
"image.adjustments.hueSaturation",
json!({"colorize": false, "hue": 10, "saturation": -20, "lightness": 0, "reds": {"hue": 5, "saturation": 0, "lightness": 0, "range": [315, 345, 15, 45]}}),
),
("image.adjustments.brightnessContrast", json!({"brightness": 10, "contrast": -5, "legacy": false})),
(
"image.adjustments.shadowsHighlights",
json!({"shadowAmount": 60, "shadowTone": 40, "shadowRadius": 25, "highlightAmount": 10, "highlightTone": 50, "highlightRadius": 30, "blackClip": 0.02, "whiteClip": 0.05, "midtone": 15, "color": 20}),
),
("image.adjustments.threshold", json!({"level": 100})),
("image.adjustments.posterize", json!({"levels": 4})),
("image.adjustments.invert", json!({})),
("image.adjustments.colorBalance", json!({"shadows": [1, 2, 3], "midtones": [0, 0, 0], "highlights": [-5, 0, 5], "preserveLuminosity": true})),
("image.adjustments.vibrance", json!({"vibrance": 30, "saturation": 5})),
("image.adjustments.exposure", json!({"exposure": 0.5, "offset": 0.01, "gamma": 1.1})),
("image.adjustments.photoFilter", json!({"color": "#ec8a00", "density": 25, "preserveLuminosity": true})),
(
"image.adjustments.blackWhite",
json!({"reds": 40, "yellows": 60, "greens": 40, "cyans": 60, "blues": 20, "magentas": 80, "tint": true, "tintColor": "#e4d5b0"}),
),
("image.adjustments.channelMixer", json!({"red": [100, 0, 0, 0], "green": [0, 90, 10, 0], "blue": [0, 0, 100, 5], "monochrome": false})),
("image.adjustments.selectiveColor", json!({"method": "absolute", "reds": [10, 0, -5, 0], "yellows": [0, 5, 0, 0], "neutrals": [0, 0, 0, 3]})),
("select.subject", json!({"sampleAllLayers": false})),
("select.colorRange", json!({"select": "midtones", "fuzziness": 30, "tonalRange": [90, 160], "invert": true})),
("select.colorRange", json!({"select": "skinTones", "fuzziness": 40})),
("select.colorRange", json!({"select": "highlights", "tonalRange": 200})),
("select.colorRange", json!({"select": "reds"})),
("layer.createClippingMask", json!({})),
("layer.releaseClippingMask", json!({"layer": "Shadow"})),
("layer.layerMask.revealAll", json!({})),
("layer.layerMask.hideAll", json!({})),
("layer.layerMask.revealSelection", json!({})),
("layer.layerMask.hideSelection", json!({})),
("tools.setColors", json!({"foreground": "#336699"})),
("tools.setColors", json!({"background": "#ffffff"})),
("image.adjustments.desaturate", json!({})),
("image.autoTone", json!({})),
("image.imageSize", json!({"width": 800, "height": 600, "resolution": 72, "resample": "bicubicSharper", "scaleStyles": true})),
("image.canvasSize", json!({"width": 100, "height": 50, "relative": true, "anchor": "topLeft", "extensionColor": "white"})),
("image.imageRotation.90cw", json!({})),
("image.rotation.arbitrary", json!({"angle": 12.5, "direction": "ccw"})),
("edit.transform.flipHorizontal", json!({})),
("image.imageRotation.flipCanvasVertical", json!({})),
("image.crop", json!({"x": 10, "y": 20, "width": 100, "height": 50, "deleteCroppedPixels": true})),
("image.trim", json!({"basedOn": "transparent", "top": true, "bottom": true, "left": false, "right": true})),
("image.mode.grayscale", json!({})),
("image.mode.bits16", json!({})),
("layer.duplicate", json!({"name": "copy"})),
// Duplicate into a new document is a `Mk`, not a `Dplc` (the three shapes of
// a recorded Duplicate Layer with Destination: New, plus a named layer).
("layer.duplicate", json!({"document": "new"})),
("layer.duplicate", json!({"document": "new", "documentName": "test new name"})),
("layer.duplicate", json!({"document": "new", "name": "Laer name change"})),
("layer.duplicate", json!({"layer": "Shadow", "document": "new", "documentName": "Doc"})),
("image.duplicate", json!({"mergedOnly": true})),
("layer.new.layer", json!({"name": "Ink", "opacity": 0.5, "mode": "Multiply"})),
("type.convertToShape", json!({})),
("type.createWorkPath", json!({})),
(
"path.set",
json!({"name":"work","path":{"fillRule":"evenodd","inverted":false,"subpaths":[{"closed":false,"op":"combine","knots":[
{"anchor":[1,2],"in":[1,2],"out":[3,4],"smooth":false},
{"anchor":[8,9],"in":[6,7],"out":[8,9],"smooth":false}
]}]}}),
),
("filter.blur.shapeBlur", json!({})),
("filter.blur.shapeBlur", json!({"radius": 10})),
("layer.new.group", json!({"name": "G"})),
("layer.groupLayers", json!({})),
("layer.linkLayers", json!({"linked": false})),
("layer.linkLayers", json!({"linked": false, "_atnNoTarget": true})),
("layer.arrange.reverse", json!({})),
("layer.arrange.reverse", json!({"_atnNoTarget": true})),
("layer.selectLinkedLayers", json!({})),
("layer.layerStyle.clear", json!({})),
("layer.rasterize.allLayers", json!({})),
("layer.videoLayers.newBlankVideoLayer", json!({})),
("select.isolateLayers", json!({})),
("type.loadDefaultTypeStyles", json!({})),
("type.saveDefaultTypeStyles", json!({})),
("image.analysis.recordMeasurements", json!({})),
("image.analysis.setMeasurementScale", json!({"preset": "custom", "pixelLength": 120, "logicalLength": 2.5, "units": "mm"})),
("layer.videoLayers.insertBlankFrame", json!({})),
("layer.videoLayers.duplicateFrame", json!({})),
("layer.videoLayers.deleteFrame", json!({})),
("file.automate.cropAndStraightenPhotos", json!({})),
("file.automate.cropAndStraightenPhotos", json!({"_atnNoTarget": true})),
("layer.new.artboardFromGroup", json!({})),
("layer.new.artboardFromGroup", json!({"background":"white","color":[1,1,1],"preset":"","guideIDs":[],"changeBackground":1})),
(
"layer.artboard.set",
json!({"background":"custom","color":[0.25,0.5,0.75],"recordedPreset":"","guideIDs":[],"recordedRect":[2,2,6,6],"changeSizes":8,"changeBackground":1}),
),
("layer.new.layerViaCopy", json!({})),
("layer.newFillLayer.solidColor", json!({"color": "#336699"})),
("layer.newAdjustmentLayer.hueSaturation", json!({})),
("layer.newAdjustmentLayer.threshold", json!({"level": 77})),
("file.new", json!({"width": 640, "height": 480, "resolution": 72, "mode": "rgb", "depth": 8, "background": "transparent", "name": "Doc"})),
("select.all", json!({})),
("select.deselect", json!({})),
("select.loadSelection", json!({"channel": "transparency"})),
("select.loadSelection", json!({"channel": "Alpha 1"})),
("select.rect", json!({"x": 1, "y": 2, "width": 30, "height": 40})),
("select.rect", json!({"x": 1, "y": 2, "width": 30, "height": 40, "ellipse": true})),
("layer.setProps", json!({"name": "Renamed", "opacity": 0.4, "blend": "Linear Light"})),
("layer.setProps", json!({"layer": "Shadow", "visible": false})),
("layer.setLabelColor", json!({"color": "red"})),
("layer.showOnly", json!({"layer": "Solo"})),
("layer.select", json!({"layer": "Shadow", "mode": "add"})),
("layer.select", json!({"layer": "Shadow", "mode": "range"})),
("layer.select", json!({"layer": {"index": 2}})),
("layer.moveTo", json!({"target": {"index": 0}})),
("layer.moveTo", json!({"layer": {"index": 2}, "target": {"index": 4}})),
("layer.selectAbove", json!({})),
("layer.selectBottom", json!({})),
("layer.selectTop", json!({})),
("layer.selectBelow", json!({})),
("layer.addBelowToSelection", json!({})),
("layer.addAboveToSelection", json!({})),
("layer.delete", json!({})),
("edit.clear", json!({})),
("layer.hideLayers", json!({"layer": "Background"})),
("layer.showLayers", json!({})),
("layer.translate", json!({"dx": 3, "dy": -4})),
("layer.arrange.sendToBack", json!({})),
("layer.flattenImage", json!({})),
("layer.stampVisible", json!({})),
("layer.mergeVisible", json!({})),
("layer.matting.defringe", json!({"width": 2})),
("layer.align.leftEdges", json!({"to": "canvas"})),
("select.inverse", json!({})),
("select.modify.feather", json!({"radius": 4})),
("select.modify.contract", json!({"radius": 1})),
("edit.fill", json!({"contents": "color", "color": "#ff0080", "opacity": 50, "mode": "Multiply", "preserveTransparency": true})),
("edit.fill", json!({"contents": "foreground", "opacity": 100, "mode": "Normal"})),
("edit.stroke", json!({"width": 3, "location": "outside", "opacity": 100, "mode": "Normal", "color": "#000000", "preserveTransparency": false})),
("edit.copyMerged", json!({})),
("edit.pasteSpecial.pasteInPlace", json!({})),
("edit.paste", json!({})),
("edit.purge.histories", json!({})),
("edit.checkSpelling", json!({"allLayers": true})),
("edit.checkSpelling", json!({"allLayers": false})),
("tools.defaultColors", json!({})),
(
"edit.convertToProfile",
json!({"profile": "display-p3", "intent": "perceptual", "bpc": false, "dither": false, "flatten": false, "rasterizePlaced": false}),
),
("edit.assignProfile", json!({"profile": "srgb"})),
("file.close", json!({})),
("file.save", json!({})),
("layer.layerMask.delete", json!({})),
("select.lasso", json!({"points": [[1, 2], [30, 2], [15, 40]]})),
("select.editInQuickMaskMode", json!({"on": true})),
("select.editInQuickMaskMode", json!({"on": false})),
("edit.transform", json!({"dx": 10, "dy": -5, "scaleX": 50, "scaleY": 75, "rotate": 12.5, "interpolation": "bicubic"})),
("edit.transform", json!({"layer": "Shadow", "dx": 0, "dy": 0, "scaleX": 200})),
("select.transformSelection", json!({"dx": 3, "dy": 4, "scaleX": 110, "scaleY": 110, "rotate": -30})),
(
"image.applyImage",
json!({"source": {"channel": "composite", "layer": "High-Frequency"}, "blending": "subtract", "scale": 2, "offset": 128, "preserveTransparency": true}),
),
("image.applyImage", json!({"source": {"channel": "red", "layer": "merged", "invert": true}, "blending": "multiply", "opacity": 50})),
(
"file.export.saveForWebLegacy",
json!({"path": "/tmp/out/banner.png", "format": "png24", "interlaced": false, "transparency": true, "embedIcc": false, "progressive": false, "optimized": true, "matte": "#ffffff"}),
),
(
"file.export.saveForWebLegacy",
json!({"folder": "/tmp/out", "format": "png24", "interlaced": false, "transparency": true, "embedIcc": false, "progressive": false, "optimized": true, "matte": "#ffffff"}),
),
(
"file.export.saveForWebLegacy",
json!({"path": "C:\\shots\\a.jpg", "format": "jpeg", "interlaced": false, "transparency": false, "embedIcc": true, "progressive": true, "optimized": true, "quality": 80, "matte": "none"}),
),
("image.adjustments.gradientMap", json!({"stops": [[0, "#000000"], [0.5, "#ff8000"], [1, "#ffffff"]], "reverse": true, "dither": false})),
("image.adjustments.gradientMap", json!({"stops": [[0,[0.5,0,0]],[1,[0,1,0]]], "__atnGrMp":{"name":"Recorded","colors":[[127.5,0,0],[0,255,0]]}})),
("image.adjustments.replaceColor", json!({"labMinimum":[40,30,40],"labMaximum":[60,35,45],"fuzziness":40,"hue":30,"saturation":0,"lightness":0})),
("layer.newAdjustmentLayer.gradientMap", json!({"stops": [[0, "#102030"], [1, "#f0e0d0"]], "reverse": false, "dither": true})),
("layer.smartObjects.convertToSmartObject", json!({})),
("layer.smartObjects.editContents", json!({})),
("layer.smartObjects.updateModifiedContent", json!({})),
("layer.smartObjects.newSmartObjectViaCopy", json!({})),
("layer.smartObjects.convertToLayers", json!({})),
("layer.smartObjects.convertToEmbedded", json!({})),
("layer.smartObjects.updateAllModifiedContent", json!({})),
("layer.smartObjects.exportContents", json!({"path": "/tmp/contents.png"})),
("layer.smartObjects.convertToLinked", json!({"path": "/tmp/linked.png"})),
("layer.smartObjects.relinkToFile", json!({"path": "/tmp/relinked.png"})),
("layer.smartObjects.replaceContents", json!({"path": "/tmp/replacement.png"})),
("paint.bucket", json!({"x": 10, "y": 20, "tolerance": 32, "antiAlias": true, "contiguous": false, "opacity": 100})),
("image.imageSize", json!({"percent": 200, "resample": "bicubicAutomatic", "scaleStyles": true})),
("file.placeEmbedded", json!({"path": "/tmp/logo.png", "scale": 50})),
("file.placeLinked", json!({"path": "/tmp/logo.png"})),
("file.openAs", json!({"path": "/tmp/a.psd"})),
("file.saveAs", json!({"path": "/tmp/out/a.png"})),
("file.saveACopy", json!({"path": "/tmp/out/a.tif"})),
("actions.conditional", json!({"if": "hasLayers", "then": "FS8-Layers", "else": "FS8-No-Layers", "set": "S"})),
("actions.conditional", json!({"if": "landscape", "then": "Wide", "else": null, "set": "S"})),
("tools.setColors", json!({"foreground": "#102030"})),
("tools.setColors", json!({"background": "#ffffff"})),
("view.fitOnScreen", json!({})),
("view.zoomIn", json!({})),
("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"})),
("actions.play", json!({"action": "Other", "set": "S"})),
(STOP_MESSAGE, json!({"message": "Hi", "allowContinue": false})),
("document.activate", json!({"offset": -1})),
("document.activate", json!({"document": {"index": 2}})),
// The TrueGrit Texture Supply sets (Debaser, Distress Press, Halftone Zine Machine).
("filter.distort.ripple", json!({"amount": 50, "size": "medium"})),
("filter.pixelate.crystallize", json!({"cellSize": 5})),
("filter.gallery.spatter", json!({"sprayRadius": 7, "smoothness": 15})),
("filter.gallery.accentedEdges", json!({"edgeWidth": 14, "edgeBrightness": 50, "smoothness": 15})),
("filter.gallery.angledStrokes", json!({"directionBalance": 100, "strokeLength": 50, "sharpness": 10})),
("filter.gallery.basRelief", json!({"detail": 15, "smoothness": 15, "light": "bottomLeft"})),
("filter.gallery.chalkCharcoal", json!({"charcoalArea": 20, "chalkArea": 20, "strokePressure": 5})),
("filter.gallery.charcoal", json!({"charcoalThickness": 7, "detail": 0, "lightDarkBalance": 100})),
("filter.gallery.chrome", json!({"detail": 10, "smoothness": 10})),
("filter.gallery.coloredPencil", json!({"pencilWidth": 24, "strokePressure": 15, "paperBrightness": 50})),
(
"filter.gallery.conteCrayon",
json!({"foregroundLevel": 15, "backgroundLevel": 15, "texture": "brick", "scaling": 200, "relief": 50, "light": "bottomLeft", "invert": true}),
),
("filter.gallery.craquelure", json!({"crackSpacing": 100, "crackDepth": 10, "crackBrightness": 10})),
("filter.gallery.crosshatch", json!({"strokeLength": 50, "sharpness": 20, "strength": 3})),
("filter.gallery.cutout", json!({"numberOfLevels": 8, "edgeSimplicity": 10, "edgeFidelity": 3})),
("filter.gallery.darkStrokes", json!({"balance": 10, "blackIntensity": 10, "whiteIntensity": 10})),
("filter.gallery.diffuseGlow", json!({"graininess": 10, "glowAmount": 20, "clearAmount": 20})),
("filter.gallery.dryBrush", json!({"brushSize": 10, "brushDetail": 10, "texture": 3})),
("filter.gallery.filmGrain", json!({"grain": 20, "highlightArea": 20, "intensity": 0})),
("filter.gallery.fresco", json!({"brushSize": 10, "brushDetail": 10, "texture": 3})),
("filter.gallery.glass", json!({"distortion": 20, "smoothness": 15, "texture": "canvas", "scaling": 200, "invert": true})),
("filter.gallery.glowingEdges", json!({"edgeWidth": 14, "edgeBrightness": 20, "smoothness": 15})),
("filter.gallery.graphicPen", json!({"strokeLength": 1, "lightDarkBalance": 100, "strokeDirection": "horizontal"})),
("filter.gallery.halftonePattern", json!({"size": 12, "contrast": 50, "patternType": "circle"})),
("filter.gallery.inkOutlines", json!({"strokeLength": 50, "darkIntensity": 50, "lightIntensity": 50})),
("filter.gallery.mosaicTiles", json!({"tileSize": 100, "groutWidth": 15, "lightenGrout": 10})),
("filter.gallery.neonGlow", json!({"glowSize": 24, "glowBrightness": 50, "glowColor": "#336699"})),
("filter.gallery.notePaper", json!({"imageBalance": 50, "graininess": 20, "relief": 25})),
("filter.gallery.oceanRipple", json!({"rippleSize": 15, "rippleMagnitude": 20})),
("filter.gallery.paintDaubs", json!({"brushSize": 50, "sharpness": 40, "brushType": "darkRough"})),
("filter.gallery.paletteKnife", json!({"strokeSize": 50, "strokeDetail": 1, "softness": 10})),
("filter.gallery.patchwork", json!({"squareSize": 10, "relief": 25})),
("filter.gallery.photocopy", json!({"detail": 24, "darkness": 50})),
("filter.gallery.plaster", json!({"imageBalance": 50, "smoothness": 15, "light": "bottomLeft"})),
("filter.gallery.plasticWrap", json!({"highlightStrength": 20, "detail": 15, "smoothness": 15})),
("filter.gallery.posterEdges", json!({"edgeThickness": 10, "edgeIntensity": 10, "posterization": 6})),
("filter.gallery.reticulation", json!({"density": 50, "foregroundLevel": 50, "backgroundLevel": 50})),
(
"filter.gallery.roughPastels",
json!({"strokeLength": 40, "strokeDetail": 20, "texture": "brick", "scaling": 200, "relief": 50, "light": "bottomLeft", "invert": true}),
),
("filter.gallery.smudgeStick", json!({"strokeLength": 10, "highlightArea": 20, "intensity": 0})),
("filter.gallery.sponge", json!({"brushSize": 10, "definition": 25, "smoothness": 15})),
("filter.gallery.sprayedStrokes", json!({"strokeLength": 20, "sprayRadius": 25, "strokeDirection": "horizontal"})),
("filter.gallery.stainedGlass", json!({"cellSize": 50, "borderThickness": 20, "lightIntensity": 10})),
("filter.gallery.stamp", json!({"lightDarkBalance": 50, "smoothness": 50})),
("filter.gallery.sumiE", json!({"strokeWidth": 15, "strokePressure": 15, "contrast": 40})),
("filter.gallery.texturizer", json!({"texture": "brick", "scaling": 200, "relief": 50, "light": "bottomLeft", "invert": true})),
("filter.gallery.tornEdges", json!({"imageBalance": 50, "smoothness": 15, "contrast": 25})),
(
"filter.gallery.underpainting",
json!({"brushSize": 40, "textureCoverage": 40, "texture": "brick", "scaling": 200, "relief": 50, "light": "bottomLeft", "invert": true}),
),
("filter.gallery.waterPaper", json!({"fiberLength": 50, "brightness": 100, "contrast": 100})),
("filter.gallery.watercolor", json!({"brushDetail": 14, "shadowIntensity": 10, "texture": 3})),
("filter.gallery.grain", json!({"intensity": 100, "contrast": 100, "grainType": "contrasty"})),
(
"select.refineEdge",
json!({"radius": 0, "smartRadius": false, "smooth": 2, "feather": 0.5, "contrast": 15, "shiftEdge": -100, "decontaminate": false, "output": "layerMask"}),
),
("channel.target", json!({"channel": "mask"})),
("channel.target", json!({"channel": "composite"})),
("edit.undo", json!({})),
("edit.redo", json!({})),
("layer.select", json!({"layer": "YELLOW", "mode": "remove"})),
("layer.select", json!({"layer": "Or paste photocopy texture here", "mode": "range"})),
("layer.translate", json!({"dx": 0.36, "dy": -1.08, "units": "pt"})),
("select.loadSelection", json!({"channel": "targeted"})),
("layer.newFillLayer.pattern", json!({"pattern": "Debaser-Distressed-Dark", "scale": 50, "angle": 0, "link": true})),
("layer.fill.pattern.set", json!({"pattern": "1 -R2.jpg"})),
("layer.newFillLayer.pattern", json!({"pattern": "b7a3c1d2-0000-4000-8000-000000000001", "patternName": "Twin", "scale": 50})),
("image.mode.bitmap", json!({"method": "threshold"})),
("image.imageSize", json!({"resolution": 1200, "resample": "preserveDetails", "keepPrintSize": true, "scaleStyles": true})),
("layer.new.layerFromBackground", json!({"ifPresent": true, "select": false})),
// COPYCAT, AJ_Actions2023 and the Line and Color tutorial set.
("layer.smartFilter.deleteFilterMask", json!({"ifPresent": true})),
("layer.smartFilter.blendingOptions", json!({"index": 1, "opacity": 0.5, "blend": "Darken"})),
("layer.layerStyle.blendingOptions", json!({"blendClippedLayersAsGroup": false, "transparencyShapesLayer": false})),
("layer.layerStyle.blendingOptions", json!({"blendIf": [{"channel": "gray", "thisLayer": [0, 30, 200, 255], "underlying": [0, 0, 255, 255]}]})),
(
"layer.layerStyle.blendingOptions",
json!({"opacity": 40, "fillOpacity": 70, "blend": "Multiply", "blendIf": [{"channel": "blue", "thisLayer": [5, 5, 250, 250]}]}),
),
("layer.createClippingMask", json!({})),
("layer.lockLayers", json!({"transparency": true})),
("filter.stylize.diffuse", json!({"mode": "normal", "seed": 321654})),
("history.clear", json!({})),
("history.newSnapshot", json!({"name": "CopyCat - action result", "from": "fullDocument", "replaceExisting": true})),
("layerComp.apply", json!({"comp": "Comp 1"})),
("layerComp.apply", json!({"comp": {"index": 2}})),
("layerComp.apply", json!({"comp": {"id": 7}})),
("layerComp.apply", json!({"comp": {"ordinal": "Nxt"}})),
];
for (id, params) in &cases {
let item = export_item(id, params, "S").unwrap_or_else(|| panic!("{id} does not export"));
if *id == "file.close" {
// Close exports "don't save" and imports as a plain close; the Save answer is tested below.
assert_eq!(item.event.id(), "Cls ");
continue;
}
let e = event_for(&item.event).unwrap_or_else(|| panic!("{id} exported as an unknown event {}", item.event.id()));
assert_eq!(item.descriptor.is_none(), *id == super::steps_stack::NONE, "{id}: unexpected descriptor presence");
let descriptor = item.descriptor.unwrap_or_default();
match (e.from)(&descriptor) {
Mapped::Steps(steps) => {
assert_eq!(steps.len(), 1, "{id}: {steps:?}");
let navigation = match *id {
"layer.selectAbove" => Some(("Frwr", false)),
"layer.selectBelow" => Some(("Bckw", false)),
"layer.selectTop" => Some(("Frnt", false)),
"layer.selectBottom" => Some(("Back", false)),
"layer.addAboveToSelection" => Some(("Frwr", true)),
"layer.addBelowToSelection" => Some(("Bckw", true)),
_ => None,
};
if let Some((ordinal, add)) = navigation {
let mut expected = json!({"layer": {"ordinal": ordinal}});
if add {
expected["mode"] = json!("add");
}
assert_eq!(steps, vec![("layer.select".into(), expected)]);
continue;
}
assert_eq!(steps[0].0, *id, "{id} came back as another command");
let mut back = steps[0].1.clone();
if *id == "paint.gradient" {
// Executable values match; independent public tests verify the source carrier.
back.as_object_mut().unwrap().remove("__atnClassicGradient");
}
if *id == "path.set" {
// The imported step retains the original descriptor alongside its playable path.
back.as_object_mut().unwrap().remove(super::steps_path::SOURCE).unwrap();
}
if *id == "edit.perspectiveWarp" {
back.as_object_mut().unwrap().remove("__atnPerspectiveWarp");
}
normalise(&mut back);
if *id == "actions.play" || *id == "actions.conditional" {
back["set"] = json!("S");
}
assert_eq!(back, *params, "{id}");
}
Mapped::Unsupported { .. } => panic!("{id} does not import back"),
}
}
// Partial native backgrounds need no recorded preset, guide IDs or last-used color.
for (background, imported_background) in [("white", "white"), ("black", "black"), ("transparent", "transparent"), ("custom", "white")] {
let item = export_item("layer.new.artboardFromGroup", &json!({"background":background}), "S").unwrap();
let Mapped::Steps(steps) = (event_for(&item.event).unwrap().from)(item.descriptor.as_ref().unwrap()) else { panic!("Artboard did not import") };
assert_eq!(steps, [("layer.new.artboardFromGroup".into(), json!({"background":imported_background,"color":[1.0,1.0,1.0],"preset":"","guideIDs":[]}))]);
assert_eq!(export_item(&steps[0].0, &steps[0].1, "S").unwrap().descriptor, item.descriptor);
}
// Every command the table claims to export has a case above.
let covered: std::collections::BTreeSet<&str> = cases.iter().map(|(id, _)| *id).collect();
let mut missing: Vec<&str> = EVENTS.iter().flat_map(|e| e.ids.iter().copied()).filter(|id| !covered.contains(id)).collect();
missing.sort_unstable();
let allowed = [
"edit.transform.flipVertical",
"edit.transform.rotate180",
"edit.transform.rotate90Ccw",
"edit.transform.rotate90Cw",
"image.autoColor",
"image.autoContrast",
"image.imageRotation.180",
"image.imageRotation.90ccw",
"image.imageRotation.flipCanvasHorizontal",
"image.mode.bits32",
"image.mode.bits8",
"image.mode.cmyk",
"image.mode.lab",
"image.mode.multichannel",
"image.mode.rgb",
"layer.align.bottomEdges",
"layer.align.horizontalCenters",
"layer.align.rightEdges",
"layer.align.topEdges",
"layer.align.verticalCenters",
"layer.arrange.bringForward",
"layer.arrange.bringToFront",
"layer.arrange.sendBackward",
"layer.mergeLayers",
"layer.new.layerViaCut",
"layer.newAdjustmentLayer.blackWhite",
"layer.newAdjustmentLayer.brightnessContrast",
"layer.newAdjustmentLayer.channelMixer",
"layer.newAdjustmentLayer.colorBalance",
"layer.newAdjustmentLayer.curves",
"layer.newAdjustmentLayer.exposure",
"layer.newAdjustmentLayer.invert",
"layer.newAdjustmentLayer.levels",
"layer.newAdjustmentLayer.photoFilter",
"layer.newAdjustmentLayer.posterize",
"layer.newAdjustmentLayer.selectiveColor",
"layer.newAdjustmentLayer.vibrance",
"layer.rasterize.layer",
"layer.renameLayer",
"layer.setAdjustment",
"select.allLayers",
"select.deselectLayers",
"select.grow",
"select.modify.border",
"select.modify.expand",
"select.modify.smooth",
"select.reselect",
"select.similar",
"view.actualPixels",
"view.zoomOut",
"tools.swapColors",
"edit.cut",
"edit.copy",
"edit.purge.all",
"edit.purge.clipboard",
"edit.purge.undo",
"file.newFromClipboard",
"file.revert",
"image.adjustments.equalize",
"image.revealAll",
"filter.blur.blurMore",
"filter.blur.boxBlur",
"filter.blur.radialBlur",
"filter.blur.surfaceBlur",
"filter.noise.despeckle",
"filter.noise.dustAndScratches",
"filter.other.highPass",
"filter.other.maximum",
"filter.other.minimum",
"filter.sharpen.sharpen",
"filter.sharpen.sharpenEdges",
"filter.sharpen.sharpenMore",
// Saves back only as the descriptor it was loaded from (loaded_camera_raw_filter_steps_save_back_unchanged).
"filter.cameraRaw",
// Load with the recorded descriptor riding along (gradient_fill_steps_export_and_import_back).
"gradient.fill.set",
"layer.newFillLayer.gradient",
];
let unexpected: Vec<&str> = missing.iter().copied().filter(|id| !allowed.contains(id)).collect();
assert!(unexpected.is_empty(), "add round-trip cases for {unexpected:?}");
// Every command the table names exists.
// (Save is the desktop shell's: recorded by it, replayed by it, written by Batch.)
for id in EVENTS.iter().flat_map(|e| e.ids.iter()) {
assert!(
crate::commands::find(id).is_some() || crate::actions_cmds::shell_save_command(id) || crate::actions_cmds::shell_view_command(id),
"the .atn table names an unknown command `{id}`"
);
}
}
/// `paint.bucket` exported to .atn and imported back → its params, or None when it doesn't export.
fn bucket_round_trip(params: Value) -> Option<Value> {
let item = export_item("paint.bucket", &params, "S")?;
let e = event_for(&item.event)?;
match (e.from)(item.descriptor.as_ref()?) {
Mapped::Steps(steps) => steps.first().map(|(id, p)| {
assert_eq!(id, "paint.bucket");
p.clone()
}),
_ => None,
}
}
/// A Paint Bucket step with an explicit colour keeps it through .atn (#7199): replayed on a fresh
/// document, whose foreground is black, it still fills red. A colour .atn can't hold makes the
/// step not export rather than fill with the foreground.
#[test]
fn bucket_keeps_an_explicit_color_through_atn() {
let fill = |params: &Value| {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8, "background": "#ffffff"})).unwrap();
s.execute("paint.bucket", params.clone()).unwrap();
let d = s.active().unwrap();
d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().pixel(2, 2)
};
let red = json!({"x": 2, "y": 2, "antiAlias": false, "color": "#ff0000"});
let back = bucket_round_trip(red.clone()).unwrap();
assert_eq!(back["color"], "#ff0000", "{back}");
assert_eq!(fill(&back), fill(&red));
assert_eq!(fill(&back), vec![1.0, 0.0, 0.0, 1.0]);
// The colour as an array of channels.
assert_eq!(bucket_round_trip(json!({"x": 1, "y": 1, "color": [0.0, 0.5, 1.0]})).unwrap()["color"], "#0080ff");
// No colour, or one the command can't read: the foreground, as before.
for p in [json!({"x": 1, "y": 1}), json!({"x": 1, "y": 1, "color": "red"})] {
let back = bucket_round_trip(p.clone()).unwrap();
assert!(back.get("color").is_none() && back.get("contents").is_none(), "{p} → {back}");
}
// A pattern fill ignores the colour, also when named with the older `source`.
for key in ["contents", "source"] {
let back = bucket_round_trip(json!({"x": 1, "y": 1, key: "pattern", "color": "#ff0000"})).unwrap();
assert_eq!(back["contents"], "pattern", "{key}: {back}");
assert!(back.get("color").is_none(), "{key}: {back}");
}
// Translucent or out-of-range colours have no .atn form.
for c in [json!("#ff000080"), json!([1.0, 0.0, 0.0, 0.5]), json!([2.0, 0.0, 0.0])] {
assert!(bucket_round_trip(json!({"x": 1, "y": 1, "color": c})).is_none(), "{c}");
}
}
/// A Stroke step that leaves settings at their defaults still writes them (#8303): Photoshop
/// opens the Stroke dialog for a descriptor without its blend mode, even with dialogs off.
#[test]
fn stroke_writes_the_defaults_it_leaves_out() {
let exported = |params: Value| export_item("edit.stroke", &params, "S").and_then(|item| item.descriptor).unwrap();
let d = exported(json!({"width": 2, "location": "inside", "color": "#d03060", "opacity": 63}));
assert_eq!(get_enum(&d, "Md ").as_deref(), Some("Nrml"), "{d:?}");
assert_eq!(get_enum(&d, "Lctn").as_deref(), Some("Insd"), "an explicit setting is kept");
assert_eq!(get_num(&d, "Opct"), Some(63.0));
let d = exported(json!({"width": 2}));
assert_eq!(get_enum(&d, "Lctn").as_deref(), Some("Cntr"), "{d:?}");
assert_eq!(get_num(&d, "Opct"), Some(100.0));
assert_eq!(get_enum(&d, "Md ").as_deref(), Some("Nrml"));
assert!(get_bool(&d, "PrsT") == Some(false), "{d:?}");
// An explicit mode is kept, and the step imports back to the same stroke.
let p = json!({"width": 3, "location": "outside", "mode": "Multiply", "opacity": 40, "color": "#000000", "preserveTransparency": true});
let d = exported(p.clone());
let Mapped::Steps(steps) = (event_for(&AtnEvent::Code(*b"Strk")).unwrap().from)(&d) else { panic!("{d:?}") };
assert_eq!(steps[0].1, json!({"width": 3.0, "location": "outside", "mode": "Multiply", "opacity": 40.0, "color": "#000000", "preserveTransparency": true}));
}
/// `image.adjustments.photoFilter` exported to .atn and imported back → its params, or None.
fn photo_filter_round_trip(params: Value) -> Option<Value> {
let item = export_item("image.adjustments.photoFilter", &params, "S")?;
let e = event_for(&item.event)?;
match (e.from)(item.descriptor.as_ref()?) {
Mapped::Steps(steps) => steps.first().map(|(id, p)| {
assert_eq!(id, "image.adjustments.photoFilter");
p.clone()
}),
_ => None,
}
}
/// A `Mk` layerSection step exported to .atn and imported back → (id, params), or None.
fn group_round_trip(id: &str, params: &Value) -> Option<(String, Value)> {
let item = export_item(id, params, "S")?;
let e = event_for(&item.event)?;
match (e.from)(item.descriptor.as_ref()?) {
Mapped::Steps(steps) => steps.first().map(|(i, p)| (i.clone(), p.clone())),
_ => None,
}
}
#[test]
fn photo_filter_preset_only_step_keeps_its_resolved_color() {
// #7235: the Photoshop descriptor keeps only a colour (`phfl`/`PhtF` are colour-only; the io
// notes reach the same conclusion from the CS2 oracles). A step recorded with only `filter`
// previously exported with no colour at all, so re-imported playback fell back to the default
// warming filter; it must export the resolved colour of the chosen preset instead.
let mut back = photo_filter_round_trip(json!({"filter": "cooling80", "density": 60, "preserveLuminosity": true})).unwrap();
normalise(&mut back);
assert_eq!(back, json!({"color": "#006dff", "density": 60, "preserveLuminosity": true}));
// An omitted density stays omitted: running the imported step applies the same default.
let mut back = photo_filter_round_trip(json!({"filter": "red"})).unwrap();
normalise(&mut back);
assert_eq!(back, json!({"color": "#ea1a1a"}));
// With an explicit colour too, the colour wins, as when running the command.
let mut back = photo_filter_round_trip(json!({"filter": "cooling80", "color": "#123456", "density": 25})).unwrap();
normalise(&mut back);
assert_eq!(back, json!({"color": "#123456", "density": 25}));
let mut back = photo_filter_round_trip(json!({"color": "#123456", "density": 25})).unwrap();
normalise(&mut back);
assert_eq!(back, json!({"color": "#123456", "density": 25}));
// An unknown preset id keeps the old export: no colour resolves, playback applies its default.
let mut back = photo_filter_round_trip(json!({"filter": "nope", "density": 25})).unwrap();
normalise(&mut back);
assert_eq!(back, json!({"density": 25}));
}
#[test]
fn group_layers_keeps_its_explicit_target_through_atn() {
// #7236: a recorded step with an explicit layer groups that layer, not the current
// selection. The source goes into the `From` reference by name and comes back the same
// way; a step with no source layer keeps the implicit form, imported as before.
let (id, p) = group_round_trip("layer.groupLayers", &json!({"layer": "A", "name": "Group"})).unwrap();
assert_eq!(id, "layer.groupLayers");
assert_eq!(p, json!({"layer": "A", "name": "Group"}));
// The implicit form survives unchanged: the reference stays the target layer.
let (id, p) = group_round_trip("layer.groupLayers", &json!({"name": "Group"})).unwrap();
assert_eq!(id, "layer.groupLayers");
assert_eq!(p, json!({"name": "Group"}));
let (id, p) = group_round_trip("layer.groupLayers", &json!({})).unwrap();
assert_eq!(id, "layer.groupLayers");
assert_eq!(p, json!({}));
// Group Without Layers: no `From` at all, imported as a plain new group.
let (id, p) = group_round_trip("layer.new.group", &json!({"name": "G"})).unwrap();
assert_eq!(id, "layer.new.group");
assert_eq!(p, json!({"name": "G"}));
}
#[test]
fn group_layers_from_index_reference_stays_implicit() {
// A Photoshop-authored `From` that names nothing portable (a zero-based index) keeps the
// old implicit import instead of inventing a wrong explicit target (#7236, edge).
let d = desc("null")
.with("null", class_ref("layerSection"))
.with("From", D::Reference(vec![ReferenceItem::Index { class: class("Lyr "), index: 2 }]))
.with("Usng", D::Descriptor(desc("layerSection").with("Nm ", text("Group"))));
match make_from(&d) {
Mapped::Steps(steps) => {
assert_eq!(steps[0].0, "layer.groupLayers");
assert_eq!(steps[0].1, json!({"name": "Group"}));
}
_ => panic!("an index source imports as the implicit form"),
}
}
#[test]
fn unsupported_shapes_become_photoshop_steps_not_errors() {
// Image size in two different percentages, a Paint Bucket click in percent of the canvas, a
// move in percent, an unknown adjustment class.
let items = vec![
item("ImgS", "Image Size", Some(desc("null").with("Wdth", pct(200.0)).with("Hght", pct(150.0)))),
item(
"Fl ",
"Fill",
Some(desc("null").with("From", D::Descriptor(desc("Pnt ").with("Hrzn", pct(0.0)).with("Vrtc", pct(0.0)))).with("Usng", enm("FlCn", "FrgC"))),
),
item(
"move",
"Move",
Some(desc("null").with("null", target_ref("Lyr ")).with("T ", D::Descriptor(desc("Ofst").with("Hrzn", pct(3.0)).with("Vrtc", pct(1.0))))),
),
item(
"make",
"Make",
Some(desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(desc("GdMp")))))),
),
item("GsnB", "Gaussian Blur", None),
item("GsnB", "Gaussian Blur", Some(desc("null"))),
// Duplicate into another open document: the `T` reference means nothing at
// replay, so the step is kept verbatim instead of misplayed as a same-doc copy.
item(
"Dplc",
"Duplicate",
Some(
desc("null")
.with("null", target_ref("Lyr "))
.with("T ", D::Reference(vec![ReferenceItem::Name { class: class("Dcmn"), name: UnicodeString::new_nul("Other") }])),
),
),
];
let file = AtnFile {
name: UnicodeString::new_nul("Odd"),
expanded: false,
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }],
warnings: vec![],
..Default::default()
};
let imp = import(&atn::write(&file).unwrap()).unwrap();
let ids: Vec<&str> = imp.actions[0].steps.iter().map(|(id, _)| id.as_str()).collect();
assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, MENU_ITEM, "filter.blur.gaussianBlur", PHOTOSHOP_STEP]);
assert_eq!(imp.actions[0].steps[4].1["id"], "filter.blur.gaussianBlur");
assert_eq!(imp.actions[0].steps[4].1["eventKind"], "code");
assert_eq!(imp.actions[0].steps[5].1, json!({}));
assert_eq!(imp.unsupported.iter().map(|step| step.step).collect::<Vec<_>>(), [0, 1, 2, 3, 6]);
assert!(imp.actions[0].disabled.is_empty(), "steps with a document effect stay on so playback reports them");
}
/// #8482: four-byte string events must not become `long` event codes when retained and saved.
#[test]
fn save_preserves_the_kind_of_unsupported_atn_events() {
let source = AtnFile {
name: UnicodeString::new_nul("S"),
expanded: false,
actions: vec![AtnAction {
name: UnicodeString::new_nul("A"),
items: vec![
AtnItem { event: AtnEvent::Name(b"make".to_vec()), dictionary: b"Make".to_vec(), ..Default::default() },
AtnItem { event: AtnEvent::Code(*b"Qwer"), dictionary: b"Unknown code".to_vec(), ..Default::default() },
],
..Default::default()
}],
warnings: vec![],
..Default::default()
};
let mut session = Session::new();
session.execute("actions.load", json!({"data": b64(&atn::write(&source).unwrap())})).unwrap();
let saved = session.execute("actions.save", json!({"set": "S"})).unwrap();
let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap();
let round_trip = atn::parse(&bytes).unwrap();
assert_eq!(
round_trip.actions[0].items.iter().map(|item| item.event.clone()).collect::<Vec<_>>(),
[AtnEvent::Name(b"make".to_vec()), AtnEvent::Code(*b"Qwer")]
);
// Legacy steps may omit eventKind, but an explicitly malformed value must not be treated
// as if it were absent.
assert!(export_item(PHOTOSHOP_STEP, &json!({"event": "make", "eventKind": null}), "S").is_none());
assert!(export_item(PHOTOSHOP_STEP, &json!({"event": "make", "eventKind": 4}), "S").is_none());
assert_eq!(export_item(PHOTOSHOP_STEP, &json!({"event": "make"}), "S").unwrap().event, AtnEvent::Code(*b"make"));
}
/// #6745: the events of a Photoshop 2026 retouching set (ID-photo background swap, whitening,
/// highlight cleanup, high-pass sharpening), in the stringID form Photoshop wrote them.
#[test]
fn retouching_set_events_map_to_their_commands() {
let smart_brush = desc("null")
.with("smartBrushRadius", px(3.0))
.with("smartBrushSmooth", long(2.0))
.with("smartBrushFeather", px(0.5))
.with("smartBrushContrast", pct(10.0))
.with("smartBrushShiftEdge", pct(-20.0))
.with("sampleAllLayers", D::Boolean(false))
.with("smartBrushUseSmartRadius", D::Boolean(true))
.with("smartBrushDecontaminate", D::Boolean(false))
.with("smartBrushDeconAmount", pct(100.0))
.with("refineEdgeOutput", enm("refineEdgeOutput", "refineEdgeOutputUserMask"));
let selective = desc("null").with("Mthd", enm("CrcM", "Rltv")).with(
"ClrC",
D::List(vec![
D::Descriptor(
desc("ClrC").with("Clrs", enm("Clrs", "Ylws")).with("Cyn ", pct(-30.0)).with("Mgnt", pct(0.0)).with("Yllw", pct(-40.0)).with("Blck", pct(0.0)),
),
D::Descriptor(
desc("ClrC").with("Clrs", enm("Clrs", "Whts")).with("Cyn ", pct(0.0)).with("Mgnt", pct(0.0)).with("Yllw", pct(0.0)).with("Blck", pct(-10.0)),
),
]),
);
let lab = |l: f64| D::Descriptor(desc("LbCl").with("Lmnc", D::Double(l)).with("A ", D::Double(0.0)).with("B ", D::Double(0.0)));
let sampled = desc("null").with("Fzns", long(40.0)).with("Mnm ", lab(80.0)).with("Mxm ", lab(95.0)).with("colorModel", long(0.0));
let mask = desc("null")
.with("Nw ", D::Class(class("Chnl")))
.with("At ", D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code("Msk ") }]))
.with("Usng", enm("UsrM", "RvlS"));
let fg = desc("null")
.with("null", D::Reference(vec![ReferenceItem::Property { class: class("Clr "), key: Id::new("FrgC") }]))
.with("T ", D::Descriptor(desc("RGBC").with("Rd ", D::Double(0.0)).with("Grn ", D::Double(0.0)).with("Bl ", D::Double(255.0))));
let lab_fg = desc("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Clr "), key: Id::new("BckC") }])).with("T ", lab(50.0));
let items = vec![
item("autoCutout", "Select Subject", Some(desc("null").with("sampleAllLayers", D::Boolean(false)))),
item("smartBrushWorkspace", "Select and Mask", Some(smart_brush)),
item("selectiveColor", "Selective Color", Some(selective)),
item("colorRange", "Color Range", Some(desc("null").with("Clrs", enm("Clrs", "skinTone")).with("Fzns", long(40.0)))),
item("colorRange", "Color Range", Some(sampled)),
item("colorRange", "Color Range", Some(desc("null").with("Clrs", enm("Clrs", "Rds ")).with("UsFc", D::Boolean(true)))),
item("Mk ", "Make", Some(mask)),
item("set", "Set", Some(fg)),
item("set", "Set", Some(lab_fg)),
item("groupEvent", "Create Clipping Mask", Some(desc("null").with("null", target_ref("Lyr ")))),
item("GrpL", "Create Clipping Mask", Some(desc("null").with("null", target_ref("Lyr ")))),
item("groupEvent", "Create Clipping Mask", Some(desc("null").with("null", name_ref("Lyr ", "Shadow")))),
item("someFutureEvent", "Something New", Some(desc("null").with("Amnt", long(5.0)))),
];
let file = AtnFile {
name: UnicodeString::new_nul("Retouch"),
expanded: false,
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }],
warnings: vec![],
..Default::default()
};
let imp = import(&atn::write(&file).unwrap()).unwrap();
let steps = &imp.actions[0].steps;
let expected = [
("select.subject", json!({"sampleAllLayers": false})),
(
"select.refineEdge",
json!({"radius": 3, "smartRadius": true, "smooth": 2, "feather": 0.5, "contrast": 10, "shiftEdge": -20, "decontaminate": false, "amount": 100, "output": "layerMask", "sampleAllLayers": false}),
),
("image.adjustments.selectiveColor", json!({"method": "relative", "yellows": [-30, 0, -40, 0], "whites": [0, 0, 0, -10]})),
("select.colorRange", json!({"select": "skinTones", "fuzziness": 40})),
(PHOTOSHOP_STEP, Value::Null),
(PHOTOSHOP_STEP, Value::Null),
("layer.layerMask.revealSelection", json!({})),
("tools.setColors", json!({"foreground": "#0000ff"})),
(PHOTOSHOP_STEP, Value::Null),
("layer.createClippingMask", json!({})),
("layer.createClippingMask", json!({})),
("layer.createClippingMask", json!({"layer": "Shadow"})),
(PHOTOSHOP_STEP, Value::Null),
];
assert_eq!(steps.len(), expected.len(), "{steps:?}");
for ((id, params), (want_id, want)) in steps.iter().zip(&expected) {
assert_eq!(id, want_id, "{steps:?}");
if *want_id != PHOTOSHOP_STEP {
assert_eq!(params, want, "{id}");
}
}
// Sampled Colors (Lab range), an unknown Color Range key, a Lab colour and an unknown
// event stay unsupported rather than play with other values.
let unsupported: Vec<(&str, usize)> = imp.unsupported.iter().map(|u| (u.event.as_str(), u.step)).collect();
assert_eq!(unsupported, [("colorRange", 4), ("colorRange", 5), ("set", 8), ("someFutureEvent", 12)]);
// A legacy PhotoCraft export's `Yllw` yellows still read.
let legacy = desc("null").with("ClrC", D::List(vec![D::Descriptor(desc("ClrC").with("Clrs", enm("Clrs", "Yllw")).with("Cyn ", pct(5.0)))]));
assert_eq!(selective_from(&legacy), Some(json!({"yellows": [5.0, 0.0, 0.0, 0.0]})));
// The mask of a named layer is not the target layer's mask: it stays unsupported.
let other_layer = desc("null")
.with("Nw ", D::Class(class("Chnl")))
.with(
"At ",
D::Reference(vec![
ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code("Msk ") },
ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul("Other") },
]),
)
.with("Usng", enm("UsrM", "RvlA"));
assert!(matches!(make_from(&other_layer), Mapped::Unsupported { harmless: false }));
}
/// Select and Mask exports as the older `refineSelectionEdge` event, which has no Sample All
/// Layers; a step that sets it exports as `smartBrushWorkspace` so the value survives.
#[test]
fn select_and_mask_keeps_sample_all_layers_on_export() {
let (event, d) = event_and_descriptor("select.refineEdge", &json!({"radius": 3, "output": "layerMask"})).unwrap();
assert_eq!(event.name, "refineSelectionEdge");
assert!(d.get("refineEdgeBorderRadius").is_some());
for sample in [true, false] {
let params = json!({"radius": 3, "output": "layerMask", "sampleAllLayers": sample});
let (event, d) = event_and_descriptor("select.refineEdge", &params).unwrap();
assert_eq!(event.name, "smartBrushWorkspace");
let Mapped::Steps(steps) = (event.from)(&d) else { panic!("{d:?}") };
assert_eq!(steps[0].1["sampleAllLayers"], sample);
}
}
#[test]
fn crop_sides_in_other_units_are_refused_not_played_as_pixels() {
let rect = |unit: [u8; 4]| {
let side = |v: f64| D::UnitFloat { unit, value: v };
desc("null")
.with("To ", D::Descriptor(desc("Rctn").with("Top ", side(10.0)).with("Left", side(10.0)).with("Btom", side(90.0)).with("Rght", side(90.0))))
};
for unit in [PCT, *b"#Rlt"] {
assert!(matches!(crop_from(&rect(unit)), Mapped::Unsupported { harmless: false }), "{}", String::from_utf8_lossy(&unit));
}
let Mapped::Steps(steps) = crop_from(&rect(PX)) else { panic!("a crop in pixels maps") };
assert_eq!(steps, [("image.crop".to_string(), json!({"x": 10.0, "y": 10.0, "width": 80.0, "height": 80.0}))]);
}
#[test]
fn step_commands_and_bad_params_fail_gracefully() {
let mut s = Session::new();
for p in [
json!({}),
json!({"path": ""}),
json!({"path": "/nonexistent/set.atn"}),
json!({"data": 42}),
json!({"data": "%%%"}),
json!({"data": b64(b"")}),
json!({"data": b64(&[0, 0, 0, 12, 0, 0, 0, 0])}),
json!({"data": b64(&atn::write(&sample()).unwrap()[..40])}),
json!({"data": b64(&atn::write(&sample()).unwrap()), "set": ""}),
json!({"data": b64(&atn::write(&sample()).unwrap()), "set": 3}),
json!({"data": b64(&atn::write(&sample()).unwrap()), "replace": "yes"}),
] {
assert!(s.execute("actions.load", p.clone()).is_err(), "{p}");
}
assert!(s.actions.list.is_empty());
for p in [json!({}), json!({"set": "Nope"}), json!({"action": 3}), json!({"path": 7}), json!({"skipUnsupported": "no"}), json!({"set": ""})] {
assert!(s.execute("actions.save", p.clone()).is_err(), "{p}");
}
s.actions.list.push(Action::new("One", vec![("select.all".into(), json!({}))]));
assert!(s.execute("actions.save", json!({"path": ""})).is_err());
assert!(s.execute("actions.save", json!({})).unwrap()["data"].is_string());
assert!(s.execute(STOP_MESSAGE, json!({"message": "halt"})).unwrap_err().to_string().contains("halt"));
assert_eq!(s.execute(STOP_MESSAGE, json!({"message": "go on", "allowContinue": true})).unwrap()["continued"], true);
assert!(s.execute(STOP_MESSAGE, json!({"message": 5, "allowContinue": "x"})).is_err());
for p in [json!({}), json!({"event": 1}), json!({"event": "GsnB", "name": "Gaussian Blur", "descriptor": "not base64"})] {
assert!(s.execute(PHOTOSHOP_STEP, p.clone()).is_err(), "{p}");
}
// A Photoshop step with a damaged descriptor is left out of a save rather than crashing it.
s.actions.list.push(Action::new("Broken", vec![(PHOTOSHOP_STEP.into(), json!({"event": "GsnB", "name": "Blur", "descriptor": b64(b"\x00\x00\x00")}))]));
assert!(s.execute("actions.save", json!({"action": "Broken"})).is_err());
assert_eq!(s.execute("actions.save", json!({"action": "Broken", "skipUnsupported": true})).unwrap()["skipped"].as_array().unwrap().len(), 1);
}
#[test]
fn save_writes_the_file_and_the_set_of_the_selected_action() {
let mut s = Session::new();
s.execute("actions.load", json!({"data": b64(&atn::write(&sample()).unwrap())})).unwrap();
s.actions.list.push(Action::new("Loose", vec![("select.all".into(), json!({}))]));
let dir = std::env::temp_dir().join(format!("pc-atn-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Saved.atn");
let r = s.execute("actions.save", json!({"action": "Finish", "path": path.to_string_lossy()})).unwrap();
assert_eq!((r["set"].as_str(), r["actions"].as_array().map(Vec::len)), (Some("Test Set"), Some(1)));
let file = atn::parse(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(file.actions.len(), 1);
assert_eq!(file.actions[0].name.to_string_lossy(), "Finish");
// Without a set, everything goes out under the default name.
let r = s.execute("actions.save", json!({})).unwrap();
assert_eq!((r["set"].as_str(), r["actions"].as_array().map(Vec::len)), (Some(DEFAULT_SET), Some(3)));
std::fs::remove_dir_all(&dir).ok();
}
/// #7290: saving over an existing ATN must replace it atomically (a temporary file renamed over
/// it), never truncate and rewrite it in place, so a write that fails partway (a full disk)
/// leaves the previously valid file readable. A handle opened on the old file still sees the old
/// bytes after an atomic replacement; an in-place rewrite changes them under it.
#[cfg(unix)]
#[test]
fn save_over_an_existing_file_never_rewrites_it_in_place() {
use std::io::Read;
let mut s = Session::new();
s.actions.list.push(Action::new("Owned", vec![("select.all".into(), json!({}))]));
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("Owned.atn");
let p = path.to_string_lossy().to_string();
s.execute("actions.save", json!({"path": p})).unwrap();
let before = std::fs::read(&path).unwrap();
let mut old = std::fs::File::open(&path).unwrap();
s.actions.list.push(Action::new("Second", vec![("select.all".into(), json!({}))]));
s.execute("actions.save", json!({"path": p})).unwrap();
let mut seen = Vec::new();
old.read_to_end(&mut seen).unwrap();
assert_eq!(seen, before, "the existing ATN was rewritten in place");
let after = atn::parse(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(after.actions.len(), 2);
let names: Vec<_> = std::fs::read_dir(dir.path()).unwrap().map(|e| e.unwrap().file_name()).collect();
assert_eq!(names, vec![std::ffi::OsString::from("Owned.atn")], "no temporary file is left behind");
}
#[test]
fn layer_param_accepts_a_name() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("layer.new.layer", json!({"name": "A"})).unwrap();
s.execute("layer.new.layer", json!({"name": "B"})).unwrap();
s.execute("layer.select", json!({"layer": "A"})).unwrap();
let d = s.active().unwrap();
assert_eq!(d.doc.layer(d.active_layer.unwrap()).unwrap().name, "A");
assert!(s.execute("layer.select", json!({"layer": "Nope"})).is_err());
assert!(s.execute("layer.select", json!({"layer": true})).is_err());
assert!(s.execute("layer.select", json!({"layer": -1})).is_err());
}
#[test]
fn colours_convert_both_ways() {
assert_eq!(color_hex(&desc("RGBC").with("Rd ", D::Double(255.0)).with("Grn ", D::Double(127.6)).with("Bl ", D::Double(0.0))).as_deref(), Some("#ff8000"));
assert_eq!(color_hex(&desc("HSBC").with("H ", ang(0.0)).with("Strt", D::Double(0.0)).with("Brgh", D::Double(100.0))).as_deref(), Some("#ffffff"));
assert_eq!(color_hex(&desc("HSBC").with("H ", ang(120.0)).with("Strt", D::Double(100.0)).with("Brgh", D::Double(100.0))).as_deref(), Some("#00ff00"));
assert_eq!(color_hex(&desc("Grsc").with("Gry ", D::Double(100.0))).as_deref(), Some("#000000"));
assert_eq!(color_hex(&desc("CMYC")), None);
assert_eq!(color_desc("#12ab9F").map(|d| color_hex(&d)), Some(Some("#12ab9f".into())));
assert_eq!(color_desc("12ab9f"), None);
assert_eq!(color_desc("#12ab9"), None);
assert_eq!(color_desc("#ééééé"), None);
}
#[test]
fn remaining_events_import_in_their_photoshop_shapes() {
// Close with the Save answer becomes Save then Close.
let close_yes = item("Cls ", "Close", Some(desc("null").with("Svng", enm("YsN ", "Ys "))));
let close_no = item("close", "Close", Some(desc("null").with("Svng", enm("YsN ", "N "))));
// A layer only moved is a translation; scaled, it is the relative Free Transform; the
// selection's frame goes to Transform Selection; points (not pixels) stay unsupported.
let moved = desc("null")
.with("null", target_ref("Lyr "))
.with("FTcs", enm("QCSt", "Qcsa"))
.with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(-57.0)).with("Vrtc", px(12.0))))
.with("Intr", enm("Intp", "Bcbc"));
let scaled = desc("null")
.with("null", target_ref("Lyr "))
.with("FTcs", enm("QCSt", "Qcsa"))
.with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(10.0)).with("Vrtc", px(0.0))))
.with("Wdth", pct(50.0))
.with("Hght", pct(50.0))
.with("Lnkd", D::Boolean(true))
.with("Intr", enm("Intp", "Bcbc"));
let sel = desc("null")
.with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }]))
.with("FTcs", enm("QCSt", "Qcs0"))
.with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(1014.0)).with("Vrtc", px(0.0))));
let points = desc("null").with("null", target_ref("Lyr ")).with(
"Ofst",
D::Descriptor(desc("Ofst").with("Hrzn", D::UnitFloat { unit: *b"#Rlt", value: 3.0 }).with("Vrtc", D::UnitFloat { unit: *b"#Rlt", value: 1.0 })),
);
// Apply Image as recorded by the frequency-separation action.
let apply = desc("null").with(
"With",
D::Descriptor(
desc("null")
.with(
"T ",
D::Reference(vec![
ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Chnl"), value: Id::new("RGB ") },
ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul("High-Frequency") },
]),
)
.with("Clcl", enm("Clcn", "Sbtr"))
.with("Scl ", D::Double(2.0))
.with("Ofst", D::Double(128.0))
.with("PrsT", D::Boolean(true)),
),
);
// Save for Web with and without a destination.
let web = desc("null")
.with(
"Usng",
D::Descriptor(
desc("SaveForWeb")
.with("Fmt ", enm("IRFm", "PN24"))
.with("Intr", D::Boolean(false))
.with("Trns", D::Boolean(true))
.with("Mtt ", D::Boolean(true))
.with("MttR", long(255.0))
.with("MttG", long(255.0))
.with("MttB", long(255.0))
.with("EICC", D::Boolean(false)),
),
)
.with("In ", path_value("/Users/me/Desktop/out.png"));
let web_nowhere = desc("null").with("Usng", D::Descriptor(desc("SaveForWeb").with("Fmt ", enm("IRFm", "PN24"))));
// Quick Mask on/off, a polygon selection, the mask of the target layer deleted.
let qm_on = desc("null").with("null", quick_mask_ref());
let polygon = desc("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }])).with(
"T ",
D::Descriptor(desc("Plgn").with(
"Pts ",
D::List(vec![
D::Descriptor(desc("Pnt ").with("Hrzn", px(1.0)).with("Vrtc", px(2.0))),
D::Descriptor(desc("Pnt ").with("Hrzn", px(30.0)).with("Vrtc", px(2.0))),
D::Descriptor(desc("Pnt ").with("Hrzn", px(15.0)).with("Vrtc", px(40.0))),
]),
)),
);
let mask_delete = desc("null").with("null", target_ref("Chnl"));
let items = vec![
item("GsnB", "Gaussian Blur", Some(desc("null").with("Rds ", px(1.0)))),
close_yes,
close_no,
item("transform", "Transform", Some(moved)),
item("Trnf", "Transform", Some(scaled)),
item("transform", "Transform", Some(sel)),
item("transform", "Transform", Some(points)),
item("applyImageEvent", "Apply Image", Some(apply)),
item("export", "Export", Some(web)),
item("export", "Export", Some(web_nowhere)),
item("setd", "Set", Some(qm_on.clone())),
item("clearEvent", "Clear", Some(qm_on.clone())),
item("set", "Set", Some(polygon)),
item("delete", "Delete", Some(mask_delete)),
item("newPlacedLayer", "Convert to Smart Object", None),
item("placedLayerEditContents", "Edit Contents", None),
item("updatePlacedLayer", "Update Smart Objects", None),
];
let file = AtnFile {
name: UnicodeString::new_nul("More"),
expanded: false,
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }],
warnings: vec![],
..Default::default()
};
let imp = import(&atn::write(&file).unwrap()).unwrap();
let steps = &imp.actions[0].steps;
let ids: Vec<&str> = steps.iter().map(|(id, _)| id.as_str()).collect();
assert_eq!(
ids,
[
"filter.blur.gaussianBlur",
"file.save",
"file.close",
"file.close",
"layer.translate",
"edit.transform",
"select.transformSelection",
PHOTOSHOP_STEP,
"image.applyImage",
"file.export.saveForWebLegacy",
PHOTOSHOP_STEP,
"select.editInQuickMaskMode",
"select.editInQuickMaskMode",
"select.lasso",
"layer.layerMask.delete",
"layer.smartObjects.convertToSmartObject",
"layer.smartObjects.editContents",
"layer.smartObjects.updateModifiedContent",
]
);
assert_eq!(steps[4].1, json!({"dx": -57, "dy": 12}));
assert_eq!(steps[5].1, json!({"dx": 10, "dy": 0, "scaleX": 50, "scaleY": 50, "interpolation": "bicubic"}));
assert_eq!(steps[6].1, json!({"dx": 1014, "dy": 0}));
assert_eq!(
steps[8].1,
json!({"source": {"channel": "composite", "layer": "High-Frequency"}, "blending": "subtract", "scale": 2, "offset": 128, "preserveTransparency": true})
);
assert_eq!(
steps[9].1,
json!({"path": "/Users/me/Desktop/out.png", "format": "png24", "interlaced": false, "transparency": true, "embedIcc": false, "matte": "#ffffff"})
);
assert_eq!(steps[11].1, json!({"on": true}));
assert_eq!(steps[12].1, json!({"on": false}));
assert_eq!(steps[13].1, json!({"points": [[1, 2], [30, 2], [15, 40]]}));
assert_eq!(imp.unsupported.len(), 2);
// The frequency-separation set's own conditional, a percent Image Size, a Paint Bucket
// click in pixels (percent stays unsupported) and a Place.
let cond = desc("null")
.with("null", enm("Cndt", "Lyrs"))
.with(
"then",
D::Reference(vec![
ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul("FS8-Layers") },
ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul("FS") },
]),
)
.with("else", D::Reference(vec![ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul("FS8-No-Layers") }]));
let size = desc("null")
.with("Wdth", pct(200.0))
.with("scaleStyles", D::Boolean(true))
.with("CnsP", D::Boolean(true))
.with("Intr", enm("Intp", "bicubicAutomatic"));
let bucket_px = desc("null")
.with("From", D::Descriptor(desc("Pnt ").with("Hrzn", px(0.0)).with("Vrtc", px(0.0))))
.with("Tlrn", long(32.0))
.with("Usng", enm("FlCn", "FrgC"))
.with("Cntg", D::Boolean(false));
let bucket_pct = desc("null")
.with("From", D::Descriptor(desc("Pnt ").with("Hrzn", pct(0.0)).with("Vrtc", pct(0.0))))
.with("Tlrn", long(32.0))
.with("Usng", enm("FlCn", "FrgC"));
let place = desc("null")
.with("null", path_value("/Users/me/logo.png"))
.with("FTcs", enm("QCSt", "Qcsa"))
.with("Wdth", pct(50.0))
.with("Hght", pct(50.0))
.with("Lnkd", D::Boolean(false));
let items = vec![
item("conditional", "If", Some(cond)),
item("imageSize", "Image Size", Some(size)),
item("Fl ", "Fill", Some(bucket_px)),
item("Fl ", "Fill", Some(bucket_pct)),
item("placeEvent", "Place", Some(place)),
item("Opn ", "Open", Some(desc("null").with("null", path_value("/Users/me/in.psd")))),
];
let file = AtnFile {
name: UnicodeString::new_nul("Rest"),
expanded: false,
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }],
warnings: vec![],
..Default::default()
};
let rest = import(&atn::write(&file).unwrap()).unwrap();
let rest_steps = &rest.actions[0].steps;
let rest_ids: Vec<&str> = rest_steps.iter().map(|(id, _)| id.as_str()).collect();
assert_eq!(rest_ids, ["actions.conditional", "image.imageSize", "paint.bucket", PHOTOSHOP_STEP, "file.placeEmbedded", "file.openAs"]);
// A transform without a reference (a replaced smart object) is the target layer's; the
// selection outline nudged; a layer moved to index 0 (the bottom); a View menu item; the
// foreground colour.
let replaced = desc("null")
.with("FTcs", enm("QCSt", "Qcsa"))
.with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(4.0)).with("Vrtc", px(6.0))))
.with("Wdth", pct(50.0))
.with("Hght", pct(50.0))
.with("replaceLayer", D::Descriptor(desc("null")));
let nudged = desc("null")
.with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }]))
.with("T ", D::Descriptor(desc("Ofst").with("Hrzn", px(3.0)).with("Vrtc", px(-2.0))));
let to_bottom = desc("null")
.with("null", target_ref("Lyr "))
.with("T ", D::Reference(vec![ReferenceItem::Index { class: class("Lyr "), index: 0 }]))
.with("Adjs", D::Boolean(false));
let fit =
desc("null").with("null", D::Reference(vec![ReferenceItem::Enumerated { class: class("Mn "), type_id: Id::new("MnIt"), value: Id::new("FtOn") }]));
let fg = desc("null")
.with("null", D::Reference(vec![ReferenceItem::Property { class: class("Clr "), key: Id::new("FrgC") }]))
.with("T ", D::Descriptor(desc("RGBC").with("Rd ", D::Double(0.0)).with("Grn ", D::Double(0.0)).with("Bl ", D::Double(0.0))));
let items = vec![
item("transform", "Transform", Some(replaced)),
item("move", "Move", Some(nudged)),
item("move", "Move", Some(to_bottom)),
item("select", "Select", Some(fit)),
item("set", "Set", Some(fg)),
];
let file = AtnFile {
name: UnicodeString::new_nul("More2"),
expanded: false,
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }],
warnings: vec![],
..Default::default()
};
let more = import(&atn::write(&file).unwrap()).unwrap();
let more_steps = &more.actions[0].steps;
assert_eq!(
more_steps.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(),
["edit.transform", "select.transformSelection", "layer.moveTo", "view.fitOnScreen", "tools.setColors"]
);
assert_eq!(more_steps[0].1, json!({"dx": 4, "dy": 6, "scaleX": 50, "scaleY": 50}));
assert_eq!(more_steps[1].1, json!({"dx": 3, "dy": -2}));
assert_eq!(more_steps[2].1, json!({"target": {"index": 0}}));
assert_eq!(more_steps[4].1, json!({"foreground": "#000000"}));
assert!(more.unsupported.is_empty());
assert_eq!(rest_steps[0].1, json!({"if": "hasLayers", "then": "FS8-Layers", "else": "FS8-No-Layers"}));
assert_eq!(rest_steps[1].1, json!({"percent": 200, "resample": "bicubicAutomatic", "scaleStyles": true}));
assert_eq!(rest_steps[2].1, json!({"x": 0, "y": 0, "tolerance": 32, "contiguous": false}));
assert_eq!(rest_steps[4].1, json!({"path": "/Users/me/logo.png", "scale": 50}));
assert_eq!(rest_steps[5].1, json!({"path": "/Users/me/in.psd"}));
// The Photoshop path record round-trips and rejects other shapes.
assert_eq!(path_text(&path_value("/a/b c.png")).as_deref(), Some("/a/b c.png"));
assert_eq!(path_text(&D::Path(b"txtu".to_vec())), None);
assert_eq!(path_text(&D::Path(vec![])), None);
assert_eq!(path_text(&D::Text(UnicodeString::new("x"))), None);
assert_eq!(path_text(&path_value("")), None);
// Apply Image by layer name runs on a document; a missing name is a clear error.
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("layer.new.layer", json!({"name": "High-Frequency"})).unwrap();
s.execute("layer.new.layer", json!({"name": "Top"})).unwrap();
s.execute("image.applyImage", steps[8].1.clone()).unwrap();
let e = s.execute("image.applyImage", json!({"source": {"layer": "Nope"}})).unwrap_err().to_string();
assert!(e.contains("Nope"), "{e}");
}
fn channel_ord(value: &str) -> D {
D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Chnl"), value: code(value) }])
}
fn fsel(t: D) -> Descriptor {
desc("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }])).with("T ", t)
}
/// #7103: Photoshop records switching to another open document as a `select` of `Dcmn` by
/// offset (previous -1, next +1). The template action of the report, which copies from the
/// source image and pastes into the template opened after it, imports with no Photoshop steps,
/// plays across the two documents and saves back the same shape. With no previous document
/// open, playback stops at the switch instead of pasting into the wrong document.
#[test]
fn select_document_by_offset_imports_plays_and_saves_back() {
let doc = |offset: i32| desc("null").with("null", D::Reference(vec![ReferenceItem::Offset { class: class("Dcmn"), offset }]));
// A recorded document id (`DocI`) means nothing in another session: ignored, not written back.
let copy_in = vec![
item("slct", "Select", Some(doc(-1).with("DocI", long(35.0)))),
item("setd", "Set", Some(fsel(enm("Ordn", "Al ")))),
item("copy", "Copy", None),
item("slct", "Select", Some(doc(1))),
item("past", "Paste", None),
];
let data = set_of("Template", vec![("Copy In", copy_in)]);
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8, "name": "source"})).unwrap();
s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap();
s.execute("file.new", json!({"width": 8, "height": 8, "name": "template", "background": "transparent"})).unwrap();
// Reorder tabs before playback: the source remains the previous *opened* document.
s.execute("document.move", json!({"document": 1, "to": 0})).unwrap();
let r = s.execute("actions.load", json!({"data": data})).unwrap();
assert_eq!(r["unsupported"], json!([]), "{r}");
let steps = &s.actions.list[0].steps;
assert_eq!(steps[0], ("document.activate".to_string(), json!({"offset": -1})));
assert_eq!(steps[3], ("document.activate".to_string(), json!({"offset": 1})));
let pixel = |s: &mut Session| -> Vec<f32> { serde_json::from_value(s.execute("document.pixel", json!({"x": 4, "y": 4})).unwrap()).unwrap() };
assert_eq!(pixel(&mut s), [0.0; 4]);
let r = s.execute("actions.play", json!({"action": "Copy In"})).unwrap();
assert!(r.get("failed").is_none(), "{r}");
assert_eq!(s.active().unwrap().doc.name, "template");
assert_eq!(s.active().unwrap().doc.layers.len(), 2, "the source's pixels are pasted on a new layer");
assert_eq!(pixel(&mut s), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(s.documents()[1].doc.layers.len(), 1, "the source is only copied from");
let first = s.actions.list.clone();
let r = s.execute("actions.save", json!({"set": "Template"})).unwrap();
assert_eq!(r["skipped"], json!([]));
let saved = photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap();
let file = atn::parse(&saved).unwrap();
let offsets: Vec<i32> = file.actions[0]
.items
.iter()
.filter_map(|i| match get_ref(i.descriptor.as_ref()?, "null")? {
[ReferenceItem::Offset { class, offset }] if trimmed(&class.class_id) == "Dcmn" => Some(*offset),
_ => None,
})
.collect();
assert_eq!(offsets, [-1, 1]);
assert!(file.actions[0].items[0].descriptor.as_ref().unwrap().get("DocI").is_none());
let mut t = Session::new();
t.execute("actions.load", json!({"data": b64(&saved)})).unwrap();
assert_eq!(t.actions.list, first);
// An absolute tab index has no Photoshop equivalent: it stays out of a strict save.
assert!(export_item("document.activate", &json!({"document": 0}), "S").is_none());
assert!(export_item("document.activate", &json!({"document": 0, "offset": 1}), "S").is_none());
let mut one = Session::new();
one.execute("file.new", json!({"width": 8, "height": 8, "name": "template"})).unwrap();
one.execute("actions.load", json!({"data": data})).unwrap();
let r = one.execute("actions.play", json!({"action": "Copy In"})).unwrap();
assert!(r.get("failed").is_none(), "{r}");
assert_eq!(one.active().unwrap().doc.layers.len(), 2, "offsets wrap back to the only open document");
}
fn set_of(name: &str, actions: Vec<(&str, Vec<AtnItem>)>) -> String {
let file = AtnFile {
name: UnicodeString::new_nul(name),
expanded: false,
actions: actions.into_iter().map(|(n, items)| AtnAction { name: UnicodeString::new_nul(n), items, ..Default::default() }).collect(),
warnings: vec![],
..Default::default()
};
b64(&atn::write(&file).unwrap())
}
/// The step shapes of the TrueGrit Texture Supply sets (Distress Press, Debaser, Transparency,
/// Halftone Zine Machine), rebuilt here as Photoshop records them, import and play: the
/// distressing filters land on the targeted layer mask, Remove White leaves the white
/// transparent, nudges in points follow the resolution, and Step Backward undoes a step.
#[test]
fn distressing_and_remove_white_shapes_import_and_play() {
let ord = |cls: &str, ty: &str, v: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class(cls), type_id: Id::new(ty), value: code(v) }]);
let black = D::Descriptor(desc("RGBC").with("Rd ", D::Double(0.0)).with("Grn ", D::Double(0.0)).with("Bl ", D::Double(0.0)));
let fill = desc("null")
.with("null", class_ref("contentLayer"))
.with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(desc("solidColorLayer").with("Clr ", black)))));
let refine = desc("null")
.with("refineEdgeBorderRadius", px(0.0))
.with("refineEdgeBorderContrast", pct(100.0))
.with("refineEdgeSmooth", long(2.0))
.with("refineEdgeFeatherRadius", px(0.0))
.with("refineEdgeChoke", pct(0.0))
.with("refineEdgeAutoRadius", D::Boolean(false))
.with("refineEdgeDecontaminate", D::Boolean(false))
.with("refineEdgeOutput", enm("refineEdgeOutput", "refineEdgeOutputUserMask"));
let distress = vec![
item("setd", "Set", Some(fsel(channel_ord("Trsp")))),
item("Hd ", "Hide", Some(desc("null").with("null", D::List(vec![target_ref("Lyr ")])))),
item("Mk ", "Make", Some(fill)),
item("slct", "Select", Some(desc("null").with("null", channel_ord("Msk")).with("MkVs", D::Boolean(false)))),
item("refineSelectionEdge", "Select and Mask", Some(refine)),
item("Rple", "Ripple", Some(desc("null").with("Amnt", long(50.0)).with("RplS", enm("RplS", "Sml")))),
item("Crst", "Crystallize", Some(desc("null").with("ClSz", long(3.0)))),
item("GEfc", "Filter Gallery", Some(desc("null").with("GEfk", enm("GEft", "Spt")).with("SprR", long(7.0)).with("Smth", long(15.0)))),
item("slct", "Select", Some(desc("null").with("null", ord("Chnl", "Ordn", "Trgt")).with("MkVs", D::Boolean(false)))),
item("setd", "Set", Some(fsel(ord("Chnl", "Ordn", "Trgt")))),
];
let bckg = D::Reference(vec![ReferenceItem::Property { class: class("Lyr "), key: Id::new("Bckg") }]);
let remove_white = vec![
item("CnvM", "Convert Mode", Some(desc("null").with("T ", D::Class(class("Grys"))))),
item(
"setd",
"Set",
Some(desc("null").with("null", bckg).with("T ", D::Descriptor(desc("Lyr ").with("Opct", pct(100.0)).with("Md ", enm("BlnM", "Nrml"))))),
),
item("setd", "Set", Some(fsel(ord("Chnl", "Ordn", "Trgt")))),
item("Dlt ", "Delete", None),
item("setd", "Set", Some(fsel(enm("Ordn", "None")))),
];
let pt = |v: f64| D::UnitFloat { unit: PT, value: v };
let nudge = vec![item(
"move",
"Move",
Some(desc("null").with("null", target_ref("Lyr ")).with("T ", D::Descriptor(desc("Ofst").with("Hrzn", pt(0.36)).with("Vrtc", pt(-0.36))))),
)];
let undo = vec![item("Invs", "Inverse", None), item("slct", "Select", Some(desc("null").with("null", ord("HstS", "Ordn", "Prvs"))))];
let data = set_of("TrueGrit", vec![("Distress", distress), ("Remove White", remove_white), ("Nudge", nudge), ("Undo", undo)]);
let mut s = Session::new();
s.execute("file.new", json!({"width": 120, "height": 80, "resolution": 200})).unwrap();
s.execute("layer.new.layer", json!({"name": "Ink"})).unwrap();
s.execute("select.rect", json!({"x": 20, "y": 20, "width": 80, "height": 40})).unwrap();
s.execute("edit.fill", json!({"contents": "color", "color": "#000000"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
let r = s.execute("actions.load", json!({"data": data})).unwrap();
// Only the re-selection of the targeted channel stays a Photoshop step, switched off.
let unsupported: Vec<&str> = r["unsupported"].as_array().unwrap().iter().map(|u| u["action"].as_str().unwrap()).collect();
assert_eq!(unsupported, ["Distress"], "{r}");
let r = s.execute("actions.play", json!({"action": "Distress"})).unwrap();
assert!(r.get("failed").is_none(), "{r}");
let st = s.active().unwrap();
let fill = st.doc.layer(st.active_layer.unwrap()).unwrap();
let mask = &fill.mask.as_ref().expect("the fill layer is masked by the ink").surface;
// The rectangle's straight top edge is roughened: pixels along it differ.
let edge: Vec<f32> = (20..100).map(|x| mask.sample_channel(x, 20, 0)).collect();
assert!(edge.iter().any(|v| *v < 0.5) && edge.iter().any(|v| *v > 0.5), "{edge:?}");
assert!(mask.sample_channel(60, 40, 0) > 0.9 && mask.sample_channel(5, 5, 0) < 0.1);
assert!(st.doc.selection.is_some(), "the targeted mask loads as the selection");
assert_eq!(st.channel_view.target, crate::channel_cmds::ChannelTarget::LayerMask);
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30})).unwrap();
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 10})).unwrap();
s.execute("edit.fill", json!({"contents": "color", "color": "#202020"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
s.execute("actions.load", json!({"data": data})).unwrap();
let r = s.execute("actions.play", json!({"action": "Remove White"})).unwrap();
assert!(r.get("failed").is_none(), "{r}");
let d = &s.active().unwrap().doc;
assert_eq!(d.mode, photocraft_color::ColorMode::Grayscale);
let l = d.layers.last().unwrap();
assert!(!d.is_background(l.id), "Layer From Background");
let surf = l.surface().unwrap();
assert!(surf.sample_channel(2, 2, 1) < 0.01, "white is cleared");
// #202020 is about 22 in the default (Dot Gain 20 %-compatible) working gray.
let ink = surf.sample_channel(20, 15, 1);
assert!((ink - (1.0 - 22.0 / 255.0)).abs() < 0.02, "the ink keeps its darkness as opacity: {ink}");
// An image that opened as a normal layer has no Background: the step leaves it as it is.
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30, "background": "transparent"})).unwrap();
s.execute("edit.fill", json!({"contents": "white"})).unwrap();
s.execute("actions.load", json!({"data": data})).unwrap();
let r = s.execute("actions.play", json!({"action": "Remove White"})).unwrap();
assert!(r.get("failed").is_none(), "{r}");
let surf = s.active().unwrap().doc.layers.last().unwrap().surface().unwrap().clone();
assert!(surf.sample_channel(2, 2, 1) < 0.01, "white is cleared without a Background");
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30, "resolution": 200})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 5, "height": 5})).unwrap();
s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap();
s.execute("actions.load", json!({"data": data})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
assert!(s.execute("actions.play", json!({"action": "Nudge"})).unwrap().get("failed").is_none());
let st = s.active().unwrap();
let b = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds();
assert_eq!((b.x0, b.y0), (11, 9), "0.36 pt is 1 px at 200 ppi");
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
let before = s.active().unwrap().doc.selection.clone();
assert!(s.execute("actions.play", json!({"action": "Undo"})).unwrap().get("failed").is_none());
assert_eq!(s.active().unwrap().doc.selection, before, "Step Backward undid the Inverse");
}
/// Remove White on art in its own layer above a white Background: the Background becomes a
/// normal layer, but the art layer stays targeted, so the white is cleared from the art.
#[test]
fn remove_white_clears_the_targeted_layer_not_the_converted_background() {
let ord = |cls: &str, ty: &str, v: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class(cls), type_id: Id::new(ty), value: code(v) }]);
let bckg = D::Reference(vec![ReferenceItem::Property { class: class("Lyr "), key: Id::new("Bckg") }]);
let steps = vec![
item("CnvM", "Convert Mode", Some(desc("null").with("T ", D::Class(class("Grys"))))),
item(
"setd",
"Set",
Some(desc("null").with("null", bckg).with("T ", D::Descriptor(desc("Lyr ").with("Opct", pct(100.0)).with("Md ", enm("BlnM", "Nrml"))))),
),
item("setd", "Set", Some(fsel(ord("Chnl", "Ordn", "Trgt")))),
item("Dlt ", "Delete", None),
item("setd", "Set", Some(fsel(enm("Ordn", "None")))),
];
let data = set_of("TrueGrit", vec![("Remove White", steps)]);
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30})).unwrap();
let art = s.execute("layer.new.layer", json!({"name": "Art"})).unwrap()["layer"].as_u64().unwrap();
s.execute("edit.fill", json!({"contents": "white"})).unwrap();
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 10})).unwrap();
s.execute("edit.fill", json!({"contents": "color", "color": "#000000"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
s.execute("actions.load", json!({"data": data})).unwrap();
let r = s.execute("actions.play", json!({"action": "Remove White"})).unwrap();
assert!(r.get("failed").is_none(), "{r}");
let st = s.active().unwrap();
assert_eq!(st.active_layer.map(|l| l.0), Some(art), "the art layer stays targeted");
let art = st.doc.layer(photocraft_doc::LayerId(art)).unwrap().surface().unwrap();
assert!(art.sample_channel(2, 2, 1) < 0.01, "the art's white is cleared");
assert!(art.sample_channel(20, 15, 1) > 0.99, "the black stays");
let bottom = st.doc.layers.first().unwrap();
assert!(!st.doc.is_background(bottom.id), "the Background became a normal layer");
assert!(bottom.surface().unwrap().sample_channel(2, 2, 1) > 0.99, "and kept its pixels");
}
/// Measured in Photoshop: `executeAction` fails for the codes `BoxB`, `SrfB`, `Fltn`, `BlnW`
/// and `PhtF` ("the command is not currently available"), while the names `boxblur`,
/// `surfaceBlur`, `flattenImage`, `blackAndWhite`, `photoFilter` and the code `FltI` run.
#[test]
fn events_photoshop_only_runs_by_name_export_by_name_and_old_codes_still_import() {
let bw = json!({"reds": 40, "yellows": 60, "greens": 40, "cyans": 60, "blues": 20, "magentas": 80, "tint": false, "tintColor": "#e4d5b0"});
let photo_filter = json!({"color": "#0022cd", "density": 67, "preserveLuminosity": false});
let cases = [
("filter.blur.boxBlur", json!({"radius": 3}), "boxblur", "BoxB"),
("filter.blur.surfaceBlur", json!({"radius": 5, "threshold": 15}), "surfaceBlur", "SrfB"),
("layer.flattenImage", json!({}), "FltI", "Fltn"),
("image.adjustments.blackWhite", bw, "blackAndWhite", "BlnW"),
("image.adjustments.photoFilter", photo_filter, "photoFilter", "PhtF"),
];
for (id, params, written, legacy) in &cases {
let exported = export_item(id, params, "S").unwrap_or_else(|| panic!("{id} does not export"));
assert_eq!(exported.event.id(), *written, "{id} exports under an id Photoshop runs");
assert_eq!(matches!(exported.event, AtnEvent::Name(_)), written.len() != 4, "{id}: a name is written as a name");
let d = exported.descriptor.clone();
// The written id, the name for Flatten Image, and the old code all import to the command.
let name = if *id == "layer.flattenImage" { "flattenImage" } else { written };
for event in [*written, name, *legacy] {
let e = event_for(&item(event, "", None).event).unwrap_or_else(|| panic!("{event} does not import"));
assert!(e.ids.contains(id), "{event} imports as {:?}, not {id}", e.ids);
match (e.from)(d.as_ref().unwrap()) {
Mapped::Steps(steps) => {
assert_eq!(steps.len(), 1, "{event}: {steps:?}");
assert_eq!(steps[0].0, *id, "{event}");
let mut back = steps[0].1.clone();
normalise(&mut back);
assert_eq!(back, *params, "{event}");
}
Mapped::Unsupported { .. } => panic!("{event} does not import back"),
}
}
}
// A whole set of Photoshop-shaped steps (names, FltI) and old PhotoCraft-shaped ones
// (legacy codes) loads to the same commands.
let blur = |event: &str| item(event, "Box Blur", Some(desc("null").with("Rds ", px(3.0))));
let surface = |event: &str| item(event, "Surface Blur", Some(desc("null").with("Rds ", px(5.0)).with("Thsh", long(15.0))));
let photoshop = vec![blur("boxblur"), surface("surfaceBlur"), item("FltI", "Flatten Image", None), item("flattenImage", "Flatten Image", None)];
let legacy = vec![blur("BoxB"), surface("SrfB"), item("Fltn", "Flatten Image", None)];
let data = set_of("S", vec![("Photoshop", photoshop), ("Legacy", legacy)]);
let mut s = Session::new();
let r = s.execute("actions.load", json!({"data": data})).unwrap();
assert_eq!(r["unsupported"], json!([]), "{r}");
let ids = |i: usize| s.actions.list[i].steps.iter().map(|(id, _)| id.clone()).collect::<Vec<_>>();
assert_eq!(ids(0), ["filter.blur.boxBlur", "filter.blur.surfaceBlur", "layer.flattenImage", "layer.flattenImage"]);
assert_eq!(ids(1), ["filter.blur.boxBlur", "filter.blur.surfaceBlur", "layer.flattenImage"]);
assert_eq!(s.actions.list[1].steps[1].1, json!({"radius": 5, "threshold": 15}));
// Saving them writes the ids Photoshop runs, never the old codes.
let saved = s.execute("actions.save", json!({"set": "S"})).unwrap();
let file = atn::parse(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap();
let events: Vec<String> = file.actions.iter().flat_map(|a| a.items.iter().map(|i| i.event.id())).collect();
assert_eq!(events, ["boxblur", "surfaceBlur", "FltI", "FltI", "boxblur", "surfaceBlur", "FltI"]);
// Unknown ids, near misses and the legacy codes written as names stay Photoshop steps,
// never a panic or a wrong command.
for event in ["BoxBlur", "boxBlur", "BOXB", "Fltn ", "FLTI", "", "\u{0}\u{0}\u{0}\u{0}", "BlnWx", "PHTF", "PhtF ", "photofilter"] {
let e = event_for(&item(event, "", None).event);
assert!(e.is_none(), "{event:?} maps to {:?}", e.map(|e| e.ids));
}
// A legacy code with a broken descriptor is unsupported, not a crash.
let e = event_for(&item("SrfB", "", None).event).unwrap();
assert!(matches!((e.from)(&desc("null").with("Rds ", D::Double(f64::NAN))), Mapped::Unsupported { .. } | Mapped::Steps(_)));
// A wrong-typed key is skipped like any other unreadable field: the step keeps the defaults.
match (e.from)(&desc("null").with("Rds ", text("x"))) {
Mapped::Steps(steps) => assert!(steps.iter().all(|(_, p)| p.get("radius").is_none()), "{steps:?}"),
Mapped::Unsupported { .. } => {}
}
}
/// #8301: Photoshop stops on a Photo Filter step saved as `PhtF` ("not currently available")
/// and runs the same descriptor under the name `photoFilter`. A recorded step saves by name,
/// a Photoshop-authored step re-saves by name, and actions saved before with `PhtF` still load
/// and play the same.
#[test]
fn photo_filter_saves_under_the_name_photoshop_runs() {
let params = json!({"color": "#0022cd", "density": 67, "preserveLuminosity": false});
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8, "background": "#808080"})).unwrap();
s.execute("actions.record", json!({"name": "Filter"})).unwrap();
s.execute("image.adjustments.photoFilter", params.clone()).unwrap();
s.execute("actions.stop", json!({})).unwrap();
let saved = s.execute("actions.save", json!({"action": "Filter"})).unwrap();
assert_eq!(saved["skipped"], json!([]), "{saved}");
let file = atn::parse(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap();
let step = &file.actions[0].items[0];
assert_eq!(step.event, AtnEvent::Name("photoFilter".into()), "never the code PhtF");
assert_eq!(step.dictionary, "Photo Filter".as_bytes());
let d = step.descriptor.clone().unwrap();
for key in ["Clr ", "Dnst", "PrsL"] {
assert!(d.get(key).is_some(), "the descriptor keeps {key}: {d:?}");
}
// A Photoshop-authored step (by name) and an old PhotoCraft one (`PhtF`) both load to the
// command, and saving them again writes the name.
let data =
set_of("S", vec![("Photoshop", vec![item("photoFilter", "Photo Filter", Some(d.clone()))]), ("Legacy", vec![item("PhtF", "Photo Filter", Some(d))])]);
let mut t = Session::new();
let r = t.execute("actions.load", json!({"data": data})).unwrap();
assert_eq!(r["unsupported"], json!([]), "{r}");
for a in &t.actions.list {
assert_eq!(a.steps.len(), 1, "{}", a.name);
assert_eq!(a.steps[0].0, "image.adjustments.photoFilter", "{}", a.name);
let mut p = a.steps[0].1.clone();
normalise(&mut p);
assert_eq!(p, params, "{}", a.name);
}
let resaved = t.execute("actions.save", json!({"set": "S"})).unwrap();
assert_eq!(resaved["skipped"], json!([]), "{resaved}");
let file = atn::parse(&photocraft_paint::tile::b64_decode(resaved["data"].as_str().unwrap()).unwrap()).unwrap();
let events: Vec<String> = file.actions.iter().flat_map(|a| a.items.iter().map(|i| i.event.id())).collect();
assert_eq!(events, ["photoFilter", "photoFilter"]);
// Both play like the command run directly, at 8 and 16 bits.
for depth in [8, 16] {
let pixel = |s: &Session| {
let st = s.active().unwrap();
let surface = st.doc.layers.first().unwrap().surface().unwrap();
[0, 1, 2].map(|c| surface.sample_channel(3, 3, c))
};
let new_doc = |t: &mut Session| {
t.execute("file.new", json!({"width": 8, "height": 8, "background": "#808080", "depth": depth})).unwrap();
};
new_doc(&mut t);
t.execute("image.adjustments.photoFilter", params.clone()).unwrap();
let direct = pixel(&t);
assert_ne!(direct, [128.0 / 255.0; 3], "the filter changes the grey");
for action in ["Photoshop", "Legacy"] {
new_doc(&mut t);
let r = t.execute("actions.play", json!({"action": action})).unwrap();
assert!(r.get("failed").is_none(), "{action}: {r}");
assert_eq!(pixel(&t), direct, "{action} at {depth} bits");
}
}
}
/// A Black & White adjustment layer saves with Photoshop's `BanW` class (#8481): measured in
/// Photoshop, a Make whose type is `BlnW` is refused. Photoshop's own `BanW` Make loads, and so
/// does the `BlnW` one PhotoCraft wrote before.
#[test]
fn black_white_adjustment_layer_saves_as_photoshops_class_and_old_class_still_imports() {
let id = "layer.newAdjustmentLayer.blackWhite";
let params = json!({"reds": 17, "yellows": 83, "greens": -12, "cyans": 107, "blues": 51, "magentas": 133, "tint": true, "tintColor": "#c08040"});
let mut s = Session::new();
s.actions.list.push(Action::new("BW", vec![(id.into(), params.clone())]));
let r = s.execute("actions.save", json!({"action": "BW"})).unwrap();
assert_eq!(r["skipped"], json!([]));
let saved = photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap();
let file = atn::parse(&saved).unwrap();
let make = file.actions[0].items[0].descriptor.as_ref().unwrap();
let kind = get_obj(make, "Usng").and_then(|u| get_obj(u, "Type")).unwrap();
assert_eq!(trimmed(&kind.class_id), "BanW");
let mut t = Session::new();
let r = t.execute("actions.load", json!({"data": b64(&saved)})).unwrap();
assert_eq!(r["unsupported"], json!([]), "{r}");
assert_eq!(t.actions.list[0].steps, vec![(id.to_string(), params)]);
// Photoshop's minimal Make (no settings) and an old PhotoCraft Make with a setting both load,
// the setting kept; near misses stay Photoshop steps.
let make = |class: &str| {
let kind = if class == "BlnW" { desc(class).with("Rd ", long(17.0)) } else { desc(class) };
let using = desc("AdjL").with("Type", D::Descriptor(kind));
item("Mk ", "Make", Some(desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(using))))
};
let data = set_of("S", vec![("A", ["BanW", "BlnW", "BANW", "BlnWx"].into_iter().map(make).collect())]);
let mut t = Session::new();
let r = t.execute("actions.load", json!({"data": data})).unwrap();
let steps: Vec<&str> = t.actions.list[0].steps.iter().map(|(id, _)| id.as_str()).collect();
assert_eq!(steps, [id, id, PHOTOSHOP_STEP, PHOTOSHOP_STEP], "{r}");
assert_eq!(t.actions.list[0].steps[1].1["reds"], 17, "{r}");
}
/// Smart Blur's quality uses Photoshop's `SmBQ` codes (`SBQL`/`SBQM`/`SBQH`): measured in
/// Photoshop, the old `Hgh `/`Mdm `/`Low ` play back as its fallback quality. Old codes still read.
#[test]
fn smart_blur_quality_uses_photoshop_codes() {
let smart_blur =
|quality: D, mode: &str| desc("null").with("Rds ", D::Double(6.0)).with("Thsh", D::Double(60.0)).with("SmBQ", quality).with("SmBM", enm("SmBM", mode));
// Every quality writes Photoshop's code, as a double radius/threshold and both enums,
// and reads back as the same step.
for (quality, code) in [("low", "SBQL"), ("medium", "SBQM"), ("high", "SBQH")] {
let params = json!({"radius": 6, "threshold": 60, "quality": quality, "mode": "edgeOnly"});
let a = Action::new("Q", vec![("filter.blur.smartBlur".into(), params.clone())]);
let (bytes, skipped) = export("S", &[&a], &[], false).unwrap();
assert!(skipped.is_empty());
let file = atn::parse(&bytes).unwrap();
let it = &file.actions[0].items[0];
assert_eq!(it.event.id(), "SmrB");
assert_eq!(it.descriptor.as_ref(), Some(&smart_blur(enm("SmBQ", code), "SBME")), "{quality}");
let back = import(&bytes).unwrap();
assert!(back.unsupported.is_empty(), "{quality}");
assert_eq!(back.actions[0].steps, vec![("filter.blur.smartBlur".to_string(), params)]);
}
// Photoshop-shaped steps (each quality and mode, as Photoshop stores them) and the codes
// PhotoCraft wrote before this fix read as the right quality.
let cases = [
("SBQH", "SBMN", "high", "normal"),
("SBQM", "SBMO", "medium", "overlayEdge"),
("SBQL", "SBME", "low", "edgeOnly"),
("Hgh ", "SBMN", "high", "normal"),
("Mdm ", "SBMN", "medium", "normal"),
("Low ", "SBMN", "low", "normal"),
];
let items = cases.iter().map(|(q, m, _, _)| item("SmrB", "Smart Blur", Some(smart_blur(enm("SmBQ", q), m)))).collect();
let data = set_of("Blurs", vec![("Smart", items)]);
let mut s = Session::new();
s.execute("actions.load", json!({"data": data})).unwrap();
let steps = &s.actions.list[0].steps;
for ((q, _, quality, mode), (id, p)) in cases.iter().zip(steps) {
assert_eq!(id, "filter.blur.smartBlur", "{q}");
assert_eq!(p, &json!({"radius": 6, "threshold": 60, "quality": quality, "mode": mode}), "{q}");
}
// An unknown code, a quality of the wrong type, NaN and a missing quality never panic: the
// unknown ones stay Photoshop steps, kept verbatim.
let odd = [
smart_blur(enm("SmBQ", "Xxxx"), "SBMN"),
smart_blur(D::Integer(2), "SBMN"),
smart_blur(enm("SmBQ", "SBQH"), "Xxxx"),
desc("null").with("Rds ", D::Double(f64::NAN)).with("SmBQ", enm("SmBQ", "SBQM")),
desc("null"),
];
let items: Vec<AtnItem> = odd.iter().map(|d| item("SmrB", "Smart Blur", Some(d.clone()))).collect();
let imported = import(&photocraft_paint::tile::b64_decode(&set_of("Odd", vec![("Odd", items)])).unwrap()).unwrap();
let steps = &imported.actions[0].steps;
assert_eq!(steps.len(), odd.len());
assert_eq!(steps[0].0, PHOTOSHOP_STEP, "an unknown quality is kept verbatim");
assert_eq!(steps[2].0, PHOTOSHOP_STEP, "an unknown mode is kept verbatim");
assert_eq!(imported.unsupported.iter().map(|u| u.step).collect::<Vec<_>>(), [0, 2]);
// A wrong-type quality is left out (the filter's default applies), as for every enum field.
assert_eq!(steps[1], ("filter.blur.smartBlur".to_string(), json!({"radius": 6, "threshold": 60, "mode": "normal"})));
// An unknown quality or a non-string one is not exported.
for bad in [json!({"quality": "best"}), json!({"quality": 3}), json!({"radius": "x"})] {
let a = Action::new("Bad", vec![("filter.blur.smartBlur".into(), bad.clone())]);
assert!(export("S", &[&a], &[], false).is_err(), "{bad}");
}
}
/// #5830: Shadows/Highlights (`adaptCorrect`) recorded by Photoshop, with string IDs, plays as
/// Image › Adjustments › Shadows/Highlights instead of stopping as a step with no equivalent.
#[test]
fn photoshop_shadows_highlights_steps_play() {
let tones = |amount: f64, width: f64, radius: i32| {
D::Descriptor(desc("adaptCorrectTones").with("amount", pct(amount)).with("width", pct(width)).with("radius", D::Integer(radius)))
};
let d = desc("null")
.with("shadowMode", tones(35.0, 50.0, 30))
.with("highlightMode", tones(20.0, 40.0, 24))
.with("blackClip", D::Double(0.01))
.with("whiteClip", D::Double(0.02))
.with("center", D::Integer(5))
.with("colorCorrection", D::Integer(20));
let e = event_for(&item("adaptCorrect", "Sombra/Realce", None).event).expect("adaptCorrect is mapped");
let Mapped::Steps(steps) = (e.from)(&d) else { panic!("Shadows/Highlights maps to a step") };
assert_eq!(steps.len(), 1);
let (id, p) = &steps[0];
assert_eq!(id, "image.adjustments.shadowsHighlights");
let mut p = p.clone();
normalise(&mut p);
assert_eq!(
p,
json!({"shadowAmount": 35, "shadowTone": 50, "shadowRadius": 30, "highlightAmount": 20, "highlightTone": 40, "highlightRadius": 24, "blackClip": 0.01, "whiteClip": 0.02, "midtone": 5, "color": 20})
);
// The same settings as char IDs read the same; absent keys are left to the defaults.
let codes = desc("null")
.with("sdwM", D::Descriptor(desc("adaptCorrectTones").with("Amnt", pct(35.0)).with("Wdth", pct(50.0)).with("Rds ", D::Integer(30))))
.with("BlcC", D::Double(0.01));
let Mapped::Steps(steps) = (e.from)(&codes) else { panic!("char IDs map too") };
let mut q = steps[0].1.clone();
normalise(&mut q);
assert_eq!(q, json!({"shadowAmount": 35, "shadowTone": 50, "shadowRadius": 30, "blackClip": 0.01}));
// And it runs on a document.
let mut s = Session::new();
s.execute("file.new", json!({"width": 16, "height": 16, "background": "#404040"})).unwrap();
s.execute(id, p).unwrap();
}
#[test]
fn save_while_recording_includes_pending_steps() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("actions.record", json!({"name": "Pending"})).unwrap();
s.execute("layer.new.layer", json!({"name": "Recorded"})).unwrap();
// While still recording (without calling actions.stop), save the action.
let saved = s.execute("actions.save", json!({"action": "Pending"})).unwrap();
assert_eq!(saved["steps"], 1, "pending steps must be flushed and included in save");
assert_eq!(saved["skipped"], json!([]));
// After save, recording continues and additional steps are flushed on stop without duplication.
s.execute("layer.new.layer", json!({"name": "Second"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
let action = s.execute("actions.get", json!({"action": "Pending"})).unwrap();
assert_eq!(action["steps"].as_array().unwrap().len(), 2, "both steps must be present without duplication");
}
/// #7233: the Minimum / Maximum descriptor carries the radius only, so a step with Preserve:
/// Roundness is refused by strict export (and left out, reported, otherwise) instead of replaying
/// as the default Squareness. Squareness, explicit or by default, still round-trips.
#[test]
fn minimum_and_maximum_with_roundness_are_not_exported_as_squareness() {
for id in ["filter.other.minimum", "filter.other.maximum"] {
let round = Action { name: "Round".into(), steps: vec![(id.into(), json!({"radius": 2, "preserve": "roundness"}))], ..Default::default() };
assert!(export("Audit", &[&round], &[], false).is_err(), "{id}: strict export refuses it");
let (_, skipped) = export("Audit", &[&round], &[], true).unwrap();
assert_eq!(skipped, vec![Skipped { action: "Round".into(), step: 0, id: id.into() }], "{id}");
for p in [json!({"radius": 2}), json!({"radius": 2, "preserve": "squareness"})] {
let square = Action { name: "Square".into(), steps: vec![(id.into(), p.clone())], ..Default::default() };
let (bytes, skipped) = export("Audit", &[&square], &[], false).unwrap();
assert!(skipped.is_empty(), "{id} {p}");
let imported = import(&bytes).unwrap();
let (got, params) = &imported.actions[0].steps[0];
assert_eq!((got.as_str(), params["radius"].as_f64()), (id, Some(2.0)), "{id} {p}");
assert!(params.get("preserve").is_none_or(|v| v == "squareness"), "{id} {p}: {params}");
}
}
}
/// #7515: a Transform Selection's interpolation survives the round trip as a layer transform's
/// does. An arbitrary coordinate reference, a skew or a warp can't be written,
/// so the step is refused instead of replaying as a plain scale.
#[test]
fn transform_selection_keeps_its_interpolation_and_refuses_what_it_cannot_carry() {
let one = |id: &str, p: Value| Action { name: "Probe".into(), steps: vec![(id.into(), p)], ..Default::default() };
let p = json!({"dx": 0.7, "dy": 0, "scaleX": 140, "scaleY": 80, "interpolation": "nearest"});
let (bytes, skipped) = export("Audit7", &[&one("select.transformSelection", p.clone())], &[], false).unwrap();
assert!(skipped.is_empty());
let imported = import(&bytes).unwrap();
assert_eq!(imported.actions[0].steps, vec![("select.transformSelection".to_string(), p)]);
for id in ["select.transformSelection", "edit.transform"] {
for p in [
json!({"scaleX": 140, "reference": [3, 4]}),
json!({"scaleX": 140, "skewX": 12}),
json!({"scaleX": 140, "skewY": -5}),
json!({"warp": "arc"}),
json!({"corners": [[0, 0], [10, 0], [10, 10], [0, 10]]}),
] {
let err = export("Audit7", &[&one(id, p.clone())], &[], false).unwrap_err();
assert!(err.contains("no Photoshop equivalent") && err.contains(&format!("`{id}`")), "{id} {p}: {err}");
let (_, skipped) = export("Audit7", &[&one(id, p.clone())], &[], true).unwrap();
assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: id.into() }], "{id} {p}");
}
let carried = json!({"scaleX": 140, "reference": "center", "skewX": 0, "skewY": 0});
assert!(export("Audit7", &[&one(id, carried.clone())], &[], false).is_ok(), "{id} {carried}");
}
}
/// #7540-#7542: Feather, Contract and Smooth (and Expand) with Apply Effect at Canvas Bounds, and
/// #7543-#7546: a pattern Paint Bucket with a chosen pattern, scale, angle or phase, can't be
/// written, so strict export refuses them. Their defaults still export and round-trip.
#[test]
fn canvas_bound_modify_and_placed_pattern_bucket_refuse_strict_export() {
let one = |id: &str, p: Value| Action { name: "Probe".into(), steps: vec![(id.into(), p)], ..Default::default() };
for id in ["select.modify.feather", "select.modify.expand", "select.modify.contract", "select.modify.smooth"] {
assert!(export("Audit7", &[&one(id, json!({"radius": 2, "applyAtCanvasBounds": true}))], &[], false).is_err(), "{id}");
let plain = json!({"radius": 2, "applyAtCanvasBounds": false});
let (bytes, _) = export("Audit7", &[&one(id, plain)], &[], false).unwrap_or_else(|e| panic!("{id}: {e}"));
assert_eq!(import(&bytes).unwrap().actions[0].steps, vec![(id.to_string(), json!({"radius": 2}))]);
}
let bucket = |extra: Value| {
let mut p = json!({"x": 5, "y": 5, "contents": "pattern", "opacity": 70, "tolerance": 80});
if let (Some(p), Some(e)) = (p.as_object_mut(), extra.as_object()) {
p.extend(e.clone());
}
one("paint.bucket", p)
};
for extra in [json!({"pattern": "AuditPattern"}), json!({"scale": 50}), json!({"angle": 30}), json!({"phase": [3, 5]})] {
assert!(export("Audit7", &[&bucket(extra.clone())], &[], false).is_err(), "{extra}");
}
for extra in [json!({}), json!({"scale": 100, "angle": 0, "phase": [0, 0]})] {
assert!(export("Audit7", &[&bucket(extra.clone())], &[], false).is_ok(), "{extra}");
}
}
/// #7512, #7513, #7514: a New Layer step with a colour label, neutral fill or a position below
/// the active layer isn't exported. Invalid clipping values are also refused. Defaults still
/// export, and the step round-trips.
#[test]
fn new_layer_options_the_descriptor_cannot_carry_refuse_strict_export() {
let new_layer = |p: Value| Action { name: "Probe".into(), steps: vec![("layer.new.layer".into(), p)], ..Default::default() };
for p in [
json!({"name": "New", "color": "red"}),
json!({"name": "New", "mode": "Overlay", "fillNeutral": true}),
json!({"name": "New", "position": "below"}),
json!({"name": "New", "clipToPrevious": 1}),
] {
assert!(export("Audit7", &[&new_layer(p.clone())], &[], false).is_err(), "{p}");
let (_, skipped) = export("Audit7", &[&new_layer(p.clone())], &[], true).unwrap();
assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "layer.new.layer".into() }], "{p}");
}
let defaults =
json!({"name": "New", "mode": "Multiply", "opacity": 0.6, "clipToPrevious": false, "color": "none", "fillNeutral": false, "position": "above"});
let (bytes, skipped) = export("Audit7", &[&new_layer(defaults)], &[], false).unwrap();
assert!(skipped.is_empty());
let imported = import(&bytes).unwrap();
assert_eq!(imported.actions[0].steps, vec![("layer.new.layer".to_string(), json!({"name": "New", "mode": "Multiply", "opacity": 0.6}))]);
}
/// Options the exported descriptors can't carry refuse strict export (and a partial export skips
/// the step) instead of exporting a step that replays differently; the forms they can carry still
/// export (#7196, #7246, #7287, #7503, #7506, #7507).
#[test]
fn options_the_descriptors_cannot_carry_refuse_strict_export() {
let one = |id: &str, p: Value| Action { name: "Probe".into(), steps: vec![(id.into(), p)], ..Default::default() };
let refused = [
("layer.newFillLayer.solidColor", json!({"color": "#c0603080"})),
("layer.newFillLayer.solidColor", json!({"color": "teal"})),
("file.new", json!({"width": 24, "height": 18, "background": "black"})),
("file.new", json!({"width": 24, "height": 18, "background": "#ab2233"})),
("file.new", json!({"width": 24, "height": 18, "background": "backgroundColor", "backgroundColor": [0.2, 0.4, 0.6]})),
("select.loadSelection", json!({"channel": "Alpha 1", "invert": true})),
("select.loadSelection", json!({"channel": "Alpha 1", "operation": "subtract"})),
("layer.setProps", json!({"opacity": 0.5, "locked": true})),
("layer.setProps", json!({"opacity": 0.5, "clipped": true})),
("layer.setProps", json!({"layers": [2, 3], "opacity": 0.5})),
("filter.noise.addNoise", json!({"amount": 30, "seed": 2147483648_u64})),
("filter.noise.addNoise", json!({"amount": 30, "seed": 4294967295_u64})),
];
for (id, p) in refused {
let err = export("Audit7", &[&one(id, p.clone())], &[], false).unwrap_err();
assert!(err.contains(&format!("`{id}`")), "{id} {p}: the error names the step: {err}");
let (_, skipped) = export("Audit7", &[&one(id, p.clone())], &[], true).unwrap();
assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: id.into() }], "{id} {p}");
}
let carried = [
("layer.groupLayers", json!({"name": "Group"})),
("layer.newFillLayer.solidColor", json!({"color": "#c06030"})),
("file.new", json!({"width": 24, "height": 18, "background": "white"})),
("file.new", json!({"width": 24, "height": 18, "background": "transparent"})),
("file.new", json!({"width": 24, "height": 18, "background": "backgroundColor"})),
("select.loadSelection", json!({"channel": "Alpha 1", "invert": false, "operation": "new"})),
// A named target (a numeric one is #7248, #7262).
("layer.setProps", json!({"layer": "Pixels", "opacity": 0.5, "visible": true})),
("filter.noise.addNoise", json!({"amount": 30, "seed": 2147483647})),
];
for (id, p) in carried {
let (bytes, skipped) = export("Audit7", &[&one(id, p.clone())], &[], false).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
assert!(skipped.is_empty(), "{id} {p}");
let imported = import(&bytes).unwrap();
assert_eq!(imported.actions[0].steps.first().map(|(i, _)| i.as_str()), Some(id), "{p}");
}
}
/// #8697: Photoshop's other transform interpolation modes survive import: its bicubic variants
/// are kept (they render as Bicubic and export back unchanged), Automatic becomes Bicubic
/// Automatic and Preserve Details Bicubic; before, they fell back to the command's default.
#[test]
fn photoshop_transform_interpolation_modes_survive_import() {
let one = |id: &str, p: Value| Action { name: "Probe".into(), steps: vec![(id.into(), p)], ..Default::default() };
for id in ["select.transformSelection", "edit.transform"] {
for (mode, imported) in [
("bicubicSharper", "bicubicSharper"),
("bicubicSmoother", "bicubicSmoother"),
("bicubicAutomatic", "bicubicAutomatic"),
("automatic", "bicubicAutomatic"),
("preserveDetails", "bicubic"),
] {
let (bytes, _) = export("Audit8", &[&one(id, json!({"scaleX": 140, "interpolation": mode}))], &[], false).unwrap();
let steps = import(&bytes).unwrap().actions[0].steps.clone();
let got = steps.first().and_then(|(_, p)| p.get("interpolation")).and_then(Value::as_str).map(str::to_owned);
assert_eq!(got.as_deref(), Some(imported), "{id} {mode}: {steps:?}");
}
}
}
/// #8531: a partial `layer.setAdjustment` of Color Balance or Hue/Saturation can't be written
/// (their descriptors carry every setting, filling absent ones with defaults), so strict export
/// refuses it instead of replaying an edit that also resets the rest. Complete ones still export.
#[test]
fn partial_color_balance_and_hue_saturation_edits_refuse_strict_export() {
let one = |p: Value| Action { name: "Probe".into(), steps: vec![("layer.setAdjustment".into(), p)], ..Default::default() };
for p in [
json!({"kind": "colorBalance", "shadows": [32, -24, 16]}),
json!({"kind": "colorBalance", "preserveLuminosity": true}),
json!({"kind": "hueSaturation", "hue": 20}),
json!({"kind": "hueSaturation", "reds": {"hue": 10}}),
] {
let err = export("Audit8", &[&one(p.clone())], &[], false).unwrap_err();
assert!(err.contains("`layer.setAdjustment`"), "{p}: {err}");
let (_, skipped) = export("Audit8", &[&one(p.clone())], &[], true).unwrap();
assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "layer.setAdjustment".into() }], "{p}");
}
let full = json!({"kind": "colorBalance", "shadows": [32, -24, 16], "midtones": [-17, 27, -31], "highlights": [11, -19, 23], "preserveLuminosity": false});
let (bytes, _) = export("Audit8", &[&one(full)], &[], false).unwrap();
let steps = import(&bytes).unwrap().actions[0].steps.clone();
let (id, p) = steps.first().unwrap();
assert_eq!(id, "layer.setAdjustment");
assert_eq!(p.get("midtones"), Some(&json!([-17, 27, -31])), "{p}");
assert_eq!(p.get("preserveLuminosity"), Some(&json!(false)), "{p}");
let hue = json!({"kind": "hueSaturation", "colorize": false, "hue": 20, "saturation": -5, "lightness": 0});
assert!(export("Audit8", &[&one(hue)], &[], false).is_ok());
// A colour range written without all of its own settings would reset the rest too.
let range = json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": {"hue": 10}});
assert!(export("Audit8", &[&one(range)], &[], false).is_err());
let range =
json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": {"hue": 10, "saturation": 5, "lightness": -5}});
assert!(export("Audit8", &[&one(range)], &[], false).is_ok());
let bounded = json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": {"hue": 10, "saturation": 5, "lightness": -5, "range": [315, 345, 15, 45]}});
assert!(export("Audit8", &[&one(bounded)], &[], false).is_ok());
// Values of the wrong shape would be written as 0: refused too.
for bad in [
json!({"kind": "colorBalance", "shadows": "invalid", "midtones": [0, 0, 0], "highlights": [0, 0, 0], "preserveLuminosity": true}),
json!({"kind": "colorBalance", "shadows": [1, 2], "midtones": [0, 0, 0], "highlights": [0, 0, 0], "preserveLuminosity": true}),
json!({"kind": "colorBalance", "shadows": [0, 0, 0], "midtones": [0, 0, 0], "highlights": [0, 0, 0], "preserveLuminosity": "yes"}),
json!({"kind": "hueSaturation", "colorize": false, "hue": "20", "saturation": 0, "lightness": 0}),
json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": 5}),
json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": {"hue": 1, "saturation": 0, "lightness": 0, "range": [0, 20, 40]}}),
json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": {"hue": 1, "saturation": 0, "lightness": 0, "range": "invalid"}}),
json!({"kind": "hueSaturation", "colorize": false, "hue": 0, "saturation": 0, "lightness": 0, "reds": {"hue": 1, "saturation": 0, "lightness": 0, "range": [315, 345.5, 15, 45]}}),
] {
assert!(export("Audit8", &[&one(bad.clone())], &[], false).is_err(), "{bad}");
}
}
/// Levels, like Color Balance and Hue/Saturation: the descriptor writes both ends of the Input and
/// of the Output range, filling the end an edit did not name with 0 or 255, so replaying
/// `inBlack: 31` alone would also reset a retained Input White (#8530). Strict export refuses it;
/// a pair given whole, or none of it, still exports and round-trips.
#[test]
fn partial_levels_edits_refuse_strict_export() {
let one = |p: Value| Action { name: "Probe".into(), steps: vec![("layer.setAdjustment".into(), p)], ..Default::default() };
for p in [
json!({"kind": "levels", "inBlack": 31}),
json!({"kind": "levels", "inWhite": 239}),
json!({"kind": "levels", "outBlack": 10}),
json!({"kind": "levels", "outWhite": 240}),
json!({"kind": "levels", "inBlack": 31, "inWhite": 239, "outWhite": 240}),
json!({"kind": "levels", "red": {"inBlack": 2}}),
json!({"kind": "levels", "inBlack": 1, "inWhite": 250, "blue": {"outBlack": 3}}),
// A value of the wrong shape is no value: the other end is then alone.
json!({"kind": "levels", "inBlack": 31, "inWhite": "239"}),
// CodeRabbit on batch 11: both ends of the wrong shape, or a channel that isn't an object,
// would be written as nothing too.
json!({"kind": "levels", "inBlack": "31", "inWhite": "239"}),
json!({"kind": "levels", "gamma": "1.2"}),
json!({"kind": "levels", "red": 5}),
json!({"kind": "levels", "green": [0, 255]}),
json!({"kind": "levels", "blue": {"outBlack": "3", "outWhite": "250"}}),
] {
let err = export("Audit8", &[&one(p.clone())], &[], false).unwrap_err();
assert!(err.contains("`layer.setAdjustment`"), "{p}: {err}");
let (_, skipped) = export("Audit8", &[&one(p.clone())], &[], true).unwrap();
assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "layer.setAdjustment".into() }], "{p}");
}
for p in [
json!({"kind": "levels", "inBlack": 31, "inWhite": 239}),
json!({"kind": "levels", "outBlack": 10, "outWhite": 240}),
json!({"kind": "levels", "inBlack": 31, "inWhite": 239, "outBlack": 10, "outWhite": 240, "gamma": 1.2}),
json!({"kind": "levels", "gamma": 0.8}),
json!({"kind": "levels", "inBlack": 1, "inWhite": 250, "red": {"inBlack": 2, "inWhite": 200}}),
] {
let (bytes, skipped) = export("Audit8", &[&one(p.clone())], &[], false).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(skipped.is_empty(), "{p}");
let steps = import(&bytes).unwrap().actions[0].steps.clone();
let (id, back) = steps.first().unwrap();
assert_eq!(id, "layer.setAdjustment");
for k in ["inBlack", "inWhite", "outBlack", "outWhite"] {
assert_eq!(back.get(k), p.get(k), "{p}: {k} in {back}");
}
}
}
/// Photoshop's Close has no document target: a Close of a specific document must not be
/// exported as a plain Close, which would close the active document on playback.
#[test]
fn close_with_document_target_is_not_exported_as_plain_close() {
let targeted = Action { name: "Probe".into(), steps: vec![("file.close".into(), json!({"document": 0}))], ..Default::default() };
assert!(export("Audit4", &[&targeted], &[], false).is_err());
let (_, skipped) = export("Audit4", &[&targeted], &[], true).unwrap();
assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "file.close".into() }]);
let plain = Action { name: "Plain".into(), steps: vec![("file.close".into(), json!({}))], ..Default::default() };
let (bytes, skipped) = export("Audit4", &[&plain], &[], false).unwrap();
assert!(skipped.is_empty());
let imported = import(&bytes).unwrap();
assert_eq!(imported.actions[0].steps, vec![("file.close".to_string(), json!({}))]);
}
/// A translation-only Free Transform with a fractional offset must not collapse into an
/// integer `layer.translate`: the sub-pixel placement and interpolation have to survive.
#[test]
fn fractional_translation_only_transform_keeps_offset_and_interpolation() {
let params = json!({"dx": 0.5, "dy": 0, "interpolation": "bilinear"});
let d = transform_to("edit.transform", &params).unwrap();
let file = AtnFile {
name: UnicodeString::new_nul("Frac"),
expanded: false,
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items: vec![item("transform", "Transform", Some(d))], ..Default::default() }],
warnings: vec![],
..Default::default()
};
let imp = import(&atn::write(&file).unwrap()).unwrap();
assert!(imp.unsupported.is_empty());
let steps = &imp.actions[0].steps;
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].0, "edit.transform");
assert_eq!(steps[0].1.get("dx").and_then(Value::as_f64), Some(0.5));
assert_eq!(steps[0].1.get("interpolation"), Some(&json!("bilinear")));
}
#[test]
fn numeric_layer_references_refuse_silent_fallback_to_current_selection() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let parse_first_desc = |v: &Value| -> Descriptor {
let bytes = photocraft_paint::tile::b64_decode(v["data"].as_str().unwrap()).unwrap();
let file = atn::parse(&bytes).unwrap();
file.actions[0].items[0].descriptor.clone().unwrap()
};
// Named layer reference exports successfully with Name reference to "A"
s.actions.list.push(Action::new("DeleteNamed", vec![("layer.delete".into(), json!({"layer": "A"}))]));
let saved_named = s.execute("actions.save", json!({"action": "DeleteNamed"})).unwrap();
assert_eq!(saved_named["steps"], 1);
let named_desc = parse_first_desc(&saved_named);
match get_ref(&named_desc, "null").unwrap() {
[ReferenceItem::Name { name, .. }] => assert_eq!(name.to_string_lossy(), "A"),
other => panic!("expected Name reference to A, got {other:?}"),
}
// Omitted layer reference (target selection) exports successfully with Trgt enumerated reference
s.actions.list.push(Action::new("DeleteTarget", vec![("layer.delete".into(), json!({}))]));
let saved_target = s.execute("actions.save", json!({"action": "DeleteTarget"})).unwrap();
assert_eq!(saved_target["steps"], 1);
let target_desc = parse_first_desc(&saved_target);
match get_ref(&target_desc, "null").unwrap() {
[ReferenceItem::Enumerated { value, .. }] => assert!(value.is("Trgt")),
other => panic!("expected Trgt enumerated reference, got {other:?}"),
}
// Explicit null layer reference also exports successfully as target selection with Trgt enumerated reference
s.actions.list.push(Action::new("DeleteNull", vec![("layer.delete".into(), json!({"layer": null}))]));
let saved_null = s.execute("actions.save", json!({"action": "DeleteNull"})).unwrap();
assert_eq!(saved_null["steps"], 1);
let null_desc = parse_first_desc(&saved_null);
match get_ref(&null_desc, "null").unwrap() {
[ReferenceItem::Enumerated { value, .. }] => assert!(value.is("Trgt")),
other => panic!("expected Trgt enumerated reference, got {other:?}"),
}
// Explicit numeric ID reference cannot be silently converted to current target in strict export
s.actions.list.push(Action::new("DeleteNumeric", vec![("layer.delete".into(), json!({"layer": 42}))]));
let before_actions = s.actions.list.clone();
let before_rev = s.active().unwrap().revision;
let err_delete = s.execute("actions.save", json!({"action": "DeleteNumeric"}));
assert!(err_delete.is_err(), "strict export must reject numeric layer ID for layer.delete rather than falling back to current target");
assert_eq!(s.actions.list, before_actions, "session actions must remain unchanged when save fails");
assert_eq!(s.active().unwrap().revision, before_rev, "document revision must remain unchanged when save fails");
let err_msg = err_delete.unwrap_err().to_string();
assert!(err_msg.contains("(`layer.delete` with numeric layer 42)"), "error message should name the numeric layer: {err_msg}");
// Same rejection and state invariance for remaining commands with numeric layer reference
for (name, id, params) in [
("DuplicateNumeric", "layer.duplicate", json!({"layer": 42, "name": "Copy"})),
("DuplicateNewDocNumeric", "layer.duplicate", json!({"layer": 42, "document": "new"})),
("TranslateNumeric", "layer.translate", json!({"layer": 42, "dx": 5, "dy": 5})),
("SetPropsNumeric", "layer.setProps", json!({"layer": 42, "opacity": 0.5})),
("HideNumeric", "layer.hideLayers", json!({"layer": 42})),
("TransformNumeric", "edit.transform", json!({"layer": 42, "dx": 5, "dy": 5})),
("RenameNumeric", "layer.renameLayer", json!({"layer": 42, "name": "Renamed"})),
("LabelNumeric", "layer.setLabelColor", json!({"layer": 42, "color": "red"})),
("ShowNumeric", "layer.showLayers", json!({"layer": 42})),
("SelectNumeric", "layer.select", json!({"layer": 42})),
("BringToFrontNumeric", "layer.arrange.bringToFront", json!({"layer": 42})),
("SendToBackNumeric", "layer.arrange.sendToBack", json!({"layer": 42})),
("BringForwardNumeric", "layer.arrange.bringForward", json!({"layer": 42})),
("SendBackwardNumeric", "layer.arrange.sendBackward", json!({"layer": 42})),
] {
s.actions.list.push(Action::new(name, vec![(id.into(), params)]));
let before_actions = s.actions.list.clone();
let before_rev = s.active().unwrap().revision;
assert!(s.execute("actions.save", json!({"action": name})).is_err());
assert_eq!(s.actions.list, before_actions, "session actions must remain unchanged when save fails");
assert_eq!(s.active().unwrap().revision, before_rev, "document revision must remain unchanged when save fails");
}
// With skipUnsupported: true, numeric layer references are skipped
let skipped = s.execute("actions.save", json!({"action": "DeleteNumeric", "skipUnsupported": true})).unwrap();
assert_eq!(skipped["steps"], 0);
assert_eq!(skipped["skipped"][0]["id"], "layer.delete");
// Blend-mode shortcut records layer.setProps with active layer ID, which normalises to the target form
let active_id = s.active().unwrap().active_layer.unwrap().0;
s.execute("actions.record", json!({"name": "RecordBlend"})).unwrap();
s.execute("layer.setProps", json!({"layer": active_id, "blend": "multiply"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
let saved_blend = s.execute("actions.save", json!({"action": "RecordBlend"})).unwrap();
assert_eq!(saved_blend["steps"], 1, "recorded blend shortcut step with active layer id must export successfully as target layer");
assert_eq!(saved_blend["skipped"].as_array().unwrap().len(), 0);
// Record explicit layer.delete of the sole selected layer in a two-layer document
s.execute("layer.new.layer", json!({"name": "Second"})).unwrap();
let second_id = s.active().unwrap().active_layer.unwrap().0;
s.execute("actions.record", json!({"name": "RecordDelete"})).unwrap();
s.execute("layer.delete", json!({"layer": second_id})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
let saved_delete = s.execute("actions.save", json!({"action": "RecordDelete"})).unwrap();
assert_eq!(saved_delete["steps"], 1, "recorded explicit delete of sole selected layer must export successfully as target delete");
assert_eq!(saved_delete["skipped"].as_array().unwrap().len(), 0);
}
/// Arbitrary canvas rotation must keep its interpolation through a strict export: dropping it
/// silently replays the rotation with the default bicubic resampling.
#[test]
fn arbitrary_rotation_keeps_interpolation_through_atn() {
for params in [
json!({"angle": 17, "direction": "ccw", "interpolation": "nearest"}),
json!({"angle": 17, "direction": "ccw", "interpolation": "bilinear"}),
json!({"angle": 17, "direction": "ccw", "interpolation": "bicubic"}),
json!({"angle": 17, "direction": "ccw"}),
] {
let a = Action { name: "Rot".into(), steps: vec![("image.rotation.arbitrary".into(), params.clone())], ..Default::default() };
let (bytes, skipped) = export("Rot", &[&a], &[], false).unwrap();
assert!(skipped.is_empty());
let imported = import(&bytes).unwrap();
assert!(imported.unsupported.is_empty());
assert_eq!(imported.actions[0].steps, vec![("image.rotation.arbitrary".to_string(), params)]);
}
}
/// Filter › Stylize › Emboss saves as Photoshop's `Embs` event with integer `Angl`, `Hght` and
/// `Amnt`, and loads back as the same step (#7946: it was skipped, so a portable action lost it).
#[test]
fn emboss_saves_as_photoshops_event_and_loads_back() {
let mut s = Session::new();
let params = json!({"angle": 130, "height": 1, "amount": 70});
s.actions.list.push(Action::new("Pore", vec![("filter.stylize.emboss".into(), params.clone())]));
let r = s.execute("actions.save", json!({"action": "Pore"})).unwrap();
assert_eq!(r["skipped"], json!([]));
let saved = photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap();
let file = atn::parse(&saved).unwrap();
let step = &file.actions[0].items[0];
assert_eq!(step.event.id(), "Embs");
let d = step.descriptor.as_ref().unwrap();
for (key, want) in [("Angl", 130), ("Hght", 1), ("Amnt", 70)] {
assert_eq!(d.get(key), Some(&D::Integer(want)), "{key}");
}
let mut t = Session::new();
t.execute("actions.load", json!({"data": b64(&saved)})).unwrap();
assert_eq!(t.actions.list[0].steps, vec![("filter.stylize.emboss".to_string(), params)]);
}
/// A filter recorded against a layer mask must not export as a plain filter that edits the
/// layer's pixels on replay: Photoshop's descriptor has no field for PhotoCraft's `target`.
#[test]
fn add_noise_with_mask_target_is_not_exported_as_pixel_filter() {
let masked = Action {
name: "Probe".into(),
steps: vec![("filter.noise.addNoise".into(), json!({"amount": 75, "seed": 123, "target": "mask"}))],
..Default::default()
};
assert!(export("Audit", &[&masked], &[], false).is_err());
let (_, skipped) = export("Audit", &[&masked], &[], true).unwrap();
assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "filter.noise.addNoise".into() }]);
for params in [json!({"amount": 75, "seed": 123}), json!({"amount": 75, "seed": 123, "target": "pixels"})] {
let plain = Action { name: "Plain".into(), steps: vec![("filter.noise.addNoise".into(), params)], ..Default::default() };
let (bytes, skipped) = export("Audit", &[&plain], &[], false).unwrap();
assert!(skipped.is_empty());
let imported = import(&bytes).unwrap();
assert_eq!(imported.actions[0].steps, vec![("filter.noise.addNoise".to_string(), json!({"amount": 75, "seed": 123}))]);
}
}
/// Photoshop's Duplicate Layer has no in-place flag: an in-place duplicate (the artboard copy
/// that stays on the original) must not be exported as a plain duplicate, which places the
/// copy beside the original on playback.
#[test]
fn duplicate_in_place_is_not_exported_as_plain_duplicate() {
let in_place = Action { name: "Probe".into(), steps: vec![("layer.duplicate".into(), json!({"inPlace": true}))], ..Default::default() };
assert!(export("Audit5", &[&in_place], &[], false).is_err());
let (_, skipped) = export("Audit5", &[&in_place], &[], true).unwrap();
assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "layer.duplicate".into() }]);
for params in [json!({}), json!({"inPlace": false})] {
let plain = Action { name: "Plain".into(), steps: vec![("layer.duplicate".into(), params)], ..Default::default() };
let (bytes, skipped) = export("Audit5", &[&plain], &[], false).unwrap();
assert!(skipped.is_empty());
let imported = import(&bytes).unwrap();
assert_eq!(imported.actions[0].steps, vec![("layer.duplicate".to_string(), json!({}))]);
}
}
/// A Play Action that starts the called action at a later step must not export as a call of the
/// whole action: that would replay the steps the recording deliberately skipped.
#[test]
fn partial_play_action_is_not_exported_as_whole_action_call() {
let partial = Action { name: "Parent".into(), steps: vec![("actions.play".into(), json!({"action": "Child", "from": 1}))], ..Default::default() };
assert!(export("Set", &[&partial], &[], false).is_err());
let (_, skipped) = export("Set", &[&partial], &[], true).unwrap();
assert_eq!(skipped, vec![Skipped { action: "Parent".into(), step: 0, id: "actions.play".into() }]);
for params in [json!({"action": "Child"}), json!({"action": "Child", "from": 0})] {
let whole = Action { name: "Parent".into(), steps: vec![("actions.play".into(), params)], ..Default::default() };
let (bytes, skipped) = export("Set", &[&whole], &[], false).unwrap();
assert!(skipped.is_empty());
let imported = import(&bytes).unwrap();
assert_eq!(imported.actions[0].steps, vec![("actions.play".to_string(), json!({"action": "Child"}))]);
}
}
/// #7646: an action's function key keeps Photoshop's Shift and Command options through
/// `.atn` load and save, as `Shift+F5`, `Cmd+F5` and `Cmd+Shift+F5` shortcuts.
#[test]
fn function_keys_keep_shift_and_command() {
let mut file = sample();
file.actions[1].function_key = 5;
file.actions[1].shift = true;
file.actions[1].command = true;
file.actions[0].shift = true;
let mut s = Session::new();
let r = s.execute("actions.load", json!({"data": b64(&atn::write(&file).unwrap())})).unwrap();
assert_eq!(r["functionKeys"], json!([{"action": "Soften", "key": "Shift+F3"}, {"action": "Finish", "key": "Cmd+Shift+F5"}]));
let shortcuts = &s.prefs().shortcuts;
assert_eq!(shortcuts.get("actions.play:Soften").map(String::as_str), Some("Shift+F3"));
assert_eq!(shortcuts.get("actions.play:Finish").map(String::as_str), Some("Cmd+Shift+F5"));
let r = s.execute("actions.save", json!({"set": "Test Set"})).unwrap();
let saved = atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap();
let keys: Vec<_> = saved.actions.iter().map(|a| (a.function_key, a.shift, a.command)).collect();
assert_eq!(keys, [(3, true, false), (5, true, true)]);
assert_eq!(super::parse_function_key("Cmd+F12"), Some((12, false, true)));
assert_eq!(super::parse_function_key("Shift+Cmd+F2"), Some((2, true, true)));
for bad in ["F16", "F0", "Alt+F2", "Cmd+Cmd+F2", "Shift+", "Cmd+K", ""] {
assert_eq!(super::parse_function_key(bad), None, "{bad}");
}
assert_eq!(super::function_key_name(7, false, false), "F7");
}
/// The Apply Image dialog's flat source fields keep their meaning through a strict `.atn` export
/// (#7197): they used to come back as the merged composite.
#[test]
fn apply_image_flat_source_fields_survive_a_strict_export() {
let flat = json!({"sourceLayer": "Pixels", "sourceChannel": "green", "sourceInvert": true, "blending": "normal", "opacity": 75});
let item = export_item("image.applyImage", &flat, "S").expect("the flat form exports");
let event = event_for(&item.event).expect("a known event");
let Mapped::Steps(steps) = (event.from)(item.descriptor.as_ref().unwrap()) else { panic!("does not import back") };
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].1["source"], json!({"layer": "Pixels", "channel": "green", "invert": true}));
// The nested form is unchanged, and the "merged" layer of a flat form stays merged.
let merged = json!({"sourceLayer": "merged", "sourceChannel": "red"});
let item = export_item("image.applyImage", &merged, "S").expect("exports");
let Mapped::Steps(steps) = (event.from)(item.descriptor.as_ref().unwrap()) else { panic!("does not import back") };
assert_eq!(steps[0].1["source"], json!({"layer": "merged", "channel": "red"}));
// A source in another document cannot be written: the step is skipped, not rewritten.
assert!(export_item("image.applyImage", &json!({"source": {"document": "Other", "channel": "red"}}), "S").is_none());
assert!(export_item("image.applyImage", &json!({"sourceDocument": "Other"}), "S").is_none());
}
/// What the Apply Image dialog records survives a strict `.atn` export and comes back: its source
/// channel is a Channels-panel name ("RGB", "Red", "Alpha 1"), not a lowercase key.
#[test]
fn apply_image_dialog_channel_names_survive_a_strict_export() {
let dialog = |channel: &str| json!({"sourceLayer": "merged", "sourceChannel": channel, "sourceInvert": false, "blending": "multiply", "opacity": 100.0, "scale": 1.0, "offset": 0, "preserveTransparency": false, "maskChannel": "none"});
for (channel, back) in [
("RGB", "composite"),
("CMYK", "composite"),
("Lab", "composite"),
("Red", "red"),
("Gray", "gray"),
("Alpha 1", "Alpha 1"),
("transparency", "transparency"),
] {
let a = Action::new("A", vec![("image.applyImage".into(), dialog(channel))]);
let (bytes, skipped) = export("S", &[&a], &[], false).unwrap_or_else(|e| panic!("{channel}: {e}"));
assert!(skipped.is_empty(), "{channel}");
let file = atn::parse(&bytes).unwrap();
assert_eq!(file.actions[0].items[0].event.id(), "AppI");
let back_file = import(&bytes).unwrap();
assert!(back_file.unsupported.is_empty(), "{channel}");
let p = &back_file.actions[0].steps[0].1;
assert_eq!(p["source"], json!({"layer": "merged", "channel": back, "invert": false}), "{channel}");
}
// A named channel is written as Photoshop records it: a `Chnl` name reference.
let item = export_item("image.applyImage", &dialog("Alpha 1"), "S").unwrap();
let with = get_obj(item.descriptor.as_ref().unwrap(), "With").unwrap();
let r = get_ref(with, "T ").unwrap();
assert!(matches!(&r[0], ReferenceItem::Name { class, name } if class.class_id.is("Chnl") && name.to_string_lossy() == "Alpha 1"));
}
/// A numeric layer or channel selector has no name to record: the step is refused, not exported as
/// the merged composite.
#[test]
fn apply_image_numeric_source_selectors_are_refused() {
for p in [
json!({"sourceLayer": 3}),
json!({"sourceChannel": 2}),
json!({"source": {"channel": 2}}),
json!({"source": {"layer": 7, "channel": "red"}}),
json!({"source": "red"}),
json!({"sourceChannel": "quickMask"}),
] {
assert!(export_item("image.applyImage", &p, "S").is_none(), "{p}");
}
let a = Action::new("A", vec![("image.applyImage".into(), json!({"sourceLayer": 3}))]);
assert!(export("S", &[&a], &[], false).is_err());
}
/// A param a PhotoCraft step leaves out exports as PhotoCraft's default wherever Photoshop plays
/// the missing key differently (checked against Photoshop): Canvas Size anchors top left,
/// Apply Image blends Normal, Color Balance turns preserve luminosity off, and a Paint Bucket
/// step without a tolerance doesn't play at all.
#[test]
fn defaults_photoshop_plays_differently_are_written() {
let exported = |id: &str, p: Value| export_item(id, &p, "S").and_then(|i| i.descriptor).unwrap_or_else(|| panic!("{id} does not export"));
let bucket = exported("paint.bucket", json!({"x": 1, "y": 2}));
assert_eq!((get_num(&bucket, "Tlrn"), get_bool(&bucket, "AntA")), (Some(32.0), Some(true)));
let canvas = exported("image.canvasSize", json!({"width": 10, "height": 10}));
assert_eq!((get_enum(&canvas, "Hrzn").as_deref(), get_enum(&canvas, "Vrtc").as_deref()), (Some("Cntr"), Some("Cntr")));
let apply = exported("image.applyImage", json!({}));
assert_eq!(get_obj(&apply, "With").and_then(|w| get_enum(w, "Clcl")).as_deref(), Some("Mltp"));
let balance = exported("image.adjustments.colorBalance", json!({"midtones": [10, 0, 0]}));
assert_eq!(get_bool(&balance, "PrsL"), Some(true));
assert_eq!(get_list(&balance, "ShdL").map(<[D]>::len), Some(3));
}
/// Photoshop records the composite of a Levels or Curves entry as `Chnl.Cmps` and silently skips
/// a Set that names it `Ordn.Trgt` (#8543). A colour channel keeps its own code.
#[test]
fn levels_and_curves_name_the_composite_channel_as_photoshop_records_it() {
let exported = |id: &str, p: Value| export_item(id, &p, "S").and_then(|i| i.descriptor).unwrap_or_else(|| panic!("{id} does not export"));
let channels = |d: &Descriptor| -> Vec<(String, String)> {
get_list(d, "Adjs")
.unwrap_or_default()
.iter()
.filter_map(|e| match e {
D::Descriptor(e) => match get_ref(e, "Chnl")?.first()? {
ReferenceItem::Enumerated { type_id, value, .. } => Some((trimmed(type_id), trimmed(value))),
_ => None,
},
_ => None,
})
.collect()
};
let composite = ("Chnl".to_string(), "Cmps".to_string());
let red = ("Chnl".to_string(), "Rd".to_string());
let levels = exported("image.adjustments.levels", json!({"inBlack": 31, "red": {"inBlack": 2}}));
assert_eq!(channels(&levels), vec![composite.clone(), red.clone()]);
let curves = exported("image.adjustments.curves", json!({"points": [[0, 0], [128, 140], [255, 255]], "red": [[0, 10], [255, 245]]}));
assert_eq!(channels(&curves), vec![composite, red]);
}
/// A Lab document's Lightness channel is `Chnl.Lght` (#9384): Photoshop plays a Levels or Apply
/// Image step naming it and rejects `Chnl.Lmnc` ("luminance"), which PhotoCraft wrote before.
/// Steps PhotoCraft saved with `Lmnc` still load.
#[test]
fn lab_lightness_channel_is_written_and_read_as_photoshop_names_it() {
let exported = |id: &str, p: Value| export_item(id, &p, "S").and_then(|i| i.descriptor).unwrap_or_else(|| panic!("{id} does not export"));
let entry_codes = |d: &Descriptor| -> Vec<String> {
get_list(d, "Adjs")
.unwrap_or_default()
.iter()
.filter_map(|e| match e {
D::Descriptor(e) => match get_ref(e, "Chnl")?.first()? {
ReferenceItem::Enumerated { value, .. } => Some(trimmed(value)),
_ => None,
},
_ => None,
})
.collect()
};
let levels = exported("image.adjustments.levels", json!({"lightness": {"gamma": 1.5}, "a": {"inBlack": 3}}));
assert_eq!(entry_codes(&levels), ["Cmps", "Lght", "A"]);
let curves = exported("image.adjustments.curves", json!({"lightness": [[0, 10], [255, 245]]}));
assert_eq!(entry_codes(&curves), ["Lght"]);
let apply = exported("image.applyImage", json!({"sourceLayer": "merged", "sourceChannel": "Lightness"}));
let source = get_ref(get_obj(&apply, "With").unwrap(), "T ").unwrap();
assert!(matches!(&source[0], ReferenceItem::Enumerated { value, .. } if trimmed(value) == "Lght"), "{source:?}");
// What Photoshop records, and what PhotoCraft wrote before, both read as Lightness.
let chnl = |c: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code(c) }]);
for c in ["Lght", "Lmnc"] {
let lvls = desc("Lvls").with("Adjs", D::List(vec![D::Descriptor(desc("LvlA").with("Chnl", chnl(c)).with("Gmm ", D::Double(1.5)))]));
assert_eq!(levels_from(&lvls), Some(json!({"lightness": {"gamma": 1.5}})), "{c}");
let with = desc("Clcl").with("T ", chnl(c));
let Mapped::Steps(steps) = apply_image_from(&desc("null").with("With", D::Descriptor(with))) else { panic!("{c}: Apply Image does not import") };
assert_eq!(steps[0].1["source"]["channel"], "lightness", "{c}");
}
}
/// Photoshop sets one colour per step: a step that sets both doesn't export rather than drop one.
#[test]
fn set_colors_exports_only_a_single_colour() {
assert!(export_item("tools.setColors", &json!({"foreground": "#ff0000", "background": "#0000ff"}), "S").is_none());
assert!(export_item("tools.setColors", &json!({"foregroundCmyk": [0, 0, 0, 100], "background": "#0000ff"}), "S").is_none());
assert!(export_item("tools.setColors", &json!({"background": "#0000ff"}), "S").is_some());
}
/// Photoshop's reset of an adjustment layer (`set` with an empty `T`) stays a reset: `kind`
/// only joins params that edit something, as any key makes `layer.setAdjustment` merge.
#[test]
fn adjustment_layer_reset_still_resets() {
let set = |t: Descriptor| desc("null").with("null", target_ref("AdjL")).with("T ", D::Descriptor(t));
let reset = set(desc("HStr").with("presetKind", enm("presetKindType", "presetKindDefault")));
let edit = set(desc("ClrB").with("MdtL", D::List(vec![long(10.0), long(0.0), long(0.0)])));
let file = AtnFile {
name: UnicodeString::new_nul("Reset"),
expanded: false,
actions: vec![AtnAction {
name: UnicodeString::new_nul("A"),
items: vec![item("setd", "Set", Some(reset)), item("setd", "Set", Some(edit))],
..Default::default()
}],
warnings: vec![],
..Default::default()
};
let steps = import(&atn::write(&file).unwrap()).unwrap().actions.remove(0).steps;
assert_eq!(steps[0], ("layer.setAdjustment".to_string(), json!({})));
assert_eq!(steps[1].1, json!({"midtones": [10, 0, 0], "kind": "colorBalance"}));
}
/// Photoshop's Properties panel records an edit of an existing Vibrance layer with the string
/// class `vibrance`, not the `Vbrn` code: both import as the same `layer.setAdjustment` (#8534).
#[test]
fn adjustment_layer_set_accepts_the_vibrance_string_class() {
let set = |class: &str| {
let t = desc(class).with("temperature", long(0.0)).with("tint", long(0.0)).with("vibrance", long(28.0)).with("Strt", long(-7.0));
desc("null").with("null", target_ref("AdjL")).with("T ", D::Descriptor(t))
};
let file = AtnFile {
name: UnicodeString::new_nul("Vibrance"),
expanded: false,
actions: vec![AtnAction {
name: UnicodeString::new_nul("A"),
items: vec![item("set", "Set", Some(set("vibrance"))), item("set", "Set", Some(set("Vbrn")))],
..Default::default()
}],
warnings: vec![],
..Default::default()
};
let action = import(&atn::write(&file).unwrap()).unwrap().actions.remove(0);
assert_eq!(action.steps[0].0, "layer.setAdjustment", "{:?}", action.steps);
assert_eq!(action.steps[0].1, json!({"vibrance": 28, "saturation": -7, "kind": "vibrance"}));
assert_eq!(action.steps[0], action.steps[1]);
}
/// A Vibrance Set and Make as Photoshop records them (class `vibrance`) save back with that
/// class, not the `Vbrn` code Photoshop refuses (#8383); old PhotoCraft files with `Vbrn` still
/// load and now save with `vibrance` too.
#[test]
fn vibrance_adjustment_layer_steps_save_with_the_string_class() {
let set = |class: &str| {
let t = desc(class).with("temperature", long(0.0)).with("tint", long(0.0)).with("vibrance", long(28.0)).with("Strt", long(-7.0));
desc("null").with("null", target_ref("AdjL")).with("T ", D::Descriptor(t))
};
let make = |class: &str| {
let t = desc(class).with("vibrance", long(10.0)).with("Strt", long(5.0));
desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(t))))
};
let data = set_of(
"Vibrance",
vec![(
"A",
vec![
item("set", "Set", Some(set("vibrance"))),
item("Mk ", "Make", Some(make("vibrance"))),
item("set", "Set", Some(set("Vbrn"))),
item("Mk ", "Make", Some(make("Vbrn"))),
],
)],
);
let mut s = Session::new();
let loaded = s.execute("actions.load", json!({"data": data})).unwrap();
assert_eq!(loaded["unsupported"], json!([]), "{loaded}");
let steps = &s.actions.list[0].steps;
assert_eq!(steps[1], ("layer.newAdjustmentLayer.vibrance".to_string(), json!({"vibrance": 10, "saturation": 5})), "{steps:?}");
assert_eq!((&steps[0], &steps[1]), (&steps[2], &steps[3]), "the old `Vbrn` class imports the same");
let saved = s.execute("actions.save", json!({"set": "Vibrance"})).unwrap();
assert_eq!(saved["skipped"], json!([]));
let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap();
let file = atn::parse(&bytes).unwrap();
let items = &file.actions[0].items;
assert_eq!(items.len(), 4);
for pair in items.chunks(2) {
let t = get_obj(pair[0].descriptor.as_ref().unwrap(), "T ").unwrap();
assert!(t.class_id.is("vibrance"), "Set class {:?}", t.class_id);
let ty = get_obj(pair[1].descriptor.as_ref().unwrap(), "Usng").and_then(|u| get_obj(u, "Type")).unwrap();
assert!(ty.class_id.is("vibrance"), "Make class {:?}", ty.class_id);
}
let mut reloaded = Session::new();
reloaded.execute("actions.load", json!({"data": saved["data"]})).unwrap();
assert_eq!(reloaded.actions.list[0].steps, s.actions.list[0].steps);
}
/// Our Vibrance has no `temperature` or `tint`: a recorded Vibrance Set or Make with either one
/// non-zero stays a kept Photoshop step and saves back unchanged instead of losing them.
#[test]
fn vibrance_steps_with_temperature_or_tint_save_back_unchanged() {
let t = || desc("vibrance").with("temperature", long(0.0)).with("tint", long(12.0)).with("vibrance", long(28.0)).with("Strt", long(-7.0));
let set = desc("null").with("null", target_ref("AdjL")).with("T ", D::Descriptor(t()));
let warm = desc("vibrance").with("temperature", long(-20.0)).with("vibrance", long(5.0));
let make = desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(warm))));
let data = set_of("Vibrance", vec![("A", vec![item("set", "Set", Some(set.clone())), item("Mk ", "Make", Some(make.clone()))])]);
let mut s = Session::new();
s.execute("actions.load", json!({"data": data})).unwrap();
assert!(s.actions.list[0].steps.iter().all(|(id, _)| id == "actions.photoshopStep"), "{:?}", s.actions.list[0].steps);
let saved = s.execute("actions.save", json!({"set": "Vibrance"})).unwrap();
let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap();
let file = atn::parse(&bytes).unwrap();
assert_eq!(file.actions[0].items[0].descriptor, Some(set));
assert_eq!(file.actions[0].items[1].descriptor, Some(make));
}
/// A Free Transform recorded against an unlinked mask or an alpha channel must not export as a
/// layer transform that moves the layer's pixels on replay (#7195).
#[test]
fn transform_with_mask_or_channel_target_is_not_exported_as_layer_transform() {
for target in [json!("mask"), json!({"channel": 0}), json!("quickMask")] {
let a = Action {
name: "Probe".into(),
steps: vec![("edit.transform".into(), json!({"target": target, "dx": 3, "dy": 2, "scaleX": 100, "interpolation": "nearest"}))],
..Default::default()
};
assert!(export("Targets", &[&a], &[], false).is_err(), "{target} exported");
let (_, skipped) = export("Targets", &[&a], &[], true).unwrap();
assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "edit.transform".into() }]);
}
for params in [
json!({"dx": 3, "dy": 2, "scaleX": 50, "interpolation": "nearest"}),
json!({"target": "pixels", "dx": 3, "dy": 2, "scaleX": 50, "interpolation": "nearest"}),
] {
let plain = Action { name: "Plain".into(), steps: vec![("edit.transform".into(), params)], ..Default::default() };
let (bytes, skipped) = export("Targets", &[&plain], &[], false).unwrap();
assert!(skipped.is_empty());
let imported = import(&bytes).unwrap();
assert_eq!(imported.actions[0].steps, vec![("edit.transform".to_string(), json!({"dx": 3, "dy": 2, "scaleX": 50, "interpolation": "nearest"}))]);
}
for form in [json!({"quad": [[0, 0], [1, 0], [1, 1], [0, 1]]}), json!({"matrix": [1, 0, 0, 1, 0, 0]}), json!({"rect": [0, 0, 1, 1]})] {
assert!(transform_to("edit.transform", &form).is_none());
}
}
/// Apply Image's optional blending mask has no `.atn` mapping: a strict export must refuse the
/// step rather than drop the mask and widen the edit to the whole layer.
#[test]
fn apply_image_with_blending_mask_is_not_exported_without_its_mask() {
let source = json!({"layer": "Pixels", "channel": "green", "invert": true});
for params in [json!({"source": source, "blending": "normal", "mask": {"channel": 0}}), json!({"source": source, "blending": "normal", "maskChannel": 0})] {
let masked = Action { name: "Masked".into(), steps: vec![("image.applyImage".into(), params.clone())], ..Default::default() };
assert!(export("Audit5", &[&masked], &[], false).is_err(), "{params}");
let (_, skipped) = export("Audit5", &[&masked], &[], true).unwrap();
assert_eq!(skipped, vec![Skipped { action: "Masked".into(), step: 0, id: "image.applyImage".into() }]);
}
for params in [json!({"source": source, "blending": "normal"}), json!({"source": source, "blending": "normal", "mask": {"channel": "none"}})] {
let plain = Action { name: "Plain".into(), steps: vec![("image.applyImage".into(), params.clone())], ..Default::default() };
let (bytes, skipped) = export("Audit5", &[&plain], &[], false).unwrap();
assert!(skipped.is_empty());
let imported = import(&bytes).unwrap();
assert_eq!(imported.actions[0].steps, vec![("image.applyImage".to_string(), json!({"source": source, "blending": "normal"}))]);
}
}
fn image_size_round_trip(params: &Value) -> Option<Value> {
let item = export_item("image.imageSize", params, "S")?;
let e = event_for(&item.event).unwrap();
match (e.from)(item.descriptor.as_ref().unwrap()) {
Mapped::Steps(steps) => Some(steps[0].1.clone()),
_ => panic!("image size descriptor did not import as steps"),
}
}
/// A step with brush strokes and Sample All Layers is refused by `refineSelectionEdge`; it must
/// not fall through to `smartBrushWorkspace`, which has no field for the strokes either.
#[test]
fn refine_edge_with_strokes_and_sample_all_layers_is_not_exported_without_the_strokes() {
let stroke = json!({"points": [[5, 4]], "size": 4, "mode": "add"});
let brushed = Action {
name: "Probe".into(),
steps: vec![("select.refineEdge".into(), json!({"radius": 1, "sampleAllLayers": true, "refineStrokes": [stroke]}))],
..Default::default()
};
assert!(export("Audit", &[&brushed], &[], false).is_err());
let (_, skipped) = export("Audit", &[&brushed], &[], true).unwrap();
assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "select.refineEdge".into() }]);
let plain = Action {
name: "Plain".into(),
steps: vec![("select.refineEdge".into(), json!({"radius": 1, "sampleAllLayers": true, "refineStrokes": []}))],
..Default::default()
};
assert!(export("Audit", &[&plain], &[], false).is_ok());
}
/// Photoshop's Select and Mask descriptor as mapped has no field for the Refine Edge Brush,
/// so a step with brush strokes must not export as a plain Select and Mask without them.
#[test]
fn refine_edge_with_brush_strokes_is_not_exported_without_them() {
for mode in ["add", "subtract"] {
let brushed = Action {
name: "Probe".into(),
steps: vec![("select.refineEdge".into(), json!({"radius": 1, "refineStrokes": [{"points": [[5, 4]], "size": 4, "mode": mode}]}))],
..Default::default()
};
assert!(export("Audit", &[&brushed], &[], false).is_err(), "{mode}");
let (_, skipped) = export("Audit", &[&brushed], &[], true).unwrap();
assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "select.refineEdge".into() }]);
}
for params in [json!({"radius": 1}), json!({"radius": 1, "refineStrokes": []}), json!({"radius": 1, "refineStrokes": null})] {
let plain = Action { name: "Plain".into(), steps: vec![("select.refineEdge".into(), params)], ..Default::default() };
let (bytes, skipped) = export("Audit", &[&plain], &[], false).unwrap();
assert!(skipped.is_empty());
let imported = import(&bytes).unwrap();
assert_eq!(imported.actions[0].steps, vec![("select.refineEdge".to_string(), json!({"radius": 1}))]);
}
}
/// Nor does it hold the workspace's tool edits (Quick Selection, Brush, Lasso…), with or without
/// Sample All Layers, so a step with them is reported instead of exported without them.
#[test]
fn refine_edge_with_workspace_edits_is_not_exported_without_them() {
let edit = json!({"tool": "quick", "points": [[5, 4]], "size": 4});
for params in [json!({"radius": 1, "edits": [edit]}), json!({"radius": 1, "sampleAllLayers": true, "edits": [{"tool": "invert"}]})] {
let edited = Action { name: "Probe".into(), steps: vec![("select.refineEdge".into(), params.clone())], ..Default::default() };
assert!(export("Audit", &[&edited], &[], false).is_err(), "{params}");
let (_, skipped) = export("Audit", &[&edited], &[], true).unwrap();
assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "select.refineEdge".into() }]);
}
for params in [json!({"radius": 1, "edits": []}), json!({"radius": 1, "edits": null})] {
let plain = Action { name: "Plain".into(), steps: vec![("select.refineEdge".into(), params)], ..Default::default() };
assert!(export("Audit", &[&plain], &[], false).is_ok());
}
}
#[test]
fn image_size_resolution_only_does_not_gain_keep_print_size() {
// The .atn cannot tell resolution-only from keepPrintSize:true, so the former must not
// export as a descriptor that imports back as keep-print-size.
let params = json!({"resolution": 144, "resample": "nearest"});
assert_eq!(image_size_round_trip(&params), None);
}
#[test]
fn image_size_intentional_controls_still_round_trip() {
for params in [
json!({"resolution": 144.0, "resample": "nearest", "keepPrintSize": true, "scaleStyles": true}),
json!({"width": 12.0, "height": 10.0, "resolution": 144.0, "resample": "nearest", "scaleStyles": true}),
] {
assert_eq!(image_size_round_trip(&params), Some(params.clone()));
}
}
#[test]
fn recorded_layer_select_exports_to_atn() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let l = s.execute("layer.new.layer", json!({"name": "Wanted"})).unwrap();
let wanted_id = l["layer"].as_u64().unwrap();
s.execute("actions.record", json!({"name": "SelectWanted"})).unwrap();
s.execute("layer.select", json!({"layer": wanted_id})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
let action = s.execute("actions.get", json!({"action": "SelectWanted"})).unwrap();
assert_eq!(action["steps"][0], json!(["layer.select", {"name": "Wanted"}]));
// Strict export should succeed and produce bytes without skipUnsupported
let saved = s.execute("actions.save", json!({"action": "SelectWanted"})).unwrap();
assert_eq!(saved["steps"], 1);
assert_eq!(saved["skipped"], json!([]));
// When exported with skipUnsupported: true, it shouldn't skip the step either
let saved_skip = s.execute("actions.save", json!({"action": "SelectWanted", "skipUnsupported": true})).unwrap();
assert_eq!(saved_skip["steps"], 1);
assert_eq!(saved_skip["skipped"], json!([]));
// If an action has an occurrence target (duplicate names), it cannot be represented unambiguously in .atn
let bad_action = Action::new("Dup", vec![("layer.select".into(), json!({"name": "Wanted", "occurrence": 1}))]);
let err = export("Set", &[&bad_action], &[], false);
assert!(err.is_err(), "occurrence without representation must fail strict export");
}
#[test]
fn select_rect_modes_export_without_becoming_replace() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
// Default or "replace" mode succeeds
s.actions.list.push(Action::new("ReplaceAct", vec![("select.rect".into(), json!({"x": 4, "y": 4, "width": 2, "height": 2, "mode": "replace"}))]));
let saved = s.execute("actions.save", json!({"action": "ReplaceAct"})).unwrap();
assert_eq!(saved["steps"], 1);
for (mode, event) in [("add", "AddT"), ("subtract", "SbtF"), ("intersect", "IntW")] {
s.actions.list.push(Action::new(mode, vec![("select.rect".into(), json!({"x":4,"y":4,"width":2,"height":2,"mode":mode}))]));
let before = s.actions.list.clone();
let saved = s.execute("actions.save", json!({"action":mode})).unwrap();
let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap();
assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].event.id(), event);
assert_eq!(import(&bytes).unwrap().actions[0].steps[0].1["mode"], mode);
assert_eq!(s.actions.list, before);
}
s.actions.list.push(Action::new("Unsupported", vec![("select.rect".into(), json!({"x":4,"y":4,"width":2,"height":2,"mode":"xor"}))]));
let before = s.actions.list.clone();
assert!(s.execute("actions.save", json!({"action":"Unsupported"})).is_err());
assert_eq!(s.actions.list, before, "rejected export must leave actions unchanged");
// With skipUnsupported: true, unrepresentable modes are reported in skipped
let skipped = s.execute("actions.save", json!({"action": "Unsupported", "skipUnsupported": true})).unwrap();
assert_eq!(skipped["steps"], 0);
assert_eq!(skipped["skipped"][0]["id"], "select.rect");
}
#[test]
fn radial_blur_off_center_strict_export_refuses_silent_recentering() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 16, "height": 16})).unwrap();
// Default center (omitted) exports successfully
s.actions.list.push(Action::new("DefaultCenter", vec![("filter.blur.radialBlur".into(), json!({"amount": 50, "method": "spin", "quality": "good"}))]));
let saved = s.execute("actions.save", json!({"action": "DefaultCenter"})).unwrap();
assert_eq!(saved["steps"], 1);
// Explicit (0.5, 0.5) center also exports successfully
s.actions.list.push(Action::new(
"ExplicitDefaultCenter",
vec![("filter.blur.radialBlur".into(), json!({"amount": 50, "method": "spin", "quality": "good", "centerX": 0.5, "centerY": 0.5}))],
));
let saved_exp = s.execute("actions.save", json!({"action": "ExplicitDefaultCenter"})).unwrap();
assert_eq!(saved_exp["steps"], 1);
// Off-center radial blur cannot be silently recentered in strict export
s.actions.list.push(Action::new(
"OffCenter",
vec![("filter.blur.radialBlur".into(), json!({"amount": 50, "method": "spin", "quality": "good", "centerX": 0.1, "centerY": 0.2}))],
));
let before_actions = s.actions.list.clone();
let err = s.execute("actions.save", json!({"action": "OffCenter"}));
assert!(err.is_err(), "strict export must reject off-center radial blur rather than silently recentering it");
assert_eq!(s.actions.list, before_actions, "actions list must remain unchanged after rejected save");
// With skipUnsupported: true, off-center radial blur is skipped
let skipped = s.execute("actions.save", json!({"action": "OffCenter", "skipUnsupported": true})).unwrap();
assert_eq!(skipped["steps"], 0);
assert_eq!(skipped["skipped"][0]["id"], "filter.blur.radialBlur");
// Radial blur targeting a mask must not export as a plain filter that edits pixels
s.actions
.list
.push(Action::new("MaskTarget", vec![("filter.blur.radialBlur".into(), json!({"amount": 50, "method": "spin", "quality": "good", "target": "mask"}))]));
let before_actions = s.actions.list.clone();
let err_mask = s.execute("actions.save", json!({"action": "MaskTarget"}));
assert!(err_mask.is_err(), "strict export must reject radial blur with non-pixel target");
assert_eq!(s.actions.list, before_actions, "actions list must remain unchanged after rejected save");
let skipped_mask = s.execute("actions.save", json!({"action": "MaskTarget", "skipUnsupported": true})).unwrap();
assert_eq!(skipped_mask["steps"], 0);
assert_eq!(skipped_mask["skipped"][0]["id"], "filter.blur.radialBlur");
// Explicit pixels target exports successfully
s.actions.list.push(Action::new(
"PixelsTarget",
vec![("filter.blur.radialBlur".into(), json!({"amount": 50, "method": "spin", "quality": "good", "target": "pixels"}))],
));
let saved_pixels = s.execute("actions.save", json!({"action": "PixelsTarget"})).unwrap();
assert_eq!(saved_pixels["steps"], 1);
}
/// A crop with W x H x Resolution output options must not export as a plain rectangle crop
/// that keeps the old size and resolution on replay: the mapper has no field for them.
#[test]
fn crop_with_output_size_or_resolution_is_not_exported_as_plain_crop() {
for params in [
json!({"x": 1, "y": 1, "width": 4, "height": 3, "targetWidth": 8, "targetHeight": 6, "resolution": 144, "resample": "nearest"}),
json!({"x": 1, "y": 1, "width": 4, "height": 3, "resolution": 144}),
json!({"x": 1, "y": 1, "width": 4, "height": 3, "targetWidth": 8, "targetHeight": 6}),
] {
let sized = Action { name: "Probe".into(), steps: vec![("image.crop".into(), params)], ..Default::default() };
assert!(export("Audit", &[&sized], &[], false).is_err());
let (_, skipped) = export("Audit", &[&sized], &[], true).unwrap();
assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "image.crop".into() }]);
}
let plain = Action { name: "Plain".into(), steps: vec![("image.crop".into(), json!({"x": 1, "y": 1, "width": 4, "height": 3}))], ..Default::default() };
let (bytes, skipped) = export("Audit", &[&plain], &[], false).unwrap();
assert!(skipped.is_empty());
let imported = import(&bytes).unwrap();
assert_eq!(imported.actions[0].steps, vec![("image.crop".to_string(), json!({"x": 1, "y": 1, "width": 4, "height": 3}))]);
}
/// `image.imageSize` with `resample: "none"` ignores width, height and percent; the `.atn`
/// round trip must not turn them into a pixel resize (#7189).
#[test]
fn image_size_resample_none_keeps_ignoring_its_size_through_atn_export() {
let event = event_for(&export_item("image.imageSize", &json!({"resolution": 72}), "S").expect("exports").event).expect("a known event");
let back = |p: Value| {
let item = export_item("image.imageSize", &p, "S")?;
let Mapped::Steps(steps) = (event.from)(item.descriptor.as_ref().unwrap()) else { panic!("does not import back") };
let mut back = steps[0].1.clone();
normalise(&mut back);
Some(back)
};
for sized in [json!({"width": 12, "height": 10}), json!({"width": 12}), json!({"percent": 200}), json!({"width": 12, "percent": 50, "height": 3})] {
let mut p = sized.clone();
p["resolution"] = json!(144);
p["resample"] = json!("none");
assert_eq!(back(p.clone()), Some(json!({"resolution": 144, "resample": "none", "scaleStyles": true})), "{p}");
}
// A step with nothing left to record is refused rather than exported as an empty (unsupported) event.
assert_eq!(back(json!({"width": 12, "height": 10, "resample": "none"})), None);
// Real resizes are untouched.
assert_eq!(
back(json!({"width": 12, "height": 10, "resample": "bicubic"})),
Some(json!({"width": 12, "height": 10, "resample": "bicubic", "scaleStyles": true}))
);
}
/// #8302, #8308: Hue/Saturation's hue is `H ` in Photoshop's `Hst2`, both ways. It was written
/// and read as `Hue `: exported actions failed in Photoshop, and Photoshop-recorded ones lost
/// their hue on import with no warning. PhotoCraft's own older files (`Hue `) still read.
#[test]
fn hue_saturation_uses_photoshops_hue_key() {
let params = json!({"colorize": false, "hue": 30, "saturation": 20, "lightness": -7, "greens": {"hue": -12, "saturation": 0, "lightness": 0, "range": [75, 105, 135, 165]}});
let a = Action::new("H", vec![("image.adjustments.hueSaturation".into(), params.clone())]);
let (bytes, skipped) = export("S", &[&a], &[], false).unwrap();
assert!(skipped.is_empty());
let file = atn::parse(&bytes).unwrap();
let d = file.actions[0].items[0].descriptor.clone().expect("a descriptor");
let entries: Vec<Descriptor> =
get_list(&d, "Adjs").expect("Adjs").iter().filter_map(|e| if let D::Descriptor(e) = e { Some(e.clone()) } else { None }).collect();
assert_eq!(entries.len(), 2);
assert_eq!(get_num(&entries[0], "H "), Some(30.0), "the master hue under H");
assert_eq!(get_num(&entries[1], "H "), Some(-12.0), "a range's hue under H");
assert!(entries.iter().all(|e| get_num(e, "Hue ").is_none()), "no Hue key");
// A Photoshop-recorded step (H) and an older PhotoCraft one (Hue ) both import with the hue.
let step = |key: &str| {
desc("null")
.with("presetKind", enm("presetKindType", "presetKindCustom"))
.with("Clrz", D::Boolean(false))
.with("Adjs", D::List(vec![D::Descriptor(desc("Hst2").with(key, long(30.0)).with("Strt", long(20.0)).with("Lght", long(-7.0)))]))
};
for key in ["H ", "Hue "] {
let e = event_for(&item("HStr", "", None).event).expect("HStr imports");
match (e.from)(&step(key)) {
Mapped::Steps(steps) => {
assert_eq!(steps[0].0, "image.adjustments.hueSaturation");
assert_eq!(steps[0].1["hue"], json!(30.0), "{key:?}: {}", steps[0].1);
assert_eq!(steps[0].1["saturation"], json!(20.0));
}
Mapped::Unsupported { .. } => panic!("{key:?} is unsupported"),
}
}
// Through a whole file, saved and loaded back: the hue survives.
let back = import(&bytes).unwrap();
let mut p = back.actions[0].steps[0].1.clone();
normalise(&mut p);
assert_eq!(p["hue"], json!(30), "{p}");
}
/// #6745: a recorded Camera Raw Filter step (`Adobe Camera Raw Filter`, the layout of 62 real
/// recordings) imports as `filter.cameraRaw` with its sliders, colour mixer, grading wheels,
/// vignette style and point curves, and plays. One that uses the Calibration panel (or noise
/// settings the command lacks) stays a Photoshop step rather than replaying a different look.
#[test]
fn camera_raw_filter_steps_import_as_filter_camera_raw() {
let int = |v: i32| D::Integer(v);
// Every step carries the version keys a recording has (a verified process version).
let cr = || desc("null").with("CrVe", D::Text(UnicodeString::new_nul("16.1.1"))).with("PrVN", int(5)).with("PrVe", int(184_549_376));
let mut d = cr()
.with("WBal", enm("WBal", "Cst "))
.with("Temp", int(12))
.with("Tint", int(-4))
.with("Ex12", D::Double(0.35))
.with("Cr12", int(10))
.with("Hi12", int(-30))
.with("Sh12", int(25))
.with("Cl12", int(8))
.with("Vibr", int(15))
.with("PC_1", int(20))
.with("PC_2", int(50))
.with("PC_3", int(80))
.with("STSH", int(210))
.with("STSS", int(12))
.with("CgSL", int(0))
.with("CgBl", int(60))
.with("STB ", int(-10))
.with("Shrp", int(40))
.with("ShpR", D::Double(1.2))
.with("LNR ", int(20))
.with("LNRC", int(0))
.with("CNR ", int(25))
.with("CNRS", int(50))
.with("PCVA", int(-20))
.with("PCVS", int(2))
.with("Crv ", D::List([0, 0, 64, 50, 255, 255].map(int).to_vec()))
.with("CrvR", D::List([0, 0, 255, 255].map(int).to_vec()));
for (k, v) in ["R", "O", "Y", "G", "A", "B", "P", "M"].iter().zip(1..) {
d = d.with(&format!("HA_{k}"), int(v));
}
d = d.with("SA_O", int(-15));
let calibrated = cr().with("Ex12", D::Double(0.2)).with("RHue", int(8));
let smoothed = cr().with("CNR ", int(25)).with("CNRS", int(70));
let auto_wb = cr().with("WBal", enm("WBal", "Atmt")).with("Temp", int(5));
let partial_splits = cr().with("PC_1", int(20));
let odd_curve = cr().with("Crv ", int(3));
// Review of #8714: values that would replay differently, or fail to play, stay Photoshop steps.
let reversed_splits = cr().with("PC_1", int(80)).with("PC_2", int(50)).with("PC_3", int(20));
let split_past_100 = cr().with("PC_1", int(20)).with("PC_2", int(50)).with("PC_3", int(120));
let one_point_curve = cr().with("Crv ", D::List([0, 0].map(int).to_vec()));
let unordered_curve = cr().with("CrvG", D::List([0, 0, 128, 90, 64, 50, 255, 255].map(int).to_vec()));
let text_contrast = cr().with("Cr12", D::Text(UnicodeString::new_nul("ten")));
let text_hue = cr().with("HA_R", D::Text(UnicodeString::new_nul("red")));
let text_grade = cr().with("STSH", D::Text(UnicodeString::new_nul("blue")));
let exposure_past_5 = cr().with("Ex12", D::Double(6.0));
let negative_sharpening = cr().with("Shrp", int(-10));
// CodeRabbit on batch 11: a process version the PSD mapping hasn't verified renders differently.
let unknown_version =
desc("null").with("CrVe", D::Text(UnicodeString::new_nul("16.1.1"))).with("PrVN", int(4)).with("PrVe", int(184_549_376)).with("Ex12", D::Double(0.2));
let no_version_number = desc("null").with("CrVe", D::Text(UnicodeString::new_nul("16.1.1"))).with("PrVe", int(184_549_376)).with("Ex12", D::Double(0.2));
let mismatched_pair =
desc("null").with("CrVe", D::Text(UnicodeString::new_nul("16.1.1"))).with("PrVN", int(6)).with("PrVe", int(184_549_376)).with("Ex12", D::Double(0.2));
let no_camera_raw_version = desc("null").with("PrVN", int(5)).with("PrVe", int(184_549_376)).with("Ex12", D::Double(0.2));
let items = vec![
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(d)),
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(calibrated)),
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(smoothed)),
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(auto_wb)),
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(partial_splits)),
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(odd_curve)),
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(reversed_splits)),
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(split_past_100)),
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(one_point_curve)),
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(unordered_curve)),
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(text_contrast)),
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(text_hue)),
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(text_grade)),
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(exposure_past_5)),
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(negative_sharpening)),
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(unknown_version)),
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(no_version_number)),
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(mismatched_pair)),
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(no_camera_raw_version)),
];
let file = AtnFile {
name: UnicodeString::new_nul("Looks"),
expanded: false,
actions: vec![AtnAction { name: UnicodeString::new_nul("Warm"), items, ..Default::default() }],
warnings: vec![],
..Default::default()
};
let imp = import(&atn::write(&file).unwrap()).unwrap();
let steps = &imp.actions[0].steps;
let (id, p) = &steps[0];
assert_eq!(id, "filter.cameraRaw");
// The recorded descriptor rides along, so Save Actions can write the step back.
let mut p = p.clone();
assert!(p.as_object_mut().unwrap().remove("__atnDescriptor").is_some(), "{p}");
assert_eq!(
p,
json!({
"temperature": 12, "tint": -4, "exposure": 0.35, "contrast": 10, "highlights": -30, "shadows": 25,
"clarity": 8, "vibrance": 15, "curveSplits": [20, 50, 80],
"hslHue": [1, 2, 3, 4, 5, 6, 7, 8], "hslSat": [0, -15, 0, 0, 0, 0, 0, 0],
"gradeShadows": {"hue": 210, "sat": 12, "lum": 0}, "gradeBlending": 60, "gradeBalance": -10,
"sharpenAmount": 40, "sharpenRadius": 1.2, "noiseLuminance": 20, "noiseColor": 25,
"vignetteAmount": -20, "vignetteStyle": "colorPriority", "pointCurve": [[0, 0], [64, 50], [255, 255]]
})
);
for (k, (id, _)) in steps.iter().enumerate().skip(1) {
assert_eq!(id, PHOTOSHOP_STEP, "step {k}");
}
assert_eq!(imp.unsupported.iter().map(|u| u.step).collect::<Vec<_>>(), (1..=18).collect::<Vec<_>>());
// The imported step plays.
let mut s = Session::new();
s.execute("file.new", json!({"width": 16, "height": 12})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("edit.fill", json!({"color": "#808080"})).unwrap();
s.execute(id, p.clone()).unwrap();
}
/// Review of #8714: a Camera Raw Filter step that was loaded saves back as the recorded step,
/// byte for byte, as it did while it was a kept Photoshop step; once edited (or when PhotoCraft
/// recorded it) Save Actions refuses it as before. The loaded step plays.
#[test]
fn loaded_camera_raw_filter_steps_save_back_unchanged() {
let recorded = desc("null")
.with("CrVe", D::Text(UnicodeString::new_nul("16.1.1")))
.with("PrVN", D::Integer(5))
.with("PrVe", D::Integer(184_549_376))
.with("WBal", enm("WBal", "Cst "))
.with("Ex12", D::Double(0.35))
.with("Cr12", D::Integer(10))
.with("Crv ", D::List([0, 0, 64, 50, 255, 255].map(D::Integer).to_vec()));
let file = one_action(vec![item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(recorded.clone()))]);
let mut s = Session::new();
let loaded = s.execute("actions.load", json!({"data": b64(&atn::write(&file).unwrap())})).unwrap();
assert_eq!(loaded["unsupported"], json!([]), "{loaded}");
assert_eq!(s.actions.list[0].steps[0].0, "filter.cameraRaw");
let saved_step = |r: &Value| {
let saved = atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap();
saved.actions[0].items.first().map(|i| (i.event.clone(), i.dictionary.clone(), i.descriptor.clone()))
};
// Strict save, and the Actions panel's save (which skips what can't be written). The step's
// dictionary is stored action data, so it keeps Photoshop's wording and not the menu label
// ("RAW Develop Filter"): a loaded step reads the same in Photoshop's Actions panel.
for skip in [false, true] {
let r = s.execute("actions.save", json!({"action": "A", "skipUnsupported": skip})).unwrap();
assert_eq!(r["skipped"], json!([]), "{r}");
let expected = (AtnEvent::name("Adobe Camera Raw Filter"), b"Camera Raw Filter".to_vec(), Some(recorded.clone()));
assert_eq!(saved_step(&r), Some(expected));
}
// It plays.
s.execute("file.new", json!({"width": 16, "height": 12})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("edit.fill", json!({"color": "#808080"})).unwrap();
let played = s.execute("actions.play", json!({"action": "A"})).unwrap();
assert_eq!(played["ran"], 1, "{played}");
assert!(played.get("failed").is_none(), "{played}");
// Edited, it no longer matches what was recorded.
let original = s.actions.list[0].steps[0].1.clone();
s.actions.list[0].steps[0].1["exposure"] = json!(1.5);
let e = s.execute("actions.save", json!({"action": "A"})).unwrap_err().to_string();
assert!(e.contains("filter.cameraRaw"), "{e}");
// Recorded by PhotoCraft: no descriptor to write back.
s.actions.list[0].steps[0].1 = original;
s.actions.list[0].steps[0].1.as_object_mut().unwrap().remove("__atnDescriptor");
let r = s.execute("actions.save", json!({"action": "A", "skipUnsupported": true})).unwrap();
assert_eq!(r["skipped"].as_array().unwrap().len(), 1, "{r}");
}
/// Review of #8714: a Camera Raw Filter step with a key the table doesn't map (an Upright
/// setting, a profile name, any other panel) or with no settings at all stays a Photoshop step
/// and is reported, rather than replaying without that processing.
#[test]
fn camera_raw_filter_steps_with_unknown_keys_stay_photoshop_steps() {
let cr = || desc("null").with("CrVe", D::Text(UnicodeString::new_nul("16.1.1"))).with("PrVN", D::Integer(5)).with("PrVe", D::Integer(184_549_376));
let upright = cr().with("Ex12", D::Double(0.2)).with("PerV", D::Integer(20));
let profile = cr().with("Cr12", D::Integer(10)).with("LookName", D::Text(UnicodeString::new_nul("B&W 05")));
let file = one_action(vec![
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(upright.clone())),
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(profile)),
item("Adobe Camera Raw Filter", "Camera Raw Filter", None),
item("Adobe Camera Raw Filter", "Camera Raw Filter", Some(cr().with("Ex12", D::Double(0.2)))),
]);
let mut s = Session::new();
let loaded = s.execute("actions.load", json!({"data": b64(&atn::write(&file).unwrap())})).unwrap();
let unsupported: Vec<u64> = loaded["unsupported"].as_array().unwrap().iter().map(|u| u["step"].as_u64().unwrap()).collect();
assert_eq!(unsupported, [0, 1, 2], "{loaded}");
let ids: Vec<&str> = s.actions.list[0].steps.iter().map(|(id, _)| id.as_str()).collect();
assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.cameraRaw"]);
// Kept, it still saves back as recorded.
let r = s.execute("actions.save", json!({"action": "A"})).unwrap();
let saved = atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap();
assert_eq!(saved.actions[0].items[0].descriptor, Some(upright));
}
fn one_action(items: Vec<AtnItem>) -> AtnFile {
AtnFile {
name: UnicodeString::new_nul("Saves"),
expanded: false,
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }],
warnings: vec![],
..Default::default()
}
}
fn save_as(format: &str, path: &str) -> Descriptor {
desc("null").with("As ", D::Descriptor(desc(format))).with("In ", path_value(path)).with("DocI", long(53.0))
}
#[test]
fn save_as_to_a_file_imports_with_its_destination() {
let file = one_action(vec![
item("save", "Save", Some(save_as("PNGF", "/out/result.png"))),
item("save", "Save", Some(save_as("Google WebPShop", "C:\\out\\result.webp").with("Cpy ", D::Boolean(true)))),
item("save", "Save", Some(save_as("JPEG", "/out/result.jpeg"))),
item("save", "Save", Some(save_as("WebPFormat", "/out/result.webp"))),
item("save", "Save", None),
item("save", "Save", Some(desc("null").with("DocI", long(53.0)))),
]);
let imp = import(&atn::write(&file).unwrap()).unwrap();
assert_eq!(
imp.actions[0].steps,
[
("file.saveAs".to_string(), json!({"path": "/out/result.png"})),
("file.saveACopy".to_string(), json!({"path": "C:\\out\\result.webp"})),
("file.saveAs".to_string(), json!({"path": "/out/result.jpeg"})),
("file.saveAs".to_string(), json!({"path": "/out/result.webp"})),
("file.save".to_string(), json!({})),
("file.save".to_string(), json!({})),
]
);
assert!(imp.unsupported.is_empty());
}
#[test]
fn save_as_that_cannot_be_mapped_stops_playback_instead_of_saving_over_the_file() {
// Before #7102 each of these imported as a plain File › Save, which overwrote the file the
// action was played on.
let file = one_action(vec![
item("save", "Save", Some(save_as("PNGF", "/out/folder"))),
// Save a Copy into a folder under the document's own name has no PhotoCraft step.
item("save", "Save", Some(save_as("PNGF", "/out").with("DIDr", D::Boolean(true)).with("Cpy ", D::Boolean(true)))),
item("save", "Save", Some(save_as("Not a format", "/out").with("DIDr", D::Boolean(true)))),
item("save", "Save", Some(save_as("PNGF", "/out/result.webp"))),
item("save", "Save", Some(save_as("GIFf", "/out/result.gif"))),
item("save", "Save", Some(desc("null").with("As ", D::Descriptor(desc("PNGF"))))),
// A format that isn't a descriptor, with and without a destination.
item("save", "Save", Some(desc("null").with("As ", long(1.0)))),
item("save", "Save", Some(desc("null").with("As ", long(1.0)).with("In ", path_value("/out/result.png")))),
// A destination with no format at all.
item("save", "Save", Some(desc("null").with("In ", path_value("/out/result.png")))),
]);
let imp = import(&atn::write(&file).unwrap()).unwrap();
let ids: Vec<&str> = imp.actions[0].steps.iter().map(|(id, _)| id.as_str()).collect();
assert_eq!(ids, [PHOTOSHOP_STEP; 9]);
assert!(imp.actions[0].disabled.is_empty(), "kept saves stay on, so playback stops on them");
// The kept steps are written back byte for byte.
let a = &imp.actions[0];
let (bytes, skipped) = export("Saves", &[a], &[], false).unwrap();
assert!(skipped.is_empty());
let back = atn::parse(&bytes).unwrap();
for (out, orig) in back.actions[0].items.iter().zip(&file.actions[0].items) {
assert_eq!(out.descriptor, orig.descriptor);
}
}
#[test]
fn save_as_into_a_folder_imports_as_a_folder_step_and_saves_back() {
// #5305: "save in directory" (`DIDr`), recorded when Photoshop's suggested name is kept:
// `In` is the folder, and each played document saves under its own name there.
let file = one_action(vec![
item("save", "Save", Some(save_as("PNGF", "/out/web").with("DIDr", D::Boolean(true)))),
item("save", "Save", Some(save_as("JPEG", "C:\\shots").with("DIDr", D::Boolean(true)))),
]);
let imp = import(&atn::write(&file).unwrap()).unwrap();
let steps = vec![
("file.saveAs".to_string(), json!({"folder": "/out/web", "extension": "png"})),
("file.saveAs".to_string(), json!({"folder": "C:\\shots", "extension": "jpg"})),
];
assert_eq!(imp.actions[0].steps, steps);
assert!(imp.unsupported.is_empty());
// Saved back as the same "save in directory" steps.
let (bytes, skipped) = export("Saves", &[&imp.actions[0]], &[], false).unwrap();
assert!(skipped.is_empty());
let back = atn::parse(&bytes).unwrap();
for (out, orig) in back.actions[0].items.iter().zip(&file.actions[0].items) {
let (out, orig) = (out.descriptor.as_ref().unwrap(), orig.descriptor.as_ref().unwrap());
for key in ["As ", "In ", "DIDr"] {
assert_eq!(out.get(key), orig.get(key), "{key}");
}
}
assert_eq!(import(&bytes).unwrap().actions[0].steps, steps);
// A folder step without its extension, or as a copy, has no Photoshop event.
let odd = Action::new("B", vec![("file.saveAs".to_string(), json!({"folder": "/out"}))]);
assert!(export("Saves", &[&odd], &[], false).is_err());
let copy = Action::new("C", vec![("file.saveACopy".to_string(), json!({"folder": "/out", "extension": "png"}))]);
assert!(export("Saves", &[&copy], &[], false).is_err());
}
#[test]
fn saves_round_trip_through_photoshop_events() {
let steps = vec![
("file.saveAs".to_string(), json!({"path": "/out/result.psd"})),
("file.saveACopy".to_string(), json!({"path": "/out/copy.webp"})),
("file.save".to_string(), json!({})),
];
let a = Action::new("A", steps.clone());
let (bytes, skipped) = export("Saves", &[&a], &[], false).unwrap();
assert!(skipped.is_empty());
let file = atn::parse(&bytes).unwrap();
let items = &file.actions[0].items;
assert!(items.iter().all(|i| i.event.id() == "save"));
let first = items[0].descriptor.as_ref().unwrap();
assert_eq!(get_obj(first, "As ").map(|f| trimmed(&f.class_id)).as_deref(), Some("Pht3"));
assert_eq!(first.get("In ").and_then(path_text).as_deref(), Some("/out/result.psd"));
assert_eq!(get_bool(items[1].descriptor.as_ref().unwrap(), "Cpy "), Some(true));
// Photoshop's own WebP class, not the plug-in's: Photoshop 2026 rejects `Google WebPShop`.
assert_eq!(get_obj(items[1].descriptor.as_ref().unwrap(), "As ").map(|f| trimmed(&f.class_id)).as_deref(), Some("WebPFormat"));
assert_eq!(import(&bytes).unwrap().actions[0].steps, steps);
// A Save As PhotoCraft can't express in Photoshop's terms is skipped, never exported as a
// plain Save.
let odd = Action::new("B", vec![("file.saveAs".to_string(), json!({"path": "/out/result.xcf"}))]);
assert!(export("Saves", &[&odd], &[], false).is_err());
}
#[test]
fn long_names_are_clipped_instead_of_copied_into_every_diagnostic() {
let long = "N".repeat(5000);
let unknown = |n: usize| (0..n).map(|_| item("zzzz", "Mystery", None)).collect::<Vec<_>>();
let file = AtnFile {
name: UnicodeString::new_nul(&long),
expanded: false,
actions: vec![AtnAction { name: UnicodeString::new_nul(&long), items: unknown(50), ..Default::default() }],
warnings: vec![],
..Default::default()
};
let imp = import(&atn::write(&file).unwrap()).unwrap();
assert_eq!(imp.set.chars().count(), MAX_NAME_CHARS);
assert_eq!(imp.actions[0].name.chars().count(), MAX_NAME_CHARS);
assert_eq!(imp.actions[0].set.as_deref().map(|s| s.chars().count()), Some(MAX_NAME_CHARS));
assert_eq!(imp.unsupported.len(), 50);
assert!(imp.unsupported.iter().all(|u| u.action.chars().count() == MAX_NAME_CHARS));
}
/// A Filter Gallery step as Photoshop records it: `GEfc` with the effect in `GEfk`, or the
/// effect's own event carrying the same key.
fn gallery_step(event: &str, effect: &str, d: Descriptor) -> Mapped {
let d = d.with("GEfk", enm("GEft", effect));
let event = AtnEvent::Code(event.as_bytes().try_into().unwrap());
(events::event_for(&event).unwrap_or_else(|| panic!("{effect} is unknown")).from)(&d)
}
fn gallery_params(m: Mapped) -> (String, Value) {
match m {
Mapped::Steps(mut s) if s.len() == 1 => s.remove(0),
_ => panic!("kept"),
}
}
/// The Filter Gallery effects load with Photoshop's defaults for missing keys and its clamping,
/// from `GEfc` or from the effect's own event, play, and save back as `GEfc` with every key in
/// recorded order (#10592).
#[test]
fn gallery_effect_steps_load_clamp_play_and_save_back() {
let edges = desc("null").with("EdgW", D::Integer(3)).with("EdgB", D::Integer(20)).with("Smth", D::Integer(7));
assert_eq!(
gallery_params(gallery_step("GEfc", "AccE", edges.clone())),
("filter.gallery.accentedEdges".into(), json!({"edgeWidth": 3, "edgeBrightness": 20, "smoothness": 7}))
);
// Run from its own menu item, the effect's id is the event.
assert_eq!(gallery_params(gallery_step("AccE", "AccE", edges.clone())).0, "filter.gallery.accentedEdges");
// Missing keys take Photoshop's defaults; enumerations, booleans and colours map.
assert_eq!(gallery_params(gallery_step("GEfc", "BsRl", desc("null"))).1, json!({"detail": 13, "smoothness": 3, "light": "bottom"}));
let pastels = desc("null").with("TxtT", enm("TxtT", "TxBr")).with("LghD", enm("LghD", "LDTR")).with("InvT", D::Boolean(true));
let p = gallery_params(gallery_step("GEfc", "RghP", pastels)).1;
assert_eq!((p["texture"].as_str(), p["light"].as_str(), p["invert"].as_bool()), (Some("brick"), Some("topRight"), Some(true)));
let glow = desc("null").with("Clr ", D::Descriptor(color_desc("#33cc66").unwrap()));
assert_eq!(gallery_params(gallery_step("GEfc", "NGlw", glow)).1["glowColor"], "#33cc66");
// Out-of-range values are clamped as Photoshop clamps them.
let wide = desc("null").with("EdgW", D::Integer(99)).with("EdgB", D::Integer(-5));
let p = gallery_params(gallery_step("GEfc", "AccE", wide)).1;
assert_eq!((p["edgeWidth"].as_i64(), p["edgeBrightness"].as_i64()), (Some(14), Some(0)));
// Saved back as `GEfc`, with `GEfk` first and every key in recorded order.
let item = export_item("filter.gallery.accentedEdges", &json!({"edgeWidth": 3, "edgeBrightness": 20, "smoothness": 7}), "S").unwrap();
assert_eq!(item.event.id(), "GEfc");
let mut expected = vec![(Id::new("GEfk"), enm("GEft", "AccE"))];
expected.extend(edges.items);
assert_eq!(item.descriptor.unwrap().items, expected);
// They play.
let mut s = Session::new();
s.execute("file.new", json!({"width": 24, "height": 24})).unwrap();
s.execute("filter.render.clouds", json!({})).unwrap();
for (code, effect) in [("AccE", "accentedEdges"), ("Ct ", "cutout"), ("Plst", "plaster")] {
let (id, p) = gallery_params(gallery_step("GEfc", code, desc("null")));
assert_eq!(id, format!("filter.gallery.{effect}"));
s.actions.list.push(Action::new(code, vec![(id, p)]));
let played = s.execute("actions.play", json!({"action": code})).unwrap();
assert_eq!(played["ran"], 1, "{code}: {played}");
}
}
/// What the effects cannot play keeps the Photoshop step: an unknown effect or value, a Glass
/// texture the reference gives no code for, a key the effect does not have (#10592).
#[test]
fn gallery_effect_steps_the_commands_cannot_play_are_kept() {
let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false });
assert!(kept(gallery_step("GEfc", "Zzzz", desc("null"))));
assert!(kept(gallery_step("GEfc", "BsRl", desc("null").with("LghD", enm("LghD", "LDCn")))));
assert!(kept(gallery_step("GEfc", "Gls ", desc("null").with("TxtT", enm("TxtT", "TxBr")))));
assert!(kept(gallery_step("GEfc", "AccE", desc("null").with("Rds ", D::Integer(3)))));
}
/// What the event cannot hold refuses the step: a fraction in an integer key, the colours a
/// Sketch effect pinned, a value with no Photoshop code, a mask target (#10592).
#[test]
fn gallery_effect_steps_that_cannot_be_written_are_refused() {
for (id, params) in [
("filter.gallery.accentedEdges", json!({"edgeWidth": 2.5})),
("filter.gallery.charcoal", json!({"foreground": "#000000"})),
("filter.gallery.glass", json!({"texture": "blocks"})),
("filter.gallery.cutout", json!({"target": "mask"})),
] {
assert!(export_item(id, &params, "S").is_none(), "{id} {params} exported");
}
assert!(export_item("filter.gallery.glass", &json!({"texture": "frosted"}), "S").is_some());
}
/// An integer outside the setting's range refuses the step rather than saving a value Photoshop
/// clamps on playback and that loads back as another (`edgeWidth` 99 as 14); each end of a range
/// still saves (#10592).
#[test]
fn gallery_effect_integers_outside_the_settings_range_are_refused() {
for (id, params) in [
("filter.gallery.accentedEdges", json!({"edgeWidth": 99})),
("filter.gallery.accentedEdges", json!({"edgeWidth": 0})),
("filter.gallery.neonGlow", json!({"glowSize": -25, "glowColor": "#336699"})),
("filter.gallery.glass", json!({"scaling": 201})),
] {
assert!(export_item(id, &params, "S").is_none(), "{id} {params} exported");
}
for (id, params) in [
("filter.gallery.accentedEdges", json!({"edgeWidth": 1})),
("filter.gallery.accentedEdges", json!({"edgeWidth": 14})),
("filter.gallery.neonGlow", json!({"glowSize": -24, "glowColor": "#336699"})),
("filter.gallery.glass", json!({"scaling": 200})),
] {
assert!(export_item(id, &params, "S").is_some(), "{id} {params} refused");
}
}
/// Neon Glow without a colour plays with the effect's default glow colour, so it saves with that
/// colour, component for component (#10592).
#[test]
fn gallery_neon_glow_without_a_colour_saves_the_colour_it_plays_with() {
let item = export_item("filter.gallery.neonGlow", &json!({"glowSize": 5}), "S").expect("refused");
let d = item.descriptor.unwrap();
let c = get_obj(&d, "Clr ").expect("no colour");
assert_eq!(trimmed(&c.class_id), "RGBC");
let neon = photocraft_algo::GalleryEffect::new(photocraft_algo::GalleryFilter::NeonGlow).color;
for (key, v) in [("Rd ", neon[0]), ("Grn ", neon[1]), ("Bl ", neon[2])] {
assert_eq!(get_num(c, key), Some(f64::from(v) * 255.0), "{key}");
}
}
/// Each enumeration saves with the type Photoshop's terminology gives it: stroke direction's is
/// `StrD`, not its key, and texture's is `TxtT`, as Photoshop records Glass (#10592).
#[test]
fn gallery_effect_enumerations_save_with_photoshops_type_ids() {
let enum_type = |id: &str, params: Value, key: &str| match export_item(id, &params, "S").unwrap().descriptor.unwrap().get(key) {
Some(D::Enumerated { type_id, .. }) => trimmed(type_id),
other => panic!("{id} {key}: {other:?}"),
};
assert_eq!(enum_type("filter.gallery.graphicPen", json!({}), "SDir"), "StrD");
assert_eq!(enum_type("filter.gallery.sprayedStrokes", json!({}), "SDir"), "StrD");
for id in ["filter.gallery.conteCrayon", "filter.gallery.glass", "filter.gallery.roughPastels", "filter.gallery.texturizer", "filter.gallery.underpainting"]
{
assert_eq!(enum_type(id, json!({}), "TxtT"), "TxtT", "{id}");
}
assert_eq!(enum_type("filter.gallery.basRelief", json!({}), "LghD"), "LghD");
assert_eq!(enum_type("filter.gallery.plaster", json!({}), "LghP"), "LghP");
assert_eq!(enum_type("filter.gallery.halftonePattern", json!({}), "ScrT"), "ScrT");
assert_eq!(enum_type("filter.gallery.paintDaubs", json!({}), "BrsT"), "BrsT");
assert_eq!(enum_type("filter.gallery.grain", json!({}), "Grnt"), "Grnt");
}
/// Neon Glow recorded without `Clr ` glows in Photoshop's default colour, blue (0, 0, 255) per
/// the event reference, not PhotoCraft's own default: the step loads with that colour and saves
/// it back, loading again as the same step (#10592).
#[test]
fn gallery_neon_glow_without_a_colour_loads_photoshops_blue() {
let (id, p) = gallery_params(gallery_step("GEfc", "NGlw", desc("null")));
assert_eq!(id, "filter.gallery.neonGlow");
assert_eq!(p, json!({"glowSize": 5, "glowBrightness": 15, "glowColor": "#0000ff"}));
let d = export_item(&id, &p, "S").expect("refused").descriptor.unwrap();
let c = get_obj(&d, "Clr ").expect("no colour");
assert_eq!(trimmed(&c.class_id), "RGBC");
assert_eq!([get_num(c, "Rd "), get_num(c, "Grn "), get_num(c, "Bl ")], [Some(0.0), Some(0.0), Some(255.0)]);
let event = AtnEvent::Code(*b"GEfc");
let back = (events::event_for(&event).unwrap().from)(&d);
assert_eq!(gallery_params(back), (id, p));
}
/// An effect recorded under its own event by string ID (`cutout`, as newer versions record it)
/// maps like its 4-char code; only the effect's string IDs count as names (#10592).
#[test]
fn gallery_effects_recorded_by_string_id_map_like_their_codes() {
for (code, name, effect) in [("Ct ", "cutout", "Ct"), ("HlfS", "halftoneScreen", "HlfS"), ("Grn ", "grain", "Grn")] {
let d = desc("null").with("GEfk", enm("GEft", effect));
let by_name = (events::event_for(&AtnEvent::name(name)).unwrap_or_else(|| panic!("{name} by name")).from)(&d);
let by_code = (events::event_for(&AtnEvent::Code(code.as_bytes().try_into().unwrap())).unwrap().from)(&d);
assert_eq!(gallery_params(by_name), gallery_params(by_code), "{name}");
}
assert!(events::event_for(&AtnEvent::name("Ct ")).is_none());
assert!(events::event_for(&AtnEvent::Code(*b"MscT")).is_some());
}
/// A step recorded as the effect's own event (by code or string ID) loads, plays and saves back
/// as that event with its panel name and descriptor unchanged; a `GEfc` step stays `GEfc`
/// (#10592).
#[test]
fn gallery_effect_steps_save_back_as_the_event_they_were_recorded_as() {
let edges = desc("null").with("GEfk", enm("GEft", "AccE")).with("EdgW", D::Integer(2)).with("EdgB", D::Integer(38)).with("Smth", D::Integer(5));
let cutout = desc("null").with("GEfk", enm("GEft", "Ct")).with("NmbL", D::Integer(6)).with("EdgS", D::Integer(4)).with("EdgF", D::Integer(2));
let gallery = desc("null").with("GEfk", enm("GEft", "AccE")).with("EdgW", D::Integer(3)).with("EdgB", D::Integer(20)).with("Smth", D::Integer(7));
let source = vec![item("AccE", "Accented Edges", Some(edges)), item("cutout", "Cutout", Some(cutout)), item("GEfc", "Filter Gallery", Some(gallery))];
let data = set_of("S", vec![("A", source.clone())]);
let mut s = Session::new();
let loaded = s.execute("actions.load", json!({"data": data})).unwrap();
assert_eq!(loaded["unsupported"], json!([]), "{loaded}");
let ids: Vec<&str> = s.actions.list[0].steps.iter().map(|(id, _)| id.as_str()).collect();
assert_eq!(ids, ["filter.gallery.accentedEdges", "filter.gallery.cutout", "filter.gallery.accentedEdges"]);
s.execute("file.new", json!({"width": 16, "height": 16})).unwrap();
s.execute("filter.render.clouds", json!({})).unwrap();
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
let played = s.execute("actions.play", json!({"action": "A"})).unwrap();
assert_eq!(played["ran"], 3, "{played}");
// The remembered event is for Save Actions only: playback strips it, so the journal (Last
// Filter) and the smart filters (`.pcraft` saves) never carry it.
let gallery: Vec<&Value> = s.journal.iter().filter(|(id, _)| id.starts_with("filter.gallery.")).map(|(_, p)| p).collect();
assert_eq!(gallery.len(), 3, "{:?}", s.journal);
assert!(gallery.iter().all(|p| p.get("_atnEvent").is_none()), "{gallery:?}");
let d = s.active().unwrap();
let photocraft_doc::LayerContent::Smart(sm) = &d.doc.layer(d.active_layer.unwrap()).unwrap().content else { panic!("not smart") };
assert_eq!(sm.smart_filters.len(), 3);
assert!(sm.smart_filters.iter().all(|f| f.params.get("_atnEvent").is_none()), "{:?}", sm.smart_filters);
let saved = s.execute("actions.save", json!({"set": "S"})).unwrap();
assert_eq!(saved["skipped"], json!([]));
let file = atn::parse(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap();
let got: Vec<_> = file.actions[0].items.iter().map(|i| (i.event.clone(), i.dictionary.clone(), i.descriptor.clone())).collect();
let want: Vec<_> = source.iter().map(|i| (i.event.clone(), i.dictionary.clone(), i.descriptor.clone())).collect();
assert_eq!(got, want);
// A step made in PhotoCraft keeps saving as `GEfc`, and so does a forged event param.
assert_eq!(export_item("filter.gallery.cutout", &json!({}), "S").unwrap().event, AtnEvent::Code(*b"GEfc"));
let forged = json!({"_atnEvent": {"event": "GsnB", "eventKind": "code", "name": "Gaussian Blur"}});
assert_eq!(export_item("filter.gallery.cutout", &forged, "S").unwrap().event, AtnEvent::Code(*b"GEfc"));
// Another effect's event (stale or forged) does not relabel the step either.
for (event, kind) in [("AccE", "code"), ("accentedEdges", "name")] {
let other = json!({"_atnEvent": {"event": event, "eventKind": kind, "name": "Accented Edges"}});
let item = export_item("filter.gallery.cutout", &other, "S").unwrap();
assert_eq!((item.event, item.dictionary), (AtnEvent::Code(*b"GEfc"), b"Filter Gallery".to_vec()), "{event}");
}
let own = json!({"_atnEvent": {"event": "cutout", "eventKind": "name", "name": "Cutout"}});
assert_eq!(export_item("filter.gallery.cutout", &own, "S").unwrap().event, AtnEvent::name("cutout"));
}
/// Only a missing key takes Photoshop's default: a key there with the wrong type or a non-finite
/// number keeps the Photoshop step instead of playing a default (#10592).
#[test]
fn gallery_effect_keys_with_the_wrong_type_keep_the_step() {
let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false });
assert!(kept(gallery_step("GEfc", "Txtz", desc("null").with("InvT", D::Integer(1)))));
assert!(kept(gallery_step("GEfc", "AccE", desc("null").with("EdgW", D::Double(f64::NAN)))));
assert!(kept(gallery_step("GEfc", "AccE", desc("null").with("EdgW", D::Double(f64::INFINITY)))));
assert!(kept(gallery_step("GEfc", "AccE", desc("null").with("EdgW", text("9")))));
assert!(kept(gallery_step("GEfc", "BsRl", desc("null").with("LghD", D::Integer(3)))));
assert!(kept(gallery_step("GEfc", "NGlw", desc("null").with("Clr ", D::Integer(3)))));
// Missing keys still take the defaults.
assert_eq!(gallery_params(gallery_step("GEfc", "Txtz", desc("null"))).1["invert"], false);
assert_eq!(gallery_params(gallery_step("GEfc", "AccE", desc("null").with("EdgB", D::Integer(9)))).1["edgeWidth"], 2);
}
/// Photoshop's Split Channels, Merge Spot Channel, Trap and Match Color steps load as their
/// commands and save back with the keys Photoshop records (#10609).
#[test]
fn channel_and_color_steps_load_and_save_back() {
let ids = D::List(vec![D::Integer(182), D::Integer(185), D::Integer(188)]);
let matched = desc("null").with("Lght", D::Integer(120)).with("ClrR", D::Integer(80)).with("Fade", D::Integer(25)).with("noReference", D::Boolean(true));
let items = vec![
item("SplC", "Split Channels", Some(desc("null").with("documentIDs", ids))),
item("MSpt", "Merge Spot Channel", Some(desc("null"))),
item("Trap", "Trap", Some(desc("null").with("Wdth", px(2.0)))),
item("matchColor", "Match Color", Some(matched.clone())),
];
let file = AtnFile {
name: UnicodeString::new_nul("Prepress"),
actions: vec![AtnAction { name: UnicodeString::new_nul("Inks"), items: items.clone(), ..Default::default() }],
..Default::default()
};
let mut s = Session::new();
let r = s.execute("actions.load", json!({"data": b64(&atn::write(&file).unwrap())})).unwrap();
assert_eq!(r["unsupported"], json!([]));
assert_eq!(
s.actions.list[0].steps,
vec![
("channel.split".to_string(), json!({})),
("channel.mergeSpot".to_string(), json!({})),
("image.trap".to_string(), json!({"width": 2})),
("image.adjustments.matchColor".to_string(), json!({"luminance": 120, "intensity": 80, "fade": 25})),
]
);
let saved = s.execute("actions.save", json!({"action": "Inks"})).unwrap();
let back = atn::parse(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap();
let back = &back.actions[0].items;
let events: Vec<String> = back.iter().map(|i| i.event.id()).collect();
assert_eq!(events, ["SplC", "MSpt", "Trap", "matchColor"]);
// The split's document IDs belong to the run that recorded it, so none are written.
assert!(back[0].descriptor.as_ref().unwrap().items.is_empty());
for (b, original) in back.iter().zip(&items).skip(1) {
assert_eq!(b.descriptor.as_ref().unwrap().items, original.descriptor.as_ref().unwrap().items, "{}", original.event.id());
}
// Match Color with no reference plays on the image itself.
s.execute("file.new", json!({"width": 16, "height": 16})).unwrap();
s.execute("filter.render.clouds", json!({})).unwrap();
s.actions.list.push(Action::new("Match", vec![s.actions.list[0].steps[3].clone()]));
let played = s.execute("actions.play", json!({"action": "Match"})).unwrap();
assert_eq!(played["ran"], 1, "{played}");
}
/// Missing Match Color keys take Photoshop's defaults and Trap's width is clamped to its
/// 32767 px; a step matching another image, or with a key the event lacks, stays a Photoshop
/// step (#10609).
#[test]
fn channel_and_color_steps_default_clamp_and_keep() {
let from = |name: &str, d: Descriptor| {
let event = if name.len() == 4 { AtnEvent::Code(name.as_bytes().try_into().unwrap()) } else { AtnEvent::name(name) };
(events::event_for(&event).unwrap_or_else(|| panic!("{name} is unknown")).from)(&d)
};
let params = |m: Mapped| match m {
Mapped::Steps(s) => s[0].1.clone(),
Mapped::Unsupported { .. } => panic!("kept"),
};
assert_eq!(params(from("matchColor", desc("null"))), json!({"luminance": 100, "intensity": 100, "fade": 0}));
assert_eq!(params(from("Trap", desc("null").with("Wdth", px(99_999.0)))), json!({"width": 32767.0}));
let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false });
assert!(kept(from("matchColor", desc("null").with("noReference", D::Boolean(false)))));
assert!(kept(from("SplC", desc("null").with("Chnl", D::Integer(1)))));
assert!(kept(from("MSpt", desc("null").with("Chnl", D::Integer(1)))));
assert!(kept(from("Trap", desc("null").with("Hght", px(2.0)))));
}
/// Settings the events have no key for refuse the step instead of replaying differently: a
/// split that keeps the original, a named spot channel, a Match Color source or option, a
/// fractional luminance (#10609).
#[test]
fn channel_and_color_steps_that_cannot_be_written_are_refused() {
for (id, params) in [
("channel.split", json!({"closeOriginal": false})),
("channel.mergeSpot", json!({"channel": 1})),
("image.trap", json!({"width": 2, "layer": 3})),
("image.adjustments.matchColor", json!({"source": 0})),
("image.adjustments.matchColor", json!({"neutralize": true})),
("image.adjustments.matchColor", json!({"luminance": 99.5})),
] {
assert!(export_item(id, &params, "S").is_none(), "{id} {params} exported");
}
assert!(export_item("image.adjustments.matchColor", &json!({"neutralize": false}), "S").is_some());
}
/// A Trap width above the 32767 px the import clamps to refuses the step instead of writing a
/// `Wdth` Photoshop doesn't take (#10609).
#[test]
fn trap_wider_than_photoshop_takes_is_refused() {
assert!(export_item("image.trap", &json!({"width": 32768}), "S").is_none());
let d = export_item("image.trap", &json!({"width": 32767}), "S").unwrap().descriptor.unwrap();
assert_eq!(d.items, desc("null").with("Wdth", px(32767.0)).items);
}
/// The channel or colour step `d` of the Photoshop event `name`, as PhotoCraft loads it.
fn channel_step(name: &str, d: Descriptor) -> Mapped {
let event = if name.len() == 4 { AtnEvent::Code(name.as_bytes().try_into().unwrap()) } else { AtnEvent::name(name) };
(events::event_for(&event).unwrap_or_else(|| panic!("{name} is unknown")).from)(&d)
}
/// A `noReference` that isn't a Boolean doesn't say the step has no source, so the step stays
/// a Photoshop step instead of playing as a source-less Match Color (#10609).
#[test]
fn match_color_with_a_non_boolean_no_reference_is_kept() {
for v in [D::Integer(1), text("true"), enm("Ordn", "Trgt")] {
let m = channel_step("matchColor", desc("null").with("noReference", v.clone()));
assert!(matches!(m, Mapped::Unsupported { harmless: false }), "noReference {v:?} was mapped");
}
}
/// A recorded Match Color or Trap setting that isn't a number is the step's own setting, not a
/// missing one: the step stays a Photoshop step instead of playing with the default (#10609).
#[test]
fn channel_and_color_steps_with_a_non_numeric_setting_are_kept() {
for (name, key, v) in [
("matchColor", "Lght", text("120")),
("matchColor", "ClrR", D::Boolean(true)),
("matchColor", "Fade", enm("Ordn", "Trgt")),
("matchColor", "Lght", D::Double(f64::NAN)),
("Trap", "Wdth", text("2")),
("Trap", "Wdth", D::Boolean(true)),
] {
let m = channel_step(name, desc("null").with(key, v.clone()));
assert!(matches!(m, Mapped::Unsupported { harmless: false }), "{name} with {key} = {v:?} was mapped");
}
}
/// Trap saves its width as `#Pxl`, so a width in another unit, or without one, stays a
/// Photoshop step instead of loading as that many pixels and saving back as pixels (#10609).
#[test]
fn trap_width_not_in_pixels_is_kept() {
for v in [
D::UnitFloat { unit: *b"#Pnt", value: 2.0 },
D::UnitFloat { unit: *b"#Mlm", value: 2.0 },
D::UnitFloat { unit: *b"#Prc", value: 2.0 },
D::Double(2.0),
D::Integer(2),
] {
let m = channel_step("Trap", desc("null").with("Wdth", v.clone()));
assert!(matches!(m, Mapped::Unsupported { harmless: false }), "Wdth {v:?} was mapped");
}
assert!(matches!(channel_step("Trap", desc("null").with("Wdth", px(2.5))), Mapped::Steps(_)));
}
/// Match Color saves its settings as integers, so a recorded value it couldn't save back as
/// the same integer stays a Photoshop step instead of loading and then failing to save (#10609).
#[test]
fn match_color_setting_that_cannot_be_saved_back_is_kept() {
for (key, v) in [
("Lght", D::Double(99.5)),
("Lght", D::Double(100.0)),
("ClrR", D::LargeInteger(1 << 40)),
("ClrR", D::LargeInteger(80)),
("Fade", D::UnitFloat { unit: *b"#Prc", value: 25.0 }),
] {
let m = channel_step("matchColor", desc("null").with(key, v.clone()));
assert!(matches!(m, Mapped::Unsupported { harmless: false }), "{key} = {v:?} was mapped");
}
}
/// Photoshop's narrowest Trap width wasn't measured, so a recorded width below the command's
/// 1 px isn't clamped up to it: the step stays a Photoshop step, and a PhotoCraft step that
/// narrow is refused on export instead of written as a width the command doesn't trap (#10609).
#[test]
fn trap_width_below_one_pixel_is_kept() {
for w in [0.25, 0.0, -2.0] {
let m = channel_step("Trap", desc("null").with("Wdth", px(w)));
assert!(matches!(m, Mapped::Unsupported { harmless: false }), "Wdth {w} was mapped");
assert!(export_item("image.trap", &json!({"width": w}), "S").is_none(), "width {w} exported");
}
let d = export_item("image.trap", &json!({"width": 1}), "S").unwrap().descriptor.unwrap();
assert_eq!(d.items, desc("null").with("Wdth", px(1.0)).items);
assert!(matches!(channel_step("Trap", desc("null").with("Wdth", px(1.0))), Mapped::Steps(_)));
}
/// Match Color's Luminance and Color Intensity take 1..200 and Fade 0..100, and Photoshop's
/// handling of a recorded value outside them wasn't measured: such a step stays a Photoshop
/// step rather than being clamped, and a PhotoCraft step outside them is refused on export
/// instead of written as a value the command plays clamped (#10609).
#[test]
fn match_color_setting_outside_its_range_is_kept() {
for (key, n) in [("Lght", 0), ("Lght", 201), ("ClrR", 0), ("ClrR", 201), ("Fade", -1), ("Fade", 101)] {
let m = channel_step("matchColor", desc("null").with(key, D::Integer(n)));
assert!(matches!(m, Mapped::Unsupported { harmless: false }), "{key} = {n} was mapped");
}
let edges = desc("null").with("Lght", D::Integer(1)).with("ClrR", D::Integer(200)).with("Fade", D::Integer(100));
assert!(matches!(channel_step("matchColor", edges), Mapped::Steps(_)));
for p in [json!({"luminance": 0}), json!({"luminance": 201}), json!({"intensity": 0}), json!({"fade": -1}), json!({"fade": 101})] {
assert!(export_item("image.adjustments.matchColor", &p, "S").is_none(), "{p} exported");
}
assert!(export_item("image.adjustments.matchColor", &json!({"luminance": 1, "intensity": 200, "fade": 100}), "S").is_some());
}
/// A Match Color step that names its source (`Srce`, a document by name, or the name as text)
/// matches another image the command isn't given, so it stays a Photoshop step even when it
/// also says `noReference` (#10609).
#[test]
fn match_color_with_a_source_is_kept() {
let from = |d: Descriptor| (events::event_for(&AtnEvent::name("matchColor")).unwrap().from)(&d);
let document = D::Reference(vec![ReferenceItem::Name { class: class("Dcmn"), name: UnicodeString::new_nul("Reference.psd") }]);
for source in [document, text("Reference.psd")] {
for d in [desc("null").with("Srce", source.clone()), desc("null").with("Srce", source.clone()).with("noReference", D::Boolean(true))] {
assert!(matches!(from(d.clone()), Mapped::Unsupported { harmless: false }), "{:?} was mapped", d.items);
}
}
}
/// A Trap step recorded without `Wdth` plays the command's 1 px and saves back as `Wdth` 1 px,
/// so it must load as that width too: load, save and load give the same step.
#[test]
fn trap_step_without_a_width_saves_back_the_same() {
let file = AtnFile {
name: UnicodeString::new_nul("S"),
actions: vec![AtnAction { name: UnicodeString::new_nul("T"), items: vec![item("Trap", "Trap", Some(desc("null")))], ..Default::default() }],
..Default::default()
};
let mut s = Session::new();
s.execute("actions.load", json!({"data": b64(&atn::write(&file).unwrap())})).unwrap();
let loaded = s.actions.list.clone();
assert_eq!(loaded[0].steps, vec![("image.trap".to_string(), json!({"width": 1}))]);
let saved = s.execute("actions.save", json!({"set": "S"})).unwrap();
let mut t = Session::new();
t.execute("actions.load", json!({"data": saved["data"].as_str().unwrap()})).unwrap();
assert_eq!(t.actions.list, loaded);
}
/// The Match Color dialog records Source "None" as `"source": -1`, which the command plays
/// without a source: such a step saves, while a real source document is still refused.
#[test]
fn match_color_recorded_with_source_none_saves() {
let none = json!({"source": -1, "luminance": 100.0, "intensity": 100.0, "fade": 0.0, "neutralize": false, "useSelectionInSource": false, "useSelectionInTarget": false});
let d = export_item("image.adjustments.matchColor", &none, "S").unwrap().descriptor.unwrap();
let expected = desc("null").with("Lght", D::Integer(100)).with("ClrR", D::Integer(100)).with("Fade", D::Integer(0)).with("noReference", D::Boolean(true));
assert_eq!(d.items, expected.items);
assert!(export_item("image.adjustments.matchColor", &json!({"source": -1, "sourceLayer": 4}), "S").is_some());
for source in [json!({"source": 0}), json!({"source": 0, "sourceLayer": 4}), json!({"source": "x"}), json!({"source": -1, "neutralize": true})] {
assert!(export_item("image.adjustments.matchColor", &source, "S").is_none(), "{source} exported");
}
}
/// Native component references import, select the same target as the command and export again.
#[test]
fn select_color_components_import_play_and_round_trip() {
for (mode, channels) in [
("rgb", &["red", "green", "blue"][..]),
("cmyk", &["cyan", "magenta", "yellow", "black"][..]),
("lab", &["lightness", "a", "b"][..]),
("gray", &["gray"][..]),
] {
for depth in [8, 16, 32] {
for channel in channels {
let mut s = Session::new();
s.execute("file.new", json!({"width": 2, "height": 1, "mode": mode, "depth": depth})).unwrap();
let expected = s.execute("channel.target", json!({"channel": channel})).unwrap();
s.execute("channel.target", json!({"channel": "composite"})).unwrap();
let selection = desc("null").with("null", channel_ref(Some(channel)));
let data = set_of("Components", vec![("Pick", vec![item("select", "Select", Some(selection))])]);
let loaded = s.execute("actions.load", json!({"data": data})).unwrap();
assert_eq!(loaded["unsupported"], json!([]), "{mode}/{depth}/{channel}");
let played = s.execute("actions.play", json!({"action": "Pick"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
assert_eq!(s.execute("channel.list", json!({})).unwrap(), expected, "{mode}/{depth}/{channel}");
let saved = s.execute("actions.save", json!({"set": "Components"})).unwrap();
let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap();
assert_eq!(import(&bytes).unwrap().actions[0].steps, s.actions.list[0].steps);
}
}
}
}
/// Unknown targets and selection modes retain their failure instead of selecting another channel.
#[test]
fn select_color_components_preserve_unsupported_selection_shapes() {
let chained = vec![
ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code("Cyn") },
ReferenceItem::Identifier { class: class("Dcmn"), id: 7 },
];
for (mode, selection) in [
("cmyk", desc("null").with("null", channel_ord("Xxxx"))),
("cmyk", desc("null").with("null", channel_ref(Some("cyan"))).with("selectionModifier", enm("selectionModifierType", "addToSelection"))),
("cmyk", desc("null").with("null", D::Reference(chained))),
("cmyk", desc("null").with("null", D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Ordn"), value: code("Cyn") }]))),
// Shift-click adds the channel to the selection; playing it as a plain select would lose that.
("cmyk", desc("null").with("null", channel_ref(Some("cyan"))).with("Extd", D::Boolean(true))),
// The legacy `Lmnc` code would otherwise target Lightness and save back as `Lght`.
("lab", desc("null").with("null", channel_ord("Lmnc"))),
] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 2, "height": 1, "mode": mode})).unwrap();
let data = set_of("Unknown", vec![("Pick", vec![item("select", "Select", Some(selection.clone()))])]);
let loaded = s.execute("actions.load", json!({"data": data})).unwrap();
assert_eq!(loaded["unsupported"].as_array().unwrap().len(), 1);
assert_eq!(s.actions.list[0].steps[0].0, PHOTOSHOP_STEP);
let target = s.execute("channel.list", json!({})).unwrap();
let played = s.execute("actions.play", json!({"action": "Pick"})).unwrap();
assert_eq!(played["failed"]["id"], PHOTOSHOP_STEP);
assert_eq!(s.execute("channel.list", json!({})).unwrap(), target);
let saved = s.execute("actions.save", json!({"set": "Unknown"})).unwrap();
let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap();
assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor.as_ref(), Some(&selection));
}
for params in [
json!({"channel": {"color": "cyan"}, "extend": true}),
json!({"channel": {"color": "cyan"}, "colors": [0, 1]}),
json!({"channel": 0}),
json!({"channel": null}),
json!({"channel": {"alpha": 0}}),
json!({"channel": "cyan"}),
] {
assert!(super::steps_layer::select_to("channel.target", &params).is_none(), "{params}");
}
assert!(super::steps_layer::select_to("channel.target", &json!({"channel": {"color": "CYAN"}, "extend": false})).is_some());
let gray = set_of("Gray", vec![("Pick", vec![item("select", "Select", Some(desc("null").with("null", channel_ord("Gry"))))])]);
let bytes = photocraft_paint::tile::b64_decode(&gray).unwrap();
assert_eq!(import(&bytes).unwrap().actions[0].steps[0], ("channel.target".into(), json!({"channel": "composite"})));
// A plain click (`Extd` false) is still an ordinary single-channel select.
let plain = set_of(
"Plain",
vec![("Pick", vec![item("select", "Select", Some(desc("null").with("null", channel_ref(Some("cyan"))).with("Extd", D::Boolean(false))))])],
);
let bytes = photocraft_paint::tile::b64_decode(&plain).unwrap();
assert_eq!(import(&bytes).unwrap().actions[0].steps[0], ("channel.target".into(), json!({"channel": {"color": "cyan"}})));
}
/// A CMYK component cannot retarget a same-named alpha channel when replayed on RGB.
#[test]
fn select_color_components_do_not_fall_back_to_alpha_names() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 2, "height": 1, "mode": "rgb"})).unwrap();
s.execute("channel.new", json!({"name": "cyan"})).unwrap();
for channel in [json!("cyan"), json!(0), json!({"alpha": 0})] {
assert_eq!(s.execute("channel.target", json!({"channel": channel})).unwrap()["target"]["kind"], "alpha");
}
s.execute("channel.target", json!({"channel": "composite"})).unwrap();
let data = set_of("Components", vec![("Pick", vec![item("select", "Select", Some(desc("null").with("null", channel_ref(Some("cyan")))))])]);
assert_eq!(s.execute("actions.load", json!({"data": data})).unwrap()["unsupported"], json!([]));
let played = s.execute("actions.play", json!({"action": "Pick"})).unwrap();
assert_eq!(played["failed"]["id"], "channel.target");
assert_eq!(s.execute("channel.list", json!({})).unwrap()["target"]["kind"], "composite");
}
/// Nested color indices take precedence over the object's single-color fallback at runtime.
#[test]
fn select_channel_export_rejects_nested_color_selections() {
for colors in [vec![0, 1], vec![1]] {
let params = json!({"channel":{"color":"cyan","colors":colors}});
let mut s = Session::new();
s.execute("file.new", json!({"width":2,"height":1,"mode":"cmyk"})).unwrap();
s.execute("channel.target", params.clone()).unwrap();
assert_eq!(s.active().unwrap().channel_view.selected_colors, colors);
s.actions.list.push(Action::new("Nested", vec![("channel.target".into(), params.clone())]));
assert!(s.execute("actions.save", json!({"action":"Nested"})).is_err(), "{params} must not become a single Cyan selection");
let skipped = s.execute("actions.save", json!({"action":"Nested","skipUnsupported":true})).unwrap();
assert_eq!(skipped["skipped"], json!([{"action":"Nested","step":0,"id":"channel.target"}]));
let bytes = photocraft_paint::tile::b64_decode(skipped["data"].as_str().unwrap()).unwrap();
assert!(atn::parse(&bytes).unwrap().actions[0].items.is_empty());
assert_eq!(s.actions.list[0].steps[0].1, params);
}
let mut s = Session::new();
s.actions.list.push(Action::new("Cyan", vec![("channel.target".into(), json!({"channel":{"color":"CYAN"},"extend":false}))]));
let saved = s.execute("actions.save", json!({"action":"Cyan"})).unwrap();
assert_eq!(saved["skipped"], json!([]));
let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap();
assert_eq!(import(&bytes).unwrap().actions[0].steps, vec![("channel.target".into(), json!({"channel":{"color":"cyan"}}))]);
}
/// A typed Gray target must retain its failure on RGB instead of becoming portable composite.
#[test]
fn select_channel_export_rejects_mode_specific_gray_objects() {
for color in ["gray", "GRAY", "GrAy"] {
for mode in ["gray", "rgb"] {
let params = json!({"channel":{"color":color}});
let mut s = Session::new();
s.execute("file.new", json!({"width":2,"height":1,"mode":mode})).unwrap();
assert_eq!(s.execute("channel.target", params.clone()).is_ok(), mode == "gray");
s.actions.list.push(Action::new("Gray object", vec![("channel.target".into(), params.clone())]));
assert!(s.execute("actions.save", json!({"action":"Gray object"})).is_err(), "{mode}/{color} must not become composite");
let skipped = s.execute("actions.save", json!({"action":"Gray object","skipUnsupported":true})).unwrap();
assert_eq!(skipped["skipped"], json!([{"action":"Gray object","step":0,"id":"channel.target"}]));
let bytes = photocraft_paint::tile::b64_decode(skipped["data"].as_str().unwrap()).unwrap();
assert!(atn::parse(&bytes).unwrap().actions[0].items.is_empty());
assert_eq!(s.actions.list[0].steps[0].1, params);
}
}
let mut s = Session::new();
s.actions.list.push(Action::new("Portable", vec![("channel.target".into(), json!({"channel":"composite"}))]));
let saved = s.execute("actions.save", json!({"action":"Portable"})).unwrap();
assert_eq!(saved["skipped"], json!([]));
let bytes = photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap();
assert_eq!(import(&bytes).unwrap().actions[0].steps, s.actions.list[0].steps);
let gray = set_of("Gray", vec![("Pick", vec![item("select", "Select", Some(desc("null").with("null", channel_ord("Gry"))))])]);
let mut rgb = Session::new();
rgb.execute("file.new", json!({"width":2,"height":1,"mode":"rgb"})).unwrap();
rgb.execute("actions.load", json!({"data":gray})).unwrap();
assert_eq!(rgb.actions.list[0].steps[0], ("channel.target".into(), json!({"channel":"composite"})));
assert!(rgb.execute("actions.play", json!({"action":"Pick"})).unwrap().get("failed").is_none());
}
/// Newer Group from Layers recordings put the name outside Usng; existing nested names win.
#[test]
fn mapped_make_group_name_survives_load_and_save() {
for (top, nested, expected) in [(Some("Selected"), None, "Selected"), (None, Some("Nested"), "Nested"), (Some("Top"), Some("Nested"), "Nested")] {
let mut d = desc("null").with("null", class_ref("layerSection")).with("From", target_ref("Lyr "));
if let Some(name) = top {
d = d.with("Nm ", text(name));
}
if let Some(name) = nested {
d = d.with("Usng", D::Descriptor(desc("layerSection").with("Nm ", text(name))));
}
let source = one_action(vec![item("Mk ", "Group from Layers", Some(d))]);
let loaded = import(&atn::write(&source).unwrap()).unwrap();
assert_eq!(loaded.actions[0].steps, vec![("layer.groupLayers".into(), json!({"name": expected}))]);
let (saved, skipped) = export("Groups", &[&loaded.actions[0]], &[], false).unwrap();
assert!(skipped.is_empty());
assert_eq!(import(&saved).unwrap().actions[0].steps, loaded.actions[0].steps);
}
}
/// A recorded New Layer clipping flag must reach the command and survive strict export.
#[test]
fn mapped_make_clipping_flag_survives_load_and_save() {
for clip in [None, Some(false), Some(true)] {
let mut using = desc("Lyr ").with("Nm ", text("Clipped"));
if let Some(clip) = clip {
using = using.with("Grup", D::Boolean(clip));
}
let d = desc("null").with("null", class_ref("Lyr ")).with("Usng", D::Descriptor(using));
let loaded = import(&atn::write(&one_action(vec![item("Mk ", "New Layer", Some(d))])).unwrap()).unwrap();
let mut expected = json!({"name": "Clipped"});
if clip == Some(true) {
expected["clipToPrevious"] = json!(true);
}
assert_eq!(loaded.actions[0].steps, vec![("layer.new.layer".into(), expected)]);
let (saved, skipped) = export("Layers", &[&loaded.actions[0]], &[], false).unwrap();
assert!(skipped.is_empty());
assert_eq!(import(&saved).unwrap().actions[0].steps, loaded.actions[0].steps);
}
}
/// An unreadable clipping flag keeps the original step instead of creating an unclipped layer.
#[test]
fn mapped_make_invalid_clipping_flag_is_kept() {
let using = desc("Lyr ").with("Grup", long(1.0));
let d = desc("null").with("null", class_ref("Lyr ")).with("Usng", D::Descriptor(using));
let loaded = import(&atn::write(&one_action(vec![item("Mk ", "New Layer", Some(d))])).unwrap()).unwrap();
assert_eq!(loaded.actions[0].steps[0].0, PHOTOSHOP_STEP);
}
/// Load a recorded descriptor through the same parser as Load Actions.
fn mapped_recording(event: &str, descriptor: Descriptor) -> Imported {
import(&atn::write(&one_action(vec![item(event, event, Some(descriptor))])).unwrap()).unwrap()
}
/// Every frame anchor survives import/export; unknown anchors stay available verbatim.
#[test]
fn mapped_transform_reference_points_round_trip() {
for (code, reference) in [
("Qcsa", "center"),
("Qcs0", "topLeft"),
("Qcs4", "top"),
("Qcs1", "topRight"),
("Qcs7", "left"),
("Qcs5", "right"),
("Qcs3", "bottomLeft"),
("Qcs6", "bottom"),
("Qcs2", "bottomRight"),
] {
for target in [target_ref("Lyr "), D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }])] {
let d = desc("null")
.with("null", target)
.with("FTcs", enm("QCSt", code))
.with("Wdth", pct(50.0))
.with("Hght", pct(50.0))
.with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(0.0)).with("Vrtc", px(0.0))));
let loaded = mapped_recording("Trnf", d);
let params = &loaded.actions[0].steps[0].1;
assert_eq!(params.get("reference").and_then(Value::as_str).unwrap_or("center"), reference);
let (bytes, _) = export("Anchors", &[&loaded.actions[0]], &[], false).unwrap();
assert_eq!(import(&bytes).unwrap().actions[0].steps, loaded.actions[0].steps);
if reference == "topLeft" && loaded.actions[0].steps[0].0 == "select.transformSelection" {
let mut s = Session::new();
s.execute("file.new", json!({"width":256,"height":256})).unwrap();
s.execute("select.rect", json!({"x":60,"y":60,"width":100,"height":100})).unwrap();
let (id, p) = &loaded.actions[0].steps[0];
s.execute(id, p.clone()).unwrap();
assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(), photocraft_geom::Rect::new(60, 60, 110, 110));
}
}
}
// The reference application never records `Qcs8` or `Qcsi` (independent): kept steps.
for code in ["unknown", "Qcs8", "Qcsi"] {
let d = desc("null").with("null", target_ref("Lyr ")).with("FTcs", enm("QCSt", code)).with("Wdth", pct(50.0));
assert_eq!(mapped_recording("Trnf", d).actions[0].steps[0].0, PHOTOSHOP_STEP, "{code}");
}
// A native anchor saves as the reference application's code; nothing writes `Qcs8`.
for (reference, code) in [("top", "Qcs4"), ("topRight", "Qcs1"), ("bottom", "Qcs6"), ("bottomRight", "Qcs2")] {
let native = Action::new("Native", vec![("edit.transform".into(), json!({"scaleX":50,"scaleY":50,"reference":reference}))]);
let (bytes, _) = export("Anchors", &[&native], &[], false).unwrap();
let saved = atn::parse(&bytes).unwrap();
let d = saved.actions[0].items[0].descriptor.as_ref().unwrap();
assert!(matches!(d.get("FTcs"), Some(D::Enumerated { value, .. }) if value.is(code)), "{reference}: {:?}", d.get("FTcs"));
}
}
/// Alt-clicking an eye twice must isolate and then restore visibility, not just show a layer.
#[test]
fn mapped_show_only_round_trip_and_toggle() {
let d = desc("null").with("null", D::List(vec![name_ref("Lyr ", "Solo")])).with("TglO", D::Boolean(true));
let loaded = mapped_recording("Shw ", d);
assert_eq!(loaded.actions[0].steps, vec![("layer.showOnly".into(), json!({"layer": "Solo"}))]);
let (bytes, _) = export("Eyes", &[&loaded.actions[0]], &[], false).unwrap();
let saved = atn::parse(&bytes).unwrap();
assert_eq!(get_bool(saved.actions[0].items[0].descriptor.as_ref().unwrap(), "TglO"), Some(true));
assert_eq!(import(&bytes).unwrap().actions[0].steps, loaded.actions[0].steps);
let mut s = Session::new();
s.execute("file.new", json!({"width":4,"height":4})).unwrap();
s.execute("layer.new.layer", json!({"name":"Solo"})).unwrap();
let visible = |s: &Session| s.active().unwrap().doc.layers.iter().map(|l| l.visible).collect::<Vec<_>>();
let original = visible(&s);
let (id, params) = &loaded.actions[0].steps[0];
assert_eq!(s.execute(id, params.clone()).unwrap()["shownAlone"], true);
assert_ne!(visible(&s), original);
assert_eq!(s.execute(id, params.clone()).unwrap()["shownAlone"], false);
assert_eq!(visible(&s), original);
let malformed = desc("null").with("null", D::List(vec![name_ref("Lyr ", "Solo"), long(1.0)])).with("TglO", D::Boolean(true));
assert_eq!(mapped_recording("Shw ", malformed).actions[0].steps[0].0, PHOTOSHOP_STEP);
}
/// A layer label is separate from an adjustment's own colour parameter.
#[test]
fn mapped_adjustment_label_survives_export() {
let d = desc("null")
.with("null", class_ref("AdjL"))
.with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(desc("Invr"))).with("Clr ", enm("Clr ", "Rd "))));
let loaded = mapped_recording("Mk ", d);
assert_eq!(loaded.actions[0].steps, vec![("layer.newAdjustmentLayer.invert".into(), json!({})), ("layer.setLabelColor".into(), json!({"color":"red"}))]);
let (bytes, _) = export("Labels", &[&loaded.actions[0]], &[], false).unwrap();
assert_eq!(import(&bytes).unwrap().actions[0].steps, loaded.actions[0].steps);
let mut s = Session::new();
s.execute("file.new", json!({"width":4,"height":4})).unwrap();
for (id, params) in &loaded.actions[0].steps {
s.execute(id, params.clone()).unwrap();
}
let st = s.active().unwrap();
assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().label.id(), "red");
}
/// Keeping an unmapped shape is safer than creating a solid fill over the whole canvas.
#[test]
fn mapped_shape_creation_is_kept_with_its_geometry_and_stroke() {
let d = desc("null").with("null", class_ref("contentLayer")).with("Shp ", D::Descriptor(desc("Rctn").with("Wdth", px(20.0)))).with(
"Usng",
D::Descriptor(
desc("contentLayer")
.with("Type", D::Descriptor(desc("solidColorLayer")))
.with("strokeStyle", D::Descriptor(desc("strokeStyle").with("strokeStyleLineWidth", px(3.0)))),
),
);
let loaded = mapped_recording("Mk ", d.clone());
assert_eq!(loaded.actions[0].steps[0].0, PHOTOSHOP_STEP);
let (bytes, _) = export("Shapes", &[&loaded.actions[0]], &[], false).unwrap();
assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor.as_ref(), Some(&d));
}
/// Copy and Cut retain all recorded layer options, including labels and fractional opacity.
#[test]
fn mapped_copy_cut_layer_options_survive_export() {
for (copy, id) in [(true, "layer.new.layerViaCopy"), (false, "layer.new.layerViaCut")] {
let options = desc("Lyr ").with("Nm ", text("Extracted")).with("Md ", enm("BlnM", "Mltp")).with("Opct", pct(37.5)).with("Clr ", enm("Clr ", "Bl "));
let d = desc("null").with("Nw ", D::Descriptor(options)).with("Usng", enm("ArSl", "Slct")).with("Cpy ", D::Boolean(copy));
let loaded = mapped_recording("Mk ", d);
assert_eq!(
loaded.actions[0].steps,
vec![
(id.into(), json!({})),
("layer.setLabelColor".into(), json!({"color":"blue"})),
("layer.setProps".into(), json!({"name":"Extracted","blend":"Multiply","opacity":0.375}))
]
);
let (bytes, _) = export("Extract", &[&loaded.actions[0]], &[], false).unwrap();
let saved = import(&bytes).unwrap();
assert_eq!(saved.actions[0].steps[..2], loaded.actions[0].steps[..2]);
let mut props = serde_json::Map::new();
for (id, p) in &saved.actions[0].steps[2..] {
assert_eq!(id, "layer.setProps");
props.extend(p.as_object().unwrap().clone());
}
assert_eq!(Value::Object(props), loaded.actions[0].steps[2].1);
let mut s = Session::new();
s.execute("file.new", json!({"width":4,"height":4,"background":"white"})).unwrap();
s.execute("select.rect", json!({"x":0,"y":0,"width":2,"height":2})).unwrap();
for (id, params) in &saved.actions[0].steps {
s.execute(id, params.clone()).unwrap();
}
let st = s.active().unwrap();
let layer = st.doc.layer(st.active_layer.unwrap()).unwrap();
assert_eq!(layer.name, "Extracted");
assert_eq!(layer.opacity, 0.375);
assert_eq!(layer.blend, photocraft_color::BlendMode::Multiply);
assert_eq!(layer.label.id(), "blue");
}
}
/// Loading transparency must keep the source layer instead of using the active layer.
#[test]
fn mapped_named_layer_transparency_survives_export() {
let source = D::Reference(vec![
ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code("Trsp") },
ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul("Source") },
]);
let d = desc("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: code("fsel") }])).with("T ", source);
let loaded = mapped_recording("setd", d);
assert_eq!(loaded.actions[0].steps, vec![("select.loadSelection".into(), json!({"channel":"transparency","layer":"Source"}))]);
let (bytes, _) = export("Transparency", &[&loaded.actions[0]], &[], false).unwrap();
assert_eq!(import(&bytes).unwrap().actions[0].steps, loaded.actions[0].steps);
let mut s = Session::new();
s.execute("file.new", json!({"width":4,"height":4,"background":"white"})).unwrap();
s.execute("layer.setProps", json!({"name":"Source"})).unwrap();
s.execute("layer.new.layer", json!({"name":"Empty"})).unwrap();
let (id, p) = &loaded.actions[0].steps[0];
s.execute(id, p.clone()).unwrap();
assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(), photocraft_geom::Rect::new(0, 0, 4, 4));
}
/// A localized Background and an ordinary layer named Background are distinct targets.
#[test]
fn mapped_background_property_survives_export_and_playback() {
let r = D::Reference(vec![ReferenceItem::Property { class: class("Lyr "), key: code("Bckg") }]);
let loaded = mapped_recording("Hd ", desc("null").with("null", D::List(vec![r.clone()])));
assert_eq!(loaded.actions[0].steps, vec![("layer.hideLayers".into(), json!({"layer":{"background":true}}))]);
let (bytes, _) = export("Background", &[&loaded.actions[0]], &[], false).unwrap();
assert_eq!(get_list(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor.as_ref().unwrap(), "null"), Some(&[r][..]));
let mut s = Session::new();
s.execute("file.new", json!({"width":4,"height":4,"background":"white"})).unwrap();
std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).layers[0].name = "Hintergrund".into();
s.execute("layer.new.layer", json!({"name":"Background"})).unwrap();
let (id, p) = &loaded.actions[0].steps[0];
s.execute(id, p.clone()).unwrap();
let layers = &s.active().unwrap().doc.layers;
assert!(!layers[0].visible);
assert!(layers[1].visible);
s.execute("layer.hideLayers", json!({"layer":"Background"})).unwrap();
assert!(!s.active().unwrap().doc.layers[1].visible);
assert!(s.execute("layer.showLayers", json!({"layer":{"background":false}})).is_err());
s.execute("layer.setLabelColor", json!({"layer":{"background":true},"color":"red"})).unwrap();
assert_eq!(s.active().unwrap().doc.layers[0].label.id(), "red");
s.execute("select.loadSelection", json!({"channel":"transparency","layer":{"background":true}})).unwrap();
assert_eq!(s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(), photocraft_geom::Rect::new(0, 0, 4, 4));
let selected = mapped_recording("slct", desc("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Lyr "), key: code("Bckg") }])));
let (bytes, _) = export("Select Background", &[&selected.actions[0]], &[], false).unwrap();
assert_eq!(import(&bytes).unwrap().actions[0].steps, selected.actions[0].steps);
let (id, p) = &selected.actions[0].steps[0];
s.execute(id, p.clone()).unwrap();
assert_eq!(s.active().unwrap().active_layer, Some(s.active().unwrap().doc.layers[0].id));
let revision = s.active().unwrap().revision;
for selector in [json!({"background":false}), json!({"background":"true"}), json!({"background":true,"extra":0})] {
assert!(s.execute("select.loadSelection", json!({"channel":"transparency","layer":selector})).is_err());
assert_eq!(s.active().unwrap().revision, revision);
}
}
/// An unanswered Close must not turn into an explicit discard or close a changed document.
#[test]
fn mapped_close_keeps_ask_and_discard_distinct() {
let loaded = mapped_recording("Cls ", desc("null"));
assert_eq!(loaded.actions[0].steps, vec![("file.close".into(), json!({"save":"ask"}))]);
let (bytes, _) = export("Close", &[&loaded.actions[0]], &[], false).unwrap();
assert!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor.as_ref().unwrap().get("Svng").is_none());
assert_eq!(import(&bytes).unwrap().actions[0].steps, loaded.actions[0].steps);
let mut s = Session::new();
s.execute("file.new", json!({"width":4,"height":4})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
let (id, p) = &loaded.actions[0].steps[0];
assert!(s.execute(id, p.clone()).is_err());
assert_eq!(s.documents().len(), 1);
s.execute("file.close", json!({"save":"discard"})).unwrap();
assert!(s.documents().is_empty());
s.execute("file.new", json!({"width":4,"height":4})).unwrap();
s.execute(id, p.clone()).unwrap();
assert!(s.documents().is_empty());
}
/// `"save": false` is the older spelling of discard: it closes a changed document and saves as
/// "don't save" (`Svng N`); `true` and other values stay refused.
#[test]
fn close_save_false_discards_and_saves_as_dont_save() {
let mut s = Session::new();
s.execute("file.new", json!({"width":4,"height":4})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
for refused in [json!(true), json!("yes"), json!(0)] {
assert!(s.execute("file.close", json!({"save": refused})).is_err(), "{refused}");
assert_eq!(s.documents().len(), 1);
}
s.execute("file.close", json!({"save": false})).unwrap();
assert!(s.documents().is_empty());
let native = Action::new("Close", vec![("file.close".into(), json!({"save": false}))]);
let (bytes, _) = export("Close", &[&native], &[], false).unwrap();
let d = atn::parse(&bytes).unwrap().actions[0].items[0].descriptor.clone().unwrap();
assert!(matches!(d.get("Svng"), Some(D::Enumerated { type_id, value }) if type_id.is("YsN ") && value.is("N ")), "{:?}", d.get("Svng"));
}
/// Lab Photo Filter colours use the CMS conversion and remain mapped after saving as RGB.
#[test]
fn mapped_lab_photo_filter_imports_and_round_trips() {
for (lab, expected) in
[([0.0, 0.0, 0.0], "#000000"), ([100.0, 0.0, 0.0], "#ffffff"), ([50.0, 0.0, 0.0], "#777777"), ([54.2917, 80.8125, 69.8851], "#ff0000")]
{
let color = desc("LbCl").with("Lmnc", D::Double(lab[0])).with("A ", D::Double(lab[1])).with("B ", D::Double(lab[2]));
let loaded = mapped_recording("photoFilter", desc("null").with("Clr ", D::Descriptor(color)).with("Dnst", long(25.0)));
let (id, params) = &loaded.actions[0].steps[0];
assert_eq!(id, "image.adjustments.photoFilter");
assert_eq!(params["color"], expected);
assert_eq!(params["density"].as_f64(), Some(25.0));
let (bytes, _) = export("Lab", &[&loaded.actions[0]], &[], false).unwrap();
let saved = import(&bytes).unwrap();
assert_eq!(saved.actions[0].steps[0].0, *id);
assert_eq!(saved.actions[0].steps[0].1["color"], expected);
assert_eq!(saved.actions[0].steps[0].1["density"].as_f64(), Some(25.0));
}
}
/// A Photoshop step of one of the newer features, as its event reference records it (#10610).
fn newer(name: &str, d: Descriptor) -> Mapped {
(events::event_for(&AtnEvent::name(name)).unwrap_or_else(|| panic!("{name} is unknown")).from)(&d)
}
fn mapped(m: Mapped) -> (String, Value) {
match m {
Mapped::Steps(mut s) if s.len() == 1 => s.remove(0),
_ => panic!("not one step"),
}
}
/// Oil Paint, Smart Sharpen, Focus Area, Select Sky and Remove Background load with the keys and
/// defaults Photoshop records; Oil Paint's and Select Sky's defaults differ from the commands'
/// and are written out (#10610).
#[test]
fn newer_feature_steps_load_with_photoshops_defaults() {
let oil = desc("null")
.with("lightingOn", D::Boolean(true))
.with("stylization", D::Double(4.0))
.with("cleanliness", D::Double(2.299_999_952_316_28))
.with("brushScale", D::Double(0.800_000_011_920_93))
.with("microBrush", D::Double(10.0))
.with("LghD", D::Integer(-60))
.with("specularity", D::Double(1.299_999_952_316_28));
let expected_oil = json!({"lighting": true, "stylization": 4.0, "cleanliness": 2.299_999_952_316_28, "scale": 0.800_000_011_920_93,
"bristleDetail": 10.0, "angle": -60.0, "shine": 1.299_999_952_316_28});
assert_eq!(mapped(newer("oilPaint", oil.clone())), ("filter.stylize.oilPaint".into(), expected_oil.clone()));
assert_eq!(mapped(newer("oilPaint", desc("null"))).1, expected_oil, "a missing key takes Photoshop's default");
// Smart Sharpen in the form the command plays (the newer filter, removing a Gaussian blur).
let sharpen = desc("null")
.with("presetKind", enm("presetKindType", "presetKindCustom"))
.with("useLegacy", D::Boolean(false))
.with("Amnt", pct(150.0))
.with("Rds ", px(2.5))
.with("noiseReduction", pct(20.0))
.with("blur", enm("blurType", "GsnB"));
assert_eq!(
mapped(newer("smartSharpen", sharpen.clone())),
("filter.sharpen.smartSharpen".into(), json!({"amount": 150.0, "radius": 2.5, "reduceNoise": 20.0}))
);
assert_eq!(
mapped(newer("smartSharpen", gaussian_sharpen(&[]))).1,
json!({"amount": 200.0, "radius": 1.0, "reduceNoise": 10.0}),
"a missing amount, radius or noise reduction takes Photoshop's default"
);
assert_eq!(mapped(newer("selectSky", desc("null"))), ("select.sky".into(), json!({"sampleAllLayers": false})));
assert_eq!(mapped(newer("focusMask", desc("null"))), ("select.focusArea".into(), json!({})));
assert_eq!(mapped(newer("removeBackground", desc("null"))), ("layer.removeBackground".into(), json!({})));
// Written back, they carry the same keys in the same order.
let back = |id: &str, p: Value| export_item(id, &p, "S").unwrap_or_else(|| panic!("{id} does not export")).descriptor.unwrap();
assert_eq!(back("filter.stylize.oilPaint", expected_oil).items, oil.items);
assert_eq!(back("filter.sharpen.smartSharpen", json!({"amount": 150, "radius": 2.5, "reduceNoise": 20})).items, sharpen.items);
let (id, params) = mapped(newer("smartSharpen", sharpen.clone()));
assert_eq!(back(&id, params).items, sharpen.items, "load -> save changed the step");
// Oil Paint and Smart Sharpen play.
let mut s = Session::new();
s.execute("file.new", json!({"width": 24, "height": 24})).unwrap();
s.execute("filter.render.clouds", json!({})).unwrap();
let action = Action::new(
"Paint",
vec![
("filter.stylize.oilPaint".into(), mapped(newer("oilPaint", desc("null"))).1),
("filter.sharpen.smartSharpen".into(), mapped(newer("smartSharpen", gaussian_sharpen(&[]))).1),
],
);
s.actions.list.push(action);
let played = s.execute("actions.play", json!({"action": "Paint"})).unwrap();
assert_eq!(played["ran"], 2, "{played}");
}
/// What the commands cannot do keeps the Photoshop step instead of playing something else
/// (#10610).
#[test]
fn newer_feature_steps_the_commands_cannot_play_are_kept() {
let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false });
// Photoshop's own default step: the legacy filter removing Lens Blur, which the command's
// Gaussian sharpen does not play.
let legacy = desc("null")
.with("presetKind", enm("presetKindType", "presetKindCustom"))
.with("useLegacy", D::Boolean(true))
.with("Amnt", pct(200.0))
.with("Rds ", px(1.0))
.with("moreAccurate", D::Boolean(false))
.with("blur", enm("blurType", "lensBlur"));
assert!(kept(newer("smartSharpen", legacy)));
assert!(kept(newer("smartSharpen", desc("null"))), "an empty step is the legacy filter");
assert!(kept(newer("smartSharpen", desc("null").with("useLegacy", D::Boolean(false)))), "no blur recorded is Lens Blur");
assert!(kept(newer("smartSharpen", desc("null").with("blur", enm("blurType", "GsnB")))), "no useLegacy recorded is the legacy filter");
for blur in ["lensBlur", "MtnB", "motionBlur"] {
assert!(kept(newer("smartSharpen", gaussian_sharpen(&[("blur", enm("blurType", blur))]))), "{blur}");
}
assert!(kept(newer("smartSharpen", gaussian_sharpen(&[("useLegacy", D::Boolean(true))]))));
assert!(kept(newer("smartSharpen", gaussian_sharpen(&[("blur", enm("Ordn", "GsnB"))]))), "a blur of another type");
assert!(kept(newer("smartSharpen", gaussian_sharpen(&[("moreAccurate", D::Boolean(false))]))));
assert!(kept(newer("smartSharpen", gaussian_sharpen(&[("presetKind", enm("presetKindType", "presetKindDefault"))]))));
assert!(kept(newer("focusMask", desc("null").with("Rng ", D::Double(3.0)))));
assert!(kept(newer("removeBackground", desc("null").with("Lyr ", D::Integer(1)))));
assert!(kept(newer("selectSky", desc("null").with("smartRadius", D::Boolean(true)))));
assert!(kept(newer("oilPaint", desc("null").with("Angl", D::Integer(3)))));
}
/// Settings the events have no key for refuse the step rather than replay differently (#10610).
#[test]
fn newer_feature_steps_that_cannot_be_written_are_refused() {
for (id, params) in [
("filter.sharpen.smartSharpen", json!({"amount": 150, "blur": "lens"})),
("filter.sharpen.smartSharpen", json!({"target": "mask"})),
("filter.stylize.oilPaint", json!({"angle": 10.5})),
("filter.stylize.oilPaint", json!({"target": "mask"})),
("select.focusArea", json!({"range": 0.3})),
("select.sky", json!({"mode": "add"})),
("select.sky", json!({"threshold": 70})),
("layer.removeBackground", json!({"layer": 3})),
] {
assert!(export_item(id, &params, "S").is_none(), "{id} {params} exported");
}
assert!(export_item("select.sky", &json!({"mode": "replace", "threshold": 50, "softness": 50}), "S").is_some());
}
/// A Smart Sharpen step in the form the command plays (`useLegacy` false, `blur` `GsnB`) plus
/// `settings`, which replace those two when they name them.
fn gaussian_sharpen(settings: &[(&str, D)]) -> Descriptor {
let named = |k: &str| settings.iter().any(|(key, _)| key.trim_end() == k);
let mut d = desc("null");
if !named("useLegacy") {
d = d.with("useLegacy", D::Boolean(false));
}
if !named("blur") {
d = d.with("blur", enm("blurType", "GsnB"));
}
settings.iter().fold(d, |d, (k, v)| d.with(k, v.clone()))
}
/// `kept_when_wrong_typed` for Smart Sharpen, on the form it would otherwise map.
fn sharpen_kept_when(settings: &[(&str, D)]) {
for (key, v) in settings {
let m = newer("smartSharpen", gaussian_sharpen(&[(key, v.clone())]));
assert!(matches!(m, Mapped::Unsupported { harmless: false }), "smartSharpen with {key:?} = {v:?} was mapped");
}
}
/// A recorded setting of the wrong type is the step's own setting, not a missing one: the
/// mapper must not play it with a default but keep the Photoshop step (#10610).
fn kept_when_wrong_typed(name: &str, settings: &[(&str, D)]) {
for (key, v) in settings {
let m = newer(name, desc("null").with(key, v.clone()));
assert!(matches!(m, Mapped::Unsupported { harmless: false }), "{name} with {key:?} = {v:?} was mapped");
}
}
#[test]
fn select_sky_with_a_wrong_typed_setting_is_kept() {
kept_when_wrong_typed("selectSky", &[("sampleAllLayers", D::Integer(1)), ("sampleAllLayers", text("true"))]);
}
#[test]
fn oil_paint_with_a_wrong_typed_setting_is_kept() {
kept_when_wrong_typed(
"oilPaint",
&[
("lightingOn", D::Integer(0)),
("stylization", D::Boolean(true)),
("cleanliness", text("5")),
("brushScale", enm("Ordn", "Trgt")),
("microBrush", D::Double(f64::NAN)),
("LghD", text("45")),
("specularity", D::Boolean(false)),
],
);
}
#[test]
fn smart_sharpen_with_a_wrong_typed_setting_is_kept() {
sharpen_kept_when(&[
("presetKind", text("presetKindDefault")),
("useLegacy", D::Integer(0)),
("blur", text("GsnB")),
("Amnt", D::Boolean(true)),
("Rds ", text("2")),
("Rds", text("2")),
("noiseReduction", text("10")),
]);
}
/// `only_keys` admits a code without its space padding, so the mapper reads it too: an
/// unpadded `Rds` radius is the step's radius, not the default 1 (#10610).
#[test]
fn smart_sharpen_reads_an_unpadded_radius() {
let d = gaussian_sharpen(&[("Amnt", pct(150.0)), ("Rds", px(2.5))]);
assert_eq!(mapped(newer("smartSharpen", d)), ("filter.sharpen.smartSharpen".into(), json!({"amount": 150.0, "radius": 2.5, "reduceNoise": 10.0})));
}
/// A step recording one of its keys twice, padded or not, can't be saved back with both values,
/// so it stays a Photoshop step instead of playing the first and dropping the second (#10610).
#[test]
fn newer_feature_steps_with_a_repeated_key_are_kept() {
let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false });
let mapped = [
("smartSharpen", "Amnt", "Amnt", pct(150.0), pct(250.0)),
("smartSharpen", "Rds ", "Rds ", px(2.0), px(3.0)),
("smartSharpen", "Rds ", "Rds", px(2.0), px(3.0)),
("smartSharpen", "Rds", "Rds", px(2.0), px(2.0)),
("smartSharpen", "presetKind", "presetKind", enm("presetKindType", "presetKindCustom"), enm("presetKindType", "presetKindCustom")),
("smartSharpen", "useLegacy", "useLegacy", D::Boolean(false), D::Boolean(false)),
("smartSharpen", "noiseReduction", "noiseReduction", pct(10.0), pct(20.0)),
("smartSharpen", "blur", "blur", enm("blurType", "GsnB"), enm("blurType", "GsnB")),
("oilPaint", "lightingOn", "lightingOn", D::Boolean(true), D::Boolean(false)),
("oilPaint", "stylization", "stylization", D::Double(4.0), D::Double(6.0)),
("oilPaint", "cleanliness", "cleanliness", D::Double(2.0), D::Double(3.0)),
("oilPaint", "brushScale", "brushScale", D::Double(1.0), D::Double(2.0)),
("oilPaint", "microBrush", "microBrush", D::Double(5.0), D::Double(7.0)),
("oilPaint", "LghD", "LghD", D::Integer(-60), D::Integer(45)),
("oilPaint", "specularity", "specularity", D::Double(1.0), D::Double(2.0)),
("selectSky", "sampleAllLayers", "sampleAllLayers", D::Boolean(false), D::Boolean(true)),
]
.into_iter()
.filter(|(name, key, again, a, b)| {
let d = if *name == "smartSharpen" {
gaussian_sharpen(&[(key, a.clone()), (again, b.clone())])
} else {
desc("null").with(key, a.clone()).with(again, b.clone())
};
!kept(newer(name, d))
})
.map(|(name, key, again, ..)| format!("{name} {key:?} {again:?}"))
.collect::<Vec<_>>();
assert!(mapped.is_empty(), "mapped with a repeated key: {mapped:?}");
}
/// Oil Paint records its settings as plain doubles and the light direction as an integer, and
/// saves them back so: a value with a unit, or of the other number type, would load as a bare
/// number and save back changed, so the step stays a Photoshop step (#10610).
#[test]
fn oil_paint_setting_with_a_unit_or_another_number_type_is_kept() {
kept_when_wrong_typed(
"oilPaint",
&[
("stylization", pct(4.0)),
("cleanliness", px(2.0)),
("brushScale", D::UnitFloat { unit: *b"#Rlt", value: 0.8 }),
("microBrush", D::Integer(10)),
("LghD", ang(-60.0)),
("LghD", D::Double(-60.5)),
("specularity", D::UnitFloat { unit: *b"#Prc", value: 1.3 }),
],
);
}
/// Smart Sharpen records `Amnt` in `#Prc` and its radius in `#Pxl`, and saves them back so:
/// another unit, or none, would load as that number and save back in the other unit, so the
/// step stays a Photoshop step, whether the radius key is padded or not (#10610).
#[test]
fn smart_sharpen_amount_or_radius_in_another_unit_is_kept() {
sharpen_kept_when(&[
("Amnt", px(150.0)),
("Amnt", D::Double(150.0)),
("Amnt", D::Integer(150)),
("Rds ", pct(2.0)),
("Rds ", D::Double(2.0)),
("Rds", D::UnitFloat { unit: *b"#Mlm", value: 2.0 }),
("Rds", D::Double(2.0)),
("noiseReduction", px(10.0)),
("noiseReduction", D::Double(10.0)),
]);
for rds in ["Rds ", "Rds"] {
let d = gaussian_sharpen(&[("Amnt", pct(150.0)), (rds, px(2.5))]);
assert_eq!(
mapped(newer("smartSharpen", d)),
("filter.sharpen.smartSharpen".into(), json!({"amount": 150.0, "radius": 2.5, "reduceNoise": 10.0})),
"{rds:?}"
);
}
}
/// `filter.sharpen.smartSharpen` takes an amount of 1..500 %, a radius of 0.1..64 px and a noise
/// reduction of 0..100 %. Photoshop clamps a recorded value outside them when the step plays,
/// so the import clamps it too; a step outside them is not written (#10610).
#[test]
fn smart_sharpen_outside_the_commands_limits_is_clamped_on_import_and_refused_on_export() {
for (key, v, param, want) in [
("Amnt", pct(0.5), "amount", 1.0),
("Amnt", pct(500.5), "amount", 500.0),
("Rds ", px(0.05), "radius", 0.1),
("Rds ", px(64.5), "radius", 64.0),
("Rds", px(1000.0), "radius", 64.0),
("noiseReduction", pct(-5.0), "reduceNoise", 0.0),
("noiseReduction", pct(150.0), "reduceNoise", 100.0),
] {
let (id, p) = mapped(newer("smartSharpen", gaussian_sharpen(&[(key, v.clone())])));
assert_eq!((id.as_str(), &p[param]), ("filter.sharpen.smartSharpen", &json!(want)), "{key:?} = {v:?}");
}
for (amount, radius, noise) in [(1.0, 0.1, 0.0), (500.0, 64.0, 100.0)] {
let d = gaussian_sharpen(&[("Amnt", pct(amount)), ("Rds ", px(radius)), ("noiseReduction", pct(noise))]);
assert_eq!(
mapped(newer("smartSharpen", d)),
("filter.sharpen.smartSharpen".into(), json!({"amount": amount, "radius": radius, "reduceNoise": noise})),
"{amount} {radius}"
);
}
for params in [
json!({"amount": 0.5}),
json!({"amount": 501}),
json!({"radius": 0.05}),
json!({"radius": 65}),
json!({"reduceNoise": -1}),
json!({"reduceNoise": 101}),
] {
assert!(export_item("filter.sharpen.smartSharpen", &params, "S").is_none(), "{params} exported");
}
}
/// Focus Area, Select Sky and Remove Background, mapped from the empty steps Photoshop records,
/// play and do their work: on a textured red disc against a smooth blue sky over textured ground,
/// Focus Area selects the textured ground, Select Sky the sky, and Remove Background masks the
/// layer to the disc. Each step plays on its own, with nothing selected, so a step that runs
/// without finding anything, or that works from the selection left by the step before, fails
/// (#10610).
#[test]
fn newer_feature_selection_steps_play() {
let (w, h) = (160u32, 100u32);
let mut s = Session::new();
s.execute("file.new", json!({"width": w, "height": h})).unwrap();
s.edit("paint", |doc, _| {
let bg = doc.layers[0].surface_mut().unwrap();
let n = bg.channels();
let mut data = Vec::with_capacity((w * h) as usize * n);
for y in 0..h {
for x in 0..w {
let nz = (((x * 7919 + y * 104_729) % 97) as f32 / 97.0 - 0.5) * 0.25;
let (dx, dy) = (x as f32 - 80.0, y as f32 - 40.0);
let c = if dx * dx + dy * dy <= 22.0 * 22.0 {
[0.85 + nz * 0.5, 0.2 + nz, 0.15]
} else if y < 60 {
let t = y as f32 / 60.0;
[0.25 + 0.4 * t, 0.5 + 0.3 * t, 0.9 + 0.05 * t]
} else {
[0.35 + nz, 0.3 + nz, 0.15]
};
data.extend_from_slice(&c.map(|v| v.clamp(0.0, 1.0))[..n.min(3)]);
if n == 4 {
data.push(1.0);
}
}
}
bg.write_region(photocraft_geom::Rect::new(0, 0, w as i32, h as i32), &data);
Ok(())
})
.unwrap();
let play = |s: &mut Session, name: &str| {
s.actions.list.push(Action::new(name, vec![mapped(newer(name, desc("null")))]));
let played = s.execute("actions.play", json!({"action": name})).unwrap();
assert!(played.get("failed").is_none(), "{name}: {played}");
assert_eq!(played["ran"], 1, "{name}: {played}");
};
let selected = |s: &Session, x: i32, y: i32| s.active().unwrap().doc.selection.as_ref().map(|m| m.sample_channel(x, y, 0));
play(&mut s, "focusMask");
assert!(selected(&s, 20, 85).is_some_and(|v| v > 0.5), "Focus Area left the textured ground out");
assert!(selected(&s, 20, 15).is_some_and(|v| v < 0.5), "Focus Area took the smooth sky");
s.execute("select.deselect", json!({})).unwrap();
play(&mut s, "selectSky");
assert!(selected(&s, 20, 15).is_some_and(|v| v > 0.9), "Select Sky left the sky out");
assert!(selected(&s, 20, 85).is_some_and(|v| v < 0.1), "Select Sky took the ground");
s.execute("select.deselect", json!({})).unwrap();
assert!(s.active().unwrap().doc.selection.is_none(), "Remove Background would play with the sky selected");
play(&mut s, "removeBackground");
let mask = s.active().unwrap().doc.layers[0].mask.as_ref().expect("Remove Background set no mask");
assert!(mask.value(80, 40) > 0.8, "Remove Background hid the red disc");
assert!(mask.value(20, 15) < 0.2, "Remove Background revealed the sky");
}
/// `filter.stylize.oilPaint` takes a stylization and scale of 0.1..10, a cleanliness, bristle
/// detail and shine of 0..10 and a light direction of -180..180. Photoshop clamps a recorded
/// value outside them when the step plays, so the import clamps it too; a step outside them is
/// not written (#10610).
#[test]
fn oil_paint_outside_the_commands_ranges_is_clamped_on_import_and_refused_on_export() {
for (key, v, param, want) in [
("stylization", D::Double(0.05), "stylization", 0.1),
("stylization", D::Double(10.5), "stylization", 10.0),
("cleanliness", D::Double(-0.5), "cleanliness", 0.0),
("cleanliness", D::Double(10.5), "cleanliness", 10.0),
("brushScale", D::Double(0.05), "scale", 0.1),
("brushScale", D::Double(10.5), "scale", 10.0),
("microBrush", D::Double(-1.0), "bristleDetail", 0.0),
("microBrush", D::Double(10.5), "bristleDetail", 10.0),
("specularity", D::Double(-0.5), "shine", 0.0),
("specularity", D::Double(10.5), "shine", 10.0),
("LghD", D::Integer(500), "angle", 180.0),
("LghD", D::Integer(-500), "angle", -180.0),
("LghD", D::Integer(181), "angle", 180.0),
] {
let (id, p) = mapped(newer("oilPaint", desc("null").with(key, v.clone())));
assert_eq!((id.as_str(), &p[param]), ("filter.stylize.oilPaint", &json!(want)), "{key:?} = {v:?}");
}
for (low, high) in [(0.1, 0.0), (10.0, 10.0)] {
let d = desc("null")
.with("stylization", D::Double(low))
.with("cleanliness", D::Double(high))
.with("brushScale", D::Double(low))
.with("microBrush", D::Double(high))
.with("specularity", D::Double(high));
assert_eq!(
mapped(newer("oilPaint", d)),
(
"filter.stylize.oilPaint".into(),
json!({"lighting": true, "stylization": low, "cleanliness": high, "scale": low, "bristleDetail": high, "angle": -60.0, "shine": high})
),
"{low} {high}"
);
}
for params in [
json!({"stylization": 0.05}),
json!({"stylization": 10.5}),
json!({"cleanliness": -0.5}),
json!({"cleanliness": 10.5}),
json!({"scale": 0.05}),
json!({"scale": 10.5}),
json!({"bristleDetail": -1}),
json!({"bristleDetail": 11}),
json!({"shine": -0.5}),
json!({"shine": 10.5}),
json!({"angle": 181}),
json!({"angle": -181}),
json!({"angle": 1000}),
] {
assert!(export_item("filter.stylize.oilPaint", &params, "S").is_none(), "{params} exported");
}
}
/// The step `d` of the Photoshop event `code` (a four-character code), as PhotoCraft loads it.
fn other_step(code: &str, d: Descriptor) -> Mapped {
let event = AtnEvent::Code(code.as_bytes().try_into().unwrap());
(events::event_for(&event).unwrap_or_else(|| panic!("{code} is unknown")).from)(&d)
}
/// Photoshop's De-Interlace, NTSC Colors, HSB/HSL, Custom, Average and Remove Black / White
/// Matte steps load as their commands, play, and save back with the same keys (#10607).
#[test]
fn video_other_and_matting_steps_load_play_and_save_back() {
let cells: Vec<D> = (0..25)
.map(|i| {
D::Integer(if i == 12 {
5
} else if [7, 11, 13, 17].contains(&i) {
-1
} else {
0
})
})
.collect();
let kernel: Vec<i32> = (0..25)
.map(|i| {
if i == 12 {
5
} else if [7, 11, 13, 17].contains(&i) {
-1
} else {
0
}
})
.collect();
let cases = [
(
"Dntr",
desc("null").with("IntE", enm("IntE", "ElmE")).with("IntC", enm("IntC", "CrtD")),
"filter.video.deInterlace",
json!({"eliminate": "evenFields", "createBy": "duplication"}),
),
("NTSC", desc("null"), "filter.video.ntscColors", json!({})),
(
"HsbP",
desc("null").with("Inpt", enm("ClrS", "RGBC")).with("Otpt", enm("ClrS", "HSLC")),
"filter.other.hsbHsl",
json!({"inputMode": "rgb", "rowOrder": "hsl"}),
),
(
"Cstm",
desc("null").with("Scl ", D::Integer(1)).with("Ofst", D::Integer(0)).with("Mtrx", D::List(cells)),
"filter.other.custom",
json!({"kernel": kernel, "scale": 1, "offset": 0}),
),
("Avrg", desc("null"), "filter.blur.average", json!({})),
("RmvB", desc("null"), "layer.matting.removeBlackMatte", json!({})),
("RmvW", desc("null"), "layer.matting.removeWhiteMatte", json!({})),
];
let mut s = Session::new();
s.execute("file.new", json!({"width": 24, "height": 24, "background": "transparent"})).unwrap();
s.execute("filter.render.clouds", json!({})).unwrap();
for (code, d, id, params) in cases {
let Mapped::Steps(steps) = other_step(code, d.clone()) else { panic!("{code} kept") };
let mut p = steps[0].1.clone();
// Numbers come back as the descriptor's integers.
if let Some(k) = p.get_mut("kernel") {
*k = json!(k.as_array().unwrap().iter().map(|v| v.as_f64().unwrap() as i32).collect::<Vec<_>>());
}
for k in ["scale", "offset"] {
if let Some(v) = p.get_mut(k) {
*v = json!(v.as_f64().unwrap() as i32);
}
}
assert_eq!((steps[0].0.as_str(), &p), (id, &params), "{code}");
assert_eq!(export_item(id, &steps[0].1, "S").unwrap().descriptor.unwrap().items, d.items, "{code}");
s.actions.list.push(Action::new(code, vec![(id.to_string(), steps[0].1.clone())]));
let played = s.execute("actions.play", json!({"action": code})).unwrap();
assert_eq!(played["ran"], 1, "{code}: {played}");
}
// Missing keys are Photoshop's defaults.
let Mapped::Steps(steps) = other_step("Dntr", desc("null")) else { panic!("kept") };
assert_eq!(steps[0].1, json!({"eliminate": "oddFields", "createBy": "interpolation"}));
let Mapped::Steps(steps) = other_step("HsbP", desc("null")) else { panic!("kept") };
assert_eq!(steps[0].1, json!({"inputMode": "rgb", "rowOrder": "hsb"}));
}
/// A step the commands cannot play keeps the Photoshop step: a Custom matrix that is not 5 × 5,
/// an unknown enumeration value, or a key the event does not have (#10607).
#[test]
fn video_other_and_matting_steps_the_commands_cannot_play_are_kept() {
let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false });
let nine = D::List((0..9).map(D::Integer).collect());
assert!(kept(other_step("Cstm", desc("null").with("Mtrx", nine))));
assert!(kept(other_step("Cstm", desc("null"))));
assert!(kept(other_step("HsbP", desc("null").with("Inpt", enm("ClrS", "LbCl")))));
assert!(kept(other_step("Dntr", desc("null").with("IntE", enm("IntE", "ElmA")))));
assert!(kept(other_step("NTSC", desc("null").with("Lvl ", D::Integer(1)))));
assert!(kept(other_step("RmvW", desc("null").with("Lyr ", D::Integer(1)))));
}
/// What the events cannot hold refuses the step: a fractional kernel cell, a kernel that is not
/// 25 cells, a mask target, an unknown mode (#10607).
#[test]
fn video_other_and_matting_steps_that_cannot_be_written_are_refused() {
let mut cells = vec![json!(0); 25];
cells[12] = json!(1.5);
for (id, params) in [
("filter.other.custom", json!({"kernel": cells})),
("filter.other.custom", json!({"kernel": [0, 0, 0, 0, 1, 0, 0, 0, 0]})),
("filter.other.custom", json!({"scale": 2.5})),
("filter.blur.average", json!({"target": "mask"})),
("filter.other.hsbHsl", json!({"inputMode": "lab"})),
("filter.video.deInterlace", json!({"eliminate": "allFields"})),
("filter.other.custom", json!([1])),
("filter.blur.average", json!(5)),
("filter.other.hsbHsl", json!("hsl")),
] {
assert!(export_item(id, &params, "S").is_none(), "{id} {params} exported");
}
// `null` params set nothing.
assert!(export_item("filter.blur.average", &Value::Null, "S").is_some());
// A Custom step without a kernel is the command's identity kernel.
let d = export_item("filter.other.custom", &json!({}), "S").unwrap().descriptor.unwrap();
let cells = get_list(&d, "Mtrx").unwrap();
assert_eq!((cells.len(), cells.get(12)), (25, Some(&D::Integer(1))));
}
/// A recorded setting of the wrong type is the step's own setting, not a missing one: the
/// mapper keeps the Photoshop step instead of playing it with Photoshop's default (#10607).
#[test]
fn video_other_steps_with_a_wrong_typed_setting_are_kept() {
let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false });
let identity = D::List((0..25).map(|i| D::Integer(i32::from(i == 12))).collect());
for (code, d) in [
("Dntr", desc("null").with("IntE", D::Integer(1))),
("Dntr", desc("null").with("IntC", text("CrtD"))),
("HsbP", desc("null").with("Inpt", D::Boolean(true))),
("HsbP", desc("null").with("Otpt", text("HSLC"))),
("Cstm", desc("null").with("Mtrx", identity.clone()).with("Scl ", text("2"))),
("Cstm", desc("null").with("Mtrx", identity.clone()).with("Ofst", D::Boolean(true))),
("Cstm", desc("null").with("Mtrx", identity.clone()).with("Scl", enm("Ordn", "Trgt"))),
] {
assert!(kept(other_step(code, d.clone())), "{code} {d:?} was mapped");
}
}
/// The key gate admits `Scl` without its padding, so the mapper reads it too: an unpadded
/// scale is the step's scale, not the default 1.
#[test]
fn custom_reads_an_unpadded_scale() {
let identity = D::List((0..25).map(|i| D::Integer(i32::from(i == 12))).collect());
let Mapped::Steps(steps) = other_step("Cstm", desc("null").with("Scl", D::Integer(3)).with("Mtrx", identity)) else { panic!("kept") };
assert_eq!(steps[0].1["scale"], json!(3.0));
}
/// `filter.other.custom` plays a scale of 1..9999, an offset of -9999..9999 and cells of
/// -999..999, and clamps anything else: `Scl 0` would play as 1 and save back as 0. Such a step
/// stays a Photoshop step, as does a matrix of more than 25 cells, and export refuses those
/// values instead of writing what the command doesn't play (#10607).
#[test]
fn custom_outside_the_commands_range_is_kept_and_refused() {
let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false });
let kernel = |cell: i32| D::List((0..25).map(|i| D::Integer(if i == 12 { cell } else { 0 })).collect());
let identity = kernel(1);
for d in [
desc("null").with("Mtrx", identity.clone()).with("Scl ", D::Integer(0)),
desc("null").with("Mtrx", identity.clone()).with("Scl ", D::Integer(10_000)),
desc("null").with("Mtrx", identity.clone()).with("Ofst", D::Integer(-10_000)),
desc("null").with("Mtrx", identity.clone()).with("Ofst", D::Integer(10_000)),
desc("null").with("Mtrx", kernel(1000)),
desc("null").with("Mtrx", kernel(-1000)),
desc("null").with("Mtrx", D::List((0..26).map(|_| D::Integer(0)).collect())),
desc("null").with("Mtrx", D::List((0..1000).map(|_| D::Integer(0)).collect())),
// Fractions: export writes integers, so Save Actions would refuse the mapped step.
desc("null").with("Mtrx", identity.clone()).with("Scl ", D::Double(1.5)),
desc("null").with("Mtrx", identity.clone()).with("Ofst", D::Double(2.5)),
desc("null").with("Mtrx", D::List((0..25).map(|i| if i == 12 { D::Double(0.5) } else { D::Integer(0) }).collect())),
] {
assert!(kept(other_step("Cstm", d.clone())), "{:?} was mapped", d.items);
}
for (scale, offset, cell) in [(1, -9999, -999), (9999, 9999, 999)] {
let d = desc("null").with("Scl ", D::Integer(scale)).with("Ofst", D::Integer(offset)).with("Mtrx", kernel(cell));
let Mapped::Steps(steps) = other_step("Cstm", d) else { panic!("{scale} {offset} {cell} kept") };
assert_eq!(
(&steps[0].1["scale"], &steps[0].1["offset"], &steps[0].1["kernel"][12]),
(&json!(f64::from(scale)), &json!(f64::from(offset)), &json!(f64::from(cell)))
);
}
// An integral Double is a whole number and still maps.
let Mapped::Steps(steps) = other_step("Cstm", desc("null").with("Mtrx", identity.clone()).with("Scl ", D::Double(2.0))) else { panic!("Scl 2.0 kept") };
assert_eq!(steps[0].1["scale"], json!(2.0));
let mut cells = vec![json!(0); 25];
cells[12] = json!(1000);
for params in [json!({"scale": 0}), json!({"scale": 10_000}), json!({"offset": -10_000}), json!({"offset": 10_000}), json!({"kernel": cells})] {
assert!(export_item("filter.other.custom", &params, "S").is_none(), "{params} exported");
}
}
/// A local absolute map path on this platform: playback may read only such a path.
#[cfg(windows)]
const LOCAL_MAP: &str = r"C:\máp.psd";
#[cfg(not(windows))]
const LOCAL_MAP: &str = "/máp.psd";
/// [`LOCAL_MAP`] as a `txtu` path, encoded independently of the production path writer.
#[cfg(windows)]
const LOCAL_MAP_WIRE: &[u8] = b"txtu\x22\0\0\0\x0b\0\0\0C\0:\0\\\0m\0\xe1\0p\0.\0p\0s\0d\0\0\0";
#[cfg(not(windows))]
const LOCAL_MAP_WIRE: &[u8] = b"txtu\x1e\0\0\0\x09\0\0\0/\0m\0\xe1\0p\0.\0p\0s\0d\0\0\0";
/// The selected-map descriptor measured in a Windows Displace recording.
fn displace_descriptor(path: D) -> Descriptor {
desc("null")
.with("HrzS", D::Integer(10))
.with("VrtS", D::Integer(10))
.with("DspM", enm("DspM", "StrF"))
.with("UndA", enm("UndA", "RptE"))
.with("DspF", path)
}
/// Preserve the typed path and materialize native defaults when saving sparse steps.
#[test]
fn recorded_displace_round_trips_text_event_and_typed_path() {
let descriptor = displace_descriptor(D::Path(LOCAL_MAP_WIRE.to_vec()));
let data = set_of("Maps", vec![("A", vec![item("displace", "Displace", Some(descriptor.clone()))])]);
let imported = import(&photocraft_paint::tile::b64_decode(&data).unwrap()).unwrap();
assert!(imported.unsupported.is_empty());
let (id, params) = &imported.actions[0].steps[0];
assert_eq!(id, "filter.distort.displace");
assert_eq!(*params, json!({"horizontal":10,"vertical":10,"fit":"stretch","undefinedAreas":"repeat","mapPath":LOCAL_MAP}));
let exported = export_item(id, params, "Maps").unwrap();
assert_eq!(exported.event, AtnEvent::name("displace"));
assert_eq!(exported.descriptor, Some(descriptor.clone()));
let minimal = json!({"mapPath":LOCAL_MAP});
assert_eq!(export_item(id, &minimal, "Maps").unwrap().descriptor, Some(descriptor.clone()));
for key in ["horizontal", "vertical", "fit", "undefinedAreas"] {
let mut sparse = params.clone();
sparse.as_object_mut().unwrap().remove(key);
assert_eq!(export_item(id, &sparse, "Maps").unwrap().descriptor, Some(descriptor.clone()), "{key}");
}
let custom = json!({"horizontal":-7,"mapPath":LOCAL_MAP});
let mut expected = descriptor;
expected.items[0].1 = D::Integer(-7);
assert_eq!(export_item(id, &custom, "Maps").unwrap().descriptor, Some(expected));
}
/// A shared action file must not make playback open a network share, a device or a path taken
/// from the working directory: such a map path stays a kept step with its bytes.
#[test]
fn displace_map_paths_a_document_may_not_name_stay_kept_steps() {
let refused = [r"\\server\share\map.psd", "//server/share/map.psd", r"\\?\UNC\server\share\map.psd", r"\\.\COM1", "file://server/share/map.psd", "map.psd"];
let cases: Vec<_> = refused.iter().map(|path| item("displace", "Displace", Some(displace_descriptor(path_value(path))))).collect();
let data = set_of("Maps", vec![("A", cases.clone())]);
let imported = import(&photocraft_paint::tile::b64_decode(&data).unwrap()).unwrap();
assert_eq!(imported.unsupported.len(), cases.len());
for ((id, params), expected) in imported.actions[0].steps.iter().zip(cases) {
assert_eq!(id, PHOTOSHOP_STEP, "{params}");
assert_eq!(export_item(id, params, "Maps").unwrap().descriptor, expected.descriptor);
}
}
/// Keep unmeasured inputs intact and refuse settings that would be changed by export.
#[test]
fn unmeasured_displace_stays_opaque_and_lossy_export_is_refused() {
assert!(!super::menu_item_commands().contains(&"filter.distort.displace"));
let path = r"C:\map.psd";
let wire = path_value(path);
let descriptor = displace_descriptor(wire.clone());
let replace = |key: &str, value: D| {
let mut d = descriptor.clone();
d.items.iter_mut().find(|(id, _)| id.is(key)).unwrap().1 = value;
d
};
let alter_path = |i: usize, pair: [u8; 2]| {
let D::Path(mut bytes) = wire.clone() else { panic!("expected a path") };
bytes[i..i + 2].copy_from_slice(&pair);
D::Path(bytes)
};
let mut without_path = descriptor.clone();
without_path.items.retain(|(id, _)| !id.is("DspF"));
let mut string_key = descriptor.clone();
string_key.items[0].0 = Id::Str(b"HrzS".to_vec());
let cases = [
item("displace", "Displace", None),
item("displace", "Displace", Some(without_path)),
item("displace", "Displace", Some(string_key)),
item("displace", "Displace", Some(descriptor.clone().with("HrzS", D::Integer(10)))),
item("displace", "Displace", Some(replace("HrzS", D::Double(10.0)))),
item("displace", "Displace", Some(replace("VrtS", D::Integer(1000)))),
item("displace", "Displace", Some(replace("DspM", enm("DspM", "Tile")))),
item("displace", "Displace", Some(replace("UndA", enm("OtherEnum", "RptE")))),
item("displace", "Displace", Some(replace("DspF", D::Alias(vec![1, 2, 3])))),
item("displace", "Displace", Some(replace("DspF", alter_path(4, [0, 0])))),
item("displace", "Displace", Some(replace("DspF", alter_path(8, [9, 0])))),
item("displace", "Displace", Some(replace("DspF", alter_path(12, [0, 0])))),
item("displace", "Displace", Some(replace("DspF", alter_path(12, [0, 0xd8])))),
item("Dspl", "Displace", Some(descriptor)),
];
let data = set_of("Maps", vec![("A", cases.to_vec())]);
let imported = import(&photocraft_paint::tile::b64_decode(&data).unwrap()).unwrap();
assert_eq!(imported.unsupported.len(), cases.len());
assert_eq!(imported.actions[0].steps.len(), cases.len());
for ((id, params), expected) in imported.actions[0].steps.iter().zip(cases) {
assert_eq!(id, PHOTOSHOP_STEP);
let exported = export_item(id, params, "Maps").unwrap();
assert_eq!(exported.event, expected.event);
assert_eq!(exported.descriptor, expected.descriptor);
}
let good = json!({"horizontal":10,"vertical":10,"fit":"stretch","undefinedAreas":"repeat","mapPath":path});
for (key, value) in [
("horizontal", json!(10.5)),
("horizontal", json!(1000)),
("vertical", json!(-1000)),
("vertical", json!("10")),
("fit", json!("tile")),
("fit", Value::Null),
("undefinedAreas", json!("wrap")),
("undefinedAreas", json!(false)),
("mapPath", json!("x\0y.psd")),
("mapPath", json!("")),
("mapLayer", json!(1)),
("mapDocument", json!(0)),
("target", json!("mask")),
] {
let mut params = good.clone();
params[key] = value;
assert!(export_item("filter.distort.displace", &params, "Maps").is_none(), "{params}");
}
}
/// Loading needs no map; playback reads it only after authorization and stretches it.
#[test]
fn recorded_displace_loads_without_map_and_plays_through_the_step_gate() {
use photocraft_geom::Rect;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("synthetic-map.psd");
let wire = displace_descriptor(path_value(path.to_str().unwrap()));
let data = set_of("Maps", vec![("A", vec![item("displace", "Displace", Some(wire))])]);
let mut source = Session::new();
let loaded = source.execute("actions.load", json!({"data":data})).unwrap();
assert_eq!(loaded["unsupported"], json!([]));
assert!(!path.exists(), "loading the ATN needs no map");
let paint_split = |s: &mut Session, side: i32, split: i32, right: [f32; 4]| {
s.execute("file.new", json!({"width":side,"height":side})).unwrap();
s.edit("Two-tone fixture", |doc, _| {
let id = doc.layers[0].id;
let surface = doc.layer_mut(id).unwrap().surface_mut().unwrap();
surface.fill_rect(Rect::new(0, 0, split, side), &[0.0, 0.0, 0.0, 1.0]);
surface.fill_rect(Rect::new(split, 0, side, side), &right);
Ok(())
})
.unwrap();
};
// Offset the source edge so the positive shift crosses it within the white part of the map.
paint_split(&mut source, 64, 48, [1.0, 0.0, 0.0, 1.0]);
source
.edit("Bottom edge fixture", |doc, _| {
let id = doc.layers[0].id;
let surface = doc.layer_mut(id).unwrap().surface_mut().unwrap();
surface.fill_rect(Rect::new(0, 48, 64, 64), &[0.0, 1.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
let snapshot = |s: &Session| {
let active = s.active().unwrap();
(active.revision, active.history.past_len(), photocraft_compose::render(&active.doc, active.doc.bounds()).into_px())
};
let before = snapshot(&source);
let missing = source.execute("actions.play", json!({"action":"A"})).unwrap();
assert_eq!(missing["ran"], 0);
assert_eq!(missing["failed"]["id"], "filter.distort.displace");
assert!(missing["failed"]["error"].as_str().unwrap().contains("cannot read displacement map"));
assert_eq!(snapshot(&source), before);
// Leave the map absent so a read before authorization would report the same I/O failure.
source.authorize = Some(|id, p| {
if id == "filter.distort.displace" && p["mapPath"].is_string() {
return Err(EngineError::Other("test denied the map path".into()));
}
Ok(())
});
let denied = source.execute("actions.play", json!({"action":"A"})).unwrap();
assert_eq!(denied["ran"], 0);
assert_eq!(denied["failed"]["id"], "filter.distort.displace");
assert!(denied["failed"]["error"].as_str().unwrap().contains("test denied the map path"));
assert_eq!(snapshot(&source), before);
source.authorize = None;
let mut map = Session::new();
paint_split(&mut map, 16, 8, [1.0, 1.0, 1.0, 1.0]);
let encoded = photocraft_io::export(&map.active().unwrap().doc, "psd", &Default::default()).unwrap();
std::fs::write(&path, encoded.bytes).unwrap();
let played = source.execute("actions.play", json!({"action":"A"})).unwrap();
assert_eq!(played["ran"], 1, "{played}");
assert!(played.get("failed").is_none(), "{played}");
let pixels = snapshot(&source).2;
// With stretch, x=38 samples the white half and shifts into red; tiling samples black.
assert_eq!(before.2[32 * 64 + 38], [0.0, 0.0, 0.0, 1.0]);
assert_eq!(pixels[32 * 64 + 38], [1.0, 0.0, 0.0, 1.0]);
assert_eq!(pixels[32 * 64 + 20], [0.0, 0.0, 0.0, 1.0]);
// The vertical shift crosses into green, then repeats the bottom edge instead of wrapping to red.
assert_eq!(before.2[40 * 64 + 38], [0.0, 0.0, 0.0, 1.0]);
assert_eq!(pixels[40 * 64 + 38], [0.0, 1.0, 0.0, 1.0]);
assert_eq!(pixels[60 * 64 + 38], [0.0, 1.0, 0.0, 1.0]);
}
/// The step `d` of Photoshop event `code`, as PhotoCraft loads it.
fn distort(code: &str, d: Descriptor) -> Mapped {
let event = AtnEvent::Code(code.as_bytes().try_into().unwrap());
(events::event_for(&event).unwrap_or_else(|| panic!("{code} is unknown")).from)(&d)
}
fn one_step(m: Mapped) -> (String, Value) {
match m {
Mapped::Steps(mut s) if s.len() == 1 => s.remove(0),
_ => panic!("not one step"),
}
}
/// Photoshop's Distort filter steps load with its defaults for missing keys and its clamping for
/// out-of-range values, play, and save back with every key in recorded order (#10605).
#[test]
fn distort_filter_steps_load_clamp_play_and_save_back() {
// Photoshop's defaults, as its steps record them.
let wave = desc("null")
.with("Wvtp", enm("Wvtp", "WvSn"))
.with("NmbG", D::Integer(5))
.with("WLMn", D::Integer(10))
.with("WLMx", D::Integer(120))
.with("AmMn", D::Integer(5))
.with("AmMx", D::Integer(35))
.with("SclH", D::Integer(100))
.with("SclV", D::Integer(100))
.with("UndA", enm("UndA", "RptE"))
.with("RndS", D::Integer(-7));
let zigzag = desc("null").with("Amnt", D::Integer(10)).with("NmbR", D::Integer(5)).with("ZZTy", enm("ZZTy", "PndR"));
let cases = [
("Pnch", desc("null").with("Amnt", D::Integer(50)), "filter.distort.pinch", json!({"amount": 50})),
("Plr ", desc("null").with("Cnvr", enm("Cnvr", "RctP")), "filter.distort.polarCoordinates", json!({"mode": "rectangularToPolar"})),
(
"Sphr",
desc("null").with("Amnt", D::Integer(100)).with("SphM", enm("SphM", "Nrml")),
"filter.distort.spherize",
json!({"amount": 100, "mode": "normal"}),
),
("Twrl", desc("null").with("Angl", D::Integer(50)), "filter.distort.twirl", json!({"angle": 50})),
(
"Wave",
wave,
"filter.distort.wave",
json!({"type": "sine", "generators": 5, "wavelengthMin": 10, "wavelengthMax": 120, "amplitudeMin": 5, "amplitudeMax": 35,
"undefinedAreas": "repeat", "seed": 4_294_967_289_u32}),
),
("ZgZg", zigzag, "filter.distort.zigZag", json!({"amount": 10, "ridges": 5, "style": "pondRipples"})),
];
let mut s = Session::new();
s.execute("file.new", json!({"width": 24, "height": 24})).unwrap();
s.execute("filter.render.clouds", json!({})).unwrap();
for (code, d, id, params) in cases {
let (got, p) = one_step(distort(code, d.clone()));
assert_eq!((got.as_str(), &p), (id, &params), "{code}");
// Every key, in the order Photoshop records them.
assert_eq!(export_item(id, &p, "S").unwrap().descriptor.unwrap().items, d.items, "{code}");
s.actions.list.push(Action::new(code, vec![(id.to_string(), p)]));
let played = s.execute("actions.play", json!({"action": code})).unwrap();
assert_eq!(played["ran"], 1, "{code}: {played}");
}
// A step that leaves its keys out takes Photoshop's defaults: Wave's undefined areas are
// Repeat Edge Pixels, where the command's own default is wrap.
assert_eq!(one_step(distort("Wave", desc("null"))).1["undefinedAreas"], "repeat");
assert_eq!(one_step(distort("Sphr", desc("null"))).1, json!({"amount": 100, "mode": "normal"}));
// Out-of-range values are clamped, as Photoshop clamps them.
assert_eq!(one_step(distort("Pnch", desc("null").with("Amnt", D::Integer(500)))).1, json!({"amount": 100}));
assert_eq!(one_step(distort("Twrl", desc("null").with("Angl", D::Integer(-5000)))).1, json!({"angle": -999}));
assert_eq!(one_step(distort("ZgZg", desc("null").with("NmbR", D::Integer(40)))).1["ridges"], 20);
assert_eq!(one_step(distort("Wave", desc("null").with("NmbG", D::Integer(0)))).1["generators"], 1);
}
/// PhotoCraft's renderer draws at most `MAX_WAVE_GENERATORS` (64) generators, so a Wave step
/// recorded with more keeps the Photoshop step on import and is refused on export, as the PSD
/// smart-filter mapper does, rather than mapping a step that plays differently (#10605).
#[test]
fn wave_generators_past_what_photocraft_draws_keep_the_step() {
assert_eq!(photocraft_algo::MAX_WAVE_GENERATORS, 64);
let p = one_step(distort("Wave", desc("null").with("NmbG", D::Integer(64)))).1;
assert_eq!(p["generators"], 64, "{p}");
for n in [65, 999, 1_000_000] {
let m = distort("Wave", desc("null").with("NmbG", D::Integer(n)));
assert!(matches!(m, Mapped::Unsupported { harmless: false }), "NmbG {n}");
}
assert!(export_item("filter.distort.wave", &json!({"generators": 65}), "S").is_none());
assert!(export_item("filter.distort.wave", &json!({"generators": 64}), "S").is_some());
}
/// What the commands cannot play keeps the Photoshop step: an unknown enumeration value, a Wave
/// scaled below 100 % (the command has no scale), or a key the event does not have (#10605).
#[test]
fn distort_filter_steps_the_commands_cannot_play_are_kept() {
let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false });
assert!(kept(distort("Sphr", desc("null").with("SphM", enm("SphM", "Dgnl")))));
assert!(kept(distort("Plr ", desc("null").with("Cnvr", enm("Cnvr", "Sprl")))));
assert!(kept(distort("Wave", desc("null").with("SclH", D::Integer(50)))));
assert!(kept(distort("Wave", desc("null").with("UndA", enm("UndA", "Trns")))));
assert!(kept(distort("Pnch", desc("null").with("Rds ", D::Integer(3)))));
// A scale of 100 % (or above, which Photoshop clamps to 100) plays.
assert!(!kept(distort("Wave", desc("null").with("SclH", D::Integer(100)).with("SclV", D::Integer(250)))));
}
/// A setting the events cannot hold refuses the step instead of replaying differently: a
/// fractional amount (the keys are integers), a mask target, a seed past 32 bits (#10605).
#[test]
fn distort_filter_steps_that_cannot_be_written_are_refused() {
for (id, params) in [
("filter.distort.pinch", json!({"amount": 12.5})),
("filter.distort.twirl", json!({"angle": 10, "target": "mask"})),
("filter.distort.zigZag", json!({"ridges": 2.5})),
("filter.distort.wave", json!({"seed": 4_294_967_296_u64})),
("filter.distort.wave", json!({"undefinedAreas": "transparent"})),
("filter.distort.spherize", json!({"mode": "diagonal"})),
] {
assert!(export_item(id, &params, "S").is_none(), "{id} {params} exported");
}
// Left out, a setting is written as the command replays it: Wave wraps.
let wave = export_item("filter.distort.wave", &json!({}), "S").unwrap().descriptor.unwrap();
assert_eq!(get_enum(&wave, "UndA").as_deref(), Some("WrpA"));
}
/// A recorded setting of the wrong type is the step's own setting, not a missing one: the
/// mapper keeps the Photoshop step instead of playing it with Photoshop's default (#10605).
#[test]
fn distort_filter_steps_with_a_wrong_typed_setting_are_kept() {
let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false });
for (code, key, v) in [
("Pnch", "Amnt", D::Boolean(true)),
("Twrl", "Angl", text("90")),
("Plr ", "Cnvr", D::Integer(1)),
("Sphr", "SphM", text("HrzO")),
("ZgZg", "NmbR", enm("Ordn", "Trgt")),
("Wave", "NmbG", D::Boolean(false)),
("Wave", "Wvtp", D::Integer(2)),
("Wave", "SclH", text("50")),
("Wave", "SclV", D::Boolean(true)),
("Wave", "UndA", D::Integer(1)),
("Wave", "RndS", text("7")),
] {
assert!(kept(distort(code, desc("null").with(key, v.clone()))), "{code} with {key} = {v:?} was mapped");
}
}
/// An integer outside the range Photoshop clamps the key to refuses the step: written as is, it
/// would load back clamped, as another value (#10605).
#[test]
fn distort_filter_steps_outside_photoshops_range_are_refused() {
for (id, params) in [
("filter.distort.pinch", json!({"amount": 500})),
("filter.distort.spherize", json!({"amount": -101})),
("filter.distort.twirl", json!({"angle": 1000})),
("filter.distort.zigZag", json!({"ridges": 21})),
("filter.distort.zigZag", json!({"ridges": -1})),
("filter.distort.wave", json!({"generators": 0})),
("filter.distort.wave", json!({"amplitudeMax": 1000})),
] {
assert!(export_item(id, &params, "S").is_none(), "{id} {params} exported");
}
// The limits themselves are written.
for (id, params) in [
("filter.distort.pinch", json!({"amount": -100})),
("filter.distort.twirl", json!({"angle": 999})),
("filter.distort.zigZag", json!({"ridges": 0})),
("filter.distort.wave", json!({"generators": 64, "wavelengthMin": 1})),
] {
assert!(export_item(id, &params, "S").is_some(), "{id} {params} refused");
}
}
/// Wave's `RndS` is the seed's 32 bits, signed or not: it loads exactly or keeps the step,
/// never saturated or truncated to another seed (#10605).
#[test]
fn wave_seed_loads_its_exact_32_bits_or_keeps_the_step() {
let seed = |v: D| match distort("Wave", desc("null").with("RndS", v)) {
Mapped::Steps(s) => Some(s[0].1["seed"].clone()),
Mapped::Unsupported { .. } => None,
};
assert_eq!(seed(D::LargeInteger(4_000_000_000)), Some(json!(4_000_000_000_u32)));
assert_eq!(seed(D::LargeInteger(-1)), Some(json!(u32::MAX)));
assert_eq!(seed(D::Integer(-7)), Some(json!(4_294_967_289_u32)));
assert_eq!(seed(D::Double(12.0)), Some(json!(12)));
for v in [D::Double(7.5), D::LargeInteger(4_294_967_296), D::LargeInteger(i64::from(i32::MIN) - 1)] {
assert_eq!(seed(v.clone()), None, "RndS {v:?} was mapped");
}
}
/// A step recording one of its keys twice can't be saved back with both values, so it stays a
/// Photoshop step instead of playing the first and dropping the second (#10605).
#[test]
fn distort_filter_steps_with_a_repeated_key_are_kept() {
let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false });
for (code, key, a, b) in [
("Pnch", "Amnt", D::Integer(10), D::Integer(20)),
("Sphr", "SphM", enm("SphM", "Nrml"), enm("SphM", "HrzO")),
("Plr ", "Cnvr", enm("Cnvr", "RctP"), enm("Cnvr", "RctP")),
("Wave", "NmbG", D::Integer(5), D::Integer(7)),
("Wave", "SclH", D::Integer(100), D::Integer(50)),
("Wave", "UndA", enm("UndA", "WrpA"), enm("UndA", "RptE")),
("Wave", "RndS", D::Integer(1), D::Integer(2)),
("ZgZg", "NmbR", D::Integer(5), D::Integer(6)),
] {
assert!(kept(distort(code, desc("null").with(key, a.clone()).with(key, b.clone()))), "{code} with {key} twice was mapped");
}
}
/// An enumeration of another type isn't the setting Photoshop records, so the step stays a
/// Photoshop step instead of loading its value and saving back the expected type (#10605).
#[test]
fn distort_filter_steps_with_an_enumeration_of_another_type_are_kept() {
let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false });
for (code, key, v) in [
("Plr ", "Cnvr", enm("Ordn", "RctP")),
("Sphr", "SphM", enm("Cnvr", "Nrml")),
("ZgZg", "ZZTy", enm("Ordn", "PndR")),
("Wave", "Wvtp", enm("WvSn", "WvSn")),
("Wave", "UndA", enm("Ordn", "WrpA")),
] {
assert!(kept(distort(code, desc("null").with(key, v.clone()))), "{code} with {key} = {v:?} was mapped");
}
assert!(!kept(distort("Plr ", desc("null").with("Cnvr", enm("Cnvr", "PlrR")))));
assert!(!kept(distort("Wave", desc("null").with("UndA", enm("UndA", "WrpA")))));
}
/// Photoshop clamps Wave's wavelengths and amplitudes to 1..999, but `filter.distort.wave`
/// takes a minimum wavelength of 1..998, a maximum of 2..999 and a minimum amplitude of 1..998.
/// A value the command rejects keeps the Photoshop step on import and refuses it on export,
/// rather than mapping a step that fails when it plays; a minimum above its maximum, which the
/// command takes, maps and plays (#10605).
#[test]
fn wave_values_the_command_rejects_are_kept_or_refused() {
let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false });
for (key, n) in [("WLMn", 999), ("WLMn", 5000), ("WLMx", 1), ("WLMx", 0), ("AmMn", 999), ("AmMn", 1200)] {
assert!(kept(distort("Wave", desc("null").with(key, D::Integer(n)))), "{key} {n} was mapped");
}
for (param, n) in [("wavelengthMin", 999), ("wavelengthMax", 1), ("amplitudeMin", 999)] {
assert!(export_item("filter.distort.wave", &json!({ param: n }), "S").is_none(), "{param} {n} exported");
}
let mut s = Session::new();
s.execute("file.new", json!({"width": 24, "height": 24})).unwrap();
s.execute("filter.render.clouds", json!({})).unwrap();
let plays = [
desc("null").with("WLMn", D::Integer(998)),
desc("null").with("WLMx", D::Integer(2)),
desc("null").with("AmMn", D::Integer(998)),
desc("null").with("WLMn", D::Integer(200)).with("WLMx", D::Integer(100)),
desc("null").with("AmMn", D::Integer(40)).with("AmMx", D::Integer(20)),
];
for (k, d) in plays.into_iter().enumerate() {
let (id, p) = one_step(distort("Wave", d));
assert!(export_item(&id, &p, "S").is_some(), "{p} refused");
let name = format!("W{k}");
s.actions.list.push(Action::new(&name, vec![(id, p.clone())]));
let played = s.execute("actions.play", json!({"action": name})).unwrap();
assert_eq!(played["ran"], 1, "{p}: {played}");
}
}
/// Wave saves its scales as the integer 100, so a fractional scale, even one that rounds or
/// clamps to 100, stays a Photoshop step instead of saving back as 100 (#10605).
#[test]
fn wave_with_a_fractional_scale_is_kept() {
let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false });
for (key, n) in [("SclH", 99.6), ("SclV", 99.5), ("SclH", 100.4)] {
assert!(kept(distort("Wave", desc("null").with(key, D::Double(n)))), "{key} {n} was mapped");
}
assert!(!kept(distort("Wave", desc("null").with("SclH", D::Integer(100)).with("SclV", D::Integer(100)))));
}
/// A Pattern Fill step whose `Ptrn` holds `id` and `name`, as the reference application records it.
fn pattern_fill_step(id: &str, name: &str) -> AtnItem {
let t = desc("patternLayer").with("Ptrn", D::Descriptor(desc("Ptrn").with("Idnt", text(id)).with("Nm ", text(name))));
item(
"Mk ",
"Make",
Some(desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t))))),
)
}
/// Defines a 4×4 pattern of `color` named `name` in `s`'s library from its active document.
fn define_pattern(s: &mut Session, color: &str, name: &str) -> String {
s.execute("edit.fill", json!({"contents": "color", "color": color})).unwrap();
s.execute("edit.definePattern", json!({"name": name})).unwrap()["pattern"].as_str().unwrap().to_string()
}
fn pixel(s: &mut Session) -> Vec<f64> {
let v = s.execute("document.pixel", json!({"x": 1, "y": 1})).unwrap();
v.as_array().unwrap().iter().map(|c| c.as_f64().unwrap()).collect()
}
/// A Pattern Fill step whose `Ptrn` names both an installed id and a display name shared by
/// another pattern plays the pattern with that id; a foreign id falls back to the name.
#[test]
fn pattern_fill_prefers_an_installed_id_over_a_colliding_name() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap();
let mut define = |color: &str| {
s.execute("edit.fill", json!({"contents": "color", "color": color})).unwrap();
s.execute("edit.definePattern", json!({"name": "Twin"})).unwrap()["pattern"].as_str().unwrap().to_string()
};
let red = define("#ff0000");
let blue = define("#0000ff");
assert_ne!(red, blue);
s.patterns.items.retain(|p| p.id == red || p.id == blue);
s.patterns.items.sort_by_key(|p| p.id != blue);
let fill_step = |id: &str| {
let t = desc("patternLayer").with("Ptrn", D::Descriptor(desc("Ptrn").with("Idnt", text(id)).with("Nm ", text("Twin"))));
item(
"Mk ",
"Make",
Some(desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t))))),
)
};
let data = set_of("Pat", vec![("Installed", vec![fill_step(&red)]), ("Foreign", vec![fill_step("not-installed-id")])]);
let r = s.execute("actions.load", json!({"data": data})).unwrap();
assert!(r["unsupported"].as_array().is_none_or(|u| u.is_empty()), "{r}");
for (action, want) in [("Installed", [1.0, 0.0, 0.0, 1.0]), ("Foreign", [0.0, 0.0, 1.0, 1.0])] {
s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap();
let r = s.execute("actions.play", json!({"action": action})).unwrap();
assert!(r.get("failed").is_none(), "{r}");
let v = s.execute("document.pixel", json!({"x": 1, "y": 1})).unwrap();
let got: Vec<f64> = v.as_array().unwrap().iter().map(|c| c.as_f64().unwrap()).collect();
assert_eq!(got, want, "{action}");
}
}
/// The recorded id is looked up by id alone, in the document and then the library, before any
/// name: a document pattern *named* like the id does not beat the library pattern with that id,
/// and a foreign id that happens to be another pattern's name falls back to the recorded name.
/// An id and a name of the same pattern play it.
#[test]
fn pattern_fill_ids_beat_names_across_document_and_library() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap();
let red = define_pattern(&mut s, "#ff0000", "Red");
define_pattern(&mut s, "#0000ff", "Blue");
let green = define_pattern(&mut s, "#00ff00", &red);
let data = set_of(
"Pat",
vec![
("IdWins", vec![pattern_fill_step(&red, "Blue")]),
("NameFallback", vec![pattern_fill_step("Red", "Blue")]),
("Same", vec![pattern_fill_step(&red, "Red")]),
],
);
s.execute("actions.load", json!({"data": data})).unwrap();
// Only the playback document holds the green pattern named like the red one's id.
s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap();
s.execute("layer.newFillLayer.pattern", json!({"pattern": green})).unwrap();
s.execute("pattern.delete", json!({"pattern": green})).unwrap();
assert!(s.active().unwrap().doc.patterns.iter().any(|p| p.id == green && p.name == red));
assert_eq!(pixel(&mut s), [0.0, 1.0, 0.0, 1.0]);
for (action, want) in [("IdWins", [1.0, 0.0, 0.0, 1.0]), ("NameFallback", [0.0, 0.0, 1.0, 1.0]), ("Same", [1.0, 0.0, 0.0, 1.0])] {
let r = s.execute("actions.play", json!({"action": action})).unwrap();
assert!(r.get("failed").is_none(), "{action}: {r}");
assert_eq!(pixel(&mut s), want, "{action}");
}
}
/// CodeRabbit on batch 11: a pattern step that names its pattern only by `patternName` exports
/// that name, rather than a step with no pattern (which plays the current one).
#[test]
fn pattern_steps_named_only_by_pattern_name_export_the_name() {
let one = Action { name: "P".into(), steps: vec![("layer.newFillLayer.pattern".into(), json!({"patternName": "Checkerboard"}))], ..Default::default() };
let (bytes, skipped) = export("Pat", &[&one], &[], false).unwrap();
assert!(skipped.is_empty());
let steps = import(&bytes).unwrap().actions[0].steps.clone();
assert_eq!(steps[0].0, "layer.newFillLayer.pattern");
assert_eq!(steps[0].1["pattern"], json!("Checkerboard"), "{}", steps[0].1);
}
/// CodeRabbit on batch 11: when the recorded id is installed nowhere, the recorded name is looked
/// up as a name only. A pattern whose id equals that name text doesn't beat the one named so.
#[test]
fn pattern_fill_name_fallback_matches_names_not_ids() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap();
let red = define_pattern(&mut s, "#ff0000", "Red");
// Green is named like the red pattern's id.
define_pattern(&mut s, "#00ff00", &red);
let data = set_of("Pat", vec![("NameIsAnId", vec![pattern_fill_step("5e1c0000-aaaa-4bbb-8ccc-000000000000", &red)])]);
s.execute("actions.load", json!({"data": data})).unwrap();
s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap();
let r = s.execute("actions.play", json!({"action": "NameIsAnId"})).unwrap();
assert!(r.get("failed").is_none(), "{r}");
assert_eq!(pixel(&mut s), [0.0, 1.0, 0.0, 1.0]);
}
/// A recorded pattern installed neither by id nor by name fails with an error that names the
/// pattern, from the imported step and from the Pattern Fill dialog's command alike.
#[test]
fn pattern_fill_missing_everywhere_names_the_pattern() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 4, "height": 4, "mode": "rgb", "depth": 8})).unwrap();
define_pattern(&mut s, "#ff0000", "Red");
let data = set_of("Pat", vec![("Missing", vec![pattern_fill_step("5e1c0000-aaaa-4bbb-8ccc-000000000000", "My Wood")])]);
s.execute("actions.load", json!({"data": data})).unwrap();
let r = s.execute("actions.play", json!({"action": "Missing"})).unwrap();
let err = r["failed"]["error"].as_str().unwrap_or_default();
assert!(err.contains("\"My Wood\""), "{r}");
s.execute("layer.newFillLayer.pattern", json!({"pattern": "Red"})).unwrap();
let e = s.execute("layer.fill.pattern.set", json!({"pattern": "5e1c0000-aaaa-4bbb-8ccc-000000000000", "patternName": "My Wood"})).unwrap_err();
assert!(e.to_string().contains("\"My Wood\""), "{e}");
}
/// FredApps review on #10459: a set whose name has surrounding spaces (a Photoshop `.atn` or an
/// older actions.json can have one, and New Action records into it under that exact name) saves
/// by that name. `actions.save` used to trim it and then find no set.
#[test]
fn save_finds_a_set_by_its_exact_name_with_spaces() {
let mut s = Session::new();
s.actions.list.push(Action { set: Some(" Web ".into()), ..Action::new("Old", Vec::new()) });
s.execute("actions.record", json!({"name": "New", "set": " Web "})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
let saved = s.execute("actions.save", json!({"set": " Web "})).unwrap();
assert_eq!((&saved["set"], &saved["actions"]), (&json!(" Web "), &json!(["Old", "New"])));
// The file keeps the name, as Photoshop's does.
let mut reloaded = Session::new();
let r = reloaded.execute("actions.load", json!({"data": saved["data"]})).unwrap();
assert_eq!(r["set"], " Web ");
// Other names are still trimmed.
s.execute("actions.assignSet", json!({"action": "Old", "set": "Print"})).unwrap();
assert_eq!(s.execute("actions.save", json!({"set": " Print "})).unwrap()["set"], "Print");
}
/// FredApps review on #10459: Save Actions… on an empty set made with New Set writes it, as
/// Photoshop saves an empty set to the `.atn`, and loading that file brings the empty set back.
#[test]
fn an_empty_new_set_saves_and_loads_back() {
let mut s = Session::new();
s.execute("actions.newSet", json!({"name": "Later"})).unwrap();
let saved = s.execute("actions.save", json!({"set": "Later"})).unwrap();
assert_eq!((&saved["set"], &saved["actions"], &saved["steps"]), (&json!("Later"), &json!([]), &json!(0)));
let mut other = Session::new();
let r = other.execute("actions.load", json!({"data": saved["data"]})).unwrap();
assert_eq!((&r["set"], &r["actions"]), (&json!("Later"), &json!([])));
assert_eq!(crate::actions_edit_cmds::set_names(&other), ["Later"], "an empty folder");
other.execute("actions.load", json!({"data": saved["data"]})).unwrap();
assert_eq!(crate::actions_edit_cmds::set_names(&other), ["Later"], "loaded twice, still one folder");
// New Action records into it as into any set, and it saves with that action.
other.execute("actions.record", json!({"name": "Now", "set": "Later"})).unwrap();
other.execute("actions.stop", json!({})).unwrap();
assert_eq!(other.execute("actions.save", json!({"set": "Later"})).unwrap()["actions"], json!(["Now"]));
// A set that doesn't exist is still refused, and so is saving every action when there is none.
let err = s.execute("actions.save", json!({"set": "Nope"})).unwrap_err().to_string();
assert!(err.contains("no actions in a set named `Nope`"), "{err}");
let err = Session::new().execute("actions.save", json!({})).unwrap_err().to_string();
assert!(err.contains("there are no actions to save"), "{err}");
}
/// A set from Chinese Photoshop: UTF-16 set and action names, a step name in GBK (可选颜色,
/// the bytes from #6743) on an event PhotoCraft has no command for, with no descriptor.
fn gbk_set() -> Vec<u8> {
let step = AtnItem { dictionary: b"\xbf\xc9\xd1\xa1\xd1\xd5\xc9\xab".to_vec(), ..item("syntheticFill", "", None) };
let file = AtnFile {
name: UnicodeString::new_nul("相馆"),
actions: vec![AtnAction { name: UnicodeString::new_nul("调白"), items: vec![step], ..Default::default() }],
..Default::default()
};
atn::write(&file).unwrap()
}
#[test]
fn load_decodes_legacy_step_names() {
let mut s = Session::new();
let r = s.execute("actions.load", json!({"data": b64(&gbk_set())})).unwrap();
assert_eq!(r["encoding"], "GBK", "{r}");
assert_eq!(r["unsupported"][0]["name"], "可选颜色", "{r}");
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let played = s.execute("actions.play", json!({"action": "调白"})).unwrap();
assert!(played["failed"]["error"].as_str().unwrap().contains("可选颜色"), "{played}");
}
#[test]
fn load_keeps_windows_1252_symbols() {
// Windows Photoshop step names with no Unicode copy: Mac Roman reads ° and » as ∞ and ª,
// which must not win ("50 ∞", "Grˆfle ª"). Save Actions writes the names back as that text.
let steps = vec![
AtnItem { dictionary: b"50 \xb0".to_vec(), ..item("syntheticFill", "", None) },
AtnItem { dictionary: b"Gr\xf6\xdfe \xbb".to_vec(), ..item("syntheticFill", "", None) },
];
let file = AtnFile {
name: UnicodeString::new_nul("Größe ändern"),
actions: vec![AtnAction { name: UnicodeString::new_nul("Logo"), items: steps, ..Default::default() }],
..Default::default()
};
let mut s = Session::new();
let r = s.execute("actions.load", json!({"data": b64(&atn::write(&file).unwrap())})).unwrap();
assert_eq!(r["encoding"], "windows-1252", "{r}");
assert_eq!(r["unsupported"][0]["name"], "50 °", "{r}");
assert_eq!(r["unsupported"][1]["name"], "Größe »", "{r}");
let saved = s.execute("actions.save", json!({"set": "Größe ändern"})).unwrap();
let saved = atn::parse(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap();
let names: Vec<String> = saved.actions[0].items.iter().map(|i| saved.item_label(i)).collect();
assert_eq!(names, ["50 °", "Größe »"]);
}
#[test]
fn load_encoding_override() {
let mut s = Session::new();
let r = s.execute("actions.load", json!({"data": b64(&gbk_set()), "encoding": "shift_jis"})).unwrap();
assert_eq!(r["encoding"], "Shift_JIS", "{r}");
let r = s.execute("actions.load", json!({"data": b64(&gbk_set()), "encoding": "auto"})).unwrap();
assert_eq!(r["encoding"], "GBK", "{r}");
}
#[test]
fn load_reports_utf8() {
let mut s = Session::new();
let r = s.execute("actions.load", json!({"data": b64(&atn::write(&sample()).unwrap())})).unwrap();
assert_eq!(r["encoding"], "UTF-8", "{r}");
}
#[test]
fn load_rejects_bad_encodings() {
let mut s = Session::new();
for bad in [json!(3), json!(""), json!("nope"), json!("replacement"), json!(["gbk"])] {
assert!(s.execute("actions.load", json!({"data": b64(&gbk_set()), "encoding": bad})).is_err(), "{bad}");
}
assert!(s.actions.list.is_empty(), "nothing is loaded when the encoding is bad");
}
/// Edit › Check Spelling in its recorded form: `spellCheck`, `null` = the `spellCheck` property of
/// all text layers, `Usng` = `checkAll`, with the step's dialog on.
fn spell_check(all: bool) -> Descriptor {
desc("null")
.with(
"null",
D::Reference(vec![
ReferenceItem::Property { class: class("Prpr"), key: Id::new("spellCheck") },
ReferenceItem::Enumerated { class: class("TxLr"), type_id: code("Ordn"), value: code("Al") },
]),
)
.with("Usng", D::Descriptor(desc("spellCheck").with("checkAll", D::Boolean(all))))
}
#[test]
fn check_spelling_loads_and_saves_as_the_spell_check_event() {
for all in [true, false] {
let mut step = item("spellCheck", "Check Spelling", Some(spell_check(all)));
step.with_dialog = true;
let file = AtnFile {
name: UnicodeString::new_nul("S"),
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items: vec![step], ..Default::default() }],
..Default::default()
};
let imported = import(&atn::write(&file).unwrap()).unwrap();
assert!(imported.unsupported.is_empty(), "{:?}", imported.unsupported);
let action = &imported.actions[0];
assert_eq!(action.steps, [("edit.checkSpelling".to_string(), json!({"allLayers": all}))]);
assert_eq!(action.dialogs.iter().copied().collect::<Vec<_>>(), [0]);
let (bytes, skipped) = export("S", &[action], &[], false).unwrap();
assert!(skipped.is_empty());
let saved = atn::parse(&bytes).unwrap();
let saved = &saved.actions[0].items[0];
assert_eq!(saved.event.id(), "spellCheck");
assert!(saved.with_dialog);
assert_eq!(saved.descriptor.as_ref().unwrap().to_bytes(), spell_check(all).to_bytes(), "checkAll {all}");
}
}
#[test]
fn other_check_spelling_forms_stay_kept_steps_and_corrections_are_not_saved() {
let event = event_for(&AtnEvent::name("spellCheck")).unwrap();
let mut extra = spell_check(true);
extra.items.push((Id::new("Fwd "), D::Boolean(true)));
let mut other_target = spell_check(true);
other_target.items[0].1 = D::Reference(vec![ReferenceItem::Property { class: class("Prpr"), key: Id::new("spellCheck") }]);
let mut other_class = spell_check(true);
other_class.items[1].1 = D::Descriptor(desc("findReplace").with("checkAll", D::Boolean(true)));
let mut more_using = spell_check(true);
more_using.items[1].1 = D::Descriptor(desc("spellCheck").with("checkAll", D::Boolean(true)).with("Fwd ", D::Boolean(true)));
let mut twice = spell_check(true);
twice.items[1] = twice.items[0].clone();
for d in [extra, other_target, other_class, more_using, twice, desc("null")] {
assert!(matches!((event.from)(&d), Mapped::Unsupported { harmless: false }), "{d:?}");
}
for params in [
json!({"action": "change", "start": 0, "end": 3, "replace": "The"}),
json!({"action": "changeAll", "word": "teh", "replace": "the"}),
json!({"allLayers": true, "layer": "Title"}),
json!({"allLayers": "yes"}),
] {
assert!(export_item("edit.checkSpelling", &params, "S").is_none(), "{params}");
}
}
/// Check Spelling's `null` and `Usng` are read by name: a step that holds them in the other
/// order loads as the same step.
#[test]
fn check_spelling_reads_its_keys_in_either_order() {
let event = event_for(&AtnEvent::name("spellCheck")).unwrap();
for all in [true, false] {
let mut reversed = spell_check(all);
reversed.items.reverse();
match (event.from)(&reversed) {
Mapped::Steps(steps) => assert_eq!(steps, [("edit.checkSpelling".to_string(), json!({"allLayers": all}))]),
Mapped::Unsupported { .. } => panic!("Usng before null was kept: {reversed:?}"),
}
}
}
/// A setting recorded twice (`Wdth`, `Fade`, `Scl `, a gallery key) would play one value and
/// save one, dropping the other: such a step stays a Photoshop step (CodeRabbit on #10776).
#[test]
fn steps_that_record_a_setting_twice_are_kept() {
let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false });
let identity = D::List((0..25).map(|i| D::Integer(i32::from(i == 12))).collect());
let once = [
("Trap", desc("null").with("Wdth", px(2.0))),
("matchColor", desc("null").with("Fade", D::Integer(10))),
("Cstm", desc("null").with("Mtrx", identity.clone()).with("Scl ", D::Integer(3))),
];
for (event, d) in once {
assert!(!kept(channel_step(event, d.clone())), "{event} once was kept");
let (key, value) = d.items.last().cloned().unwrap();
let twice = Descriptor { items: [d.items.clone(), vec![(key, value)]].concat(), ..d };
assert!(kept(channel_step(event, twice)), "{event} twice was mapped");
}
let edges = || desc("null").with("EdgW", D::Integer(2)).with("EdgB", D::Integer(38)).with("Smth", D::Integer(5));
assert!(!kept(gallery_step("GEfc", "AccE", edges())));
assert!(kept(gallery_step("GEfc", "AccE", edges().with("EdgW", D::Integer(9)))));
}
/// A gallery effect's own step whose panel name is in the file's legacy code page (GBK
/// "强化的边缘", not UTF-8) saves back with the same name bytes (CodeRabbit on #10776).
#[test]
fn gallery_effect_steps_keep_a_legacy_panel_name_byte_for_byte() {
let edges = desc("null").with("GEfk", enm("GEft", "AccE")).with("EdgW", D::Integer(2)).with("EdgB", D::Integer(38)).with("Smth", D::Integer(5));
let gbk = vec![0xC7, 0xBF, 0xBB, 0xAF, 0xB5, 0xC4, 0xB1, 0xDF, 0xD4, 0xB5];
assert!(std::str::from_utf8(&gbk).is_err());
let source = AtnItem { dictionary: gbk.clone(), ..item("AccE", "", Some(edges)) };
let mut s = Session::new();
let loaded = s.execute("actions.load", json!({"data": set_of("S", vec![("A", vec![source])])})).unwrap();
assert_eq!(loaded["unsupported"], json!([]), "{loaded}");
let saved = s.execute("actions.save", json!({"set": "S"})).unwrap();
let file = atn::parse(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap();
let back = &file.actions[0].items[0];
assert_eq!((back.event.clone(), back.dictionary.clone()), (AtnEvent::Code(*b"AccE"), gbk));
}
/// A Cmd-click that adds the Background (a property reference) to the selection keeps its
/// modifier, as a click on a named layer does, and saves back the same (CodeRabbit on #10776).
#[test]
fn a_background_selection_keeps_its_modifier() {
let bckg = || D::Reference(vec![ReferenceItem::Property { class: class("Lyr "), key: code("Bckg") }]);
for (modifier, mode) in [("addToSelection", "add"), ("removeFromSelection", "remove"), ("addToSelectionContinuous", "range")] {
let d = desc("null").with("null", bckg()).with("selectionModifier", enm("selectionModifierType", modifier)).with("MkVs", D::Boolean(false));
let loaded = mapped_recording("slct", d.clone());
assert_eq!(loaded.actions[0].steps, vec![("layer.select".into(), json!({"layer":{"background":true},"mode":mode}))], "{modifier}");
let (bytes, _) = export("S", &[&loaded.actions[0]], &[], false).unwrap();
assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor, Some(d));
}
}
/// A Move whose destination is an ordinal of something other than a layer is no stack move:
/// it stays a Photoshop step (CodeRabbit on #10776).
#[test]
fn a_move_to_an_ordinal_that_is_not_a_layer_is_kept() {
let ord = |cls: &str, o: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class(cls), type_id: code("Ordn"), value: code(o) }]);
let target = || D::Reference(vec![ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code("Trgt") }]);
for o in ["Frnt", "Back", "Nxt ", "Prvs"] {
let layer = mapped_recording("move", desc("null").with("null", target()).with("T ", ord("Lyr ", o)));
assert!(layer.unsupported.is_empty(), "{o} on a layer");
let channel = mapped_recording("move", desc("null").with("null", target()).with("T ", ord("Chnl", o)));
assert_eq!(channel.unsupported.len(), 1, "{o} on a channel was mapped: {:?}", channel.actions[0].steps);
}
}
/// Nonfinite recorded doubles remain unsupported before native parameters can be constructed.
#[test]
fn shear_nonfinite_coordinates_stay_kept() {
let point = |x, y| D::Descriptor(desc("Pnt ").with("Hrzn", D::Double(x)).with("Vrtc", D::Double(y)));
let finite = desc("null").with("ShrP", D::List(vec![point(-10000.0, -1000.0), point(10000.0, 1000.0)]));
assert!(matches!(steps_distort::shear_from(&finite), Mapped::Steps(_)), "finite coordinates still clamp and map");
for n in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
for d in [
desc("null").with("ShrP", D::List(vec![point(n, 1.0), point(0.0, 128.0)])),
desc("null").with("ShrP", D::List(vec![point(0.0, 1.0), point(0.0, n)])),
] {
assert!(matches!(steps_distort::shear_from(&d), Mapped::Unsupported { .. }), "{d:?}");
}
}
}
/// #10771: a Gradient Fill as Photoshop records it: three stops, a transparency stop at 40 % and
/// one midpoint per stop (`Mdpn` of stop k is the segment that ends at it).
fn recorded_gradient(mids: [i32; 3]) -> Descriptor {
let stop = |hex: &str, at: i32, mid: i32| {
D::Descriptor(
desc("Clrt")
.with("Clr ", D::Descriptor(color_desc(hex).unwrap()))
.with("Type", enm("Clry", "UsrS"))
.with("Lctn", D::Integer(at))
.with("Mdpn", D::Integer(mid)),
)
};
let trn = |at: i32, o: f64| D::Descriptor(desc("TrnS").with("Opct", pct(o)).with("Lctn", D::Integer(at)).with("Mdpn", D::Integer(50)));
let grad = desc("Grdn")
.with("Nm ", text("Custom"))
.with("GrdF", enm("GrdF", "CstS"))
.with("Intr", D::Double(4096.0))
.with("Clrs", D::List(vec![stop("#ff0000", 0, mids[0]), stop("#00ff00", 2048, mids[1]), stop("#0000ff", 4096, mids[2])]))
.with("Trns", D::List(vec![trn(0, 100.0), trn(4096, 40.0)]));
desc("gradientLayer").with("Angl", ang(30.0)).with("Type", enm("GrdT", "Rdl ")).with("Grad", D::Descriptor(grad))
}
fn gradient_set(t: Descriptor) -> AtnItem {
item("setd", "Set", Some(desc("null").with("null", target_ref("contentLayer")).with("T ", D::Descriptor(t))))
}
fn gradient_make(t: Descriptor) -> AtnItem {
let d = desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t))));
item("Mk ", "Make", Some(d))
}
/// The first saved step of `action` (strict save).
fn saved_first(s: &mut Session) -> (AtnEvent, Option<Descriptor>) {
let r = s.execute("actions.save", json!({"action": "A"})).unwrap();
assert_eq!(r["skipped"], json!([]), "{r}");
let saved = atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap();
let i = &saved.actions[0].items[0];
(i.event.clone(), i.descriptor.clone())
}
fn without_hidden(p: &Value) -> Value {
let mut p = p.clone();
p.as_object_mut().unwrap().remove("__atnDescriptor");
normalise(&mut p);
p
}
fn active_fill(s: &Session) -> photocraft_doc::Fill {
let st = s.active().unwrap();
match &st.doc.layer(st.active_layer.unwrap()).unwrap().content {
photocraft_doc::LayerContent::Fill(f) => f.clone(),
c => panic!("not a fill layer: {}", c.kind_name()),
}
}
/// #10771: a recorded Set of a Gradient Fill loads as `gradient.fill.set` with its midpoints on
/// the segment they belong to, plays them, and saves back as the recorded descriptor.
#[test]
fn gradient_fill_set_imports_midpoints_plays_and_saves_back_unchanged() {
let recorded = recorded_gradient([50, 25, 70]);
let mut s = Session::new();
let loaded = s.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![gradient_set(recorded.clone())])).unwrap())})).unwrap();
assert_eq!(loaded["unsupported"], json!([]), "{loaded}");
let (id, p) = s.actions.list[0].steps[0].clone();
assert_eq!(id, "gradient.fill.set");
let mut expected = json!({
"style": "radial", "angle": 30,
"stops": [[0, "#ff0000"], [0.5, "#00ff00"], [1, "#0000ff"]],
"transparency": [[0, 100], [1, 40]],
"midpoints": [0.25, 0.7]
});
normalise(&mut expected);
assert_eq!(without_hidden(&p), expected);
assert_eq!(
saved_first(&mut s),
(AtnEvent::Code(*b"setd"), Some(desc("null").with("null", target_ref("contentLayer")).with("T ", D::Descriptor(recorded))))
);
// It plays on a Gradient Fill layer.
s.execute("file.new", json!({"width": 16, "height": 12})).unwrap();
s.execute("layer.newFillLayer.gradient", json!({"from": "#000000", "to": "#ffffff"})).unwrap();
let played = s.execute("actions.play", json!({"action": "A"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
let photocraft_doc::Fill::Gradient { stops, midpoints, opacity_stops, .. } = active_fill(&s) else { panic!("not a gradient") };
assert_eq!(stops.len(), 3);
assert_eq!(midpoints, vec![0.25, 0.7]);
assert_eq!(opacity_stops.len(), 2);
// Default midpoints load as `[]`, which clears the custom ones when played.
let mut s2 = Session::new();
s2.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![gradient_set(recorded_gradient([50, 50, 50]))])).unwrap())})).unwrap();
assert_eq!(s2.actions.list[0].steps[0].1["midpoints"], json!([]));
s.actions = s2.actions;
s.execute("actions.play", json!({"action": "A"})).unwrap();
let photocraft_doc::Fill::Gradient { midpoints, .. } = active_fill(&s) else { panic!("not a gradient") };
assert!(midpoints.is_empty() || midpoints.iter().all(|m| *m == 0.5), "{midpoints:?}");
}
/// #10771: an edited loaded step writes a fresh descriptor from its params, midpoints included.
#[test]
fn edited_gradient_fill_set_writes_a_fresh_descriptor() {
let mut s = Session::new();
s.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![gradient_set(recorded_gradient([50, 25, 70]))])).unwrap())})).unwrap();
s.actions.list[0].steps[0].1["midpoints"] = json!([0.4, 0.6]);
let (_, d) = saved_first(&mut s);
let t = get_obj(d.as_ref().unwrap(), "T ").unwrap();
let clrs = get_list(get_obj(t, "Grad").unwrap(), "Clrs").unwrap();
let mids: Vec<f64> = clrs.iter().map(|c| if let D::Descriptor(c) = c { get_num(c, "Mdpn").unwrap() } else { panic!() }).collect();
assert_eq!(mids, vec![50.0, 40.0, 60.0]);
// Its own import is the edited params.
let Mapped::Steps(steps) = (event_for(&AtnEvent::Code(*b"setd")).unwrap().from)(d.as_ref().unwrap()) else { panic!("did not import") };
let mut edited = without_hidden(&s.actions.list[0].steps[0].1);
edited["midpoints"] = json!([0.4, 0.6]);
normalise(&mut edited);
assert_eq!(without_hidden(&steps[0].1), edited);
}
/// #10771: a recorded Make of a Gradient Fill layer plays its stops, transparency and midpoints
/// and saves back unchanged.
#[test]
fn gradient_fill_make_imports_plays_and_saves_back_unchanged() {
let recorded = recorded_gradient([50, 25, 70]);
let mut s = Session::new();
let loaded = s.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![gradient_make(recorded.clone())])).unwrap())})).unwrap();
assert_eq!(loaded["unsupported"], json!([]), "{loaded}");
assert_eq!(s.actions.list[0].steps[0].0, "layer.newFillLayer.gradient");
let (event, d) = saved_first(&mut s);
assert_eq!(event, AtnEvent::Code(*b"Mk "));
assert_eq!(get_obj(get_obj(d.as_ref().unwrap(), "Usng").unwrap(), "Type"), Some(&recorded));
for depth in [8, 16] {
s.execute("file.new", json!({"width": 16, "height": 12, "depth": depth})).unwrap();
let played = s.execute("actions.play", json!({"action": "A"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
let photocraft_doc::Fill::Gradient { stops, midpoints, opacity_stops, .. } = active_fill(&s) else { panic!("not a gradient") };
assert_eq!((stops.len(), midpoints, opacity_stops.len()), (3, vec![0.25, 0.7], 2));
// One undo step.
s.execute("edit.undo", json!({})).unwrap();
assert!(!matches!(s.active().unwrap().doc.layers.last().unwrap().content, photocraft_doc::LayerContent::Fill(_)));
}
}
/// #10771: PhotoCraft's own Gradient Fill steps export and load back as the same fill.
#[test]
fn gradient_fill_steps_export_and_import_back() {
let set = json!({"style": "linear", "angle": 45, "stops": [[0, "#ff0000"], [0.25, "#00ff00"], [1, "#0000ff"]], "transparency": [[0, 100], [1, 50]], "midpoints": [0.3, 0.6]});
let item = export_item("gradient.fill.set", &set, "S").unwrap();
let Mapped::Steps(steps) = (event_for(&item.event).unwrap().from)(item.descriptor.as_ref().unwrap()) else { panic!("did not import") };
let mut expected = set.clone();
normalise(&mut expected);
assert_eq!((steps[0].0.as_str(), without_hidden(&steps[0].1)), ("gradient.fill.set", expected));
// The two-colour Make plays as the same two stops at full opacity.
let item = export_item("layer.newFillLayer.gradient", &json!({"from": "#ff0000", "to": "#0000ff", "angle": 45}), "S").unwrap();
let Mapped::Steps(steps) = (event_for(&item.event).unwrap().from)(item.descriptor.as_ref().unwrap()) else { panic!("did not import") };
let mut expected = json!({"angle": 45, "stops": [[0, "#ff0000"], [1, "#0000ff"]], "transparency": [[0, 100], [1, 100]], "midpoints": []});
normalise(&mut expected);
assert_eq!((steps[0].0.as_str(), without_hidden(&steps[0].1)), ("layer.newFillLayer.gradient", expected));
// Stops replace `from` and `to` when it plays, so a Make with all three exports its stops.
let p = json!({"from": "#ff0000", "to": "#0000ff", "stops": [[0, "#00ff00"], [1, "#ffffff"]], "transparency": [[0, 100], [1, 100]]});
let item = export_item("layer.newFillLayer.gradient", &p, "S").unwrap();
let Mapped::Steps(steps) = (event_for(&item.event).unwrap().from)(item.descriptor.as_ref().unwrap()) else { panic!("did not import") };
assert_eq!(steps[0].1["stops"], json!([[0.0, "#00ff00"], [1.0, "#ffffff"]]));
// What the event cannot hold is refused: a Set without its transparency, toolbox colours,
// dragged handles, a path, a midpoint off a whole percent.
for (id, p) in [
("gradient.fill.set", json!({"stops": [[0, "#ff0000"], [1, "#0000ff"]]})),
("gradient.fill.set", json!({"stops": [[0, "foreground"], [1, "#0000ff"]], "transparency": [[0, 100], [1, 100]]})),
("gradient.fill.set", json!({"from": [0, 0], "stops": [[0, "#ff0000"], [1, "#0000ff"]], "transparency": [[0, 100], [1, 100]]})),
("gradient.fill.set", json!({"stops": [[0, "#ff0000"], [1, "#0000ff"]], "transparency": [[0, 100], [1, 100]], "midpoints": [0.333]})),
("layer.newFillLayer.gradient", json!({"from": "#ff0000", "to": "#0000ff", "path": "work"})),
("layer.newFillLayer.gradient", json!({"from": "#ff0000"})),
] {
assert!(export_item(id, &p, "S").is_none(), "{id}: {p}");
}
}
/// #10771: a Noise gradient, smoothness below 100 % or a transparency midpoint stay Photoshop
/// steps rather than playing as a different fill.
#[test]
fn gradient_fills_the_command_cannot_play_stay_photoshop_steps() {
// `with` appends; these replace the recorded value.
fn replaced(mut d: Descriptor, key: &str, v: D) -> Descriptor {
d.items.retain(|(k, _)| !k.is(key));
d.with(key, v)
}
let base = recorded_gradient([50, 50, 50]);
let with_grad = |f: &dyn Fn(Descriptor) -> Descriptor| {
let g = get_obj(&base, "Grad").unwrap().clone();
replaced(base.clone(), "Grad", D::Descriptor(f(g)))
};
let trns_mid = desc("TrnS").with("Opct", pct(100.0)).with("Lctn", D::Integer(0)).with("Mdpn", D::Integer(30));
let trns_end = desc("TrnS").with("Opct", pct(100.0)).with("Lctn", D::Integer(4096)).with("Mdpn", D::Integer(50));
let trns = |at: i32, o: f64| D::Descriptor(desc("TrnS").with("Opct", pct(o)).with("Lctn", D::Integer(at)).with("Mdpn", D::Integer(50)));
for t in [
// A midpoint outside the dialog's 5..95 % would play clamped.
recorded_gradient([50, 2, 50]),
recorded_gradient([50, 50, 97]),
// Transparency stops out of range or out of order would play clamped and sorted.
with_grad(&|g| replaced(g, "Trns", D::List(vec![trns(0, 100.0), trns(5000, 40.0)]))),
with_grad(&|g| replaced(g, "Trns", D::List(vec![trns(0, 150.0), trns(4096, 40.0)]))),
with_grad(&|g| replaced(g, "Trns", D::List(vec![trns(4096, 100.0), trns(0, 40.0)]))),
// A Set without transparency stops would keep the layer's own.
with_grad(&|mut g| {
g.items.retain(|(k, _)| !k.is("Trns"));
g
}),
with_grad(&|g| replaced(g, "GrdF", enm("GrdF", "ClNs"))),
with_grad(&|g| replaced(g, "Intr", D::Double(2048.0))),
with_grad(&|g| replaced(g, "Trns", D::List(vec![D::Descriptor(trns_mid.clone()), D::Descriptor(trns_end.clone())]))),
base.clone().with("gradientsInterpolationMethod", enm("gradientInterpolationMethodType", "Perc")),
] {
let imp = import(&atn::write(&one_action(vec![gradient_set(t)])).unwrap()).unwrap();
assert_eq!(imp.actions[0].steps[0].0, PHOTOSHOP_STEP);
}
// A Make whose new layer also gets a name, opacity, mode or clipping: the command plays none
// of them, so the step stays as recorded.
for (key, v) in [("Nm ", text("Sky")), ("Opct", pct(50.0)), ("Md ", enm("BlnM", "Mltp")), ("Grup", D::Boolean(true))] {
let using = desc("contentLayer").with("Type", D::Descriptor(base.clone())).with(key, v);
let make = item("Mk ", "Make", Some(desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(using))));
let imp = import(&atn::write(&one_action(vec![make])).unwrap()).unwrap();
assert_eq!(imp.actions[0].steps[0].0, PHOTOSHOP_STEP, "{key}");
}
}
fn blend_if_entry(channel: &str, this: [i32; 4], under: Option<[i32; 4]>) -> D {
let mut d = desc("Blnd").with("Chnl", channel_ref(Some(channel)));
for (k, v) in ["SrcB", "Srcl", "SrcW", "Srcm"].into_iter().zip(this) {
d = d.with(k, D::Integer(v));
}
if let Some(u) = under {
for (k, v) in ["DstB", "Dstl", "DstW", "Dstt"].into_iter().zip(u) {
d = d.with(k, D::Integer(v));
}
}
D::Descriptor(d)
}
fn layer_set(t: Descriptor) -> AtnItem {
item("setd", "Set", Some(desc("null").with("null", target_ref("Lyr ")).with("T ", D::Descriptor(t))))
}
fn blend_layers(depth: u8) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8, "depth": depth})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("edit.fill", json!({"color": "#808080"})).unwrap();
s
}
fn active_info(s: &Session) -> Value {
let st = s.active().unwrap();
crate::inspect::layer(st.doc.layer(st.active_layer.unwrap()).unwrap())
}
/// #10771: Blend If loads as `layer.layerStyle.blendingOptions` and plays (it used to be a
/// silent no-op), on 8- and 16-bit documents, Gray and a colour channel, split sliders.
#[test]
fn blend_if_imports_and_plays() {
let blnd = D::List(vec![blend_if_entry("gray", [0, 30, 200, 255], Some([10, 10, 240, 250])), blend_if_entry("red", [20, 40, 255, 255], None)]);
for depth in [8, 16] {
let mut s = blend_layers(depth);
let loaded = s
.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![layer_set(desc("Lyr ").with("Blnd", blnd.clone()))])).unwrap())}))
.unwrap();
assert_eq!(loaded["unsupported"], json!([]), "{loaded}");
let mut expected = json!({"blendIf": [{"channel": "gray", "thisLayer": [0, 30, 200, 255], "underlying": [10, 10, 240, 250]}, {"channel": "red", "thisLayer": [20, 40, 255, 255]}]});
normalise(&mut expected);
let (id, p) = &s.actions.list[0].steps[0];
assert_eq!((id.as_str(), p), ("layer.layerStyle.blendingOptions", &expected));
let played = s.execute("actions.play", json!({"action": "A"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
let b = active_info(&s)["blendIf"].clone();
assert_eq!(b[0], json!({"channel": 0, "thisLayer": [0, 30, 200, 255], "underlying": [10, 10, 240, 250]}), "{b}");
assert_eq!(b[1], json!({"channel": 1, "thisLayer": [20, 40, 255, 255], "underlying": [0, 0, 255, 255]}), "{b}");
// It saves back as the same `Blnd`.
let (_, d) = saved_first(&mut s);
assert_eq!(get_obj(d.as_ref().unwrap(), "T ").unwrap().get("Blnd"), Some(&blnd));
}
}
/// #10771: the Blending Options dialog's OK (opacity, fill, mode and Blend If in one Set) is one
/// step that applies them all; a Set that also renames splits into setProps and Blend If.
#[test]
fn blend_if_with_the_dialog_options_is_one_step_and_a_rename_splits() {
let blnd = D::List(vec![blend_if_entry("gray", [0, 0, 128, 255], None)]);
let dialog = desc("Lyr ").with("Opct", pct(40.0)).with("fillOpacity", pct(70.0)).with("Md ", enm("BlnM", "Mltp")).with("Blnd", blnd.clone());
let mut s = blend_layers(8);
s.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![layer_set(dialog.clone())])).unwrap())})).unwrap();
let steps = &s.actions.list[0].steps;
assert_eq!(steps.len(), 1, "{steps:?}");
assert_eq!(steps[0].0, "layer.layerStyle.blendingOptions");
let played = s.execute("actions.play", json!({"action": "A"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
let info = active_info(&s);
assert!((info["opacity"].as_f64().unwrap() - 0.4).abs() < 1e-3, "{info}");
assert_eq!(info["blend"], json!("Multiply"), "{info}");
assert_eq!(info["blendIf"][0]["thisLayer"], json!([0, 0, 128, 255]), "{info}");
let (_, d) = saved_first(&mut s);
assert_eq!(get_obj(d.as_ref().unwrap(), "T "), Some(&dialog));
// With a rename the Set is not the dialog's: the name and opacity go through setProps.
let mut s = blend_layers(8);
s.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![layer_set(desc("Lyr ").with("Nm ", text("Renamed")).with("Opct", pct(40.0)).with("Blnd", blnd))])).unwrap())}))
.unwrap();
let ids: Vec<&str> = s.actions.list[0].steps.iter().map(|(id, _)| id.as_str()).collect();
assert!(ids.contains(&"layer.setProps") && ids.contains(&"layer.layerStyle.blendingOptions"), "{ids:?}");
let played = s.execute("actions.play", json!({"action": "A"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
let info = active_info(&s);
assert_eq!(info["name"], json!("Renamed"));
assert_eq!(info["blendIf"][0]["thisLayer"], json!([0, 0, 128, 255]), "{info}");
}
/// #10771: the Layer Style dialog's OK on a layer without effects also records the effects'
/// scale (`Lefx` = {Scl}). With Blend If it is still the dialog's one step and saves back with it.
#[test]
fn blend_if_with_the_recorded_effects_scale_saves_back_unchanged() {
let blnd = D::List(vec![blend_if_entry("gray", [10, 20, 200, 230], Some([0, 0, 255, 255]))]);
let dialog = desc("Lyr ").with("Opct", pct(80.0)).with("Blnd", blnd.clone()).with("Lefx", D::Descriptor(desc("Lefx").with("Scl ", pct(100.0))));
let mut s = blend_layers(8);
s.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![layer_set(dialog.clone())])).unwrap())})).unwrap();
let steps = &s.actions.list[0].steps;
assert_eq!(steps.len(), 1, "{steps:?}");
assert_eq!(steps[0].0, "layer.layerStyle.blendingOptions");
let played = s.execute("actions.play", json!({"action": "A"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
let info = active_info(&s);
assert!((info["opacity"].as_f64().unwrap() - 0.8).abs() < 1e-3, "{info}");
assert_eq!(info["blendIf"][0]["thisLayer"], json!([10, 20, 200, 230]), "{info}");
let (_, d) = saved_first(&mut s);
assert_eq!(get_obj(d.as_ref().unwrap(), "T "), Some(&dialog));
// A `Lefx` with an effect in it is not the scale alone: the step is not the dialog's.
let with_fx = desc("Lyr ").with("Blnd", blnd).with("Lefx", D::Descriptor(desc("Lefx").with("Scl ", pct(100.0)).with("DrSh", D::Descriptor(desc("DrSh")))));
let imp = import(&atn::write(&one_action(vec![layer_set(with_fx)])).unwrap()).unwrap();
assert!(imp.actions[0].steps.iter().all(|(_, p)| p.get("__atnEffectsScale").is_none()), "{:?}", imp.actions[0].steps);
// Nor can it play without the shadow: it stays the recorded step.
assert_eq!(imp.actions[0].steps.iter().map(|(c, _)| c.as_str()).collect::<Vec<_>>(), [PHOTOSHOP_STEP], "{:?}", imp.actions[0].steps);
}
/// #10771: Blend If entries that are not whole levels, out of order, with half the Underlying
/// sliders or an unknown key stay Photoshop steps.
#[test]
fn blend_if_the_command_cannot_play_stays_a_photoshop_step() {
let with = |key: &str| match blend_if_entry("gray", [0, 0, 255, 255], None) {
D::Descriptor(d) => D::Descriptor(d.with(key, D::Integer(3))),
e => e,
};
let (half, unknown) = (with("DstB"), with("Zzzz"));
let inverted = blend_if_entry("gray", [0, 200, 100, 255], None);
for e in [blend_if_entry("gray", [0, 0, 300, 255], None), inverted, half, unknown] {
let imp = import(&atn::write(&one_action(vec![layer_set(desc("Lyr ").with("Blnd", D::List(vec![e])))])).unwrap()).unwrap();
assert_eq!(imp.actions[0].steps[0].0, PHOTOSHOP_STEP);
}
for p in [
json!({"blendIf": null}),
json!({"blendIf": {"channel": 2, "thisLayer": [0, 255]}}),
json!({"blendIf": {"channel": "gray", "thisLayer": [200, 100]}}),
json!({"blendIf": {"channel": "gray", "underlying": [0, 255]}}),
] {
assert!(export_item("layer.layerStyle.blendingOptions", &p, "S").is_none(), "{p}");
}
}
/// Save one action of steps through `actions.save`, returning the skipped list and the steps
/// `actions.load` reads back.
fn save_and_reload(steps: Vec<(String, Value)>) -> (Value, Vec<(String, Value)>) {
let mut s = Session::new();
let mut action = Action::new("A", steps);
action.set = Some("Review".into());
s.actions.list.push(action);
let saved = s.execute("actions.save", json!({"set": "Review", "skipUnsupported": true})).unwrap();
let mut fresh = Session::new();
fresh.execute("actions.load", json!({"data": saved["data"]})).unwrap();
(saved["skipped"].clone(), fresh.actions.list[0].steps.clone())
}
/// Calculations plays an omitted blending as Multiply, so Save Actions writes it as Multiply.
#[test]
fn calculations_without_blending_saves_as_multiply() {
let sources = json!({"source1": {"channel": {"color": "red"}}, "source2": {"channel": {"color": "green"}}});
for blending in [None, Some(Value::Null)] {
let mut params = sources.clone();
if let Some(b) = blending.clone() {
params["blending"] = b;
}
let (skipped, steps) = save_and_reload(vec![("image.calculations".into(), params)]);
assert_eq!(skipped, json!([]), "{blending:?}");
let mut expected = sources.clone();
expected["blending"] = json!("multiply");
assert_eq!(steps, vec![("image.calculations".to_string(), expected)], "{blending:?}");
}
let mut unknown = sources.clone();
unknown["blending"] = json!("bogus");
assert_eq!(save_and_reload(vec![("image.calculations".into(), unknown)]).0.as_array().map(Vec::len), Some(1));
}
/// A transparency source with no layer reads the active layer's transparency; Save Actions
/// refuses it rather than write the merged transparency. Merged and named layers still save.
#[test]
fn apply_image_active_layer_transparency_is_not_saved_as_merged() {
for source in [json!({"channel": "transparency"}), json!({"channel": "Transparency", "layer": null})] {
let (skipped, _) = save_and_reload(vec![("image.applyImage".into(), json!({"source": source, "blending": "normal"}))]);
assert_eq!(skipped.as_array().map(Vec::len), Some(1), "{source}: {skipped}");
}
for layer in ["merged", "Pixels"] {
let params = json!({"source": {"channel": "transparency", "layer": layer}, "blending": "normal"});
let (skipped, steps) = save_and_reload(vec![("image.applyImage".into(), params)]);
assert_eq!(skipped, json!([]), "{layer}");
assert_eq!(steps[0].1["source"]["layer"], layer, "{layer}: {steps:?}");
}
// Other channels keep the merged default for an omitted layer.
let (skipped, steps) = save_and_reload(vec![("image.applyImage".into(), json!({"source": {"channel": "red"}, "blending": "normal"}))]);
assert_eq!(skipped, json!([]));
assert_eq!(steps[0].1["source"]["layer"], "merged");
}
fn make_levels_layer(using: Descriptor) -> Imported {
let levels = desc("Lvls").with("Adjs", D::List(vec![D::Descriptor(desc("LvlA").with("Chnl", channel_ref(None)).with("Gmm ", D::Double(1.2)))]));
let make = desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(using.with("Type", D::Descriptor(levels))));
let file = AtnFile {
name: UnicodeString::new_nul("S"),
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items: vec![item("Mk ", "Make", Some(make))], ..Default::default() }],
..Default::default()
};
import(&atn::write(&file).unwrap()).unwrap()
}
/// A Make adjustment layer takes `Md ` as its blend mode only when it is a `BlnM` enumeration.
#[test]
fn adjustment_layer_blend_mode_needs_a_blend_mode_enumeration() {
let imported = make_levels_layer(desc("AdjL").with("Md ", enm("BlnM", "Mltp")));
assert!(imported.unsupported.is_empty());
assert_eq!(imported.actions[0].steps[0].1["mode"], "Multiply");
let imported = make_levels_layer(desc("AdjL").with("Md ", enm("Ordn", "Mltp")));
assert_eq!(imported.unsupported.len(), 1);
assert_eq!(imported.actions[0].steps[0].0, PHOTOSHOP_STEP);
// A plain Make layer drops it rather than read another enumeration as a mode.
let make = desc("null").with("null", class_ref("Lyr ")).with("Usng", D::Descriptor(desc("Lyr ").with("Md ", enm("Ordn", "Mltp"))));
let file = AtnFile {
name: UnicodeString::new_nul("S"),
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items: vec![item("Mk ", "Make", Some(make))], ..Default::default() }],
..Default::default()
};
let imported = import(&atn::write(&file).unwrap()).unwrap();
assert_eq!(imported.actions[0].steps[0].0, "layer.new.layer");
assert!(imported.actions[0].steps[0].1.get("mode").is_none(), "{:?}", imported.actions[0].steps);
}
/// An adjustment layer name that would not save back unchanged keeps the whole step.
#[test]
fn adjustment_layer_name_must_round_trip() {
for (name, kept) in [
(UnicodeString::new_nul("Grade"), true),
(UnicodeString::new("Grade"), true),
(UnicodeString(vec![u16::from(b'G'), 0xD800, 0]), false),
(UnicodeString(vec![u16::from(b'G'), 0, 0]), false),
] {
let imported = make_levels_layer(desc("AdjL").with("Nm ", D::Text(name.clone())));
let step = &imported.actions[0].steps[0];
if kept {
assert!(imported.unsupported.is_empty(), "{name:?}");
assert_eq!(step.1["name"], "Grade", "{name:?}");
} else {
assert_eq!(step.0, PHOTOSHOP_STEP, "{name:?}");
}
}
}