Files
photocraft/crates/engine/src/atn_cmds/tests.rs
T
aiongg 4eef797fdf Merge PR #7968: fix(atn): a Group Layers step keeps its explicit layer through .atn (#7236)
Conflicts:
crates/engine/src/atn_cmds/tests.rs: appended test helper and tests (two blocks), kept both
2026-10-22 02:25:59 +00:00

1900 lines
106 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;
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)
}
/// 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("make", "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("play", "Play", Some(play)),
off,
],
..Default::default()
},
AtnAction { name: UnicodeString::new_nul("Finish"), items: vec![item("Invs", "Inverse", None)], ..Default::default() },
],
warnings: Vec::new(),
}
}
#[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");
}
#[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![("filter.render.clouds".into(), json!({}))]));
let e = s.execute("actions.save", json!({"action": "Ours"})).unwrap_err().to_string();
assert!(e.contains("filter.render.clouds"), "{e}");
let r = s.execute("actions.save", json!({"action": "Ours", "skipUnsupported": true})).unwrap();
assert_eq!(r["skipped"], json!([{"action": "Ours", "step": 0, "id": "filter.render.clouds"}]));
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}");
}
#[test]
fn every_mapped_command_exports_and_imports_back() {
let cases: Vec<(&str, Value)> = vec![
("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})),
("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!({})),
(
"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"})),
("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"})),
("layer.new.group", json!({"name": "G"})),
("layer.groupLayers", json!({})),
("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.select", json!({"layer": "Shadow", "mode": "add"})),
("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!({})),
("tools.defaultColors", json!({})),
("edit.convertToProfile", json!({"profile": "display-p3", "intent": "perceptual", "bpc": 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!({"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})),
("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!({})),
("paint.bucket", json!({"x": 10, "y": 20, "tolerance": 32, "antiAlias": true, "contiguous": false, "opacity": 100})),
("image.imageSize", json!({"percent": 200, "resample": "bicubicAutomatic"})),
("file.placeEmbedded", json!({"path": "/tmp/logo.png", "scale": 50})),
("file.placeLinked", json!({"path": "/tmp/logo.png"})),
("file.openAs", json!({"path": "/tmp/a.psd"})),
("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.pasteLayerStyle", json!({})),
("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})),
// 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})),
(
"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.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"})),
("image.mode.bitmap", json!({"method": "threshold"})),
("image.imageSize", json!({"resolution": 1200, "resample": "preserveDetails", "keepPrintSize": true})),
("layer.new.layerFromBackground", json!({"ifPresent": true, "select": false})),
];
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()));
match (e.from)(item.descriptor.as_ref().unwrap()) {
Mapped::Steps(steps) => {
assert_eq!(steps.len(), 1, "{id}: {steps:?}");
assert_eq!(steps[0].0, *id, "{id} came back as another command");
let mut back = steps[0].1.clone();
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"),
}
}
// 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.selectAbove",
"layer.selectBottom",
"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",
];
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}");
}
}
/// `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),
// 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![],
};
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, "filter.blur.gaussianBlur", PHOTOSHOP_STEP]);
assert_eq!(imp.unsupported.len(), 5);
assert!(imp.actions[0].disabled.is_empty(), "steps with a document effect stay on so playback reports them");
}
/// #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("make", "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![],
};
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![],
};
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("fill", "Fill", Some(bucket_px)),
item("fill", "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![],
};
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![],
};
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.arrange.sendToBack", "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[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"}));
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)
}
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![],
};
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` and
/// `BlnW` ("the command is not currently available"), while the names `boxblur`,
/// `surfaceBlur`, `flattenImage`, `blackAndWhite` 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 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"),
];
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"] {
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 { .. } => {}
}
}
/// 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. #7516-#7519: a reference point other than the centre, 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": "topLeft"}),
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}");
}
}
/// 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![],
};
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")));
}
/// 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}))]);
}
}
/// 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");
}
/// 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 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![],
};
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"}));
}
/// 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"}))]);
}
}