From 3204231d7dae696d340db02ce8cfa8a820686350 Mon Sep 17 00:00:00 2001 From: Fabio Lissi Date: Mon, 12 Oct 2026 17:39:57 -0500 Subject: [PATCH] Load and save Photoshop Actions (.atn) (#4356, #219) (#4434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Load and save Photoshop Actions (.atn) (#219) - photocraft-psd: `atn` parser and writer for version-16 action sets, exact round trip, truncation/garbage safe, fuzz target and `atn_dump` example. Descriptor fix: `indx` and `Idnt` reference items carry a class before the number (real action files failed to parse). - photocraft-engine: `atn_cmds` maps 92 Photoshop events to commands and back (filters, adjustments per channel, image/canvas/crop/trim/rotate/flip/mode, make/set/select/delete/ hide/show/move/duplicate, fill/stroke, selection, clipboard, purge, profiles, play, stop). Other steps load as `actions.photoshopStep` (descriptor kept, written back by save, fails at playback by name); tool selections load switched off. Commands `actions.load`, `actions.save`, `actions.stopMessage`, `actions.photoshopStep`. `Action` gains `dialogs` and `set`. `layer` params accept a layer name, so loaded steps replay in any document. - UI: Actions panel menu › Load Actions… / Save Actions…; Photoshop steps labelled. - CLI: `batch --actions set.atn`. Automation policy covers the new path params. - Docs: file-format parity (.atn R/W), gaps G11, target-app parity, scorecard AUTO-219-2 partial, development guide. * Translate the Actions panel's Load/Save strings in every catalog The eight new panel strings (Load/Save Actions…, the loaded/saved counts, the kept-step label) were missing from all 19 catalogs; the languages that were complete are back at 100%. * Show the Actions entries in the default dock's panel menu The legacy dock renders its own group menu and only knew the Layers and Swatches entries, so Load Actions… and Save Actions… were missing there. --- apps/photocraft-cli/src/lib.rs | 43 +- apps/photocraft-cli/tests/cli.rs | 30 + crates/automation/src/workspace.rs | 8 +- crates/automation/tests/agent_tasks.rs | 1 + crates/engine/src/actions_cmds.rs | 69 +- crates/engine/src/atn_cmds.rs | 2470 +++++++++++++++++ crates/engine/src/atn_cmds/tests.rs | 488 ++++ crates/engine/src/automate_cmds/tests.rs | 16 +- crates/engine/src/commands.rs | 29 +- crates/engine/src/cutout_cmds.rs | 7 +- crates/engine/src/layer_multi_cmds.rs | 10 +- crates/engine/src/layer_style.rs | 10 +- crates/engine/src/lib.rs | 1 + crates/engine/src/transform_cmds.rs | 5 +- crates/psd/README.md | 1 + crates/psd/examples/atn_dump.rs | 84 + crates/psd/fuzz/Cargo.toml | 7 + crates/psd/fuzz/fuzz_targets/atn.rs | 14 + crates/psd/src/atn.rs | 426 +++ crates/psd/src/descriptor.rs | 40 +- crates/psd/src/lib.rs | 1 + crates/ui-egui/src/actions.rs | 152 +- crates/ui-egui/src/control.rs | 3 + crates/ui-egui/src/dock.rs | 4 + crates/ui-egui/src/i18n/cs.tsv | 10 + crates/ui-egui/src/i18n/de.tsv | 10 + crates/ui-egui/src/i18n/el.tsv | 10 + crates/ui-egui/src/i18n/es.tsv | 10 + crates/ui-egui/src/i18n/fr.tsv | 10 + crates/ui-egui/src/i18n/hu.tsv | 10 + crates/ui-egui/src/i18n/id.tsv | 10 + crates/ui-egui/src/i18n/it.tsv | 10 + crates/ui-egui/src/i18n/ja.tsv | 10 + crates/ui-egui/src/i18n/ko.tsv | 10 + crates/ui-egui/src/i18n/nl.tsv | 10 + crates/ui-egui/src/i18n/pl.tsv | 10 + crates/ui-egui/src/i18n/pt-br.tsv | 10 + crates/ui-egui/src/i18n/ru.tsv | 10 + crates/ui-egui/src/i18n/tr.tsv | 10 + crates/ui-egui/src/i18n/uk.tsv | 10 + crates/ui-egui/src/i18n/vi.tsv | 10 + crates/ui-egui/src/i18n/zh-hans.tsv | 10 + crates/ui-egui/src/i18n/zh-hant.tsv | 10 + crates/ui-egui/src/panel_docking.rs | 4 + crates/ui-egui/src/shortcut_dispatch/tests.rs | 4 + docs/development.md | 15 + docs/file-format-parity.md | 5 +- docs/gaps.md | 14 +- docs/scorecard.md | 4 +- docs/target-app-parity.md | 11 +- scorecard/automation.toml | 4 +- 51 files changed, 4091 insertions(+), 79 deletions(-) create mode 100644 crates/engine/src/atn_cmds.rs create mode 100644 crates/engine/src/atn_cmds/tests.rs create mode 100644 crates/psd/examples/atn_dump.rs create mode 100644 crates/psd/fuzz/fuzz_targets/atn.rs create mode 100644 crates/psd/src/atn.rs diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index 93090610a5..7bf7f8164d 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -26,8 +26,9 @@ USAGE: photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] Open a file, run engine commands in order, save the result. Each --params applies to the preceding --cmd. Prints each command's JSON result. - photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] - Apply an action list to every image in a directory. Steps are [id, params] pairs, + photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] + Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a + set: its action --action names plays, as below. JSON steps are [id, params] pairs, {\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them (a list, or wrapped in {\"actions\": …}, {\"steps\": …} or a droplet). An Actions set (the Actions panel's actions.json, {\"actions\": [{\"name\", \"steps\"}…]}) plays the action --action names (needed @@ -392,6 +393,38 @@ pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec) -> Result<(Steps, Vec, Vec), String> { + let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; + let is_atn = Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("atn")) || bytes.starts_with(&[0, 0, 0, 16]); + if !is_atn { + let text = String::from_utf8(bytes).map_err(|e| format!("{path}: {e}"))?; + let (steps, set) = load_actions(&text, name)?; + return Ok((steps, set, Vec::new())); + } + let imp = photocraft_engine::atn_cmds::import(&bytes).map_err(|e| format!("{path}: {e}"))?; + let names = || imp.actions.iter().map(|a| format!("`{}`", a.name)).collect::>().join(", "); + let action = match (name, imp.actions.as_slice()) { + (Some(n), _) => { + imp.actions.iter().find(|a| a.name == n).ok_or_else(|| format!("no action named `{n}` in the set `{}` (it holds {})", imp.set, names()))? + } + (None, [only]) => only, + (None, []) => return Err(format!("{path}: the set holds no actions")), + (None, _) => return Err(format!("the set `{}` holds {} actions: pick one with --action ({})", imp.set, imp.actions.len(), names())), + }; + let warnings = imp + .unsupported + .iter() + .filter(|u| u.action == action.name) + .map(|u| format!("`{}` step {}: Photoshop's \"{}\" ({}) has no PhotoCraft equivalent yet", u.action, u.step + 1, u.name, u.event)) + .chain(imp.warnings.iter().cloned()) + .collect(); + let play = vec![("actions.play".to_string(), json!({"action": action.name}))]; + Ok((play, imp.actions, warnings)) +} + /// Whether `a` and `b` name the same existing folder, however they are spelt (relative, with a /// trailing separator, through a symbolic link). fn same_dir(a: &Path, b: &Path) -> bool { @@ -410,8 +443,10 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { let actions_path = a.get("--actions").ok_or("batch needs --actions ")?; let in_dir = PathBuf::from(a.get("--in").ok_or("batch needs --in ")?); let out_dir = PathBuf::from(a.get("--out").ok_or("batch needs --out ")?); - let text = std::fs::read_to_string(actions_path).map_err(|e| format!("{actions_path}: {e}"))?; - let (actions, set) = load_actions(&text, a.get("--action"))?; + let (actions, set, warnings) = load_actions_file(actions_path, a.get("--action"))?; + for w in &warnings { + let _ = writeln!(err, "warning: {w}"); + } let opts = export_opts(a)?; // Results are saved as `/.`, so an `--out` that is the `--in` folder would // replace the originals (#492). diff --git a/apps/photocraft-cli/tests/cli.rs b/apps/photocraft-cli/tests/cli.rs index ea807e9882..162483bcf9 100644 --- a/apps/photocraft-cli/tests/cli.rs +++ b/apps/photocraft-cli/tests/cli.rs @@ -684,3 +684,33 @@ fn a_non_unicode_argument_is_a_usage_error_not_a_panic() { assert!(!err.contains("panicked"), "{err}"); } } + +/// `batch --actions set.atn`: a Photoshop Actions file plays like an Actions set, and its steps +/// PhotoCraft can't play are warned about up front. +#[test] +fn batch_plays_a_photoshop_actions_file() { + use photocraft_engine::actions_cmds::Action; + let d = tmp("atn"); + let input = d.join("in"); + std::fs::create_dir_all(&input).unwrap(); + write_png(&input.join("a.png"), 8, 4, 3); + let invert = Action::new("Negative", vec![("image.adjustments.invert".into(), json!({})), ("select.all".into(), json!({}))]); + let odd = Action::new("Odd", vec![(photocraft_engine::atn_cmds::PHOTOSHOP_STEP.into(), json!({"event": "syntheticFill", "name": "Generative Fill"}))]); + let (bytes, skipped) = photocraft_engine::atn_cmds::export("Set", &[&invert, &odd], &[], false).unwrap(); + assert!(skipped.is_empty()); + let atn = d.join("Set.atn"); + std::fs::write(&atn, bytes).unwrap(); + let out_dir = d.join("out"); + // Two actions: the set needs --action. + let o = bin().args(["batch", "--actions"]).arg(&atn).arg("--in").arg(&input).arg("--out").arg(&out_dir).output().unwrap(); + assert!(!o.status.success()); + assert!(String::from_utf8_lossy(&o.stderr).contains("--action"), "{}", String::from_utf8_lossy(&o.stderr)); + let (_, err) = ok(bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Negative", "--in"]).arg(&input).arg("--out").arg(&out_dir)); + assert!(out_dir.join("a.png").exists()); + assert!(!err.contains("warning"), "{err}"); + // The unsupported step is warned about, and fails the file as Photoshop would stop on it. + let o = bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Odd", "--in"]).arg(&input).arg("--out").arg(d.join("out2")).output().unwrap(); + let err = String::from_utf8_lossy(&o.stderr); + assert!(err.contains("Generative Fill"), "{err}"); + assert!(!o.status.success()); +} diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index d34219e762..eaed0e1c65 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -363,12 +363,12 @@ fn preference_uses_ambient_filesystem(path: &str) -> bool { fn command_uses_ambient_path(id: &str, params: &Value) -> bool { match id { - "brush.presets.importAbr" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => { + "actions.load" | "brush.presets.importAbr" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => { // `data` wins over `path` in these commands; any `path` without it reads the filesystem. params.get("data").is_none() && params.get("path").is_some() } // With a `path` the file is written there; without one the bytes come back as `data`. - "swatches.export" => params.get("path").is_some(), + "actions.save" | "swatches.export" => params.get("path").is_some(), "plugin.reload" => true, _ => false, } @@ -765,6 +765,8 @@ mod tests { ("plugin.install", serde_json::json!({"path": " "})), ("swatches.import", serde_json::json!({"path": "/outside/set.aco"})), ("swatches.export", serde_json::json!({"path": "/outside/set.ase"})), + ("actions.load", serde_json::json!({"path": "/outside/set.atn"})), + ("actions.save", serde_json::json!({"path": "/outside/set.atn"})), ] { assert!(authorize_engine_command(id, ¶ms).is_err(), "{id}: {params}"); } @@ -774,6 +776,8 @@ mod tests { ("plugin.install", serde_json::json!({"data": "QUJD"})), ("swatches.import", serde_json::json!({"data": "QUJD"})), ("swatches.export", serde_json::json!({"format": "ase"})), + ("actions.load", serde_json::json!({"data": "QUJD"})), + ("actions.save", serde_json::json!({"set": "S"})), ] { assert!(authorize_engine_command(id, ¶ms).is_ok(), "{id}: {params}"); } diff --git a/crates/automation/tests/agent_tasks.rs b/crates/automation/tests/agent_tasks.rs index b185e310a7..271993d2bb 100644 --- a/crates/automation/tests/agent_tasks.rs +++ b/crates/automation/tests/agent_tasks.rs @@ -331,6 +331,7 @@ async fn agent_play_refuses_a_recorded_file_open() { name: "Open".into(), steps: vec![("file.open".into(), json!({"path": "/etc/passwd"})), ("layer.new.layer".into(), json!({}))], disabled: Default::default(), + ..Default::default() }); let (c, server) = connect_headless(headless).await; let played = run(&c, "actions.play", json!({"action": "Open"})).await; diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index f6707c1b9f..2611853b2d 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -36,11 +36,26 @@ pub struct Action { /// Indices of steps excluded from playback (unchecked in the Actions panel). #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] pub disabled: BTreeSet, + /// Indices of steps whose dialog Photoshop shows during playback (the panel's dialog + /// toggle column). Headless playback runs them with their recorded params; the flag is + /// kept so Save Actions writes it back. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub dialogs: BTreeSet, + /// The action set the action belongs to: the folder Photoshop shows in the Actions panel, + /// filled by Load Actions (`actions.load`) and written by Save Actions (`actions.save`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub set: Option, } impl Action { pub fn new(name: impl Into, steps: Vec<(String, Value)>) -> Self { - Self { name: name.into(), steps, disabled: BTreeSet::new() } + Self { name: name.into(), steps, disabled: BTreeSet::new(), dialogs: BTreeSet::new(), set: None } + } + + /// Apply `f` to every per-step index set (`disabled`, `dialogs`) after steps moved. + fn remap_steps(&mut self, f: impl Fn(usize) -> Option) { + self.disabled = self.disabled.iter().filter_map(|&i| f(i)).collect(); + self.dialogs = self.dialogs.iter().filter_map(|&i| f(i)).collect(); } pub fn step_enabled(&self, step: usize) -> bool { @@ -166,7 +181,7 @@ fn num(v: &Value) -> Option { v.as_i64().or_else(|| v.as_f64().filter(|f| f.is_finite()).map(|f| f.round() as i64)) } -fn resolve(list: &[Action], p: &Value, cmd: &str) -> Result { +pub(crate) fn resolve(list: &[Action], p: &Value, cmd: &str) -> Result { let Some(v) = p.get("action") else { return Err(bad(cmd, "needs \"action\" (a name or an index)")); }; @@ -210,7 +225,7 @@ fn from_step(p: &Value, len: usize) -> Result { } fn list(s: &mut Session, _p: &Value) -> Result { - let actions: Vec = s.actions.list.iter().map(|a| json!({"name": a.name, "steps": a.steps.len()})).collect(); + let actions: Vec = s.actions.list.iter().map(|a| json!({"name": a.name, "steps": a.steps.len(), "set": a.set})).collect(); let recording = s.actions.recording.and_then(|(_, i)| s.actions.list.get(i).map(|a| a.name.clone())); Ok(json!({"actions": actions, "recording": recording})) } @@ -219,7 +234,7 @@ fn get(s: &mut Session, p: &Value) -> Result { let idx = resolve(&s.actions.list, p, "actions.get")?; let action = &s.actions.list[idx]; let steps: Vec = action.steps.iter().map(|(id, params)| json!([id, params])).collect(); - Ok(json!({"name": action.name, "steps": steps, "disabled": action.disabled})) + Ok(json!({"name": action.name, "steps": steps, "disabled": action.disabled, "dialogs": action.dialogs, "set": action.set})) } fn record(s: &mut Session, p: &Value) -> Result { @@ -386,7 +401,7 @@ fn move_item(s: &mut Session, p: &Value) -> Result { let action = &mut s.actions.list[idx]; let moved = action.steps.remove(step); action.steps.insert(to, moved); - action.disabled = action.disabled.iter().map(|&i| moved_index(i, step, to)).collect(); + action.remap_steps(|i| Some(moved_index(i, step, to))); let result = json!({"action": action.name, "step": to}); s.actions.touch(); Ok(result) @@ -434,7 +449,15 @@ fn delete(s: &mut Session, p: &Value) -> Result { } let action = &mut s.actions.list[idx]; action.steps.remove(step); - action.disabled = action.disabled.iter().filter(|&&i| i != step).map(|&i| if i > step { i - 1 } else { i }).collect(); + action.remap_steps(|i| { + if i == step { + None + } else if i > step { + Some(i - 1) + } else { + Some(i) + } + }); let result = json!({"action": action.name, "deletedStep": step, "steps": action.steps.len()}); s.actions.touch(); return Ok(result); @@ -546,7 +569,7 @@ pub fn specs() -> Vec { label: "List Actions", menu: &[], shortcut: None, - params: "{} → {actions:[{name, steps:count}], recording:name|null}", + params: "{} → {actions:[{name, steps:count, set:name|null}], recording:name|null}", enabled: always, run: list, journal: false, @@ -556,7 +579,7 @@ pub fn specs() -> Vec { label: "Get Action", menu: &[], shortcut: None, - params: r##"{"action":name|index} → {name, steps:[[id, params]…], disabled:[step…]} (steps is the shape file.automate.batch and droplets take; disabled lists the 0-based steps playback skips)"##, + params: r##"{"action":name|index} → {name, steps:[[id, params]…], disabled:[step…], dialogs:[step…], set:name|null} (steps is the shape file.automate.batch and droplets take; disabled lists the 0-based steps playback skips; dialogs the steps whose dialog Photoshop would show; set the action set from Load Actions)"##, enabled: always, run: get, journal: false, @@ -679,11 +702,17 @@ mod tests { #[test] fn rename_keeps_calls_and_function_key_and_rejects_bad_names() { let mut s = Session::new(); - s.actions.list.push(Action { name: "Action 1".into(), steps: vec![("layer.new.layer".into(), json!({}))], disabled: Default::default() }); + s.actions.list.push(Action { + name: "Action 1".into(), + steps: vec![("layer.new.layer".into(), json!({}))], + disabled: Default::default(), + ..Default::default() + }); s.actions.list.push(Action { name: "Caller".into(), steps: vec![("actions.play".into(), json!({"action": "Action 1"}))], disabled: Default::default(), + ..Default::default() }); s.edit_prefs(|p| p.shortcuts.insert(format!("{PLAY_SHORTCUT_PREFIX}Action 1"), "F2".into())); assert_eq!(s.execute("actions.rename", json!({"action": 0, "name": "Sepia"})).unwrap()["action"], "Sepia"); @@ -950,7 +979,12 @@ mod tests { #[test] fn play_refuses_recursion() { let mut s = Session::new(); - s.actions.list.push(Action { name: "Loop".into(), steps: vec![("actions.play".into(), json!({"action": "Loop"}))], disabled: Default::default() }); + s.actions.list.push(Action { + name: "Loop".into(), + steps: vec![("actions.play".into(), json!({"action": "Loop"}))], + disabled: Default::default(), + ..Default::default() + }); let r = s.execute("actions.play", json!({"action": "Loop"})).unwrap(); assert_eq!(r["ran"], 0); assert_eq!(r["failed"]["id"], "actions.play"); @@ -969,6 +1003,7 @@ mod tests { name: "Open".into(), steps: vec![("file.open".into(), json!({"path": "/etc/passwd"})), ("layer.new.layer".into(), json!({}))], disabled: Default::default(), + ..Default::default() }); let r = s.execute("actions.play", json!({"action": "Open"})).unwrap(); assert_eq!(r["ran"], 0); @@ -1046,6 +1081,7 @@ mod tests { name: "Child".into(), steps: vec![("layer.new.layer".into(), json!({"name": "Child layer"}))], disabled: Default::default(), + ..Default::default() }); s.execute("actions.record", json!({"name": "Parent"})).unwrap(); s.execute("layer.new.layer", json!({"name": "Before"})).unwrap(); @@ -1075,8 +1111,14 @@ mod tests { name: "A".into(), steps: vec![("actions.play".into(), json!({"action": "B"})), ("file.new".into(), json!({"width": 2,"height": 2}))], disabled: Default::default(), + ..Default::default() + }); + s.actions.list.push(Action { + name: "B".into(), + steps: vec![("actions.play".into(), json!({"action": "A"}))], + disabled: Default::default(), + ..Default::default() }); - s.actions.list.push(Action { name: "B".into(), steps: vec![("actions.play".into(), json!({"action": "A"}))], disabled: Default::default() }); let r = s.execute("actions.play", json!({"action": "A"})).unwrap(); assert!(r["failed"]["error"].as_str().unwrap().contains("recursive")); assert!(s.documents().is_empty()); @@ -1092,6 +1134,7 @@ mod tests { name: format!("N{i}"), steps: if i < 16 { vec![("actions.play".into(), json!({"action":format!("N{}", i + 1)}))] } else { vec![] }, disabled: Default::default(), + ..Default::default() }); } let r = s.execute("actions.play", json!({"action": "N0"})).unwrap(); @@ -1136,7 +1179,7 @@ mod tests { #[test] fn new_names_do_not_collide_with_named_calls() { let mut s = Session::new(); - s.actions.list.push(Action { name: "Action 2".into(), steps: vec![], disabled: Default::default() }); + s.actions.list.push(Action { name: "Action 2".into(), steps: vec![], disabled: Default::default(), ..Default::default() }); assert_eq!(s.execute("actions.record", json!({})).unwrap()["action"], "Action 3"); s.execute("actions.stop", json!({})).unwrap(); assert!(s.execute("actions.record", json!({"name":"Action 2"})).is_err()); @@ -1148,7 +1191,7 @@ mod tests { let mut s = Session::new(); s.execute("file.new", json!({"width":4,"height":4})).unwrap(); for name in ["A", "B", "C"] { - s.actions.list.push(Action { name: name.into(), steps: vec![], disabled: Default::default() }); + s.actions.list.push(Action { name: name.into(), steps: vec![], disabled: Default::default(), ..Default::default() }); } s.execute("actions.record", json!({"action":"B"})).unwrap(); s.execute("layer.new.layer", json!({"name":"before"})).unwrap(); diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs new file mode 100644 index 0000000000..4e5de06970 --- /dev/null +++ b/crates/engine/src/atn_cmds.rs @@ -0,0 +1,2470 @@ +//! Photoshop Actions files (`.atn`): Load Actions (`actions.load`) and Save Actions +//! (`actions.save`), plus the two step commands a Photoshop set needs: `actions.stopMessage` +//! (Photoshop's Insert Stop) and `actions.photoshopStep` (a step PhotoCraft has no command for +//! yet, kept verbatim so Save Actions writes it back unchanged). +//! +//! Parsing and writing live in `photocraft-psd` (`atn`); this module maps Photoshop *events* +//! (`gaussianBlur` with `Rds`, `make` of an adjustment layer, `set` of the selection…) onto +//! PhotoCraft commands and back. An event is keyed by both its four-character code (`GsnB`, +//! older files) and its string name (`gaussianBlur`, Photoshop 2024 and later), as the two are +//! interchangeable in Photoshop. The table ([`EVENTS`]) is bidirectional: a step that was +//! imported exports to the same event. Events outside it become `actions.photoshopStep` steps, +//! which fail at playback with the step's Photoshop name, so an action never silently skips +//! work; tool selections and other steps with no document effect are imported switched off. +//! +//! Layer references by name (`select` of layer "Shadow") become `{"layer": "Shadow"}`, which +//! `commands::layer_param` resolves in whatever document the action plays on (#219). + +use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; +use photocraft_psd::descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}; +use serde_json::{Map, Value, json}; + +use crate::actions_cmds::Action; +use crate::commands::CommandSpec; +use crate::{EngineError, Result, Session}; + +/// A step PhotoCraft cannot play yet, kept verbatim (`{"event", "name", "descriptor": base64}`). +pub const PHOTOSHOP_STEP: &str = "actions.photoshopStep"; +/// Photoshop's Insert Stop: `{"message", "allowContinue"}`. +pub const STOP_MESSAGE: &str = "actions.stopMessage"; +/// The set name Save Actions uses for actions that came from no set. +pub const DEFAULT_SET: &str = "PhotoCraft Actions"; + +fn bad(cmd: &str, msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } +} + +fn always(_: &Session) -> std::result::Result<(), String> { + Ok(()) +} + +// ------------------------------------------------------------------ descriptor access + +fn num(v: &D) -> Option { + match v { + D::Integer(i) => Some(f64::from(*i)), + D::LargeInteger(i) => Some(*i as f64), + D::Double(d) | D::UnitFloat { value: d, .. } => Some(*d), + _ => None, + } + .filter(|f| f.is_finite()) +} + +fn trimmed(id: &Id) -> String { + String::from_utf8_lossy(id.as_bytes()).trim_end().to_string() +} + +fn get_num(d: &Descriptor, k: &str) -> Option { + d.get(k).and_then(num) +} + +fn get_bool(d: &Descriptor, k: &str) -> Option { + match d.get(k) { + Some(D::Boolean(b)) => Some(*b), + _ => None, + } +} + +fn get_text(d: &Descriptor, k: &str) -> Option { + match d.get(k) { + Some(D::Text(t)) => Some(t.to_string_lossy()), + _ => None, + } +} + +/// An enumerated value's id, trailing spaces removed (`Ordn.Trgt` → `"Trgt"`). +fn get_enum(d: &Descriptor, k: &str) -> Option { + match d.get(k) { + Some(D::Enumerated { value, .. }) => Some(trimmed(value)), + _ => None, + } +} + +fn get_obj<'a>(d: &'a Descriptor, k: &str) -> Option<&'a Descriptor> { + match d.get(k) { + Some(D::Descriptor(o) | D::GlobalObject(o)) => Some(o), + _ => None, + } +} + +fn get_list<'a>(d: &'a Descriptor, k: &str) -> Option<&'a [D]> { + match d.get(k) { + Some(D::List(l)) => Some(l), + _ => None, + } +} + +fn get_ref<'a>(d: &'a Descriptor, k: &str) -> Option<&'a [ReferenceItem]> { + match d.get(k) { + Some(D::Reference(r)) => Some(r), + _ => None, + } +} + +/// The class of a reference's first item, trimmed (`Lyr`, `Chnl`, `AdjL`…). +fn ref_class(r: &[ReferenceItem]) -> Option { + Some(trimmed(match r.first()? { + ReferenceItem::Property { class, .. } + | ReferenceItem::Class(class) + | ReferenceItem::Enumerated { class, .. } + | ReferenceItem::Offset { class, .. } + | ReferenceItem::Identifier { class, .. } + | ReferenceItem::Index { class, .. } + | ReferenceItem::Name { class, .. } => &class.class_id, + })) +} + +/// How a reference names its target: the ordinal (`Trgt`, `Frnt`…), a name, a property… +enum Target { + Ordinal(String), + Name(String), + Property(String), + Class, + Offset(i32), + Other, +} + +fn target(r: &[ReferenceItem]) -> Target { + match r.first() { + Some(ReferenceItem::Enumerated { value, .. }) => Target::Ordinal(trimmed(value)), + Some(ReferenceItem::Name { name, .. }) => Target::Name(name.to_string_lossy()), + Some(ReferenceItem::Property { key, .. }) => Target::Property(trimmed(key)), + Some(ReferenceItem::Class(_)) => Target::Class, + Some(ReferenceItem::Offset { offset, .. }) => Target::Offset(*offset), + _ => Target::Other, + } +} + +/// `{"layer": name}` for a reference that names a layer, `{}` for the target layer, `None` +/// for anything else (an index or id: meaningless in another document). +fn layer_target(r: &[ReferenceItem]) -> Option> { + let mut m = Map::new(); + match target(r) { + Target::Ordinal(o) if o == "Trgt" => {} + Target::Name(n) => { + m.insert("layer".into(), json!(n)); + } + Target::Property(p) if p == "Bckg" => { + m.insert("layer".into(), json!("Background")); + } + _ => return None, + } + Some(m) +} + +// ------------------------------------------------------------------ descriptor building + +/// A Photoshop id: codes shorter than four characters are space-padded (`H `, `Rd `), +/// longer names stay strings. +fn code(s: &str) -> Id { + if s.len() < 4 { Id::new(&format!("{s:<4}")) } else { Id::new(s) } +} + +fn class(c: &str) -> Class { + Class { name: UnicodeString::new_nul(""), class_id: code(c) } +} + +/// The pixel unit of a `UntF` value. +const PX: [u8; 4] = *b"#Pxl"; + +fn px(v: f64) -> D { + D::UnitFloat { unit: PX, value: v } +} + +fn pct(v: f64) -> D { + D::UnitFloat { unit: *b"#Prc", value: v } +} + +fn ang(v: f64) -> D { + D::UnitFloat { unit: *b"#Ang", value: v } +} + +fn long(v: f64) -> D { + D::Integer(v.round().clamp(f64::from(i32::MIN), f64::from(i32::MAX)) as i32) +} + +fn text(s: &str) -> D { + D::Text(UnicodeString::new_nul(s)) +} + +fn enm(ty: &str, v: &str) -> D { + D::Enumerated { type_id: code(ty), value: code(v) } +} + +fn target_ref(cls: &str) -> D { + D::Reference(vec![ReferenceItem::Enumerated { class: class(cls), type_id: Id::new("Ordn"), value: Id::new("Trgt") }]) +} + +fn name_ref(cls: &str, name: &str) -> D { + D::Reference(vec![ReferenceItem::Name { class: class(cls), name: UnicodeString::new_nul(name) }]) +} + +fn class_ref(cls: &str) -> D { + D::Reference(vec![ReferenceItem::Class(class(cls))]) +} + +/// The `null` reference for a command's `layer` param: by name, else the target layer. +fn layer_ref(p: &Value) -> D { + match p.get("layer") { + Some(Value::String(n)) => name_ref("Lyr ", n), + _ => target_ref("Lyr "), + } +} + +fn desc(cls: &str) -> Descriptor { + Descriptor::new(cls) +} + +// ------------------------------------------------------------------ JSON access + +fn jnum(p: &Value, k: &str) -> Option { + p.get(k).and_then(Value::as_f64).filter(|f| f.is_finite()) +} + +fn jbool(p: &Value, k: &str) -> Option { + p.get(k).and_then(Value::as_bool) +} + +fn jstr<'a>(p: &'a Value, k: &str) -> Option<&'a str> { + p.get(k).and_then(Value::as_str) +} + +// ------------------------------------------------------------------ colours and enums + +/// A Photoshop colour object as `#rrggbb`: `RGBC` (0..255), `HSBC`, `Grsc`. +fn color_hex(c: &Descriptor) -> Option { + let hex = |r: f64, g: f64, b: f64| { + format!("#{:02x}{:02x}{:02x}", r.round().clamp(0.0, 255.0) as u8, g.round().clamp(0.0, 255.0) as u8, b.round().clamp(0.0, 255.0) as u8) + }; + if let (Some(r), Some(g), Some(b)) = (get_num(c, "Rd "), get_num(c, "Grn "), get_num(c, "Bl ")) { + return Some(hex(r, g, b)); + } + if let (Some(h), Some(s), Some(v)) = (get_num(c, "H "), get_num(c, "Strt"), get_num(c, "Brgh")) { + let (s, v) = ((s / 100.0).clamp(0.0, 1.0), (v / 100.0).clamp(0.0, 1.0)); + let h = h.rem_euclid(360.0) / 60.0; + let i = h.floor(); + let f = h - i; + let (p, q, t) = (v * (1.0 - s), v * (1.0 - s * f), v * (1.0 - s * (1.0 - f))); + let (r, g, b) = match i as i32 { + 0 => (v, t, p), + 1 => (q, v, p), + 2 => (p, v, t), + 3 => (p, q, v), + 4 => (t, p, v), + _ => (v, p, q), + }; + return Some(hex(r * 255.0, g * 255.0, b * 255.0)); + } + get_num(c, "Gry ").map(|g| { + let v = 255.0 - g * 2.55; + hex(v, v, v) + }) +} + +fn color_desc(hex: &str) -> Option { + let h = hex.strip_prefix('#')?; + if h.len() != 6 || !h.is_ascii() { + return None; + } + let ch = |i: usize| u8::from_str_radix(h.get(i..i + 2)?, 16).ok().map(f64::from); + Some(desc("RGBC").with("Rd ", D::Double(ch(0)?)).with("Grn ", D::Double(ch(2)?)).with("Bl ", D::Double(ch(4)?))) +} + +/// Photoshop's `BlnM` enumeration ↔ PhotoCraft's blend mode labels. +const BLEND_MODES: &[(&str, &str)] = &[ + ("Nrml", "Normal"), + ("Dslv", "Dissolve"), + ("Drkn", "Darken"), + ("Mltp", "Multiply"), + ("CBrn", "Color Burn"), + ("linearBurn", "Linear Burn"), + ("darkerColor", "Darker Color"), + ("Lghn", "Lighten"), + ("Scrn", "Screen"), + ("CDdg", "Color Dodge"), + ("linearDodge", "Linear Dodge (Add)"), + ("lighterColor", "Lighter Color"), + ("Ovrl", "Overlay"), + ("SftL", "Soft Light"), + ("HrdL", "Hard Light"), + ("vividLight", "Vivid Light"), + ("linearLight", "Linear Light"), + ("pinLight", "Pin Light"), + ("hardMix", "Hard Mix"), + ("Dfrn", "Difference"), + ("Xclu", "Exclusion"), + ("blendSubtraction", "Subtract"), + ("blendDivide", "Divide"), + ("H", "Hue"), + ("Strt", "Saturation"), + ("Clr", "Color"), + ("Lmns", "Luminosity"), + ("PsTh", "Pass Through"), +]; + +fn blend_label(ps: &str) -> Option<&'static str> { + BLEND_MODES.iter().find(|(k, _)| *k == ps).map(|(_, v)| *v) +} + +fn blend_ps(label: &str) -> Option<&'static str> { + crate::commands::blend_from_str(label).and_then(|m| BLEND_MODES.iter().find(|(_, v)| crate::commands::blend_from_str(v) == Some(m))).map(|(k, _)| *k) +} + +/// Photoshop ↔ PhotoCraft value tables; Photoshop codes compare without their space padding. +fn lookup<'a>(table: &'a [(&'a str, &'a str)], key: &str) -> Option<&'a str> { + table.iter().find(|(k, _)| k.trim_end() == key.trim_end()).map(|(_, v)| *v) +} + +fn lookup_back<'a>(table: &'a [(&'a str, &'a str)], value: &str) -> Option<&'a str> { + table.iter().find(|(_, v)| *v == value).map(|(k, _)| *k) +} + +/// `Chnl` enumeration ↔ the per-channel keys of the adjustment commands. +const CHANNELS: &[(&str, &str)] = &[ + ("Rd", "red"), + ("Grn", "green"), + ("Bl", "blue"), + ("Gry", "gray"), + ("Cyn", "cyan"), + ("Mgnt", "magenta"), + ("Yllw", "yellow"), + ("Blck", "black"), + ("Lmnc", "lightness"), + ("A", "a"), + ("B", "b"), +]; + +/// The channel an adjustment entry's `Chnl` reference names: `None` = the composite. +fn channel_key(d: &Descriptor) -> Option> { + let r = get_ref(d, "Chnl")?; + Some(match target(r) { + Target::Ordinal(o) if o == "Trgt" || o == "Cmps" => None, + Target::Ordinal(o) => Some(lookup(CHANNELS, &o)?), + _ => return None, + }) +} + +fn channel_ref(key: Option<&str>) -> D { + let value = key.and_then(|k| lookup_back(CHANNELS, k)); + D::Reference(vec![ReferenceItem::Enumerated { + class: class("Chnl"), + type_id: code(if value.is_some() { "Chnl" } else { "Ordn" }), + value: code(value.unwrap_or("Trgt")), + }]) +} + +// ------------------------------------------------------------------ field tables + +#[derive(Clone, Copy)] +enum Kind { + Px, + Pct, + Ang, + Long, + Dbl, + Bool, + /// An enumeration: its type id and the Photoshop ↔ PhotoCraft value pairs. + Enum(&'static str, &'static [(&'static str, &'static str)]), + /// A blend mode (`BlnM`). + Blend, + /// A colour object ↔ `#rrggbb`. + Color, +} + +/// One descriptor key ↔ one command param. +struct F(&'static str, &'static str, Kind); + +fn fields_from(d: &Descriptor, fields: &[F]) -> Option> { + let mut m = Map::new(); + for F(key, param, kind) in fields { + let v = match kind { + Kind::Px | Kind::Pct | Kind::Ang | Kind::Long | Kind::Dbl => get_num(d, key).map(Value::from), + Kind::Bool => get_bool(d, key).map(Value::from), + Kind::Enum(_, table) => match get_enum(d, key) { + Some(e) => Some(Value::from(lookup(table, &e)?)), + None => None, + }, + Kind::Blend => match get_enum(d, key) { + Some(e) => Some(Value::from(blend_label(&e)?)), + None => None, + }, + Kind::Color => match get_obj(d, key) { + Some(c) => Some(Value::from(color_hex(c)?)), + None => None, + }, + }; + if let Some(v) = v { + m.insert((*param).into(), v); + } + } + Some(m) +} + +fn fields_to(p: &Value, fields: &[F], mut d: Descriptor) -> Option { + for F(key, param, kind) in fields { + let Some(v) = p.get(param) else { continue }; + let out = match kind { + Kind::Px => px(v.as_f64()?), + Kind::Pct => pct(v.as_f64()?), + Kind::Ang => ang(v.as_f64()?), + Kind::Long => long(v.as_f64()?), + Kind::Dbl => D::Double(v.as_f64()?), + Kind::Bool => D::Boolean(v.as_bool()?), + Kind::Enum(ty, table) => enm(ty, lookup_back(table, v.as_str()?)?), + Kind::Blend => enm("BlnM", blend_ps(v.as_str()?)?), + Kind::Color => D::Descriptor(color_desc(v.as_str()?)?), + }; + d = d.with(key, out); + } + Some(d) +} + +// ------------------------------------------------------------------ adjustments + +/// The `Adjs` entries of a per-channel adjustment (Levels, Curves): `(channel key, entry)`. +fn per_channel(d: &Descriptor) -> Option, &Descriptor)>> { + get_list(d, "Adjs")? + .iter() + .map(|v| match v { + D::Descriptor(e) => Some((channel_key(e)?, e)), + _ => None, + }) + .collect() +} + +fn levels_from(d: &Descriptor) -> Option { + if get_bool(d, "Auto") == Some(true) { + return None; + } + let mut out = Map::new(); + for (chan, e) in per_channel(d)? { + let mut m = Map::new(); + if let Some(i) = get_list(e, "Inpt") { + if let Some(v) = i.first().and_then(num) { + m.insert("inBlack".into(), v.into()); + } + if let Some(v) = i.get(1).and_then(num) { + m.insert("inWhite".into(), v.into()); + } + } + if let Some(o) = get_list(e, "Otpt") { + if let Some(v) = o.first().and_then(num) { + m.insert("outBlack".into(), v.into()); + } + if let Some(v) = o.get(1).and_then(num) { + m.insert("outWhite".into(), v.into()); + } + } + if let Some(g) = get_num(e, "Gmm ") { + m.insert("gamma".into(), g.into()); + } + match chan { + None => out.extend(m), + Some(k) => { + out.insert(k.into(), Value::Object(m)); + } + } + } + Some(Value::Object(out)) +} + +fn levels_entry(chan: Option<&str>, p: &Value) -> Descriptor { + let mut e = desc("LvlA").with("Chnl", channel_ref(chan)); + if jnum(p, "inBlack").is_some() || jnum(p, "inWhite").is_some() { + e = e.with("Inpt", D::List(vec![long(jnum(p, "inBlack").unwrap_or(0.0)), long(jnum(p, "inWhite").unwrap_or(255.0))])); + } + if let Some(g) = jnum(p, "gamma") { + e = e.with("Gmm ", D::Double(g)); + } + if jnum(p, "outBlack").is_some() || jnum(p, "outWhite").is_some() { + e = e.with("Otpt", D::List(vec![long(jnum(p, "outBlack").unwrap_or(0.0)), long(jnum(p, "outWhite").unwrap_or(255.0))])); + } + e +} + +fn levels_to(p: &Value) -> Option { + let mut entries = vec![D::Descriptor(levels_entry(None, p))]; + for (_, k) in CHANNELS { + if let Some(c) = p.get(*k).filter(|v| v.is_object()) { + entries.push(D::Descriptor(levels_entry(Some(k), c))); + } + } + Some(desc("Lvls").with("presetKind", enm("presetKindType", "presetKindCustom")).with("Adjs", D::List(entries))) +} + +fn curve_points(e: &Descriptor) -> Option { + let pts: Option> = get_list(e, "Crv ")? + .iter() + .map(|v| match v { + D::Descriptor(pt) => Some(json!([get_num(pt, "Hrzn")?, get_num(pt, "Vrtc")?])), + _ => None, + }) + .collect(); + pts.map(Value::Array) +} + +fn curves_from(d: &Descriptor) -> Option { + let mut out = Map::new(); + for (chan, e) in per_channel(d)? { + let pts = curve_points(e)?; + out.insert(chan.unwrap_or("points").into(), pts); + } + Some(Value::Object(out)) +} + +fn curve_entry(chan: Option<&str>, pts: &Value) -> Option { + let list: Option> = pts + .as_array()? + .iter() + .map(|pt| { + let a = pt.as_array()?; + Some(D::Descriptor(desc("Pnt ").with("Hrzn", D::Double(a.first()?.as_f64()?)).with("Vrtc", D::Double(a.get(1)?.as_f64()?)))) + }) + .collect(); + Some(desc("CrvA").with("Chnl", channel_ref(chan)).with("Crv ", D::List(list?))) +} + +fn curves_to(p: &Value) -> Option { + let mut entries = Vec::new(); + if let Some(pts) = p.get("points") { + entries.push(D::Descriptor(curve_entry(None, pts)?)); + } + for (_, k) in CHANNELS { + if let Some(pts) = p.get(*k) { + entries.push(D::Descriptor(curve_entry(Some(k), pts)?)); + } + } + Some(desc("Crvs").with("presetKind", enm("presetKindType", "presetKindCustom")).with("Adjs", D::List(entries))) +} + +const HUE_RANGES: [&str; 6] = ["reds", "yellows", "greens", "cyans", "blues", "magentas"]; + +fn hue_sat_from(d: &Descriptor) -> Option { + let mut out = Map::new(); + if let Some(c) = get_bool(d, "Clrz") { + out.insert("colorize".into(), c.into()); + } + for e in get_list(d, "Adjs").unwrap_or(&[]) { + let D::Descriptor(e) = e else { return None }; + let mut m = Map::new(); + for (key, param) in [("Hue ", "hue"), ("Strt", "saturation"), ("Lght", "lightness")] { + if let Some(v) = get_num(e, key) { + m.insert(param.into(), v.into()); + } + } + match get_num(e, "LclR") { + None => out.extend(m), + Some(r) => { + let name = *HUE_RANGES.get((r as usize).checked_sub(1)?)?; + if let (Some(a), Some(b), Some(c), Some(dd)) = (get_num(e, "BgnR"), get_num(e, "BgnS"), get_num(e, "EndS"), get_num(e, "EndR")) { + m.insert("range".into(), json!([a, b, c, dd])); + } + out.insert(name.into(), Value::Object(m)); + } + } + } + Some(Value::Object(out)) +} + +fn hue_entry(p: &Value, range: Option<(usize, &Value)>) -> Option { + let mut e = desc("Hst2"); + if let Some((i, r)) = range { + e = e.with("LclR", long(i as f64 + 1.0)); + if let Some(a) = r.get("range").and_then(Value::as_array) + && a.len() == 4 + { + for (k, v) in ["BgnR", "BgnS", "EndS", "EndR"].iter().zip(a) { + e = e.with(k, long(v.as_f64()?)); + } + } + } + for (key, param) in [("Hue ", "hue"), ("Strt", "saturation"), ("Lght", "lightness")] { + e = e.with(key, long(jnum(p, param).unwrap_or(0.0))); + } + Some(e) +} + +fn hue_sat_to(p: &Value) -> Option { + let mut entries = vec![D::Descriptor(hue_entry(p, None)?)]; + for (i, name) in HUE_RANGES.iter().enumerate() { + if let Some(r) = p.get(*name).filter(|v| v.is_object()) { + entries.push(D::Descriptor(hue_entry(r, Some((i, r)))?)); + } + } + Some( + desc("HStr") + .with("presetKind", enm("presetKindType", "presetKindCustom")) + .with("Clrz", D::Boolean(jbool(p, "colorize").unwrap_or(false))) + .with("Adjs", D::List(entries)), + ) +} + +fn triple(d: &Descriptor, k: &str) -> Option { + let l = get_list(d, k)?; + let v: Option> = l.iter().map(num).collect(); + Some(json!(v?)) +} + +fn triple_to(p: &Value, k: &str) -> Option { + Some(D::List(p.get(k)?.as_array()?.iter().map(|v| v.as_f64().map(long)).collect::>>()?)) +} + +fn color_balance_from(d: &Descriptor) -> Option { + let mut out = Map::new(); + for (key, param) in [("ShdL", "shadows"), ("MdtL", "midtones"), ("HghL", "highlights")] { + if let Some(v) = triple(d, key) { + out.insert(param.into(), v); + } + } + if let Some(b) = get_bool(d, "PrsL") { + out.insert("preserveLuminosity".into(), b.into()); + } + Some(Value::Object(out)) +} + +fn color_balance_to(p: &Value) -> Option { + let mut d = desc("ClrB"); + for (key, param) in [("ShdL", "shadows"), ("MdtL", "midtones"), ("HghL", "highlights")] { + d = d.with(key, triple_to(p, param).unwrap_or_else(|| D::List(vec![long(0.0), long(0.0), long(0.0)]))); + } + Some(d.with("PrsL", D::Boolean(jbool(p, "preserveLuminosity").unwrap_or(true)))) +} + +const BW: &[F] = &[ + F("Rd ", "reds", Kind::Long), + F("Yllw", "yellows", Kind::Long), + F("Grn ", "greens", Kind::Long), + F("Cyn ", "cyans", Kind::Long), + F("Bl ", "blues", Kind::Long), + F("Mgnt", "magentas", Kind::Long), + F("useTint", "tint", Kind::Bool), + F("tintColor", "tintColor", Kind::Color), +]; + +fn mixer_channel(e: &Descriptor) -> Option { + Some(json!([get_num(e, "Rd ").unwrap_or(0.0), get_num(e, "Grn ").unwrap_or(0.0), get_num(e, "Bl ").unwrap_or(0.0), get_num(e, "Cnst").unwrap_or(0.0)])) +} + +fn channel_mixer_from(d: &Descriptor) -> Option { + let mut out = Map::new(); + for (key, param) in [("Rd ", "red"), ("Grn ", "green"), ("Bl ", "blue"), ("Gry ", "gray")] { + if let Some(e) = get_obj(d, key) { + out.insert(param.into(), mixer_channel(e)?); + } + } + if let Some(m) = get_bool(d, "Mnch") { + out.insert("monochrome".into(), m.into()); + } + Some(Value::Object(out)) +} + +fn channel_mixer_to(p: &Value) -> Option { + let mut d = desc("ChnM").with("presetKind", enm("presetKindType", "presetKindCustom")); + for (key, param) in [("Rd ", "red"), ("Grn ", "green"), ("Bl ", "blue"), ("Gry ", "gray")] { + if let Some(a) = p.get(param).and_then(Value::as_array) { + let v = |i: usize| a.get(i).and_then(Value::as_f64).unwrap_or(0.0); + d = d.with(key, D::Descriptor(desc("ChMx").with("Rd ", pct(v(0))).with("Grn ", pct(v(1))).with("Bl ", pct(v(2))).with("Cnst", pct(v(3))))); + } + } + Some(d.with("Mnch", D::Boolean(jbool(p, "monochrome").unwrap_or(false)))) +} + +const SELECTIVE_RANGES: &[(&str, &str)] = &[ + ("Rds", "reds"), + ("Yllw", "yellows"), + ("Grns", "greens"), + ("Cyns", "cyans"), + ("Bls", "blues"), + ("Mgnt", "magentas"), + ("Whts", "whites"), + ("Ntrl", "neutrals"), + ("Blks", "blacks"), +]; + +fn selective_from(d: &Descriptor) -> Option { + let mut out = Map::new(); + if let Some(m) = get_enum(d, "Mthd") { + out.insert("method".into(), (if m == "Absl" { "absolute" } else { "relative" }).into()); + } + for e in get_list(d, "ClrC").unwrap_or(&[]) { + let D::Descriptor(e) = e else { return None }; + let name = lookup(SELECTIVE_RANGES, &get_enum(e, "Clrs")?)?; + let v = |k: &str| get_num(e, k).unwrap_or(0.0); + out.insert(name.into(), json!([v("Cyn "), v("Mgnt"), v("Yllw"), v("Blck")])); + } + Some(Value::Object(out)) +} + +fn selective_to(p: &Value) -> Option { + let mut list = Vec::new(); + for (ps, name) in SELECTIVE_RANGES { + if let Some(a) = p.get(*name).and_then(Value::as_array) { + let v = |i: usize| a.get(i).and_then(Value::as_f64).unwrap_or(0.0); + list.push(D::Descriptor( + desc("ClrC").with("Clrs", enm("Clrs", ps)).with("Cyn ", pct(v(0))).with("Mgnt", pct(v(1))).with("Yllw", pct(v(2))).with("Blck", pct(v(3))), + )); + } + } + Some( + desc("SlcC") + .with("presetKind", enm("presetKindType", "presetKindCustom")) + .with("Mthd", enm("CrcM", if jstr(p, "method") == Some("absolute") { "Absl" } else { "Rltv" })) + .with("ClrC", D::List(list)), + ) +} + +/// An adjustment: its Photoshop class (also the direct event's code), the command suffix (`image.adjustments.`, `layer.newAdjustmentLayer.`), and +/// the converters between the adjustment descriptor and the command params. +struct Adjustment { + class: &'static str, + suffix: &'static str, + from: fn(&Descriptor) -> Option, + to: fn(&Value) -> Option, +} + +macro_rules! adj_fields { + ($class:literal, $suffix:literal, $fields:expr) => { + Adjustment { class: $class, suffix: $suffix, from: |d| fields_from(d, $fields).map(Value::Object), to: |p| fields_to(p, $fields, desc($class)) } + }; +} + +const ADJUSTMENTS: &[Adjustment] = &[ + Adjustment { class: "Lvls", suffix: "levels", from: levels_from, to: levels_to }, + Adjustment { class: "Crvs", suffix: "curves", from: curves_from, to: curves_to }, + Adjustment { class: "HStr", suffix: "hueSaturation", from: hue_sat_from, to: hue_sat_to }, + adj_fields!("BrgC", "brightnessContrast", &[F("Brgh", "brightness", Kind::Long), F("Cntr", "contrast", Kind::Long), F("useLegacy", "legacy", Kind::Bool)]), + adj_fields!("Thrs", "threshold", &[F("Lvl ", "level", Kind::Long)]), + adj_fields!("Pstr", "posterize", &[F("Lvls", "levels", Kind::Long)]), + adj_fields!("Invr", "invert", &[]), + Adjustment { class: "ClrB", suffix: "colorBalance", from: color_balance_from, to: color_balance_to }, + adj_fields!("Vbrn", "vibrance", &[F("vibrance", "vibrance", Kind::Long), F("Strt", "saturation", Kind::Long)]), + adj_fields!("Exps", "exposure", &[F("Exps", "exposure", Kind::Dbl), F("Ofst", "offset", Kind::Dbl), F("gammaCorrection", "gamma", Kind::Dbl)]), + adj_fields!("PhtF", "photoFilter", &[F("Clr ", "color", Kind::Color), F("Dnst", "density", Kind::Long), F("PrsL", "preserveLuminosity", Kind::Bool)]), + adj_fields!("BlnW", "blackWhite", BW), + Adjustment { class: "ChnM", suffix: "channelMixer", from: channel_mixer_from, to: channel_mixer_to }, + Adjustment { class: "SlcC", suffix: "selectiveColor", from: selective_from, to: selective_to }, +]; + +fn adjustment_by_class(c: &str) -> Option<&'static Adjustment> { + ADJUSTMENTS.iter().find(|a| a.class.trim_end() == c.trim_end()) +} + +fn adjustment_by_suffix(suffix: &str) -> Option<&'static Adjustment> { + ADJUSTMENTS.iter().find(|a| a.suffix == suffix) +} + +// ------------------------------------------------------------------ the event table + +/// What one Photoshop step maps to. +enum Mapped { + /// One or more PhotoCraft steps (a `make` with a name becomes new layer + rename). + Steps(Vec<(String, Value)>), + /// No PhotoCraft equivalent; `harmless` steps (tool selections…) are imported switched off. + Unsupported { harmless: bool }, +} + +fn step(id: &str, params: Value) -> Mapped { + Mapped::Steps(vec![(id.to_string(), params)]) +} + +fn steps(v: Vec<(&str, Value)>) -> Mapped { + Mapped::Steps(v.into_iter().map(|(id, p)| (id.to_string(), p)).collect()) +} + +fn none(id: &str) -> Mapped { + step(id, json!({})) +} + +/// An event: code (empty when Photoshop only has a string name), string name, the panel +/// label, the command ids it exports (the first is the one simple events import to), and the +/// two converters. +struct Event { + code: &'static str, + name: &'static str, + label: &'static str, + ids: &'static [&'static str], + from: fn(&Descriptor) -> Mapped, + to: fn(&str, &Value) -> Option, +} + +macro_rules! ev { + ($code:literal, $name:literal, $label:literal, $id:literal, $fields:expr) => { + Event { + code: $code, + name: $name, + label: $label, + ids: &[$id], + from: |d| match fields_from(d, $fields) { + Some(m) => step($id, Value::Object(m)), + None => Mapped::Unsupported { harmless: false }, + }, + to: |_, p| fields_to(p, $fields, desc("null")), + } + }; +} + +const INTERPOLATION: &[(&str, &str)] = &[ + ("Bcbc", "bicubic"), + ("Blnr", "bilinear"), + ("Nrst", "nearest"), + ("bicubicSharper", "bicubicSharper"), + ("bicubicSmoother", "bicubicSmoother"), + ("bicubicAutomatic", "bicubicAutomatic"), + ("automaticInterpolation", "automatic"), + ("preserveDetailsUpscale", "preserveDetails"), + ("deepUpscale", "preserveDetails"), +]; +const NOISE: &[(&str, &str)] = &[("Unfr", "uniform"), ("Gsn ", "gaussian")]; +const OFFSET_FILL: &[(&str, &str)] = &[("Wrp ", "wrap"), ("Rpt ", "repeat"), ("Bckg", "transparent")]; +const TRIM: &[(&str, &str)] = &[("Trns", "transparent"), ("topLeftPixelColor", "topLeft"), ("bottomRightPixelColor", "bottomRight")]; +const EXTENSION: &[(&str, &str)] = &[("Bckg", "background"), ("Frgd", "foreground"), ("Wht ", "white"), ("Blck", "black"), ("Trns", "transparent")]; +const FILL_CONTENTS: &[(&str, &str)] = &[ + ("FrgC", "foreground"), + ("BckC", "background"), + ("Wht ", "white"), + ("Blck", "black"), + ("Gry ", "gray"), + ("Clr ", "color"), + ("HstS", "history"), + ("contentAware", "contentAware"), +]; +const STROKE_LOCATION: &[(&str, &str)] = &[("Insd", "inside"), ("Cntr", "center"), ("OtsF", "outside")]; +const INTENT: &[(&str, &str)] = &[("Img ", "perceptual"), ("Clrm", "relative"), ("Grp ", "saturation"), ("AbsC", "absolute")]; +const PROFILES: &[(&str, &str)] = + &[("sRGB IEC61966-2.1", "srgb"), ("Display P3", "display-p3"), ("Adobe RGB (1998)", "adobe-rgb-compat"), ("ProPhoto RGB", "prophoto-compat")]; +const SMART_BLUR_QUALITY: &[(&str, &str)] = &[("Hgh ", "high"), ("Mdm ", "medium"), ("Low ", "low")]; +const SMART_BLUR_MODE: &[(&str, &str)] = &[("SBMN", "normal"), ("SBME", "edgeOnly"), ("SBMO", "overlayEdge")]; +const RADIAL_METHOD: &[(&str, &str)] = &[("Spn ", "spin"), ("Zm ", "zoom")]; +const RADIAL_QUALITY: &[(&str, &str)] = &[("Drft", "draft"), ("Gd ", "good"), ("Bst ", "best")]; +const LABEL_COLORS: &[(&str, &str)] = + &[("None", "none"), ("Rd", "red"), ("Orng", "orange"), ("Ylw", "yellow"), ("Grn", "green"), ("Bl", "blue"), ("Vlt", "violet"), ("Gry", "gray")]; +const ALIGN: &[(&str, &str)] = + &[("AdLf", "leftEdges"), ("AdCH", "horizontalCenters"), ("AdRg", "rightEdges"), ("AdTp", "topEdges"), ("AdCV", "verticalCenters"), ("AdBt", "bottomEdges")]; +const PURGE: &[(&str, &str)] = &[("Al", "all"), ("Clpb", "clipboard"), ("Hsty", "histories"), ("Undo", "undo")]; +const MODES: &[(&str, &str)] = &[("RGBM", "rgb"), ("Grys", "grayscale"), ("CMYM", "cmyk"), ("LbCM", "lab"), ("Mltc", "multichannel")]; + +// Events whose target depends on the descriptor (`make`, `set`, `select`…): custom converters. + +fn adjustment_steps(a: &Adjustment, prefix: &str, type_desc: Option<&Descriptor>) -> Mapped { + let params = match type_desc { + Some(t) if get_enum(t, "presetKind").as_deref() != Some("presetKindDefault") => match (a.from)(t) { + Some(p) => p, + None => return Mapped::Unsupported { harmless: false }, + }, + _ => json!({}), + }; + step(&format!("{prefix}{}", a.suffix), params) +} + +fn make_from(d: &Descriptor) -> Mapped { + if let Some(nw) = get_obj(d, "Nw ") { + // Layer via Copy / Cut (`Usng` = the selection), or a new document. + if get_enum(d, "Usng").as_deref() == Some("Slct") { + let id = if get_bool(d, "Cpy ") == Some(true) { "layer.new.layerViaCopy" } else { "layer.new.layerViaCut" }; + let mut v = vec![(id, json!({}))]; + if let Some(n) = get_text(nw, "Nm ") { + v.push(("layer.renameLayer", json!({"name": n}))); + } + return steps(v); + } + if get_text(nw, "preset").is_some() { + return none("file.newFromClipboard"); + } + if let (Some(w), Some(h)) = (get_num(nw, "Wdth"), get_num(nw, "Hght")) { + let res = get_num(nw, "Rslt").unwrap_or(72.0); + // Dimensions are in points (`#Rlt`) unless given in pixels. + let to_px = |k: &str, v: f64| match nw.get(k) { + Some(D::UnitFloat { unit: PX, .. }) => v, + _ => v * res / 72.0, + }; + let mut p = json!({"width": to_px("Wdth", w).round(), "height": to_px("Hght", h).round(), "resolution": res}); + if let Some(n) = get_text(nw, "Nm ") { + p["name"] = json!(n); + } + if let Some(D::Class(c)) = nw.get("Md ") + && let Some(m) = lookup(MODES, &trimmed(&c.class_id)) + { + p["mode"] = json!(m); + } + if let Some(depth) = get_num(nw, "Dpth") { + p["depth"] = json!(depth); + } + if let Some(f) = get_enum(nw, "Fl ") { + p["background"] = json!(match f.as_str() { + "Wht" => "white", + "BckC" => "backgroundColor", + "Trns" => "transparent", + _ => "white", + }); + } + return step("file.new", p); + } + return Mapped::Unsupported { harmless: false }; + } + let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; + let using = get_obj(d, "Usng"); + let type_desc = using.and_then(|u| get_obj(u, "Type")); + match ref_class(r).as_deref() { + Some("Lyr") => { + let mut p = Map::new(); + if let Some(u) = using { + if let Some(n) = get_text(u, "Nm ") { + p.insert("name".into(), n.into()); + } + if let Some(o) = get_num(u, "Opct") { + p.insert("opacity".into(), (o / 100.0).into()); + } + if let Some(m) = get_enum(u, "Md ").and_then(|m| blend_label(&m)) { + p.insert("mode".into(), m.into()); + } + } + step("layer.new.layer", Value::Object(p)) + } + Some("AdjL") => match type_desc.and_then(|t| adjustment_by_class(&trimmed(&t.class_id))) { + Some(a) => adjustment_steps(a, "layer.newAdjustmentLayer.", type_desc), + None => Mapped::Unsupported { harmless: false }, + }, + Some("contentLayer") => match type_desc { + Some(t) if t.class_id.is("solidColorLayer") => match get_obj(t, "Clr ").and_then(color_hex) { + Some(c) => step("layer.newFillLayer.solidColor", json!({"color": c})), + None => none("layer.newFillLayer.solidColor"), + }, + _ => Mapped::Unsupported { harmless: false }, + }, + Some("layerSection") => { + let mut p = Map::new(); + if let Some(n) = using.and_then(|u| get_text(u, "Nm ")) { + p.insert("name".into(), n.into()); + } + let id = if get_ref(d, "From").is_some() { "layer.groupLayers" } else { "layer.new.group" }; + step(id, Value::Object(p)) + } + _ => Mapped::Unsupported { harmless: false }, + } +} + +fn make_to(id: &str, p: &Value) -> Option { + let with_name = |cls: &str| { + let mut u = desc(cls); + if let Some(n) = jstr(p, "name") { + u = u.with("Nm ", text(n)); + } + u + }; + Some(match id { + "layer.new.layer" => { + let mut u = with_name("Lyr "); + if let Some(o) = jnum(p, "opacity") { + u = u.with("Opct", pct(o * 100.0)); + } + if let Some(m) = jstr(p, "mode").and_then(blend_ps) { + u = u.with("Md ", enm("BlnM", m)); + } + desc("null").with("null", class_ref("Lyr ")).with("Usng", D::Descriptor(u)) + } + "layer.new.group" => desc("null").with("null", class_ref("layerSection")).with("Usng", D::Descriptor(with_name("layerSection"))), + "layer.groupLayers" => { + desc("null").with("null", class_ref("layerSection")).with("From", target_ref("Lyr ")).with("Usng", D::Descriptor(with_name("layerSection"))) + } + "layer.new.layerViaCopy" | "layer.new.layerViaCut" => { + let mut d = desc("null").with("Nw ", D::Descriptor(desc("Lyr "))).with("Usng", enm("ArSl", "Slct")); + if id == "layer.new.layerViaCopy" { + d = d.with("Cpy ", D::Boolean(true)); + } + d + } + "layer.newFillLayer.solidColor" => { + let mut t = desc("solidColorLayer"); + if let Some(c) = jstr(p, "color").and_then(color_desc) { + t = t.with("Clr ", D::Descriptor(c)); + } + desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t)))) + } + "file.newFromClipboard" => desc("null").with("Nw ", D::Descriptor(desc("Dcmn").with("preset", text("Clipboard")))), + "file.new" => { + let mut n = desc("Dcmn"); + if let Some(name) = jstr(p, "name") { + n = n.with("Nm ", text(name)); + } + if let Some(m) = jstr(p, "mode").and_then(|m| lookup_back(MODES, if m == "gray" { "grayscale" } else { m })) { + n = n.with("Md ", D::Class(class(m))); + } + n = n.with("Wdth", px(jnum(p, "width").unwrap_or(1920.0))).with("Hght", px(jnum(p, "height").unwrap_or(1080.0))); + n = n.with("Rslt", D::UnitFloat { unit: *b"#Rsl", value: jnum(p, "resolution").unwrap_or(72.0) }); + let fill = match jstr(p, "background") { + Some("transparent") => "Trns", + Some("backgroundColor") => "BckC", + _ => "Wht ", + }; + n = n.with("Fl ", enm("Fl ", fill)).with("Dpth", long(jnum(p, "depth").unwrap_or(8.0))); + desc("null").with("Nw ", D::Descriptor(n)) + } + _ => { + let suffix = id.strip_prefix("layer.newAdjustmentLayer.")?; + let a = adjustment_by_suffix(suffix)?; + let t = if p.as_object().is_some_and(|o| o.is_empty()) { + desc(a.class).with("presetKind", enm("presetKindType", "presetKindDefault")) + } else { + (a.to)(p)? + }; + desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(t)))) + } + }) +} + +fn set_from(d: &Descriptor) -> Mapped { + let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; + let unsupported = Mapped::Unsupported { harmless: false }; + match (ref_class(r).as_deref(), target(r)) { + (Some("Chnl"), Target::Property(p)) if p == "fsel" => match d.get("T ") { + Some(D::Enumerated { value, .. }) => match trimmed(value).as_str() { + "Al" => none("select.all"), + "None" => none("select.deselect"), + "Prvs" => none("select.reselect"), + _ => unsupported, + }, + Some(D::Reference(t)) => match (ref_class(t).as_deref(), target(t)) { + (Some("Chnl"), Target::Ordinal(o)) if o == "Trsp" => step("select.loadSelection", json!({"channel": "transparency"})), + (Some("Chnl"), Target::Name(n)) => step("select.loadSelection", json!({"channel": n})), + _ => unsupported, + }, + Some(D::Descriptor(t)) if t.class_id.is("Rctn") || t.class_id.is("Elps") => { + let (Some(top), Some(left), Some(bottom), Some(right)) = (get_num(t, "Top "), get_num(t, "Left"), get_num(t, "Btom"), get_num(t, "Rght")) + else { + return unsupported; + }; + let mut p = json!({"x": left.round(), "y": top.round(), "width": (right - left).round().max(0.0), "height": (bottom - top).round().max(0.0)}); + if t.class_id.is("Elps") { + p["ellipse"] = json!(true); + } + step("select.rect", p) + } + _ => unsupported, + }, + (Some("Lyr"), _) => { + let Some(mut p) = layer_target(r) else { return unsupported }; + let Some(t) = get_obj(d, "T ") else { return unsupported }; + let mut v = Vec::new(); + if let Some(c) = get_enum(t, "Clr ").and_then(|c| lookup(LABEL_COLORS, &c)) { + let mut lp = p.clone(); + lp.insert("color".into(), c.into()); + v.push(("layer.setLabelColor", Value::Object(lp))); + } + if let Some(n) = get_text(t, "Nm ") { + p.insert("name".into(), n.into()); + } + if let Some(o) = get_num(t, "Opct") { + p.insert("opacity".into(), (o / 100.0).into()); + } + if let Some(f) = get_num(t, "fillOpacity") { + p.insert("fill".into(), (f / 100.0).into()); + } + if let Some(m) = get_enum(t, "Md ") { + let Some(label) = blend_label(&m) else { return unsupported }; + p.insert("blend".into(), label.into()); + } + if let Some(vis) = get_bool(t, "Vsbl") { + p.insert("visible".into(), vis.into()); + } + if p.keys().any(|k| k != "layer") { + v.push(("layer.setProps", Value::Object(p))); + } + if v.is_empty() { unsupported } else { steps(v) } + } + (Some("AdjL"), Target::Ordinal(o)) if o == "Trgt" => { + match get_obj(d, "T ").and_then(|t| adjustment_by_class(&trimmed(&t.class_id)).map(|a| (a, t))) { + Some((a, t)) => match (a.from)(t) { + Some(p) => step("layer.setAdjustment", p), + None => unsupported, + }, + None => unsupported, + } + } + _ => unsupported, + } +} + +fn set_to(id: &str, p: &Value) -> Option { + let fsel = D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }]); + let sel = |t: D| desc("null").with("null", fsel.clone()).with("T ", t); + Some(match id { + "select.all" => sel(enm("Ordn", "Al ")), + "select.deselect" => sel(enm("Ordn", "None")), + "select.reselect" => sel(enm("Ordn", "Prvs")), + "select.loadSelection" => match jstr(p, "channel")? { + "transparency" => sel(D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Chnl"), value: Id::new("Trsp") }])), + name => sel(name_ref("Chnl", name)), + }, + "select.rect" => { + let (x, y, w, h) = (jnum(p, "x")?, jnum(p, "y")?, jnum(p, "width")?, jnum(p, "height")?); + let cls = if jbool(p, "ellipse") == Some(true) { "Elps" } else { "Rctn" }; + sel(D::Descriptor(desc(cls).with("Top ", px(y)).with("Left", px(x)).with("Btom", px(y + h)).with("Rght", px(x + w)))) + } + "layer.setProps" | "layer.renameLayer" | "layer.setLabelColor" => { + let mut t = desc("Lyr "); + if let Some(n) = jstr(p, "name") { + t = t.with("Nm ", text(n)); + } + if let Some(o) = jnum(p, "opacity") { + t = t.with("Opct", pct(o * 100.0)); + } + if let Some(f) = jnum(p, "fill") { + t = t.with("fillOpacity", pct(f * 100.0)); + } + if let Some(m) = jstr(p, "blend") { + t = t.with("Md ", enm("BlnM", blend_ps(m)?)); + } + if let Some(v) = jbool(p, "visible") { + t = t.with("Vsbl", D::Boolean(v)); + } + if let Some(c) = jstr(p, "color") { + t = t.with("Clr ", enm("Clr ", lookup_back(LABEL_COLORS, c)?)); + } + if t.items.is_empty() { + return None; + } + desc("null").with("null", layer_ref(p)).with("T ", D::Descriptor(t)) + } + "layer.setAdjustment" => { + // The adjustment kind isn't in the params: Photoshop needs it, so only steps that + // name one (`kind`) export. + let a = adjustment_by_suffix(jstr(p, "kind")?)?; + desc("null").with("null", target_ref("AdjL")).with("T ", D::Descriptor((a.to)(p)?)) + } + _ => return None, + }) +} + +/// Tool classes Photoshop records when an action switches tools: no document effect. +fn is_tool_class(c: &str) -> bool { + c.ends_with("Tl") || c.ends_with("Tool") +} + +fn select_from(d: &Descriptor) -> Mapped { + let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; + match (ref_class(r).as_deref(), target(r)) { + (Some("Lyr"), Target::Name(n)) => { + let mut p = json!({"layer": n}); + if get_enum(d, "selectionModifier").as_deref() == Some("addToSelection") { + p["mode"] = json!("add"); + } + step("layer.select", p) + } + (Some("Lyr"), Target::Ordinal(o)) => { + // Shift+Alt+[ / ] add the next layer to the selection instead of replacing it. + let add = get_enum(d, "selectionModifier").as_deref() == Some("addToSelection"); + match (o.as_str(), add) { + ("Frwr", false) => none("layer.selectAbove"), + ("Frwr", true) => none("layer.addAboveToSelection"), + ("Bckw", false) => none("layer.selectBelow"), + ("Bckw", true) => none("layer.addBelowToSelection"), + ("Frnt", _) => none("layer.selectTop"), + ("Back", _) => none("layer.selectBottom"), + _ => Mapped::Unsupported { harmless: false }, + } + } + (Some(c), Target::Class) if is_tool_class(c) => Mapped::Unsupported { harmless: true }, + _ => Mapped::Unsupported { harmless: false }, + } +} + +fn select_to(id: &str, p: &Value) -> Option { + let ord = |o: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code(o) }]); + Some(match id { + "layer.select" => { + let mut d = desc("null").with("null", name_ref("Lyr ", jstr(p, "layer")?)); + if jstr(p, "mode") == Some("add") { + d = d.with("selectionModifier", enm("selectionModifierType", "addToSelection")); + } + d.with("MkVs", D::Boolean(false)) + } + "layer.selectAbove" => desc("null").with("null", ord("Frwr")), + "layer.selectBelow" => desc("null").with("null", ord("Bckw")), + "layer.addAboveToSelection" => desc("null").with("null", ord("Frwr")).with("selectionModifier", enm("selectionModifierType", "addToSelection")), + "layer.addBelowToSelection" => desc("null").with("null", ord("Bckw")).with("selectionModifier", enm("selectionModifierType", "addToSelection")), + "layer.selectTop" => desc("null").with("null", ord("Frnt")), + "layer.selectBottom" => desc("null").with("null", ord("Back")), + _ => return None, + }) +} + +fn delete_from(d: &Descriptor) -> Mapped { + match get_ref(d, "null") { + None => none("edit.clear"), + Some(r) if ref_class(r).as_deref() == Some("Lyr") => match layer_target(r) { + Some(p) => step("layer.delete", Value::Object(p)), + None => Mapped::Unsupported { harmless: false }, + }, + Some(_) => Mapped::Unsupported { harmless: false }, + } +} + +fn delete_to(id: &str, p: &Value) -> Option { + Some(match id { + "edit.clear" => desc("null"), + "layer.delete" => desc("null").with("null", layer_ref(p)), + _ => return None, + }) +} + +/// `hide` / `show` take a list of references (or one). +fn visibility_from(d: &Descriptor, id: &str) -> Mapped { + let refs: Vec<&[ReferenceItem]> = match d.get("null") { + Some(D::List(l)) => l.iter().filter_map(|v| if let D::Reference(r) = v { Some(r.as_slice()) } else { None }).collect(), + Some(D::Reference(r)) => vec![r.as_slice()], + _ => return Mapped::Unsupported { harmless: false }, + }; + let mut v = Vec::new(); + for r in refs { + match layer_target(r) { + Some(p) => v.push((id, Value::Object(p))), + None => return Mapped::Unsupported { harmless: false }, + } + } + if v.is_empty() { Mapped::Unsupported { harmless: false } } else { steps(v) } +} + +fn visibility_to(_: &str, p: &Value) -> Option { + Some(desc("null").with("null", D::List(vec![layer_ref(p)]))) +} + +fn move_from(d: &Descriptor) -> Mapped { + let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; + if ref_class(r).as_deref() != Some("Lyr") { + return Mapped::Unsupported { harmless: false }; + } + let Some(mut p) = layer_target(r) else { return Mapped::Unsupported { harmless: false } }; + match d.get("T ") { + Some(D::Descriptor(t)) => { + let in_px = |k: &str| matches!(t.get(k), Some(D::UnitFloat { unit: PX, .. })); + let (Some(dx), Some(dy)) = (get_num(t, "Hrzn"), get_num(t, "Vrtc")) else { return Mapped::Unsupported { harmless: false } }; + if !in_px("Hrzn") || !in_px("Vrtc") { + // Points at an unknown resolution. + return Mapped::Unsupported { harmless: false }; + } + p.insert("dx".into(), dx.round().into()); + p.insert("dy".into(), dy.round().into()); + step("layer.translate", Value::Object(p)) + } + Some(D::Reference(t)) => { + let id = match target(t) { + Target::Ordinal(o) if o == "Frnt" => "layer.arrange.bringToFront", + Target::Ordinal(o) if o == "Back" => "layer.arrange.sendToBack", + Target::Ordinal(o) if o == "Nxt" => "layer.arrange.bringForward", + Target::Ordinal(o) if o == "Prvs" => "layer.arrange.sendBackward", + Target::Offset(1) => "layer.arrange.bringForward", + Target::Offset(-1) => "layer.arrange.sendBackward", + _ => return Mapped::Unsupported { harmless: false }, + }; + step(id, Value::Object(p)) + } + _ => Mapped::Unsupported { harmless: false }, + } +} + +fn move_to(id: &str, p: &Value) -> Option { + let ord = |o: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code(o) }]); + let t = match id { + "layer.translate" => D::Descriptor(desc("Ofst").with("Hrzn", px(jnum(p, "dx").unwrap_or(0.0))).with("Vrtc", px(jnum(p, "dy").unwrap_or(0.0)))), + "layer.arrange.bringToFront" => ord("Frnt"), + "layer.arrange.sendToBack" => ord("Back"), + "layer.arrange.bringForward" => ord("Nxt "), + "layer.arrange.sendBackward" => ord("Prvs"), + _ => return None, + }; + Some(desc("null").with("null", layer_ref(p)).with("T ", t)) +} + +fn duplicate_from(d: &Descriptor) -> Mapped { + let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; + match ref_class(r).as_deref() { + Some("Lyr") => { + let Some(mut p) = layer_target(r) else { return Mapped::Unsupported { harmless: false } }; + if let Some(n) = get_text(d, "Nm ") { + p.insert("name".into(), n.into()); + } + step("layer.duplicate", Value::Object(p)) + } + Some("Dcmn") => { + let mut p = Map::new(); + if let Some(n) = get_text(d, "Nm ") { + p.insert("name".into(), n.into()); + } + if let Some(m) = get_bool(d, "Mrgd") { + p.insert("mergedOnly".into(), m.into()); + } + step("image.duplicate", Value::Object(p)) + } + _ => Mapped::Unsupported { harmless: false }, + } +} + +fn duplicate_to(id: &str, p: &Value) -> Option { + let mut d = match id { + "layer.duplicate" => desc("null").with("null", layer_ref(p)), + "image.duplicate" => { + desc("null").with("null", D::Reference(vec![ReferenceItem::Enumerated { class: class("Dcmn"), type_id: Id::new("Ordn"), value: Id::new("Frst") }])) + } + _ => return None, + }; + if let Some(n) = jstr(p, "name") { + d = d.with("Nm ", text(n)); + } + if let Some(m) = jbool(p, "mergedOnly") { + d = d.with("Mrgd", D::Boolean(m)); + } + Some(d) +} + +fn play_from(d: &Descriptor) -> Mapped { + match get_ref(d, "null").map(target) { + Some(Target::Name(n)) => step("actions.play", json!({"action": n})), + _ => Mapped::Unsupported { harmless: false }, + } +} + +fn play_to(_: &str, p: &Value) -> Option { + let name = jstr(p, "action")?; + let mut items = vec![ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul(name) }]; + if let Some(set) = jstr(p, "set") { + items.push(ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul(set) }); + } + Some(desc("null").with("null", D::Reference(items))) +} + +fn fill_from(d: &Descriptor) -> Mapped { + if d.get("From").is_some() { + // The Paint Bucket tool's click, not Edit › Fill. + return Mapped::Unsupported { harmless: false }; + } + let Some(contents) = get_enum(d, "Usng").and_then(|u| lookup(FILL_CONTENTS, &u)) else { return Mapped::Unsupported { harmless: false } }; + let mut p = json!({"contents": contents}); + if contents == "color" { + match get_obj(d, "Clr ").and_then(color_hex) { + Some(c) => p["color"] = json!(c), + None => return Mapped::Unsupported { harmless: false }, + } + } + if let Some(o) = get_num(d, "Opct") { + p["opacity"] = json!(o); + } + if let Some(m) = get_enum(d, "Md ") { + match blend_label(&m) { + Some(l) => p["mode"] = json!(l), + None => return Mapped::Unsupported { harmless: false }, + } + } + if let Some(t) = get_bool(d, "PrsT") { + p["preserveTransparency"] = json!(t); + } + step("edit.fill", p) +} + +fn fill_to(_: &str, p: &Value) -> Option { + let contents = jstr(p, "contents").unwrap_or("color"); + let mut d = desc("null").with("Usng", enm("FlCn", lookup_back(FILL_CONTENTS, contents)?)); + if contents == "color" { + d = d.with("Clr ", D::Descriptor(color_desc(jstr(p, "color")?)?)); + } + d = d.with("Opct", pct(jnum(p, "opacity").unwrap_or(100.0))).with("Md ", enm("BlnM", blend_ps(jstr(p, "mode").unwrap_or("Normal"))?)); + if let Some(t) = jbool(p, "preserveTransparency") { + d = d.with("PrsT", D::Boolean(t)); + } + Some(d) +} + +const STROKE: &[F] = &[ + F("Wdth", "width", Kind::Long), + F("Lctn", "location", Kind::Enum("StrL", STROKE_LOCATION)), + F("Opct", "opacity", Kind::Pct), + F("Md ", "mode", Kind::Blend), + F("Clr ", "color", Kind::Color), + F("PrsT", "preserveTransparency", Kind::Bool), +]; + +/// Whether an event's `null` reference targets a layer (Edit › Transform) rather than the +/// document (Image › Image Rotation). +fn targets_layer(d: &Descriptor) -> bool { + get_ref(d, "null").and_then(ref_class).as_deref() == Some("Lyr") +} + +fn rotate_from(d: &Descriptor) -> Mapped { + let Some(angle) = get_num(d, "Angl") else { return Mapped::Unsupported { harmless: false } }; + let a = angle.rem_euclid(360.0); + let (layer, doc) = if (a - 90.0).abs() < 1e-6 { + ("edit.transform.rotate90Cw", "image.imageRotation.90cw") + } else if (a - 270.0).abs() < 1e-6 { + ("edit.transform.rotate90Ccw", "image.imageRotation.90ccw") + } else if (a - 180.0).abs() < 1e-6 { + ("edit.transform.rotate180", "image.imageRotation.180") + } else if targets_layer(d) { + return Mapped::Unsupported { harmless: false }; + } else { + return step("image.rotation.arbitrary", json!({"angle": angle.abs(), "direction": if angle < 0.0 { "ccw" } else { "cw" }})); + }; + none(if targets_layer(d) { layer } else { doc }) +} + +fn rotate_to(id: &str, p: &Value) -> Option { + let (angle, layer) = match id { + "edit.transform.rotate90Cw" => (90.0, true), + "edit.transform.rotate90Ccw" => (-90.0, true), + "edit.transform.rotate180" => (180.0, true), + "image.imageRotation.90cw" => (90.0, false), + "image.imageRotation.90ccw" => (-90.0, false), + "image.imageRotation.180" => (180.0, false), + "image.rotation.arbitrary" => (jnum(p, "angle")? * if jstr(p, "direction") == Some("ccw") { -1.0 } else { 1.0 }, false), + _ => return None, + }; + let mut d = desc("null"); + if layer { + d = d.with("null", target_ref("Lyr ")); + } + Some(d.with("Angl", ang(angle))) +} + +fn flip_from(d: &Descriptor) -> Mapped { + let Some(axis) = get_enum(d, "Axis") else { return Mapped::Unsupported { harmless: false } }; + let horizontal = axis == "Hrzn"; + none(match (targets_layer(d), horizontal) { + (true, true) => "edit.transform.flipHorizontal", + (true, false) => "edit.transform.flipVertical", + (false, true) => "image.imageRotation.flipCanvasHorizontal", + (false, false) => "image.imageRotation.flipCanvasVertical", + }) +} + +fn flip_to(id: &str, _: &Value) -> Option { + let (layer, axis) = match id { + "edit.transform.flipHorizontal" => (true, "Hrzn"), + "edit.transform.flipVertical" => (true, "Vrtc"), + "image.imageRotation.flipCanvasHorizontal" => (false, "Hrzn"), + "image.imageRotation.flipCanvasVertical" => (false, "Vrtc"), + _ => return None, + }; + let mut d = desc("null"); + if layer { + d = d.with("null", target_ref("Lyr ")); + } + Some(d.with("Axis", enm("Ornt", axis))) +} + +fn convert_mode_from(d: &Descriptor) -> Mapped { + if let Some(depth) = get_num(d, "Dpth") { + return match depth as i64 { + 8 => none("image.mode.bits8"), + 16 => none("image.mode.bits16"), + 32 => none("image.mode.bits32"), + _ => Mapped::Unsupported { harmless: false }, + }; + } + match get_enum(d, "T ").and_then(|t| lookup(MODES, &t)) { + Some(m) => none(&format!("image.mode.{m}")), + None => Mapped::Unsupported { harmless: false }, + } +} + +fn convert_mode_to(id: &str, _: &Value) -> Option { + Some(match id { + "image.mode.bits8" => desc("null").with("Dpth", long(8.0)), + "image.mode.bits16" => desc("null").with("Dpth", long(16.0)), + "image.mode.bits32" => desc("null").with("Dpth", long(32.0)), + _ => desc("null").with("T ", enm("ClrS", lookup_back(MODES, id.strip_prefix("image.mode.")?)?)), + }) +} + +fn canvas_size_from(d: &Descriptor) -> Mapped { + let mut p = Map::new(); + for (k, param) in [("Wdth", "width"), ("Hght", "height")] { + match d.get(k) { + Some(D::UnitFloat { unit: PX, value }) => { + p.insert(param.into(), value.round().into()); + } + Some(_) => return Mapped::Unsupported { harmless: false }, + None => {} + } + } + if let Some(r) = get_bool(d, "Rltv") { + p.insert("relative".into(), r.into()); + } + let v = get_enum(d, "Vrtc").unwrap_or_else(|| "Cntr".into()); + let h = get_enum(d, "Hrzn").unwrap_or_else(|| "Cntr".into()); + let anchor = match (v.as_str(), h.as_str()) { + ("Top", "Left") => "topLeft", + ("Top", "Cntr") => "top", + ("Top", "Rght") => "topRight", + ("Cntr", "Left") => "left", + ("Cntr", "Cntr") => "center", + ("Cntr", "Rght") => "right", + ("Bttm", "Left") => "bottomLeft", + ("Bttm", "Cntr") => "bottom", + ("Bttm", "Rght") => "bottomRight", + _ => return Mapped::Unsupported { harmless: false }, + }; + p.insert("anchor".into(), anchor.into()); + if let Some(c) = get_enum(d, "canvasExtensionColorType") { + match lookup(EXTENSION, &c) { + Some(c) => { + p.insert("extensionColor".into(), c.into()); + } + None => match get_obj(d, "canvasExtensionColor").and_then(color_hex) { + Some(hex) => { + p.insert("extensionColor".into(), hex.into()); + } + None => return Mapped::Unsupported { harmless: false }, + }, + } + } + step("image.canvasSize", Value::Object(p)) +} + +fn canvas_size_to(_: &str, p: &Value) -> Option { + let anchor = jstr(p, "anchor").unwrap_or("center"); + let v = if anchor.starts_with("top") { + "Top " + } else if anchor.starts_with("bottom") { + "Bttm" + } else { + "Cntr" + }; + let h = if anchor.to_ascii_lowercase().ends_with("left") { + "Left" + } else if anchor.to_ascii_lowercase().ends_with("right") { + "Rght" + } else { + "Cntr" + }; + let mut d = desc("null"); + if let Some(r) = jbool(p, "relative") { + d = d.with("Rltv", D::Boolean(r)); + } + if let Some(w) = jnum(p, "width") { + d = d.with("Wdth", px(w)); + } + if let Some(hh) = jnum(p, "height") { + d = d.with("Hght", px(hh)); + } + d = d.with("Hrzn", enm("HrzL", h)).with("Vrtc", enm("VrtL", v)); + if let Some(c) = jstr(p, "extensionColor") { + d = match lookup_back(EXTENSION, c) { + Some(e) => d.with("canvasExtensionColorType", enm("canvasExtensionColorType", e)), + None => d.with("canvasExtensionColorType", enm("canvasExtensionColorType", "Othr")).with("canvasExtensionColor", D::Descriptor(color_desc(c)?)), + }; + } + Some(d) +} + +fn image_size_from(d: &Descriptor) -> Mapped { + let mut p = Map::new(); + for (k, param) in [("Wdth", "width"), ("Hght", "height")] { + match d.get(k) { + Some(D::UnitFloat { unit: PX, value }) => { + p.insert(param.into(), value.round().into()); + } + // Percent or physical units need the document's size: not replayable headlessly. + Some(_) => return Mapped::Unsupported { harmless: false }, + None => {} + } + } + if let Some(r) = get_num(d, "Rslt") { + p.insert("resolution".into(), r.into()); + } + if let Some(i) = get_enum(d, "Intr") { + match lookup(INTERPOLATION, &i) { + Some(i) => { + p.insert("resample".into(), i.into()); + } + None => return Mapped::Unsupported { harmless: false }, + } + } else if p.contains_key("resolution") && !p.contains_key("width") && !p.contains_key("height") { + p.insert("resample".into(), "none".into()); + } + if p.is_empty() { + return Mapped::Unsupported { harmless: false }; + } + step("image.imageSize", Value::Object(p)) +} + +fn image_size_to(_: &str, p: &Value) -> Option { + let mut d = desc("null"); + if let Some(w) = jnum(p, "width") { + d = d.with("Wdth", px(w)); + } + if let Some(h) = jnum(p, "height") { + d = d.with("Hght", px(h)); + } + if let Some(r) = jnum(p, "resolution") { + d = d.with("Rslt", D::UnitFloat { unit: *b"#Rsl", value: r }); + } + if let Some(i) = jstr(p, "resample").filter(|r| *r != "none") { + d = d.with("Intr", enm("Intp", lookup_back(INTERPOLATION, i)?)); + } + Some(d) +} + +fn crop_from(d: &Descriptor) -> Mapped { + let mut p = Map::new(); + if let Some(t) = get_obj(d, "To ") { + let (Some(top), Some(left), Some(bottom), Some(right)) = (get_num(t, "Top "), get_num(t, "Left"), get_num(t, "Btom"), get_num(t, "Rght")) else { + return Mapped::Unsupported { harmless: false }; + }; + p.insert("x".into(), left.round().into()); + p.insert("y".into(), top.round().into()); + p.insert("width".into(), (right - left).round().into()); + p.insert("height".into(), (bottom - top).round().into()); + } + if let Some(a) = get_num(d, "Angl") { + p.insert("angle".into(), a.into()); + } + if let Some(del) = get_bool(d, "Dlt ") { + p.insert("deleteCroppedPixels".into(), del.into()); + } + step("image.crop", Value::Object(p)) +} + +fn crop_to(_: &str, p: &Value) -> Option { + let mut d = desc("null"); + if let (Some(x), Some(y), Some(w), Some(h)) = (jnum(p, "x"), jnum(p, "y"), jnum(p, "width"), jnum(p, "height")) { + d = d.with("To ", D::Descriptor(desc("Rctn").with("Top ", px(y)).with("Left", px(x)).with("Btom", px(y + h)).with("Rght", px(x + w)))); + } + if let Some(a) = jnum(p, "angle") { + d = d.with("Angl", ang(a)); + } + Some(d.with("Dlt ", D::Boolean(jbool(p, "deleteCroppedPixels").unwrap_or(true)))) +} + +fn stop_from(d: &Descriptor) -> Mapped { + step(STOP_MESSAGE, json!({"message": get_text(d, "Msge").unwrap_or_default(), "allowContinue": get_bool(d, "Cntn").unwrap_or(false)})) +} + +fn stop_to(_: &str, p: &Value) -> Option { + Some(desc("null").with("Msge", text(jstr(p, "message").unwrap_or(""))).with("Cntn", D::Boolean(jbool(p, "allowContinue").unwrap_or(false)))) +} + +fn paste_from(d: &Descriptor) -> Mapped { + none(if get_bool(d, "inPlace") == Some(true) { "edit.pasteSpecial.pasteInPlace" } else { "edit.paste" }) +} + +fn paste_to(id: &str, _: &Value) -> Option { + let mut d = desc("null"); + if id == "edit.pasteSpecial.pasteInPlace" { + d = d.with("inPlace", D::Boolean(true)); + } + Some(d.with("AntA", enm("Annt", "Anno"))) +} + +fn merge_visible_from(d: &Descriptor) -> Mapped { + none(if get_bool(d, "Dplc") == Some(true) { "layer.stampVisible" } else { "layer.mergeVisible" }) +} + +fn merge_visible_to(id: &str, _: &Value) -> Option { + Some(if id == "layer.stampVisible" { desc("null").with("Dplc", D::Boolean(true)) } else { desc("null") }) +} + +fn align_from(d: &Descriptor) -> Mapped { + match get_enum(d, "Usng").and_then(|u| lookup(ALIGN, &u)) { + Some(a) => step(&format!("layer.align.{a}"), if get_bool(d, "alignToCanvas") == Some(true) { json!({"to": "canvas"}) } else { json!({}) }), + None => Mapped::Unsupported { harmless: false }, + } +} + +fn align_to(id: &str, p: &Value) -> Option { + let a = lookup_back(ALIGN, id.strip_prefix("layer.align.")?)?; + Some(desc("null").with("null", target_ref("Lyr ")).with("Usng", enm("ADSt", a)).with("alignToCanvas", D::Boolean(jstr(p, "to") == Some("canvas")))) +} + +fn purge_from(d: &Descriptor) -> Mapped { + match get_enum(d, "null").and_then(|u| lookup(PURGE, &u)) { + Some(p) => none(&format!("edit.purge.{p}")), + None => Mapped::Unsupported { harmless: true }, + } +} + +fn purge_to(id: &str, _: &Value) -> Option { + Some(desc("null").with("null", enm("PrgI", lookup_back(PURGE, id.strip_prefix("edit.purge.")?)?))) +} + +fn reset_from(d: &Descriptor) -> Mapped { + match get_ref(d, "null").map(target) { + Some(Target::Property(p)) if p == "Clrs" => none("tools.defaultColors"), + _ => Mapped::Unsupported { harmless: true }, + } +} + +fn reset_to(_: &str, _: &Value) -> Option { + Some(desc("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Clr "), key: Id::new("Clrs") }]))) +} + +fn profile_from(d: &Descriptor, id: &str) -> Mapped { + let name = get_text(d, "T ").or_else(|| get_text(d, "profile")); + let Some(profile) = name.as_deref().and_then(|n| lookup(PROFILES, n)) else { return Mapped::Unsupported { harmless: false } }; + let mut p = json!({"profile": profile}); + if id == "edit.convertToProfile" { + if let Some(i) = get_enum(d, "Inte").and_then(|i| lookup(INTENT, &i)) { + p["intent"] = json!(i); + } + if let Some(b) = get_bool(d, "MpBl") { + p["bpc"] = json!(b); + } + } + step(id, p) +} + +fn profile_to(id: &str, p: &Value) -> Option { + let name = lookup_back(PROFILES, jstr(p, "profile")?)?; + let mut d = desc("null").with("null", target_ref("Dcmn")); + if id == "edit.convertToProfile" { + d = d + .with("T ", text(name)) + .with("Inte", enm("Inte", lookup_back(INTENT, jstr(p, "intent").unwrap_or("relative"))?)) + .with("MpBl", D::Boolean(jbool(p, "bpc").unwrap_or(true))); + } else { + d = d.with("profile", text(name)); + } + Some(d) +} + +fn levels_event_from(d: &Descriptor) -> Mapped { + if get_bool(d, "Auto") == Some(true) { + return none("image.autoTone"); + } + if get_bool(d, "AuCo") == Some(true) { + return none("image.autoContrast"); + } + match levels_from(d) { + Some(p) => step("image.adjustments.levels", p), + None => Mapped::Unsupported { harmless: false }, + } +} + +fn levels_event_to(id: &str, p: &Value) -> Option { + Some(match id { + "image.autoTone" => desc("null").with("Auto", D::Boolean(true)), + "image.autoContrast" => desc("null").with("AuCo", D::Boolean(true)), + _ => levels_to(p)?, + }) +} + +/// A direct adjustment event (Image › Adjustments › …). +macro_rules! adjustment_event { + ($code:literal, $class:literal, $name:literal, $label:literal) => { + Event { + code: $code, + name: $name, + label: $label, + ids: &[concat!("image.adjustments.", $name)], + from: |d| match adjustment_by_class($class) { + Some(a) => adjustment_steps(a, "image.adjustments.", Some(d)), + None => Mapped::Unsupported { harmless: false }, + }, + to: |_, p| adjustment_by_class($class).and_then(|a| (a.to)(p)), + } + }; +} + +/// Every Photoshop event PhotoCraft maps. The order matters only for the panel label. +const EVENTS: &[Event] = &[ + // Filters. + ev!("GsnB", "gaussianBlur", "Gaussian Blur", "filter.blur.gaussianBlur", &[F("Rds ", "radius", Kind::Px)]), + ev!( + "UnsM", + "unsharpMask", + "Unsharp Mask", + "filter.sharpen.unsharpMask", + &[F("Amnt", "amount", Kind::Pct), F("Rds ", "radius", Kind::Px), F("Thsh", "threshold", Kind::Long)] + ), + ev!( + "AdNs", + "addNoise", + "Add Noise", + "filter.noise.addNoise", + &[ + F("Dstr", "distribution", Kind::Enum("Dstr", NOISE)), + F("Nose", "amount", Kind::Pct), + F("Mnch", "monochromatic", Kind::Bool), + F("FlRs", "seed", Kind::Long) + ] + ), + ev!("Mdn ", "median", "Median", "filter.noise.median", &[F("Rds ", "radius", Kind::Px)]), + ev!("DstS", "dustAndScratches", "Dust & Scratches", "filter.noise.dustAndScratches", &[F("Rds ", "radius", Kind::Px), F("Thsh", "threshold", Kind::Long)]), + ev!("Dspc", "despeckle", "Despeckle", "filter.noise.despeckle", &[]), + ev!("MtnB", "motionBlur", "Motion Blur", "filter.blur.motionBlur", &[F("Angl", "angle", Kind::Ang), F("Dstn", "distance", Kind::Px)]), + ev!("BoxB", "boxblur", "Box Blur", "filter.blur.boxBlur", &[F("Rds ", "radius", Kind::Px)]), + ev!("SrfB", "surfaceBlur", "Surface Blur", "filter.blur.surfaceBlur", &[F("Rds ", "radius", Kind::Px), F("Thsh", "threshold", Kind::Long)]), + ev!( + "SmrB", + "smartBlur", + "Smart Blur", + "filter.blur.smartBlur", + &[ + F("Rds ", "radius", Kind::Dbl), + F("Thsh", "threshold", Kind::Dbl), + F("SmBQ", "quality", Kind::Enum("SmBQ", SMART_BLUR_QUALITY)), + F("SmBM", "mode", Kind::Enum("SmBM", SMART_BLUR_MODE)) + ] + ), + ev!( + "RdlB", + "radialBlur", + "Radial Blur", + "filter.blur.radialBlur", + &[F("Amnt", "amount", Kind::Long), F("BlrM", "method", Kind::Enum("BlrM", RADIAL_METHOD)), F("BlrQ", "quality", Kind::Enum("BlrQ", RADIAL_QUALITY))] + ), + ev!("Blr ", "blur", "Blur", "filter.blur.blur", &[]), + ev!("BlrM", "blurMore", "Blur More", "filter.blur.blurMore", &[]), + ev!("Shrp", "sharpen", "Sharpen", "filter.sharpen.sharpen", &[]), + ev!("ShrM", "sharpenMore", "Sharpen More", "filter.sharpen.sharpenMore", &[]), + ev!("ShrE", "sharpenEdges", "Sharpen Edges", "filter.sharpen.sharpenEdges", &[]), + ev!("HghP", "highPass", "High Pass", "filter.other.highPass", &[F("Rds ", "radius", Kind::Px)]), + ev!("Mnm ", "minimum", "Minimum", "filter.other.minimum", &[F("Rds ", "radius", Kind::Px)]), + ev!("Mxm ", "maximum", "Maximum", "filter.other.maximum", &[F("Rds ", "radius", Kind::Px)]), + ev!( + "Ofst", + "offset", + "Offset", + "filter.other.offset", + &[F("Hrzn", "horizontal", Kind::Long), F("Vrtc", "vertical", Kind::Long), F("Fl ", "undefinedAreas", Kind::Enum("FlMd", OFFSET_FILL))] + ), + // Adjustments (Image › Adjustments). + Event { + code: "Lvls", + name: "levels", + label: "Levels", + ids: &["image.adjustments.levels", "image.autoTone", "image.autoContrast"], + from: levels_event_from, + to: levels_event_to, + }, + adjustment_event!("Crvs", "Crvs", "curves", "Curves"), + adjustment_event!("HStr", "HStr", "hueSaturation", "Hue/Saturation"), + Event { + code: "BrgC", + name: "brightnessEvent", + label: "Brightness/Contrast", + ids: &["image.adjustments.brightnessContrast"], + from: |d| match adjustment_by_class("BrgC") { + Some(a) => adjustment_steps(a, "image.adjustments.", Some(d)), + None => Mapped::Unsupported { harmless: false }, + }, + to: |_, p| adjustment_by_class("BrgC").and_then(|a| (a.to)(p)), + }, + adjustment_event!("Thrs", "Thrs", "threshold", "Threshold"), + adjustment_event!("Pstr", "Pstr", "posterize", "Posterize"), + adjustment_event!("Invr", "Invr", "invert", "Invert"), + adjustment_event!("ClrB", "ClrB", "colorBalance", "Color Balance"), + adjustment_event!("Vbrn", "Vbrn", "vibrance", "Vibrance"), + adjustment_event!("Exps", "Exps", "exposure", "Exposure"), + adjustment_event!("PhtF", "PhtF", "photoFilter", "Photo Filter"), + Event { + code: "BlnW", + name: "blackAndWhite", + label: "Black & White", + ids: &["image.adjustments.blackWhite"], + from: |d| match adjustment_by_class("BlnW") { + Some(a) => adjustment_steps(a, "image.adjustments.", Some(d)), + None => Mapped::Unsupported { harmless: false }, + }, + to: |_, p| adjustment_by_class("BlnW").and_then(|a| (a.to)(p)), + }, + adjustment_event!("ChnM", "ChnM", "channelMixer", "Channel Mixer"), + adjustment_event!("SlcC", "SlcC", "selectiveColor", "Selective Color"), + ev!("Dstt", "desaturate", "Desaturate", "image.adjustments.desaturate", &[]), + ev!("Eqlz", "equalize", "Equalize", "image.adjustments.equalize", &[]), + ev!("", "autoColor", "Auto Color", "image.autoColor", &[]), + // Image. + Event { code: "ImgS", name: "imageSize", label: "Image Size", ids: &["image.imageSize"], from: image_size_from, to: image_size_to }, + Event { code: "CnvS", name: "canvasSize", label: "Canvas Size", ids: &["image.canvasSize"], from: canvas_size_from, to: canvas_size_to }, + Event { + code: "Rtte", + name: "rotateEventEnum", + label: "Rotate", + ids: &[ + "image.imageRotation.90cw", + "image.imageRotation.90ccw", + "image.imageRotation.180", + "image.rotation.arbitrary", + "edit.transform.rotate90Cw", + "edit.transform.rotate90Ccw", + "edit.transform.rotate180", + ], + from: rotate_from, + to: rotate_to, + }, + Event { + code: "Flip", + name: "flip", + label: "Flip", + ids: &[ + "image.imageRotation.flipCanvasHorizontal", + "image.imageRotation.flipCanvasVertical", + "edit.transform.flipHorizontal", + "edit.transform.flipVertical", + ], + from: flip_from, + to: flip_to, + }, + Event { code: "Crop", name: "crop", label: "Crop", ids: &["image.crop"], from: crop_from, to: crop_to }, + ev!( + "", + "trim", + "Trim", + "image.trim", + &[ + F("trimBasedOn", "basedOn", Kind::Enum("trimBasedOn", TRIM)), + F("Top ", "top", Kind::Bool), + F("Btom", "bottom", Kind::Bool), + F("Left", "left", Kind::Bool), + F("Rght", "right", Kind::Bool) + ] + ), + ev!("RvlA", "revealAll", "Reveal All", "image.revealAll", &[]), + Event { + code: "CnvM", + name: "convertMode", + label: "Convert Mode", + ids: &[ + "image.mode.rgb", + "image.mode.grayscale", + "image.mode.cmyk", + "image.mode.lab", + "image.mode.multichannel", + "image.mode.bits8", + "image.mode.bits16", + "image.mode.bits32", + ], + from: convert_mode_from, + to: convert_mode_to, + }, + Event { code: "Dplc", name: "duplicate", label: "Duplicate", ids: &["layer.duplicate", "image.duplicate"], from: duplicate_from, to: duplicate_to }, + // Layers. + Event { + code: "Mk ", + name: "make", + label: "Make", + ids: &[ + "layer.new.layer", + "layer.new.group", + "layer.groupLayers", + "layer.new.layerViaCopy", + "layer.new.layerViaCut", + "layer.newFillLayer.solidColor", + "layer.newAdjustmentLayer.levels", + "layer.newAdjustmentLayer.curves", + "layer.newAdjustmentLayer.hueSaturation", + "layer.newAdjustmentLayer.brightnessContrast", + "layer.newAdjustmentLayer.threshold", + "layer.newAdjustmentLayer.posterize", + "layer.newAdjustmentLayer.invert", + "layer.newAdjustmentLayer.colorBalance", + "layer.newAdjustmentLayer.vibrance", + "layer.newAdjustmentLayer.exposure", + "layer.newAdjustmentLayer.photoFilter", + "layer.newAdjustmentLayer.blackWhite", + "layer.newAdjustmentLayer.channelMixer", + "layer.newAdjustmentLayer.selectiveColor", + "file.new", + "file.newFromClipboard", + ], + from: make_from, + to: make_to, + }, + Event { + code: "setd", + name: "set", + label: "Set", + ids: &[ + "select.all", + "select.deselect", + "select.reselect", + "select.loadSelection", + "select.rect", + "layer.setProps", + "layer.renameLayer", + "layer.setLabelColor", + "layer.setAdjustment", + ], + from: set_from, + to: set_to, + }, + Event { + code: "slct", + name: "select", + label: "Select", + ids: &[ + "layer.select", + "layer.selectAbove", + "layer.selectBelow", + "layer.addAboveToSelection", + "layer.addBelowToSelection", + "layer.selectTop", + "layer.selectBottom", + ], + from: select_from, + to: select_to, + }, + Event { code: "Dlt ", name: "delete", label: "Delete", ids: &["layer.delete", "edit.clear"], from: delete_from, to: delete_to }, + Event { code: "Hd ", name: "hide", label: "Hide", ids: &["layer.hideLayers"], from: |d| visibility_from(d, "layer.hideLayers"), to: visibility_to }, + Event { code: "Shw ", name: "show", label: "Show", ids: &["layer.showLayers"], from: |d| visibility_from(d, "layer.showLayers"), to: visibility_to }, + Event { + code: "move", + name: "move", + label: "Move", + ids: &["layer.translate", "layer.arrange.bringToFront", "layer.arrange.sendToBack", "layer.arrange.bringForward", "layer.arrange.sendBackward"], + from: move_from, + to: move_to, + }, + ev!("Fltn", "flattenImage", "Flatten Image", "layer.flattenImage", &[]), + Event { + code: "MrgV", + name: "mergeVisible", + label: "Merge Visible", + ids: &["layer.mergeVisible", "layer.stampVisible"], + from: merge_visible_from, + to: merge_visible_to, + }, + ev!("Mrg2", "mergeLayersNew", "Merge Layers", "layer.mergeLayers", &[]), + ev!("CpTL", "copyToLayer", "Layer Via Copy", "layer.new.layerViaCopy", &[]), + ev!("CtTL", "cutToLayer", "Layer Via Cut", "layer.new.layerViaCut", &[]), + ev!("", "rasterizeLayer", "Rasterize", "layer.rasterize.layer", &[]), + ev!("", "selectAllLayers", "Select All Layers", "select.allLayers", &[]), + ev!("", "selectNoLayers", "Deselect Layers", "select.deselectLayers", &[]), + ev!("Dfrg", "defringe", "Defringe", "layer.matting.defringe", &[F("Wdth", "width", Kind::Px)]), + Event { + code: "Algn", + name: "align", + label: "Align", + ids: &[ + "layer.align.leftEdges", + "layer.align.horizontalCenters", + "layer.align.rightEdges", + "layer.align.topEdges", + "layer.align.verticalCenters", + "layer.align.bottomEdges", + ], + from: align_from, + to: align_to, + }, + // Selection. + ev!("Invs", "inverse", "Inverse", "select.inverse", &[]), + ev!("Fthr", "feather", "Feather", "select.modify.feather", &[F("Rds ", "radius", Kind::Px)]), + ev!("Expn", "expand", "Expand", "select.modify.expand", &[F("By ", "radius", Kind::Px)]), + ev!("Cntc", "contract", "Contract", "select.modify.contract", &[F("By ", "radius", Kind::Px)]), + ev!("Smth", "smoothness", "Smooth", "select.modify.smooth", &[F("Rds ", "radius", Kind::Px)]), + ev!("Brdr", "border", "Border", "select.modify.border", &[F("Wdth", "radius", Kind::Px)]), + ev!("Grow", "grow", "Grow", "select.grow", &[F("Tlrn", "tolerance", Kind::Long)]), + ev!("Smlr", "similar", "Similar", "select.similar", &[F("Tlrn", "tolerance", Kind::Long)]), + // Edit. + Event { code: "Fl ", name: "fill", label: "Fill", ids: &["edit.fill"], from: fill_from, to: fill_to }, + ev!("Strk", "stroke", "Stroke", "edit.stroke", STROKE), + ev!("copy", "copyEvent", "Copy", "edit.copy", &[]), + ev!("CpyM", "copyMerged", "Copy Merged", "edit.copyMerged", &[]), + ev!("cut ", "cut", "Cut", "edit.cut", &[]), + Event { code: "past", name: "paste", label: "Paste", ids: &["edit.paste", "edit.pasteSpecial.pasteInPlace"], from: paste_from, to: paste_to }, + Event { + code: "Prge", + name: "purge", + label: "Purge", + ids: &["edit.purge.all", "edit.purge.clipboard", "edit.purge.histories", "edit.purge.undo"], + from: purge_from, + to: purge_to, + }, + Event { code: "Rset", name: "reset", label: "Reset", ids: &["tools.defaultColors"], from: reset_from, to: reset_to }, + ev!("Exch", "exchange", "Exchange", "tools.swapColors", &[]), + Event { + code: "", + name: "assignProfile", + label: "Assign Profile", + ids: &["edit.assignProfile"], + from: |d| profile_from(d, "edit.assignProfile"), + to: profile_to, + }, + Event { + code: "", + name: "convertToProfile", + label: "Convert to Profile", + ids: &["edit.convertToProfile"], + from: |d| profile_from(d, "edit.convertToProfile"), + to: profile_to, + }, + // File and actions. + ev!("Cls ", "close", "Close", "file.close", &[]), + ev!("save", "save", "Save", "file.save", &[]), + ev!("Rvrt", "revert", "Revert", "file.revert", &[]), + Event { code: "Ply ", name: "play", label: "Play", ids: &["actions.play"], from: play_from, to: play_to }, + Event { code: "Stop", name: "stop", label: "Stop", ids: &[STOP_MESSAGE], from: stop_from, to: stop_to }, +]; + +fn event_for(item: &AtnEvent) -> Option<&'static Event> { + let id = item.id(); + EVENTS.iter().find(|e| (!e.code.is_empty() && e.code.trim_end() == id.trim_end()) || e.name == id) +} + +fn event_for_id(id: &str) -> Option<&'static Event> { + EVENTS.iter().find(|e| e.ids.contains(&id)) +} + +// ------------------------------------------------------------------ import + +/// A step with no PhotoCraft equivalent. +#[derive(Clone, Debug, PartialEq, serde::Serialize)] +pub struct Unsupported { + pub action: String, + pub step: usize, + pub event: String, + pub name: String, +} + +/// What a `.atn` file imports to. +#[derive(Clone, Debug, Default)] +pub struct Imported { + pub set: String, + pub actions: Vec, + /// Function keys the file assigns, as `(action name, key)`. + pub function_keys: Vec<(String, String)>, + pub unsupported: Vec, + pub warnings: Vec, +} + +fn photoshop_step(item: &AtnItem) -> (String, Value) { + let descriptor = item.descriptor.as_ref().map(|d| photocraft_paint::tile::b64_encode(&d.to_bytes())); + (PHOTOSHOP_STEP.into(), json!({"event": item.event.id(), "name": item.dictionary, "descriptor": descriptor})) +} + +/// Integral floats become integers (`2.0` → `2`), as the commands' own docs and recordings +/// use, so an imported step reads and compares like a recorded one. +fn normalise(v: &mut Value) { + match v { + Value::Number(n) => { + if let Some(f) = n.as_f64() + && f.fract() == 0.0 + && f.abs() < 9.0e15 + && n.as_i64().is_none() + { + *v = Value::from(f as i64); + } + } + Value::Array(a) => a.iter_mut().for_each(normalise), + Value::Object(o) => o.values_mut().for_each(normalise), + _ => {} + } +} + +fn convert_action(a: &AtnAction, set: &str, unsupported: &mut Vec) -> Action { + let name = a.name.to_string_lossy(); + let mut action = Action::new(name.clone(), Vec::new()); + action.set = Some(set.to_string()); + for (i, item) in a.items.iter().enumerate() { + let mapped = match (event_for(&item.event), &item.descriptor) { + (Some(e), Some(d)) => (e.from)(d), + (Some(e), None) => (e.from)(&Descriptor::default()), + (None, _) => Mapped::Unsupported { harmless: false }, + }; + let (new_steps, enabled) = match mapped { + Mapped::Steps(s) => (s, item.enabled), + Mapped::Unsupported { harmless } => { + unsupported.push(Unsupported { action: name.clone(), step: i, event: item.event.id(), name: item.dictionary.clone() }); + (vec![photoshop_step(item)], item.enabled && !harmless) + } + }; + for mut s in new_steps { + normalise(&mut s.1); + let idx = action.steps.len(); + action.steps.push(s); + if !enabled { + action.disabled.insert(idx); + } + if item.with_dialog { + action.dialogs.insert(idx); + } + } + } + action +} + +/// Convert a `.atn` file. Fails only when the file is unreadable: every step imports, as a +/// command or as a kept Photoshop step. +pub fn import(bytes: &[u8]) -> std::result::Result { + let file = atn::parse(bytes).map_err(|e| e.to_string())?; + let set = file.name.to_string_lossy(); + let set = if set.trim().is_empty() { DEFAULT_SET.to_string() } else { set }; + let mut out = Imported { set: set.clone(), warnings: file.warnings.clone(), ..Default::default() }; + for a in &file.actions { + let action = convert_action(a, &set, &mut out.unsupported); + if a.function_key > 0 && !a.shift && !a.command { + out.function_keys.push((action.name.clone(), format!("F{}", a.function_key))); + } + out.actions.push(action); + } + Ok(out) +} + +// ------------------------------------------------------------------ export + +/// A step Save Actions could not express as a Photoshop event. +#[derive(Clone, Debug, PartialEq, serde::Serialize)] +pub struct Skipped { + pub action: String, + pub step: usize, + pub id: String, +} + +fn export_item(id: &str, params: &Value, set: &str) -> Option { + if id == PHOTOSHOP_STEP { + let event = jstr(params, "event")?; + let event = match event.as_bytes() { + [a, b, c, d] => AtnEvent::Code([*a, *b, *c, *d]), + _ => AtnEvent::Name(event.to_string()), + }; + let descriptor = match jstr(params, "descriptor") { + Some(b64) => Some(Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(b64)?).ok()?), + None => None, + }; + return Some(AtnItem { event, dictionary: jstr(params, "name").unwrap_or("").to_string(), descriptor, ..Default::default() }); + } + let e = event_for_id(id)?; + let mut params = params.clone(); + if id == "actions.play" && params.get("set").is_none() { + params["set"] = json!(set); + } + let descriptor = (e.to)(id, ¶ms)?; + let event = if e.code.is_empty() { AtnEvent::Name(e.name.to_string()) } else { AtnEvent::Code(e.code.as_bytes().try_into().ok()?) }; + Some(AtnItem { event, dictionary: e.label.to_string(), descriptor: Some(descriptor), ..Default::default() }) +} + +/// The `.atn` bytes of `actions` as the set `set`. `function_keys` gives each action's key +/// (`F1`…`F15`). Steps with no Photoshop equivalent are an error unless `skip_unsupported`. +pub fn export( + set: &str, + actions: &[&Action], + function_keys: &[(String, String)], + skip_unsupported: bool, +) -> std::result::Result<(Vec, Vec), String> { + let mut skipped = Vec::new(); + let mut file = AtnFile { name: UnicodeString::new_nul(set), expanded: true, actions: Vec::new(), warnings: Vec::new() }; + for a in actions { + let mut out = AtnAction { name: UnicodeString::new_nul(&a.name), expanded: false, ..Default::default() }; + if let Some((_, key)) = function_keys.iter().find(|(n, _)| n == &a.name) + && let Some(n) = key.strip_prefix('F').and_then(|n| n.parse::().ok()) + && (1..=15).contains(&n) + { + out.function_key = n; + } + for (i, (id, params)) in a.steps.iter().enumerate() { + let Some(mut item) = export_item(id, params, set) else { + if skip_unsupported { + skipped.push(Skipped { action: a.name.clone(), step: i, id: id.clone() }); + continue; + } + return Err(format!("step {} of `{}` (`{id}`) has no Photoshop equivalent; pass \"skipUnsupported\": true to leave it out", i + 1, a.name)); + }; + item.enabled = a.step_enabled(i); + item.with_dialog = a.dialogs.contains(&i); + item.dialog_options = if item.with_dialog { 2 } else { 0 }; + out.items.push(item); + } + file.actions.push(out); + } + let bytes = atn::write(&file).map_err(|e| e.to_string())?; + Ok((bytes, skipped)) +} + +// ------------------------------------------------------------------ commands + +/// A name not used by any action in `taken`: `name`, `name 2`, `name 3`… +fn unique(name: &str, taken: &[String]) -> String { + if !taken.iter().any(|t| t == name) { + return name.to_string(); + } + (2..).map(|i| format!("{name} {i}")).find(|n| !taken.contains(n)).unwrap_or_else(|| name.to_string()) +} + +fn load(s: &mut Session, p: &Value) -> Result { + let cmd = "actions.load"; + if s.actions.recording.is_some() || s.actions.playing > 0 { + return Err(bad(cmd, "stop recording or playing before loading actions")); + } + let (bytes, _) = crate::preset_import_cmds::file_bytes(p, cmd, ".atn file")?; + let mut imp = import(&bytes).map_err(|e| bad(cmd, e))?; + match p.get("set") { + None | Some(Value::Null) => {} + Some(Value::String(n)) if !n.trim().is_empty() => { + imp.set = n.trim().to_string(); + for a in &mut imp.actions { + a.set = Some(imp.set.clone()); + } + } + Some(_) => return Err(bad(cmd, "`set` must be a non-empty string")), + } + let replace = match p.get("replace") { + None | Some(Value::Null) => true, + Some(Value::Bool(b)) => *b, + Some(_) => return Err(bad(cmd, "`replace` must be true or false")), + }; + if replace { + s.actions.list.retain(|a| a.set.as_deref() != Some(imp.set.as_str())); + } + let mut taken: Vec = s.actions.list.iter().map(|a| a.name.clone()).collect(); + let mut names = Vec::with_capacity(imp.actions.len()); + let mut renamed = Vec::new(); + let mut steps = 0usize; + for mut a in imp.actions { + let name = unique(&a.name, &taken); + if name != a.name { + renamed.push((a.name.clone(), name.clone())); + } + a.name = name.clone(); + taken.push(name.clone()); + steps = steps.saturating_add(a.steps.len()); + names.push(name); + s.actions.list.push(a); + } + // A Play step calls its action by name: follow renames within the set. + for (from, to) in &renamed { + for a in s.actions.list.iter_mut().filter(|a| a.set.as_deref() == Some(imp.set.as_str())) { + for (id, params) in &mut a.steps { + if id == "actions.play" && params.get("action").and_then(Value::as_str) == Some(from) { + params["action"] = json!(to); + } + } + } + } + let keys: Vec<(String, String)> = + imp.function_keys.into_iter().map(|(n, k)| (renamed.iter().find(|(f, _)| f == &n).map_or(n, |(_, t)| t.clone()), k)).collect(); + if !keys.is_empty() { + s.edit_prefs(|prefs| { + for (name, key) in &keys { + // A function key belongs to one action. + prefs.shortcuts.retain(|k, v| !(k.starts_with(crate::actions_cmds::PLAY_SHORTCUT_PREFIX) && v == key)); + prefs.shortcuts.insert(format!("{}{name}", crate::actions_cmds::PLAY_SHORTCUT_PREFIX), key.clone()); + } + }); + } + s.actions.touch(); + Ok(json!({ + "set": imp.set, + "actions": names, + "steps": steps, + "unsupported": imp.unsupported, + "functionKeys": keys.iter().map(|(n, k)| json!({"action": n, "key": k})).collect::>(), + "warnings": imp.warnings, + })) +} + +fn save(s: &mut Session, p: &Value) -> Result { + let cmd = "actions.save"; + let skip = match p.get("skipUnsupported") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(_) => return Err(bad(cmd, "`skipUnsupported` must be true or false")), + }; + let set_param = match p.get("set") { + None | Some(Value::Null) => None, + Some(Value::String(n)) if !n.trim().is_empty() => Some(n.trim().to_string()), + Some(_) => return Err(bad(cmd, "`set` must be a non-empty string")), + }; + let (set, actions): (String, Vec<&Action>) = if p.get("action").is_some() { + let idx = crate::actions_cmds::resolve(&s.actions.list, p, cmd)?; + let a = &s.actions.list[idx]; + (set_param.or_else(|| a.set.clone()).unwrap_or_else(|| a.name.clone()), vec![a]) + } else if let Some(set) = set_param { + let list: Vec<&Action> = s.actions.list.iter().filter(|a| a.set.as_deref() == Some(set.as_str())).collect(); + if list.is_empty() { + return Err(bad(cmd, format!("no actions in a set named `{set}`"))); + } + (set, list) + } else { + (DEFAULT_SET.to_string(), s.actions.list.iter().collect()) + }; + if actions.is_empty() { + return Err(bad(cmd, "there are no actions to save")); + } + let names: Vec = actions.iter().map(|a| a.name.clone()).collect(); + let keys: Vec<(String, String)> = + s.prefs().shortcuts.iter().filter_map(|(k, v)| k.strip_prefix(crate::actions_cmds::PLAY_SHORTCUT_PREFIX).map(|n| (n.to_string(), v.clone()))).collect(); + let (bytes, skipped) = export(&set, &actions, &keys, skip).map_err(|e| bad(cmd, e))?; + let steps: usize = actions.iter().map(|a| a.steps.len()).sum::().saturating_sub(skipped.len()); + let mut out = json!({"set": set, "actions": names, "steps": steps, "bytes": bytes.len(), "skipped": skipped}); + match p.get("path") { + None | Some(Value::Null) => { + out["data"] = json!(photocraft_paint::tile::b64_encode(&bytes)); + } + Some(Value::String(path)) if !path.trim().is_empty() => { + write_file(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + out["path"] = json!(path); + } + Some(_) => return Err(bad(cmd, "`path` must be a non-empty string")), + } + Ok(out) +} + +#[cfg(not(target_arch = "wasm32"))] +fn write_file(path: &str, bytes: &[u8]) -> std::io::Result<()> { + std::fs::write(path, bytes) +} + +#[cfg(target_arch = "wasm32")] +fn write_file(path: &str, _bytes: &[u8]) -> std::io::Result<()> { + Err(std::io::Error::other(format!("cannot write paths on the web ({path}); omit `path` to get the bytes back as `data`"))) +} + +fn stop_message(_: &mut Session, p: &Value) -> Result { + let message = jstr(p, "message").unwrap_or("Stop").to_string(); + if jbool(p, "allowContinue").unwrap_or(false) { + Ok(json!({"message": message, "continued": true})) + } else { + Err(EngineError::Other(format!("stopped by the action: {message}"))) + } +} + +fn photoshop_step_run(_: &mut Session, p: &Value) -> Result { + let name = jstr(p, "name").filter(|n| !n.is_empty()).unwrap_or("this step"); + let event = jstr(p, "event").unwrap_or("?"); + Err(EngineError::Other(format!("Photoshop step \"{name}\" ({event}) has no PhotoCraft equivalent yet; switch it off in the Actions panel to skip it"))) +} + +pub fn specs() -> Vec { + vec![ + CommandSpec { + id: "actions.load", + label: "Load Actions…", + menu: &[], + shortcut: None, + params: r##"{"path":".atn file"?,"data":base64 bytes?,"set":name? (override the file's set name),"replace":bool=true (replace the actions of a set of the same name)} → {set, actions:[names], steps, unsupported:[{action, step, event, name}], functionKeys:[{action, key}], warnings}. Photoshop steps PhotoCraft cannot play become actions.photoshopStep steps (kept for Save Actions; they fail at playback with the step's name); tool selections and other steps with no document effect are imported switched off. Layer references by name replay in any document."##, + enabled: always, + run: load, + journal: false, + }, + CommandSpec { + id: "actions.save", + label: "Save Actions…", + menu: &[], + shortcut: None, + params: r##"{"path":".atn file"? (else the bytes come back as base64 `data`),"set":name? (that set's actions; default: every action, as the set "PhotoCraft Actions"),"action":name|index? (one action),"skipUnsupported":bool=false (leave out steps with no Photoshop equivalent instead of failing)} → {set, actions:[names], steps, bytes, skipped:[{action, step, id}], path|data}"##, + enabled: always, + run: save, + journal: false, + }, + CommandSpec { + id: STOP_MESSAGE, + label: "Stop", + menu: &[], + shortcut: None, + params: r##"{"message":str,"allowContinue":bool=false} (Photoshop's Insert Stop: a step that shows the message; headless playback continues when allowContinue is set and otherwise stops the action with the message)"##, + enabled: always, + run: stop_message, + journal: true, + }, + CommandSpec { + id: PHOTOSHOP_STEP, + label: "Photoshop Step", + menu: &[], + shortcut: None, + params: r##"{"event":id,"name":str,"descriptor":base64?} (a step from a .atn file PhotoCraft has no command for: fails at playback, written back by actions.save)"##, + enabled: always, + run: photoshop_step_run, + journal: true, + }, + ] +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs new file mode 100644 index 0000000000..ff7e040878 --- /dev/null +++ b/crates/engine/src/atn_cmds/tests.rs @@ -0,0 +1,488 @@ +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) -> AtnItem { + let event = match event.as_bytes() { + [a, b, c, dd] => AtnEvent::Code([*a, *b, *c, *dd]), + _ => AtnEvent::Name(event.into()), + }; + AtnItem { event, dictionary: label.into(), 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::>(), [0], "the dialog toggle is kept"); + assert_eq!(a.disabled.iter().copied().collect::>(), [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")); +} + +#[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 = file.actions[0].items.iter().map(|i| i.event.id()).collect(); + assert_eq!(events, ["GsnB", "Mk ", "setd", "slct", "slct", "Stop", "syntheticFill", "Ply ", "Fltn"]); + 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"); +} + +#[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.blur.motionBlur", json!({"angle": 45, "distance": 20})), + ("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.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], "neutrals": [0, 0, 0, 3]})), + ("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"})), + ("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!({})), + ("actions.play", json!({"action": "Other", "set": "S"})), + (STOP_MESSAGE, json!({"message": "Hi", "allowContinue": false})), + ]; + for (id, params) in &cases { + let item = export_item(id, params, "S").unwrap_or_else(|| panic!("{id} does not export")); + 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" { + 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", + "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.smartBlur", + "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), "the .atn table names an unknown command `{id}`"); + } +} + +#[test] +fn unsupported_shapes_become_photoshop_steps_not_errors() { + // Percent image size, a Paint Bucket fill, a move in points, an unknown adjustment class. + let items = vec![ + item("ImgS", "Image Size", Some(desc("null").with("Wdth", pct(200.0)))), + item("Fl ", "Fill", Some(desc("null").with("From", D::Descriptor(desc("Pnt "))).with("Usng", enm("FlCn", "FrgC")))), + item( + "move", + "Move", + Some(desc("null").with("null", target_ref("Lyr ")).with( + "T ", + D::Descriptor(desc("Ofst").with("Hrzn", D::UnitFloat { unit: *b"#Rlt", value: 3.0 }).with("Vrtc", D::UnitFloat { unit: *b"#Rlt", value: 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), + ]; + 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"]); + assert_eq!(imp.unsupported.len(), 4); + assert!(imp.actions[0].disabled.is_empty(), "steps with a document effect stay on so playback reports them"); +} + +#[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(); +} + +#[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); +} diff --git a/crates/engine/src/automate_cmds/tests.rs b/crates/engine/src/automate_cmds/tests.rs index d4e0a6379b..7471f1cfd2 100644 --- a/crates/engine/src/automate_cmds/tests.rs +++ b/crates/engine/src/automate_cmds/tests.rs @@ -340,8 +340,18 @@ fn a_failed_step_inside_a_called_action_fails_scripts_and_batch() { images(&dir, 1, 8, 4); let mut s = Session::new(); s.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); - s.actions.list.push(Action { name: "Rotate".into(), steps: vec![("image.imageRotation.90cw".into(), json!({}))], disabled: Default::default() }); - s.actions.list.push(Action { name: "Bad".into(), steps: vec![("layer.delete".into(), json!({"layer": 999_999}))], disabled: Default::default() }); + s.actions.list.push(Action { + name: "Rotate".into(), + steps: vec![("image.imageRotation.90cw".into(), json!({}))], + disabled: Default::default(), + ..Default::default() + }); + s.actions.list.push(Action { + name: "Bad".into(), + steps: vec![("layer.delete".into(), json!({"layer": 999_999}))], + disabled: Default::default(), + ..Default::default() + }); // A script stops at the failed call and reports it. let r = s.execute("file.scripts.browse", json!({"steps": [["actions.play", {"action": "Bad"}], ["image.imageRotation.90cw", {}]]})).unwrap(); assert_eq!(r["ok"], false, "{r}"); @@ -358,6 +368,6 @@ fn a_failed_step_inside_a_called_action_fails_scripts_and_batch() { assert!(r["errors"][0]["error"].as_str().unwrap().contains("no such layer"), "{r}"); // An action that batches itself stops as a recursive call instead of overflowing the stack. let inner = json!({"steps": [["actions.play", {"action": "Loop"}]], "input": dir.clone(), "output": format!("{dir}/inner"), "format": "png"}); - s.actions.list.push(Action { name: "Loop".into(), steps: vec![("file.automate.batch".into(), inner)], disabled: Default::default() }); + s.actions.list.push(Action { name: "Loop".into(), steps: vec![("file.automate.batch".into(), inner)], disabled: Default::default(), ..Default::default() }); batch(&mut s, "Loop"); } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 5524388c71..6771b36f47 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -254,9 +254,33 @@ fn set_clipped(s: &mut Session, p: &Value, clip: bool) -> Result { Ok(json!({"layers": ids.iter().map(|id| id.0).collect::>()})) } +/// The layer a command's `"layer"` param names: an id, or a name (the topmost layer of that +/// name, as Photoshop's "select layer by name" action step finds it, so a loaded action replays +/// in another document). `None` when the param is absent. +pub(crate) fn explicit_layer(s: &Session, p: &Value) -> Result> { + match p.get("layer") { + Some(Value::Number(n)) => { + n.as_u64().map(|id| Some(LayerId(id))).ok_or_else(|| EngineError::Other(format!("layer id {n} is not a non-negative integer"))) + } + Some(Value::String(name)) => { + let d = s.active().ok_or(EngineError::NoDocument)?; + d.doc + .walk() + .into_iter() + .rev() + .find(|(_, _, l)| l.name == *name) + .map(|(_, _, l)| Some(l.id)) + .ok_or_else(|| EngineError::Other(format!("no layer named \"{name}\""))) + } + Some(Value::Null) | None => Ok(None), + Some(other) => Err(EngineError::Other(format!("`layer` must be an id or a name, not {other}"))), + } +} + +/// The layer a command targets: [`explicit_layer`], else the active layer. pub(crate) fn layer_param(s: &Session, p: &Value) -> Result { - match p.get("layer").and_then(Value::as_u64) { - Some(id) => Ok(LayerId(id)), + match explicit_layer(s, p)? { + Some(id) => Ok(id), None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into())), } } @@ -1200,6 +1224,7 @@ fn build() -> Vec { v.extend(crate::brush_preset_cmds::specs()); v.extend(crate::eraser_cmds::specs()); v.extend(crate::preset_import_cmds::specs()); + v.extend(crate::atn_cmds::specs()); v.extend(crate::swatch_cmds::specs()); v.extend(crate::retouch_cmds::specs()); v.extend(crate::redeye_cmds::specs()); diff --git a/crates/engine/src/cutout_cmds.rs b/crates/engine/src/cutout_cmds.rs index 762830ee83..baee260bee 100644 --- a/crates/engine/src/cutout_cmds.rs +++ b/crates/engine/src/cutout_cmds.rs @@ -233,8 +233,11 @@ mod tests { let (mut s, _) = disc(8); let before = s.active().unwrap().revision; assert!(s.execute(CMD, json!({"layer": 999_999})).is_err()); - // Wrong types fall back to the defaults (the active layer, layer-only sampling, refined). - assert!(s.execute(CMD, json!({"layer": "x", "sampleAllLayers": 3, "refine": "no"})).is_ok()); + // A `layer` string names a layer; one that isn't there is an error, not the active layer. + assert!(s.execute(CMD, json!({"layer": "x"})).is_err()); + assert_eq!(s.active().unwrap().revision, before); + // Wrong types for the options fall back to the defaults (layer-only sampling, refined). + assert!(s.execute(CMD, json!({"sampleAllLayers": 3, "refine": "no"})).is_ok()); assert!(s.active().unwrap().revision > before); } diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index db96c3c1b4..0d5985e339 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -115,7 +115,7 @@ fn range_order(layers: &[Layer], include_collapsed: bool, out: &mut Vec /// `layer.select`: `{"layer": id, "mode": "replace|toggle|range|add"}`. pub fn select(s: &mut Session, p: &Value) -> Result { - let id = LayerId(p.get("layer").and_then(Value::as_u64).ok_or_else(|| bad("layer.select", "missing `layer`"))?); + let id = crate::commands::explicit_layer(s, p)?.ok_or_else(|| bad("layer.select", "missing `layer`"))?; let mode = p.get("mode").and_then(Value::as_str).unwrap_or("replace"); let st = s.active().ok_or(EngineError::NoDocument)?; if st.doc.layer(id).is_none() { @@ -293,8 +293,8 @@ pub fn translate(s: &mut Session, p: &Value) -> Result { if dx == 0 && dy == 0 { return Ok(Value::Null); } - let roots = match p.get("layer").and_then(Value::as_u64) { - Some(id) => vec![LayerId(id)], + let roots = match crate::commands::explicit_layer(s, p)? { + Some(id) => vec![id], None => selected(s), }; if roots.is_empty() { @@ -1188,8 +1188,8 @@ pub(crate) fn check_group_depth(doc: &Document, what: &str) -> Result<()> { /// Group Layers (⌘G) / Group from Layers: the explicit layer, or every selected layer, moves /// into a new group placed where the top-most of them was. Bottom-to-top order is preserved. pub fn group_layers(s: &mut Session, p: &Value) -> Result { - let ids = match p.get("layer").and_then(Value::as_u64) { - Some(id) => vec![LayerId(id)], + let ids = match crate::commands::explicit_layer(s, p)? { + Some(id) => vec![id], None => selected(s), }; if ids.is_empty() { diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index 878bcaca0a..b7fc8b7b01 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -837,10 +837,7 @@ const MAX_EFFECTS: usize = 256; /// mismatches and malformed snapshots are errors, never panics. fn replace_effects(s: &mut Session, p: &Value) -> Result { const CMD: &str = "layer.layerStyle.replace"; - let id = match p.get("layer").and_then(Value::as_u64) { - Some(id) => photocraft_doc::LayerId(id), - None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into()))?, - }; + let id = crate::commands::layer_param(s, p)?; let entries = p.get("effects").and_then(Value::as_array).ok_or_else(|| bad(CMD, "need `effects`: [{kind, params, fx?}]"))?; if entries.len() > MAX_EFFECTS { return Err(bad(CMD, format!("at most {MAX_EFFECTS} effects"))); @@ -891,10 +888,7 @@ fn set_effect(s: &mut Session, p: &Value, kind: &str) -> Result { let (fx, pattern) = crate::pattern_cmds::resolve_effect(s, fx)?; let label = format!("Layer Style: {}", fx.label()); let add = b(p, "add", false); - let id = match p.get("layer").and_then(Value::as_u64) { - Some(id) => photocraft_doc::LayerId(id), - None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into()))?, - }; + let id = crate::commands::layer_param(s, p)?; s.edit(&label, |doc, _| { if let Some(pat) = &pattern { crate::pattern_cmds::ensure_in_doc(doc, pat); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c9cb725518..1115ab7e33 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -14,6 +14,7 @@ pub mod align_cmds; mod allocation; pub mod analysis_cmds; pub mod artboard_cmds; +pub mod atn_cmds; pub mod automate_cmds; pub mod background_cmds; pub mod brush_cmds; diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index 54358fe66c..684d308de0 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -426,10 +426,7 @@ fn with_panel_target(s: &Session, p: &Value) -> Value { fn transform(s: &mut Session, p: &Value) -> Result { let p = &with_panel_target(s, p); let st = s.active().ok_or(EngineError::NoDocument)?; - let id = match p.get("layer").and_then(Value::as_u64) { - Some(v) => Some(LayerId(v)), - None => st.active_layer, - }; + let id = crate::commands::explicit_layer(s, p)?.or(st.active_layer); let lone = lone_target(&st.doc, id, p)?; let id = match (id, lone.is_some() && crate::channel_cmds::is_channel_target(p)) { (_, true) => None, diff --git a/crates/psd/README.md b/crates/psd/README.md index 7de5529952..02b76e5a7f 100644 --- a/crates/psd/README.md +++ b/crates/psd/README.md @@ -109,6 +109,7 @@ testgen::{all_cases, merged_only, layered, small, sample_descriptor, pattern_pla | RGBA8 extraction | ✓ | – | – | RGB / gray / CMYK layers; merged image also supports indexed / bitmap / duotone | | Builder | – | ✓ | – | RGB / gray / CMYK at 8/16 bit, masks, groups, blend modes, opacity, fill, visibility, clipping | | Patterns | ✓ | ✓ | ✓ | `Patt` / `Pat2` / `Pat3`, `.pat`, and `.abr` embedded patterns; non-empty tiles allow in-bounds channel subrectangles, decoded size is capped and PackBits expansion is input-bounded | +| Actions (`.atn`) | ✓ | ✓ | ✓ | `atn::parse` / `atn::write`: version-16 sets (Photoshop 6+): actions with function key, modifiers and colour; steps with event code or string name, panel name, enabled/dialog flags and their descriptor. Exact round trip; version-12 (Photoshop 5) files are rejected as unsupported | Pattern channel subrectangles are padded with zeroes to the tile rectangle. All channel metadata and the total padded size are checked against `MAX_DECODED_BYTES` before any diff --git a/crates/psd/examples/atn_dump.rs b/crates/psd/examples/atn_dump.rs new file mode 100644 index 0000000000..8a39bbb121 --- /dev/null +++ b/crates/psd/examples/atn_dump.rs @@ -0,0 +1,84 @@ +//! Print the actions and steps of `.atn` files, one event per line, with the top-level keys of +//! each step's descriptor: `cargo run -p photocraft-psd --example atn_dump -- set.atn…` +//! +//! Handy when adding a Photoshop event to the engine's `.atn` import table: it shows the event +//! id, the panel name and the parameter keys Photoshop recorded. + +use photocraft_psd::atn; +use photocraft_psd::descriptor::{Descriptor, ReferenceItem, Value}; + +fn summary(v: &Value) -> String { + match v { + Value::Integer(i) => i.to_string(), + Value::Double(d) => format!("{d}"), + Value::UnitFloat { unit, value } => format!("{value}{}", String::from_utf8_lossy(unit)), + Value::Boolean(b) => b.to_string(), + Value::Text(t) => format!("{:?}", t.to_string_lossy()), + Value::Enumerated { type_id, value } => format!("{}.{}", String::from_utf8_lossy(type_id.as_bytes()), String::from_utf8_lossy(value.as_bytes())), + Value::Descriptor(d) | Value::GlobalObject(d) => format!("{{{}}}", keys(d)), + Value::List(l) => format!("[{}]", l.iter().map(summary).collect::>().join(", ")), + Value::Reference(r) => format!("ref[{}]", r.iter().map(reference).collect::>().join(" ")), + other => format!("{other:?}").chars().take(40).collect(), + } +} + +fn id(i: &photocraft_psd::descriptor::Id) -> String { + String::from_utf8_lossy(i.as_bytes()).trim().to_string() +} + +fn reference(item: &ReferenceItem) -> String { + match item { + ReferenceItem::Property { class, key } => format!("prop({},{})", id(&class.class_id), id(key)), + ReferenceItem::Class(c) => format!("Clss({})", id(&c.class_id)), + ReferenceItem::Enumerated { class, type_id, value } => format!("Enmr({},{}.{})", id(&class.class_id), id(type_id), id(value)), + ReferenceItem::Offset { class, offset } => format!("rele({},{offset})", id(&class.class_id)), + ReferenceItem::Identifier { class, id: v } => format!("Idnt({},{v})", id(&class.class_id)), + ReferenceItem::Index { class, index } => format!("indx({},{index})", id(&class.class_id)), + ReferenceItem::Name { class, name } => format!("name({},{:?})", id(&class.class_id), name.to_string_lossy()), + } +} + +fn keys(d: &Descriptor) -> String { + d.items.iter().map(|(k, v)| format!("{}={}", String::from_utf8_lossy(k.as_bytes()), summary(v))).collect::>().join(" ") +} + +fn main() { + let mut failed = false; + for path in std::env::args().skip(1) { + let bytes = match std::fs::read(&path) { + Ok(b) => b, + Err(e) => { + eprintln!("{path}: {e}"); + failed = true; + continue; + } + }; + match atn::parse(&bytes) { + Ok(f) => { + println!( + "== {path}: set {:?}, {} actions{}", + f.name.to_string_lossy(), + f.actions.len(), + if f.warnings.is_empty() { String::new() } else { format!(" ({})", f.warnings.join("; ")) } + ); + for a in &f.actions { + println!(" action {:?} ({} steps)", a.name.to_string_lossy(), a.items.len()); + for item in &a.items { + let flags = format!("{}{}", if item.enabled { "" } else { " off" }, if item.with_dialog { " dialog" } else { "" }); + match &item.descriptor { + Some(d) => println!(" {:<22} {:<28}{flags} {}", item.event.id(), item.dictionary, keys(d)), + None => println!(" {:<22} {:<28}{flags}", item.event.id(), item.dictionary), + } + } + } + } + Err(e) => { + println!("== {path}: {e}"); + failed = true; + } + } + } + if failed { + std::process::exit(1); + } +} diff --git a/crates/psd/fuzz/Cargo.toml b/crates/psd/fuzz/Cargo.toml index 86f0f51c0c..1c874bf4f0 100644 --- a/crates/psd/fuzz/Cargo.toml +++ b/crates/psd/fuzz/Cargo.toml @@ -98,3 +98,10 @@ path = "fuzz_targets/resources.rs" test = false doc = false bench = false + +[[bin]] +name = "atn" +path = "fuzz_targets/atn.rs" +test = false +doc = false +bench = false diff --git a/crates/psd/fuzz/fuzz_targets/atn.rs b/crates/psd/fuzz/fuzz_targets/atn.rs new file mode 100644 index 0000000000..3bb1777c8a --- /dev/null +++ b/crates/psd/fuzz/fuzz_targets/atn.rs @@ -0,0 +1,14 @@ +//! Fuzz the `.atn` actions parser: any input must return `Ok` or `Err`, never panic, and +//! anything that parses must survive a write and parse back equal. +#![no_main] + +use libfuzzer_sys::fuzz_target; +use photocraft_psd::atn; + +fuzz_target!(|data: &[u8]| { + if let Ok(f) = atn::parse(data) { + let bytes = atn::write(&f).expect("a parsed set is writable"); + let again = atn::parse(&bytes).expect("a written set parses"); + assert_eq!(again.actions, f.actions); + } +}); diff --git a/crates/psd/src/atn.rs b/crates/psd/src/atn.rs new file mode 100644 index 0000000000..2d8bf9990f --- /dev/null +++ b/crates/psd/src/atn.rs @@ -0,0 +1,426 @@ +//! Photoshop Actions files (`.atn`): one action set, its actions and their steps. +//! +//! Implemented clean-room from Adobe's "Photoshop File Formats" specification (the *Actions* +//! and *Descriptor structure* sections) and observation of files Photoshop writes. No code was +//! copied. All numbers are big-endian; strings are the Unicode strings of descriptors (u32 +//! code-unit count + UTF-16BE, usually NUL-terminated) unless noted. +//! +//! ```text +//! file := version u32 (16), set name UnicodeString, expanded u8, count u32, count × action +//! action := function key u16 (0 = none, n = Fn), shift u8, command u8, colour u16, +//! name UnicodeString, expanded u8, count u32, count × item +//! item := expanded u8, enabled u8, with dialog u8, dialog options u8, +//! event kind [4]: "TEXT" (u32 length + ASCII event name) | "long" (4-char event code), +//! dictionary name (u32 length + ASCII: the name the Actions panel shows), +//! has descriptor i32 (-1 = a descriptor follows, 0 = none), [descriptor] +//! ``` +//! +//! The descriptor is a plain ActionDescriptor body ([`Descriptor`], no version prefix): the +//! event's parameters, exactly as Photoshop's own "Make", "Set", "Gaussian Blur"… events +//! carry them. Photoshop 5-era files (version 12) use a different, undocumented layout and are +//! rejected as unsupported. +//! +//! [`parse`] never panics: every count is bounds-checked against the bytes left, descriptor +//! nesting is bounded, and allocations are capped. [`write`] produces the same layout, so +//! `parse(write(f)) == f`, and `write(parse(b)) == b` for files Photoshop wrote. + +use crate::descriptor::{Descriptor, UnicodeString}; +use crate::error::{PsdError, Result}; +use crate::io::{Reader, WriteExt}; + +/// The only version this module reads and writes (Photoshop 6 and every later release). +pub const VERSION: u32 = 16; +/// Most actions read from one set (Photoshop's own sets hold a few dozen). +pub const MAX_ACTIONS: usize = 10_000; +/// Most steps read from one action. +pub const MAX_ITEMS: usize = 100_000; + +/// A parsed action set. +#[derive(Debug, Clone, PartialEq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct AtnFile { + /// The set's name (the Actions panel folder). + pub name: UnicodeString, + /// Whether the set is shown expanded in the Actions panel. + pub expanded: bool, + /// The actions, in panel order. + pub actions: Vec, + /// Problems that did not stop the parse (trailing bytes…). + #[cfg_attr(feature = "serde", serde(default))] + pub warnings: Vec, +} + +/// One action of a set. +#[derive(Debug, Clone, PartialEq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct AtnAction { + /// Function key that plays the action: 0 = none, `n` = F`n`. + pub function_key: u16, + /// Shift modifier of the function key. + pub shift: bool, + /// Command (Control on Windows) modifier of the function key. + pub command: bool, + /// Button Mode colour index (0 = none, then red, orange, yellow, green, blue, violet, gray). + pub color: u16, + /// The action's name. + pub name: UnicodeString, + /// Whether the action is shown expanded in the Actions panel. + pub expanded: bool, + /// The steps, in playback order. + pub items: Vec, +} + +/// One step of an action: a Photoshop event with its parameters. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct AtnItem { + /// Whether the step's parameters are shown expanded in the Actions panel. + pub expanded: bool, + /// The panel's check mark: a switched-off step is skipped by playback. + pub enabled: bool, + /// The panel's dialog toggle: playback shows the step's dialog. + pub with_dialog: bool, + /// Dialog options (0 = none, 2 = show the dialog, 3 = don't show), kept verbatim. + pub dialog_options: u8, + /// The event. + pub event: AtnEvent, + /// The name the Actions panel shows for the step ("Gaussian Blur", "Make", …). + pub dictionary: String, + /// The event's parameters; `None` for events without any. + pub descriptor: Option, +} + +impl Default for AtnItem { + fn default() -> Self { + AtnItem { + expanded: false, + enabled: true, + with_dialog: false, + dialog_options: 0, + event: AtnEvent::Code(*b" "), + dictionary: String::new(), + descriptor: None, + } + } +} + +/// A Photoshop event id: a four-character code (`GsnB`) or a string name (`trim`). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum AtnEvent { + /// Stored as `long` + 4 bytes. + Code([u8; 4]), + /// Stored as `TEXT` + length-prefixed ASCII. + Name(String), +} + +impl AtnEvent { + /// The event id as text: the code's four characters, or the name. + pub fn id(&self) -> String { + match self { + AtnEvent::Code(c) => String::from_utf8_lossy(c).into_owned(), + AtnEvent::Name(n) => n.clone(), + } + } + /// `true` if the event is the code or name `s`. + pub fn is(&self, s: &str) -> bool { + match self { + AtnEvent::Code(c) => c == s.as_bytes(), + AtnEvent::Name(n) => n == s, + } + } +} + +/// Reads a u32 length + ASCII bytes (event and dictionary names). +fn read_ascii(r: &mut Reader<'_>) -> Result { + let n = r.u32()?; + Ok(String::from_utf8_lossy(r.bytes_u64(u64::from(n))?).into_owned()) +} + +fn write_ascii(out: &mut Vec, s: &str) -> Result<()> { + let n = u32::try_from(s.len()).map_err(|_| PsdError::LimitExceeded("action name too long"))?; + out.put_u32(n); + out.put(s.as_bytes()); + Ok(()) +} + +fn read_item(r: &mut Reader<'_>) -> Result { + let expanded = r.u8()? != 0; + let enabled = r.u8()? != 0; + let with_dialog = r.u8()? != 0; + let dialog_options = r.u8()?; + let event = match &r.array::<4>()? { + b"TEXT" => AtnEvent::Name(read_ascii(r)?), + b"long" => AtnEvent::Code(r.array()?), + other => return Err(PsdError::invalid(format!("unknown event kind {:?}", String::from_utf8_lossy(other)))), + }; + let dictionary = read_ascii(r)?; + let descriptor = match r.i32()? { + 0 => None, + -1 => Some(Descriptor::read(r)?), + n => return Err(PsdError::invalid(format!("bad descriptor flag {n}"))), + }; + Ok(AtnItem { expanded, enabled, with_dialog, dialog_options, event, dictionary, descriptor }) +} + +fn read_action(r: &mut Reader<'_>) -> Result { + let function_key = r.u16()?; + let shift = r.u8()? != 0; + let command = r.u8()? != 0; + let color = r.u16()?; + let name = UnicodeString::read(r)?; + let expanded = r.u8()? != 0; + let n = r.u32()?; + // Each item needs at least its 4 flags, the event kind, two empty names and the descriptor flag. + r.check_count(u64::from(n), 20)?; + if n as usize > MAX_ITEMS { + return Err(PsdError::LimitExceeded("too many steps in one action")); + } + let mut items = Vec::with_capacity((n as usize).min(1024)); + for _ in 0..n { + items.push(read_item(r)?); + } + Ok(AtnAction { function_key, shift, command, color, name, expanded, items }) +} + +/// Parses an `.atn` file. +pub fn parse(data: &[u8]) -> Result { + let mut r = Reader::new(data); + let version = r.u32()?; + if version != VERSION { + return Err(PsdError::Unsupported(format!("actions file version {version} (only version 16, Photoshop 6 and later, is supported)"))); + } + let name = UnicodeString::read(&mut r)?; + let expanded = r.u8()? != 0; + let n = r.u32()?; + // Each action needs at least its key, modifiers, colour, an empty name, the flag and a count. + r.check_count(u64::from(n), 15)?; + if n as usize > MAX_ACTIONS { + return Err(PsdError::LimitExceeded("too many actions in one set")); + } + let mut actions = Vec::with_capacity((n as usize).min(256)); + for _ in 0..n { + actions.push(read_action(&mut r)?); + } + let mut warnings = Vec::new(); + if !r.is_empty() { + warnings.push(format!("{} trailing bytes ignored", r.remaining())); + } + Ok(AtnFile { name, expanded, actions, warnings }) +} + +fn write_item(out: &mut Vec, item: &AtnItem) -> Result<()> { + out.put_u8(u8::from(item.expanded)); + out.put_u8(u8::from(item.enabled)); + out.put_u8(u8::from(item.with_dialog)); + out.put_u8(item.dialog_options); + match &item.event { + AtnEvent::Name(n) => { + out.put(b"TEXT"); + write_ascii(out, n)?; + } + AtnEvent::Code(c) => { + out.put(b"long"); + out.put(c); + } + } + write_ascii(out, &item.dictionary)?; + match &item.descriptor { + None => out.put_i32(0), + Some(d) => { + out.put_i32(-1); + d.write(out); + } + } + Ok(()) +} + +fn count(n: usize, what: &'static str) -> Result { + u32::try_from(n).map_err(|_| PsdError::LimitExceeded(what)) +} + +/// Writes an `.atn` file (version 16). +pub fn write(f: &AtnFile) -> Result> { + let mut out = Vec::new(); + out.put_u32(VERSION); + f.name.write(&mut out); + out.put_u8(u8::from(f.expanded)); + out.put_u32(count(f.actions.len(), "too many actions")?); + for a in &f.actions { + out.put_u16(a.function_key); + out.put_u8(u8::from(a.shift)); + out.put_u8(u8::from(a.command)); + out.put_u16(a.color); + a.name.write(&mut out); + out.put_u8(u8::from(a.expanded)); + out.put_u32(count(a.items.len(), "too many steps")?); + for item in &a.items { + write_item(&mut out, item)?; + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::descriptor::Value; + + fn px(v: f64) -> Value { + Value::UnitFloat { unit: *b"#Pxl", value: v } + } + + fn set() -> AtnFile { + let blur = AtnItem { + expanded: true, + enabled: true, + with_dialog: true, + dialog_options: 2, + event: AtnEvent::Code(*b"GsnB"), + dictionary: "Gaussian Blur".into(), + descriptor: Some(Descriptor::new("null").with("Rds ", px(2.5))), + }; + let trim = AtnItem { + event: AtnEvent::Name("trim".into()), + dictionary: "Trim".into(), + descriptor: Some( + Descriptor { name: UnicodeString::new_nul("Trim"), class_id: crate::descriptor::Id::Str(b"trim".to_vec()), items: Vec::new() } + .with( + "trimBasedOn", + Value::Enumerated { type_id: crate::descriptor::Id::new("trimBasedOn"), value: crate::descriptor::Id::new("topLeftPixelColor") }, + ) + .with("Top ", Value::Boolean(true)), + ), + ..Default::default() + }; + let flatten = AtnItem { enabled: false, event: AtnEvent::Code(*b"Fltn"), dictionary: "Flatten Image".into(), ..Default::default() }; + AtnFile { + name: UnicodeString::new_nul("Set ✓"), + expanded: true, + actions: vec![ + AtnAction { + function_key: 2, + shift: true, + command: false, + color: 3, + name: UnicodeString::new_nul("Soften"), + expanded: true, + items: vec![blur, flatten], + }, + AtnAction { name: UnicodeString::new_nul("Tidy"), items: vec![trim], ..Default::default() }, + AtnAction { name: UnicodeString::new_nul("Empty"), ..Default::default() }, + ], + warnings: Vec::new(), + } + } + + #[test] + fn round_trip() { + let f = set(); + let bytes = write(&f).unwrap(); + let parsed = parse(&bytes).unwrap(); + assert_eq!(parsed, f); + assert_eq!(write(&parsed).unwrap(), bytes, "writing is stable"); + assert_eq!(parsed.actions[0].items[0].event.id(), "GsnB"); + assert!(parsed.actions[1].items[0].event.is("trim")); + assert_eq!(parsed.actions[1].items[0].event.id(), "trim"); + } + + /// The layout Photoshop writes for a one-step Trim action, assembled by hand from the + /// format description: field by field, so a mistake names the field. + #[test] + fn layout_matches_the_specification() { + let f = AtnFile { + name: UnicodeString::new_nul("Trim"), + expanded: false, + actions: vec![AtnAction { + name: UnicodeString::new_nul("Action 1"), + expanded: true, + items: vec![AtnItem { + expanded: true, + event: AtnEvent::Name("trim".into()), + dictionary: "Trim".into(), + descriptor: Some(Descriptor { + name: UnicodeString::new_nul("Trim"), + class_id: crate::descriptor::Id::Str(b"trim".to_vec()), + items: Vec::new(), + }), + ..Default::default() + }], + ..Default::default() + }], + warnings: Vec::new(), + }; + let mut expect = Vec::new(); + expect.extend_from_slice(&[0, 0, 0, 16]); // version + expect.extend_from_slice(&[0, 0, 0, 5, 0, b'T', 0, b'r', 0, b'i', 0, b'm', 0, 0]); // set name + expect.push(0); // set collapsed + expect.extend_from_slice(&[0, 0, 0, 1]); // one action + expect.extend_from_slice(&[0, 0, 0, 0, 0, 0]); // no function key, no modifiers, no colour + expect.extend_from_slice(&[0, 0, 0, 9]); + for c in "Action 1\0".encode_utf16() { + expect.extend_from_slice(&c.to_be_bytes()); + } + expect.push(1); // action expanded + expect.extend_from_slice(&[0, 0, 0, 1]); // one step + expect.extend_from_slice(&[1, 1, 0, 0]); // expanded, enabled, no dialog, options 0 + expect.extend_from_slice(b"TEXT\0\0\0\x04trim"); + expect.extend_from_slice(b"\0\0\0\x04Trim"); + expect.extend_from_slice(&[0xff; 4]); // a descriptor follows + expect.extend_from_slice(&[0, 0, 0, 5, 0, b'T', 0, b'r', 0, b'i', 0, b'm', 0, 0]); // class name + expect.extend_from_slice(b"\0\0\0\x04trim"); // class id, length-4 string form + expect.extend_from_slice(&[0, 0, 0, 0]); // no items + assert_eq!(write(&f).unwrap(), expect); + assert_eq!(parse(&expect).unwrap(), f); + } + + #[test] + fn truncation_never_parses_and_never_panics() { + let bytes = write(&set()).unwrap(); + for cut in 0..bytes.len() { + assert!(parse(&bytes[..cut]).is_err(), "cut at {cut}/{}", bytes.len()); + } + let mut padded = bytes.clone(); + padded.extend_from_slice(b"junk"); + let f = parse(&padded).unwrap(); + assert_eq!(f.warnings, ["4 trailing bytes ignored"]); + assert_eq!(f.actions, set().actions); + } + + #[test] + fn rejects_other_versions_and_bad_structure() { + let mut bytes = write(&set()).unwrap(); + bytes[3] = 12; + assert!(matches!(parse(&bytes), Err(PsdError::Unsupported(_)))); + // A count far beyond the data must fail before allocating. + let mut huge = vec![0, 0, 0, 16, 0, 0, 0, 0, 0]; + huge.extend_from_slice(&u32::MAX.to_be_bytes()); + assert!(matches!(parse(&huge), Err(PsdError::UnexpectedEof { .. }))); + // Unknown event kinds and descriptor flags are reported, not guessed. + let mut bad_kind = write(&set()).unwrap(); + let at = bad_kind.windows(4).position(|w| w == b"long").unwrap(); + bad_kind[at..at + 4].copy_from_slice(b"what"); + assert!(matches!(parse(&bad_kind), Err(PsdError::Invalid(_)))); + let mut bad_flag = write(&set()).unwrap(); + let at = bad_flag.windows(4).position(|w| w == [0xff; 4]).unwrap(); + bad_flag[at..at + 4].copy_from_slice(&[0, 0, 0, 7]); + assert!(matches!(parse(&bad_flag), Err(PsdError::Invalid(_)))); + } + + #[test] + fn garbage_never_panics() { + let seed = write(&set()).unwrap(); + let mut x: u32 = 0x9e37_79b9; + for i in 0..2000 { + let mut bytes = seed.clone(); + for _ in 0..(i % 7 + 1) { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + let at = (x as usize) % bytes.len(); + bytes[at] = (x >> 8) as u8; + } + let _ = parse(&bytes); + } + } +} diff --git a/crates/psd/src/descriptor.rs b/crates/psd/src/descriptor.rs index b0e64ba439..632119a12d 100644 --- a/crates/psd/src/descriptor.rs +++ b/crates/psd/src/descriptor.rs @@ -149,10 +149,20 @@ pub enum ReferenceItem { /// Offset. offset: i32, }, - /// `Idnt` - Identifier(u32), - /// `indx` - Index(u32), + /// `Idnt`: a class plus an identifier (Photoshop's layer ids in action references). + Identifier { + /// Class. + class: Class, + /// Identifier. + id: u32, + }, + /// `indx`: a class plus a zero-based index. + Index { + /// Class. + class: Class, + /// Index. + index: u32, + }, /// `name` Name { /// Class. @@ -454,8 +464,8 @@ fn read_reference_item(r: &mut Reader<'_>) -> Result { b"Clss" => ReferenceItem::Class(Class::read(r)?), b"Enmr" => ReferenceItem::Enumerated { class: Class::read(r)?, type_id: Id::read(r)?, value: Id::read(r)? }, b"rele" => ReferenceItem::Offset { class: Class::read(r)?, offset: r.i32()? }, - b"Idnt" => ReferenceItem::Identifier(r.u32()?), - b"indx" => ReferenceItem::Index(r.u32()?), + b"Idnt" => ReferenceItem::Identifier { class: Class::read(r)?, id: r.u32()? }, + b"indx" => ReferenceItem::Index { class: Class::read(r)?, index: r.u32()? }, b"name" => ReferenceItem::Name { class: Class::read(r)?, name: UnicodeString::read(r)? }, other => { return Err(PsdError::Unsupported(format!("reference item type {:?}", String::from_utf8_lossy(other)))); @@ -556,13 +566,15 @@ fn write_reference_item(it: &ReferenceItem, out: &mut Vec) { class.write(out); out.put_i32(*offset); } - ReferenceItem::Identifier(v) => { + ReferenceItem::Identifier { class, id } => { out.put(b"Idnt"); - out.put_u32(*v); + class.write(out); + out.put_u32(*id); } - ReferenceItem::Index(v) => { + ReferenceItem::Index { class, index } => { out.put(b"indx"); - out.put_u32(*v); + class.write(out); + out.put_u32(*index); } ReferenceItem::Name { class, name } => { out.put(b"name"); @@ -614,8 +626,8 @@ mod tests { ReferenceItem::Class(class("", "Dcmn")), ReferenceItem::Enumerated { class: class("", "Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") }, ReferenceItem::Offset { class: class("", "Lyr "), offset: -1 }, - ReferenceItem::Identifier(7), - ReferenceItem::Index(2), + ReferenceItem::Identifier { class: class("", "Lyr "), id: 7 }, + ReferenceItem::Index { class: class("\0", "Lyr "), index: 2 }, ReferenceItem::Name { class: class("", "Lyr "), name: UnicodeString::new("Bg") }, ]), ) @@ -793,8 +805,8 @@ pub(crate) mod proptests { arb_class().prop_map(ReferenceItem::Class), (arb_class(), arb_id(), arb_id()).prop_map(|(class, type_id, value)| ReferenceItem::Enumerated { class, type_id, value }), (arb_class(), any::()).prop_map(|(class, offset)| ReferenceItem::Offset { class, offset }), - any::().prop_map(ReferenceItem::Identifier), - any::().prop_map(ReferenceItem::Index), + (arb_class(), any::()).prop_map(|(class, id)| ReferenceItem::Identifier { class, id }), + (arb_class(), any::()).prop_map(|(class, index)| ReferenceItem::Index { class, index }), (arb_class(), arb_ustr()).prop_map(|(class, name)| ReferenceItem::Name { class, name }), ] } diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 16d539b948..00d73c4003 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -43,6 +43,7 @@ pub mod abr; pub mod aco; pub mod ase; +pub mod atn; pub mod blend; pub mod builder; pub mod compression; diff --git a/crates/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index 504ecf2beb..08565bfc3c 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -218,11 +218,126 @@ fn step_label(id: &str, params: &Value) -> String { if id == "actions.play" { let target = params.get("action").map(|v| v.as_str().map(str::to_owned).unwrap_or_else(|| v.to_string())).unwrap_or_default(); format!("{}: {target}", label_of(id)) + } else if id == photocraft_engine::atn_cmds::STOP_MESSAGE { + format!("{}: {}", label_of(id), params.get("message").and_then(Value::as_str).unwrap_or_default()) + } else if id == photocraft_engine::atn_cmds::PHOTOSHOP_STEP { + // A step from a .atn file PhotoCraft can't play yet: its Photoshop name, marked. + let name = params.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()).unwrap_or("?"); + crate::i18n::fmt(tl!("{name} (Photoshop)"), &[("name", name)]) } else { label_of(id) } } +/// Command params for a picked file: a real path keeps the journal small; browser files arrive +/// as bytes. +fn source(name: &str, bytes: &[u8]) -> Value { + #[cfg(not(target_arch = "wasm32"))] + if std::path::Path::new(name).is_absolute() && std::path::Path::new(name).is_file() { + return json!({"path": name}); + } + let _ = name; + json!({"data": photocraft_engine::paint::tile::b64_encode(bytes)}) +} + +/// Load a `.atn` file's bytes into the Actions panel (the panel menu's Load Actions…). +pub fn load_bytes(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Result { + let r = app.run("actions.load", source(name, bytes))?; + let n = r["actions"].as_array().map_or(0, Vec::len) as u64; + let unsupported = r["unsupported"].as_array().map_or(0, Vec::len); + let shown = crate::file_open::display_name(name); + let loaded = + crate::i18n::fmt(&crate::i18n::trn(crate::i18n::current(), n, "Loaded {n} action from {file}", "Loaded {n} actions from {file}"), &[("file", &shown)]); + app.ui.status_error = false; + app.ui.status = if unsupported > 0 { + let note = crate::i18n::trn( + crate::i18n::current(), + unsupported as u64, + "{n} step has no PhotoCraft equivalent yet", + "{n} steps have no PhotoCraft equivalent yet", + ); + let template = "{message} ({note})"; + crate::i18n::fmt(tl!(template), &[("message", &loaded), ("note", ¬e)]) + } else { + loaded + }; + // Show what arrived: select the first loaded action, expanded. + let list = &app.session.actions.list; + if let Some(first) = r["actions"][0].as_str() + && let Some(i) = list.iter().position(|a| a.name == first) + { + app.ui.actions.selected = Some(i); + app.ui.actions.selected_step = None; + app.ui.actions.expanded.resize(list.len(), false); + if let Some(e) = app.ui.actions.expanded.get_mut(i) { + *e = true; + } + } + app.ui.panels.history = true; + Ok(r) +} + +fn load_dialog(app: &mut PhotocraftApp) -> Result { + app.pick_file_bytes_filtered(&["atn"], |app, name, bytes| load_bytes(app, &name, &bytes)) +} + +/// Save Actions…: the selected action's set (every action of it), or every action when none +/// came from a set, as a `.atn` file Photoshop loads. +fn save_dialog(app: &mut PhotocraftApp) -> Result { + let set = selected_action(app).and_then(|a| a.set.clone()); + let params = match &set { + Some(set) => json!({"set": set, "skipUnsupported": true}), + None => json!({"skipUnsupported": true}), + }; + let r = app.run("actions.save", params)?; + let bytes = r["data"].as_str().and_then(photocraft_engine::paint::tile::b64_decode).ok_or("the export returned no data")?; + let n = r["actions"].as_array().map_or(0, Vec::len) as u64; + let skipped = r["skipped"].as_array().map_or(0, Vec::len); + let file = format!("{}.atn", r["set"].as_str().unwrap_or("Actions")); + app.pick_save(&file, move |app, path| { + let write = app.services.write.as_mut().ok_or("no writer configured")?; + write(&path, &bytes)?; + let shown = crate::file_open::display_name(&path); + let saved = + crate::i18n::fmt(&crate::i18n::trn(crate::i18n::current(), n, "Saved {n} action to {file}", "Saved {n} actions to {file}"), &[("file", &shown)]); + app.ui.status_error = false; + app.ui.status = if skipped > 0 { + let note = crate::i18n::trn( + crate::i18n::current(), + skipped as u64, + "{n} step with no Photoshop equivalent left out", + "{n} steps with no Photoshop equivalent left out", + ); + let template = "{message} ({note})"; + crate::i18n::fmt(tl!(template), &[("message", &saved), ("note", ¬e)]) + } else { + saved + }; + Ok(json!({"path": path, "actions": n, "skipped": skipped})) + }) +} + +/// The Actions entries of the panel group's menu ("…"). +pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let mut act: Option<&str> = None; + if ui.button(tl!("Load Actions…")).clicked() { + act = Some("load"); + } + let any = !app.session.actions.list.is_empty(); + if ui.add_enabled(any, egui::Button::new(tl!("Save Actions…"))).clicked() { + act = Some("save"); + } + let Some(a) = act else { return }; + ui.close(); + let r = if a == "load" { load_dialog(app) } else { save_dialog(app) }; + if let Err(e) = r + && e != "cancelled" + { + app.ui.status = e; + app.ui.status_error = true; + } +} + #[derive(Clone, Copy)] struct DragRow { action: usize, @@ -555,7 +670,7 @@ mod tests { let bottom = |height: f32| { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); for i in 0..50 { - app.session.actions.list.push(Action { name: format!("Action {i}"), steps: vec![], disabled: Default::default() }); + app.session.actions.list.push(Action { name: format!("Action {i}"), steps: vec![], disabled: Default::default(), ..Default::default() }); } let mut h = Harness::builder().with_size(vec2(320.0, height)).build_ui_state(|ui, app| panel(app, ui), app); h.run_steps(3); @@ -572,7 +687,12 @@ mod tests { use egui_kittest::{Harness, kittest::Queryable}; let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); - app.session.actions.list.push(Action { name: "Action 1".into(), steps: vec![("layer.new.layer".into(), json!({}))], disabled: Default::default() }); + app.session.actions.list.push(Action { + name: "Action 1".into(), + steps: vec![("layer.new.layer".into(), json!({}))], + disabled: Default::default(), + ..Default::default() + }); let layers = app.session.active().unwrap().doc.layers.len(); // 60 fps steps, so two clicks a frame apart count as a double-click. let mut h = Harness::builder().with_size(vec2(320.0, 260.0)).with_step_dt(1.0 / 60.0).build_ui_state(|ui, app| panel(app, ui), app); @@ -606,8 +726,18 @@ mod tests { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); assert_eq!(playback_selection(&app), None); - app.session.actions.list.push(Action { name: "Make Layer".into(), steps: vec![("layer.new.layer".into(), json!({}))], disabled: Default::default() }); - app.session.actions.list.push(Action { name: "Fit".into(), steps: vec![("view.fitOnScreen".into(), json!({}))], disabled: Default::default() }); + app.session.actions.list.push(Action { + name: "Make Layer".into(), + steps: vec![("layer.new.layer".into(), json!({}))], + disabled: Default::default(), + ..Default::default() + }); + app.session.actions.list.push(Action { + name: "Fit".into(), + steps: vec![("view.fitOnScreen".into(), json!({}))], + disabled: Default::default(), + ..Default::default() + }); assert_eq!(playback_selection(&app), Some(0)); app.ui.actions.selected = Some(1); assert_eq!(playback_selection(&app), Some(1)); @@ -654,7 +784,12 @@ mod tests { use egui_kittest::{Harness, kittest::Queryable}; let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); - app.session.actions.list.push(Action { name: "Bad action".into(), steps: vec![("not.a.command".into(), json!({}))], disabled: Default::default() }); + app.session.actions.list.push(Action { + name: "Bad action".into(), + steps: vec![("not.a.command".into(), json!({}))], + disabled: Default::default(), + ..Default::default() + }); let mut h = Harness::builder().with_size(vec2(400.0, 320.0)).build_ui_state(|ui, app| panel(app, ui), app); h.run_steps(3); h.get_by_label("Play selection").click(); @@ -669,7 +804,7 @@ mod tests { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width":4,"height":4})).unwrap(); for i in 0..30 { - app.session.actions.list.push(Action { name: format!("Existing {i}"), steps: vec![], disabled: Default::default() }); + app.session.actions.list.push(Action { name: format!("Existing {i}"), steps: vec![], disabled: Default::default(), ..Default::default() }); } let mut h = Harness::builder().with_size(vec2(320.0, 260.0)).build_ui_state(|ui, app| panel(app, ui), app); h.run_steps(4); @@ -694,8 +829,9 @@ mod tests { name: "First".into(), steps: vec![("view.zoomOut".into(), json!({})), ("view.fitOnScreen".into(), json!({})), ("view.actualPixels".into(), json!({}))], disabled: Default::default(), + ..Default::default() }); - app.session.actions.list.push(Action { name: "Second".into(), steps: vec![], disabled: Default::default() }); + app.session.actions.list.push(Action { name: "Second".into(), steps: vec![], disabled: Default::default(), ..Default::default() }); app.ui.actions.expanded = vec![true, false]; let mut h = Harness::builder().with_size(vec2(320.0, 400.0)).build_ui_state(|ui, app| panel(app, ui), app); h.run_steps(4); @@ -735,6 +871,7 @@ mod tests { name: "Child".into(), steps: vec![("layer.new.layer".into(), json!({})), ("view.fitOnScreen".into(), json!({}))], disabled: Default::default(), + ..Default::default() }); app.run("actions.record", json!({"name":"Parent"})).unwrap(); app.run("actions.play", json!({"action":"Child"})).unwrap(); @@ -827,6 +964,7 @@ mod tests { name: "Fit".into(), steps: vec![("view.fitOnScreen".into(), json!({})), ("view.actualPixels".into(), json!({}))], disabled: Default::default(), + ..Default::default() }); let result = app.run("actions.play", json!({"action": "Fit"})).unwrap(); assert_eq!(result["ran"], 0); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 8ad19f6385..2d0ef48f11 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -2514,6 +2514,7 @@ mod tests { name: "Linked contents".into(), steps: vec![(command.into(), json!({"layer": smart.0}))], disabled: Default::default(), + ..Default::default() }]; let r = call(&mut app, &ctx, "engine.execute", json!({"command": "actions.play", "params": {"action": "Linked contents"}})); assert_eq!(r["ok"], true, "{r}"); @@ -2544,6 +2545,7 @@ mod tests { name: "Open".into(), steps: vec![("file.open".into(), json!({"path": "/etc/passwd"})), ("layer.new.layer".into(), json!({}))], disabled: Default::default(), + ..Default::default() }); let ctx = egui::Context::default(); let r = call(&mut app, &ctx, "engine.execute", json!({"command": "actions.play", "params": {"action": "Open"}})); @@ -2563,6 +2565,7 @@ mod tests { name: "Open".into(), steps: vec![("file.open".into(), json!({"path": "/etc/passwd"})), ("layer.new.layer".into(), json!({}))], disabled: Default::default(), + ..Default::default() }); app.automation_input = true; let r = app.run("actions.play", json!({"action": "Open"})).unwrap(); diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 6115e06a35..4bd043da66 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -601,6 +601,10 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod crate::swatches_ui::panel_menu(app, ui); ui.separator(); } + if tabs.get(sel) == Some(&"Actions") { + crate::actions::panel_menu(app, ui); + ui.separator(); + } if let Some(label) = tabs.get(sel) { crate::panel_docking::legacy_menu(app, ui, label); } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index a8e17f4843..889f0db89f 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3660,3 +3660,13 @@ navigator Zoom Zvětšení Remove after each stroke Odstranit po každém tahu Apply the removal ({key}) Použít odstranění ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Tahy k odstranění: {n} (Enter použije, Esc zruší) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Načíst akce… + Save Actions… Uložit akce… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} Načtena {n} akce ze souboru {file}|Načteny {n} akce ze souboru {file}|Načteno {n} akcí ze souboru {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} krok zatím nemá v PhotoCraftu obdobu|{n} kroky zatím nemají v PhotoCraftu obdobu|{n} kroků zatím nemá v PhotoCraftu obdobu +@plural Saved {n} action to {file}|Saved {n} actions to {file} Uložena {n} akce do souboru {file}|Uloženy {n} akce do souboru {file}|Uloženo {n} akcí do souboru {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Vynechán {n} krok bez obdoby v Photoshopu|Vynechány {n} kroky bez obdoby v Photoshopu|Vynecháno {n} kroků bez obdoby v Photoshopu diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 596d2199ae..fdc945a901 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2692,3 +2692,13 @@ curves Smooth Glätten Remove after each stroke Nach jedem Strich entfernen Apply the removal ({key}) Entfernen anwenden ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Striche zum Entfernen: {n} (Eingabe wendet an, Esc bricht ab) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Aktionen laden… + Save Actions… Aktionen speichern… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} {n} Aktion aus {file} geladen|{n} Aktionen aus {file} geladen +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} Schritt hat noch keine Entsprechung in PhotoCraft|{n} Schritte haben noch keine Entsprechung in PhotoCraft +@plural Saved {n} action to {file}|Saved {n} actions to {file} {n} Aktion in {file} gespeichert|{n} Aktionen in {file} gespeichert +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} Schritt ohne Photoshop-Entsprechung ausgelassen|{n} Schritte ohne Photoshop-Entsprechung ausgelassen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 07b80ef575..037615cbe2 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3750,3 +3750,13 @@ curves Smooth Εξομάλυνση Remove after each stroke Αφαίρεση μετά από κάθε πινελιά Apply the removal ({key}) Εφαρμογή της αφαίρεσης ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Πινελιές προς αφαίρεση: {n} (Enter εφαρμόζει, Esc ακυρώνει) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Φόρτωση ενεργειών… + Save Actions… Αποθήκευση ενεργειών… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} Φορτώθηκε {n} ενέργεια από το {file}|Φορτώθηκαν {n} ενέργειες από το {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} βήμα δεν έχει ακόμη αντίστοιχο στο PhotoCraft|{n} βήματα δεν έχουν ακόμη αντίστοιχο στο PhotoCraft +@plural Saved {n} action to {file}|Saved {n} actions to {file} Αποθηκεύτηκε {n} ενέργεια στο {file}|Αποθηκεύτηκαν {n} ενέργειες στο {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Παραλείφθηκε {n} βήμα χωρίς αντίστοιχο στο Photoshop|Παραλείφθηκαν {n} βήματα χωρίς αντίστοιχο στο Photoshop diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index aafea8266f..9864dd74c3 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2733,3 +2733,13 @@ curves Smooth Suavizar Remove after each stroke Eliminar después de cada trazo Apply the removal ({key}) Aplicar la eliminación ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Trazos por eliminar: {n} (Intro aplica, Esc cancela) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Cargar acciones… + Save Actions… Guardar acciones… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} Se cargó {n} acción de {file}|Se cargaron {n} acciones de {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} paso aún no tiene equivalente en PhotoCraft|{n} pasos aún no tienen equivalente en PhotoCraft +@plural Saved {n} action to {file}|Saved {n} actions to {file} Se guardó {n} acción en {file}|Se guardaron {n} acciones en {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} paso sin equivalente en Photoshop omitido|{n} pasos sin equivalente en Photoshop omitidos diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index a1319f04fb..b922ebfe37 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3053,3 +3053,13 @@ curves Smooth Lisser Remove after each stroke Supprimer après chaque trait Apply the removal ({key}) Appliquer la suppression ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Traits à supprimer : {n} (Entrée applique, Échap annule) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Charger des actions… + Save Actions… Enregistrer les actions… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} {n} action chargée depuis {file}|{n} actions chargées depuis {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} étape n’a pas encore d’équivalent dans PhotoCraft|{n} étapes n’ont pas encore d’équivalent dans PhotoCraft +@plural Saved {n} action to {file}|Saved {n} actions to {file} {n} action enregistrée dans {file}|{n} actions enregistrées dans {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} étape sans équivalent Photoshop omise|{n} étapes sans équivalent Photoshop omises diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index e0eff7b8a7..10ed3d9b18 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3972,3 +3972,13 @@ cameraRaw Yellow Sárga Transform On Drop Átalakítás elengedéskor Vanishing Point is not active Az Enyészpont nem aktív Vanishing Point: click four corners to create a plane Enyészpont: sík létrehozásához kattintson a négy sarokra + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Műveletek betöltése… + Save Actions… Műveletek mentése… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} {n} művelet betöltve. Forrás: {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} lépésnek még nincs megfelelője a PhotoCraftban +@plural Saved {n} action to {file}|Saved {n} actions to {file} {n} művelet mentve. Cél: {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} Photoshop-megfelelő nélküli lépés kihagyva diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 67234cb33c..5c69b98dcd 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3040,3 +3040,13 @@ curves Smooth Haluskan Remove after each stroke Hapus setelah setiap goresan Apply the removal ({key}) Terapkan penghapusan ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Goresan untuk dihapus: {n} (Enter menerapkan, Esc membatalkan) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Muat Tindakan… + Save Actions… Simpan Tindakan… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} {n} tindakan dimuat dari {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} langkah belum memiliki padanan di PhotoCraft +@plural Saved {n} action to {file}|Saved {n} actions to {file} {n} tindakan disimpan ke {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} langkah tanpa padanan Photoshop dilewati diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 051b678ab0..fb05ce21bd 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4016,3 +4016,13 @@ navigator Zoom Zoom Remove after each stroke Rimuovi dopo ogni tratto Apply the removal ({key}) Applica la rimozione ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Tratti da rimuovere: {n} (Invio applica, Esc annulla) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Carica azioni… + Save Actions… Salva azioni… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} Caricata {n} azione da {file}|Caricate {n} azioni da {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} passaggio non ha ancora un equivalente in PhotoCraft|{n} passaggi non hanno ancora un equivalente in PhotoCraft +@plural Saved {n} action to {file}|Saved {n} actions to {file} Salvata {n} azione in {file}|Salvate {n} azioni in {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Omesso {n} passaggio senza equivalente in Photoshop|Omessi {n} passaggi senza equivalente in Photoshop diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5e99c5884d..219bbf59e1 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3662,3 +3662,13 @@ curves Smooth スムーズ Remove after each stroke ストロークごとに削除 Apply the removal ({key}) 削除を適用 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 削除するストローク: {n}(Enter で適用、Esc で取り消し) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… アクションを読み込み… + Save Actions… アクションを保存… + {name} (Photoshop) {name}(Photoshop) + {message} ({note}) {message}({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} {file} からアクション {n} 個を読み込みました +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} 個のステップにはまだ PhotoCraft の対応機能がありません +@plural Saved {n} action to {file}|Saved {n} actions to {file} {file} にアクション {n} 個を保存しました +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Photoshop に対応するもののない {n} 個のステップを省きました diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index e483ebd0b8..298e8bb3be 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3222,3 +3222,13 @@ curves Smooth 매끄럽게 Remove after each stroke 획마다 제거 Apply the removal ({key}) 제거 적용 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 제거할 획: {n} (Enter 적용, Esc 취소) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… 액션 불러오기… + Save Actions… 액션 저장… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} {file}에서 액션 {n}개를 불러왔습니다 +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n}개 단계에는 아직 PhotoCraft에 해당하는 기능이 없습니다 +@plural Saved {n} action to {file}|Saved {n} actions to {file} {file}에 액션 {n}개를 저장했습니다 +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Photoshop에 해당하는 기능이 없는 {n}개 단계를 제외했습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 03702b3866..d3f81ed3c4 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3812,3 +3812,13 @@ curves Smooth Vloeiend maken Remove after each stroke Verwijderen na elke streek Apply the removal ({key}) Verwijdering toepassen ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Streken om te verwijderen: {n} (Enter past toe, Esc annuleert) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Handelingen laden… + Save Actions… Handelingen opslaan… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} {n} handeling geladen uit {file}|{n} handelingen geladen uit {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} stap heeft nog geen equivalent in PhotoCraft|{n} stappen hebben nog geen equivalent in PhotoCraft +@plural Saved {n} action to {file}|Saved {n} actions to {file} {n} handeling opgeslagen in {file}|{n} handelingen opgeslagen in {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} stap zonder Photoshop-equivalent weggelaten|{n} stappen zonder Photoshop-equivalent weggelaten diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index cd4231e495..2ef524b758 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3649,3 +3649,13 @@ curves Smooth Wygładź Remove after each stroke Usuwaj po każdym pociągnięciu Apply the removal ({key}) Zastosuj usuwanie ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Pociągnięcia do usunięcia: {n} (Enter stosuje, Esc anuluje) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Wczytaj operacje… + Save Actions… Zapisz operacje… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} Wczytano {n} operację z {file}|Wczytano {n} operacje z {file}|Wczytano {n} operacji z {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} krok nie ma jeszcze odpowiednika w PhotoCraft|{n} kroki nie mają jeszcze odpowiednika w PhotoCraft|{n} kroków nie ma jeszcze odpowiednika w PhotoCraft +@plural Saved {n} action to {file}|Saved {n} actions to {file} Zapisano {n} operację do {file}|Zapisano {n} operacje do {file}|Zapisano {n} operacji do {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Pominięto {n} krok bez odpowiednika w Photoshopie|Pominięto {n} kroki bez odpowiednika w Photoshopie|Pominięto {n} kroków bez odpowiednika w Photoshopie diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f0125b55bf..8c9d446b2d 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3842,3 +3842,13 @@ layerLabel No Color Sem cor Remove after each stroke Remover após cada traço Apply the removal ({key}) Aplicar a remoção ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Traços a remover: {n} (Enter aplica, Esc cancela) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Carregar ações… + Save Actions… Salvar ações… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} {n} ação carregada de {file}|{n} ações carregadas de {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} etapa ainda não tem equivalente no PhotoCraft|{n} etapas ainda não têm equivalente no PhotoCraft +@plural Saved {n} action to {file}|Saved {n} actions to {file} {n} ação salva em {file}|{n} ações salvas em {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} etapa sem equivalente no Photoshop foi omitida|{n} etapas sem equivalente no Photoshop foram omitidas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e8aba875fa..b2a34e39f4 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2997,3 +2997,13 @@ curves Smooth Сгладить Remove after each stroke Удалять после каждого штриха Apply the removal ({key}) Применить удаление ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Штрихов к удалению: {n} (Enter применяет, Esc отменяет) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Загрузить операции… + Save Actions… Сохранить операции… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} Загружена {n} операция из {file}|Загружено {n} операции из {file}|Загружено {n} операций из {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} шаг пока не имеет эквивалента в PhotoCraft|{n} шага пока не имеют эквивалента в PhotoCraft|{n} шагов пока не имеют эквивалента в PhotoCraft +@plural Saved {n} action to {file}|Saved {n} actions to {file} Сохранена {n} операция в {file}|Сохранено {n} операции в {file}|Сохранено {n} операций в {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Пропущен {n} шаг без эквивалента в Photoshop|Пропущено {n} шага без эквивалента в Photoshop|Пропущено {n} шагов без эквивалента в Photoshop diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 8401f8e5ac..1a2974a005 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3784,3 +3784,13 @@ cameraData Lens Lens @plural {n} step|{n} steps {n} adım @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} fırça ön ayarı uyarısı daha) @plural {what} with {n} warning|{what} with {n} warnings {what}, {n} uyarı ile + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Eylemleri Yükle… + Save Actions… Eylemleri Kaydet… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} {file} dosyasından {n} eylem yüklendi +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} adımın PhotoCraft’ta henüz karşılığı yok +@plural Saved {n} action to {file}|Saved {n} actions to {file} {n} eylem {file} dosyasına kaydedildi +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Photoshop karşılığı olmayan {n} adım atlandı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e68e18a127..e366b0ffe1 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3750,3 +3750,13 @@ curves Smooth Згладити Remove after each stroke Видаляти після кожного штриха Apply the removal ({key}) Застосувати видалення ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Штрихів для видалення: {n} (Enter застосовує, Esc скасовує) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Завантажити операції… + Save Actions… Зберегти операції… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} Завантажено {n} операцію з {file}|Завантажено {n} операції з {file}|Завантажено {n} операцій з {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} крок ще не має відповідника в PhotoCraft|{n} кроки ще не мають відповідника в PhotoCraft|{n} кроків ще не мають відповідника в PhotoCraft +@plural Saved {n} action to {file}|Saved {n} actions to {file} Збережено {n} операцію до {file}|Збережено {n} операції до {file}|Збережено {n} операцій до {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Пропущено {n} крок без відповідника в Photoshop|Пропущено {n} кроки без відповідника в Photoshop|Пропущено {n} кроків без відповідника в Photoshop diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f7cd59aebb..6e35c4a387 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3536,3 +3536,13 @@ curves Smooth Làm mượt Remove after each stroke Xóa sau mỗi nét vẽ Apply the removal ({key}) Áp dụng việc xóa ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Nét vẽ cần xóa: {n} (Enter áp dụng, Esc hủy) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Nạp hành động… + Save Actions… Lưu hành động… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} Đã nạp {n} hành động từ {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} bước chưa có chức năng tương ứng trong PhotoCraft +@plural Saved {n} action to {file}|Saved {n} actions to {file} Đã lưu {n} hành động vào {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Đã bỏ qua {n} bước không có chức năng tương ứng trong Photoshop diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index fe8884316b..b11c51f54f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2657,3 +2657,13 @@ curves Smooth 平滑 Remove after each stroke 每次描边后移除 Apply the removal ({key}) 应用移除 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描边:{n}(Enter 应用,Esc 取消) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… 载入动作… + Save Actions… 存储动作… + {name} (Photoshop) {name}(Photoshop) + {message} ({note}) {message}({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} 已从 {file} 载入 {n} 个动作 +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} 个步骤在 PhotoCraft 中尚无对应功能 +@plural Saved {n} action to {file}|Saved {n} actions to {file} 已将 {n} 个动作存储到 {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out 已省略 {n} 个没有 Photoshop 对应功能的步骤 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 83467820d6..95a079ec1e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2642,3 +2642,13 @@ curves Smooth 平滑 Remove after each stroke 每次描邊後移除 Apply the removal ({key}) 套用移除 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描邊:{n}(Enter 套用,Esc 取消) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… 載入動作… + Save Actions… 儲存動作… + {name} (Photoshop) {name}(Photoshop) + {message} ({note}) {message}({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} 已從 {file} 載入 {n} 個動作 +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} 個步驟在 PhotoCraft 中尚無對應功能 +@plural Saved {n} action to {file}|Saved {n} actions to {file} 已將 {n} 個動作儲存到 {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out 已略過 {n} 個沒有 Photoshop 對應功能的步驟 diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index bc6bfcf087..4aff73c01e 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -843,6 +843,10 @@ fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui, panel: &str) { crate::swatches_ui::panel_menu(app, ui); ui.separator(); } + "actions" => { + crate::actions::panel_menu(app, ui); + ui.separator(); + } _ => {} } } diff --git a/crates/ui-egui/src/shortcut_dispatch/tests.rs b/crates/ui-egui/src/shortcut_dispatch/tests.rs index 9174da3ee9..7d5cafae9c 100644 --- a/crates/ui-egui/src/shortcut_dispatch/tests.rs +++ b/crates/ui-egui/src/shortcut_dispatch/tests.rs @@ -543,6 +543,7 @@ fn named_action_function_key_replays_and_survives_preferences_roundtrip() { name: "LivePrint 2R".into(), steps: vec![("layer.new.layer".into(), json!({"name": "F6 overlay"}))], disabled: Default::default(), + ..Default::default() }; h.state_mut().session.actions.list.push(action); crate::actions::assign_shortcut(h.state_mut(), "LivePrint 2R", "F6").unwrap(); @@ -568,6 +569,7 @@ fn assigning_action_function_key_moves_it_and_reports_failed_steps() { name: name.into(), steps: vec![("no.such.command".into(), json!({}))], disabled: Default::default(), + ..Default::default() }); } crate::actions::assign_shortcut(h.state_mut(), "first", "F6").unwrap(); @@ -587,6 +589,7 @@ fn action_function_key_checks_automation_authorization() { name: "restricted".into(), steps: vec![("layer.new.layer".into(), json!({"name": "should not exist"}))], disabled: Default::default(), + ..Default::default() }); crate::actions::assign_shortcut(h.state_mut(), "restricted", "F6").unwrap(); h.state_mut().services.automation_command = Some(Box::new(|id, _| if id == "actions.play" { Err("denied action playback".into()) } else { Ok(()) })); @@ -624,6 +627,7 @@ fn f6_while_recording_records_a_named_call_not_the_child_steps() { name: "LivePrint 2R".into(), steps: vec![("layer.new.layer".into(), json!({"name":"Nested overlay"})), ("view.fitOnScreen".into(), json!({}))], disabled: Default::default(), + ..Default::default() }); crate::actions::assign_shortcut(h.state_mut(), "LivePrint 2R", "F6").unwrap(); h.state_mut().run("actions.record", json!({"name":"Action 2"})).unwrap(); diff --git a/docs/development.md b/docs/development.md index b7e47956cf..73a3bde0bf 100644 --- a/docs/development.md +++ b/docs/development.md @@ -415,6 +415,21 @@ Input calls (`ui.key`, `ui.type`, `ui.click`) now reply only after the app has p so a following `ui.inspect` observes their effect. +## Photoshop Actions (.atn) + +`photocraft-psd` reads and writes Photoshop Actions files (`atn`, version 16: Photoshop 6 and +later); `photocraft-engine`'s `atn_cmds` maps their events onto commands (`actions.load`, +`actions.save`; the Actions panel menu's Load/Save Actions…; `photocraft-cli batch --actions +set.atn`). To see what a set holds (event ids, panel names, the parameter keys Photoshop +recorded) when adding an event to the table: + +```sh +cargo run -p photocraft-psd --example atn_dump -- "Default Actions.atn" +``` + +Events outside the table load as `actions.photoshopStep` steps that keep the descriptor +verbatim (so Save Actions writes them back) and fail at playback with the step's Photoshop name. + ## Remove Tool quality `cargo run --release -p photocraft-engine --example remove_quality -- [out dir]` fills diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 024d596f1e..ecda48fee7 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (Photoshop Actions `.atn` read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -130,7 +130,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | LUTs `.cube` / `.3dl` / `.look` | R/W (Export Color Lookup) | R/W / R / R | `cms/src/lutfile.rs` | | Keyboard shortcuts `.kys` | R/W | R | `engine/src/kys.rs` | | ICC profiles | R | R | Own CMS | -| Actions `.atn` | R/W | no | AUTO-219-2 | +| Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (90 Photoshop events ↔ commands; other steps kept verbatim). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | | Styles `.asl` | R/W | no | #1693 | | Contours `.shc`, custom shapes `.csh`, tool presets `.tpl` | R/W | no | FILE-215-15 | | Curves `.acv`, Levels `.alv`, other adjustment presets | R/W | not audited | | @@ -140,6 +140,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Photoshop Actions `.atn`: read and write, 90 events mapped, the rest kept verbatim (AUTO-219-2) | | 2026-10-12 | minor | Palette BMPs open in Indexed Color mode (#3802) | | 2026-10-12 | minor | Save writes a JPEG at its own estimated quality and a WebP lossy or lossless as opened (#2399) | | 2026-10-11 | minor | D5100 image area and fitted-look preservation in Camera Raw tuning | diff --git a/docs/gaps.md b/docs/gaps.md index 2ceba09952..38282b52f8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (G8: Character panel OpenType buttons landed, TYPE-218-5 stays partial; G8: triple-, quadruple- and five-click type selection landed, TYPE-218-8 done; Czech UI localization evidence added to G13; G14: D5100 crop and Camera Raw tuning validation) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; 2026-10-11: G8: Character panel OpenType buttons landed, TYPE-218-5 stays partial; G8: triple-, quadruple- and five-click type selection landed, TYPE-218-8 done; Czech UI localization evidence added to G13; G14: D5100 crop and Camera Raw tuning validation) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -87,9 +87,14 @@ what an agent can't finish alone. - **Estimate:** 120–200 h. ### G11. Actions and scripting -- **Missing:** document-independent action recording (AUTO-219-1), a full Actions panel with - step toggles and .atn import (AUTO-219-2), replay tests (AUTO-219-4), Batch recursion and - naming (AUTO-219-5), ExtendScript/UXP compatibility. +- **Missing:** document-independent action recording (AUTO-219-1), the rest of the Actions + panel: grouping by set, an Insert Stop item and editing the dialog toggle (AUTO-219-2), Batch + recursion and naming (AUTO-219-5), ExtendScript/UXP compatibility. `.atn` steps outside the + 90 mapped events (Transform with scale or rotation, Apply Image, Save for Web, Place, smart + object edits, Generative Fill…) load as kept Photoshop steps that fail at playback by name. +- **Done 2026-10-12:** Load/Save Actions `.atn` in the panel menu, `batch --actions set.atn` + and `actions.load`/`actions.save`; 90 Photoshop events map to commands both ways, Play and + Stop steps included; layer steps replay by layer name in any document (AUTO-219-2 partial). - **Ahead of Photoshop:** CLI, MCP and the JSON control channel drive every command. - **Estimate:** 120–200 h. **Human:** whether to support a JavaScript scripting dialect at all (AGENTS.md forbids JS in the app). @@ -225,6 +230,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | G11: Load/Save Actions `.atn` landed (AUTO-219-2 partial); remaining panel work listed | | 2026-10-11 | minor | D5100 image area and Camera Raw tuning validation; G14 remains open | | 2026-10-11 | minor | G13: Czech dynamic UI text localized; engine messages and native review remain open | | 2026-10-11 | minor | G8: triple/quadruple/five-click type selection done (TYPE-218-8, #3493); Type evidence recounted | diff --git a/docs/scorecard.md b/docs/scorecard.md index 2d653afce6..b30f4d9a95 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -11,7 +11,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Tools](#tools) | 28 | 14 | 1 | | | [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 27 of 156 (target 0) | | [Type](#type) | 4 | 2 | 3 | | -| [Automation](#automation) | 4 | 2 | 5 | 631 of 631 menu items live (100.0%) | +| [Automation](#automation) | 4 | 3 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 4 | 2 | | | [Open bugs](#open-bugs) | 29 | 2 | 1 | | @@ -228,7 +228,7 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T |---|---|---|---|---| | AUTO-1 | Every menu item dispatches a command id (UI, CLI, control channel and MCP share them) | done | | docs/parity-checklist.md: all menu items live; menus.rs is_live | | AUTO-219-1 | Record actions in a document-independent form (implicit layer, merged type keystrokes) | missing | [#219](https://github.com/storytold/photocraft/issues/219) | actions.rs:47 copies raw journal entries with explicit layer ids | -| AUTO-219-2 | Actions panel: step and dialog toggles, reorder, delete a step, Stop, sets, .atn import | missing | [#219](https://github.com/storytold/photocraft/issues/219) | Panel deletes whole actions only (actions.rs:146) | +| AUTO-219-2 | Actions panel: step and dialog toggles, reorder, delete a step, Stop, sets, .atn import | partial | [#219](https://github.com/storytold/photocraft/issues/219) | 2026-10-12: Load/Save Actions (.atn) in the panel menu, CLI batch and actions.load/save (atn_cmds.rs): 90 Photoshop events map to commands both ways, the rest are kept verbatim and fail at playback by name; step toggles, reorder and delete a step done; Stop steps play (actions.stopMessage) but there is no Insert Stop menu item; sets and dialog toggles are stored and round-trip but the panel neither groups by set nor edits the dialog toggle | | AUTO-219-3 | actions.record/stop/play/list commands for agents and the CLI | done | [#219](https://github.com/storytold/photocraft/issues/219) | actions.list/get/record/stop/play/delete in actions_cmds.rs; the panel, CLI and MCP share them. Nested steps pass the session authorize hook. | | AUTO-219-4 | Replay tests for crop, filters, selections and styled text on a second document | done | [#219](https://github.com/storytold/photocraft/issues/219) | actions_cmds.rs: a_recorded_edit_run_replays_on_a_second_document records a crop, a Gaussian Blur, a selection fill and faux-bold type on one document and replays all of them onto a second (plus the original layer/select/fill replay) | | AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds.rs:590-631 folder/array input, per-file errors; no recursion, template or cancel | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ae64d0c9c1..2f5509c694 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -66,7 +66,7 @@ architecture and the PSD writer are the serial parts. | Filters and adjustments | 10% | every Filter and Image › Adjustments item live; Filter Gallery 47 looks | 100% | measured (menu catalog) | | File formats (usage-weighted) | 15% | 13 of 31 Photoshop formats supported in Photoshop's directions, 4 partial; the common ones (PSD/PSB, TIFF, JPEG, PNG, WebP) all present | ~80% (48% by count) | measured list, estimated weights ([file-format-parity.md](file-format-parity.md)) | | AI / generative | 10% | classical Select Subject/Sky, Remove tool, Content-Aware; no Generative Fill/Expand, Neural Filters, Sky Replacement, Super Zoom | ~15% | estimated | -| Ecosystem (plug-ins, scripting, libraries) | 10% | WebAssembly plug-ins, CLI, MCP, actions; no .8BF, UXP/ExtendScript, .atn import, Libraries | ~20% | estimated | +| Ecosystem (plug-ins, scripting, libraries) | 10% | WebAssembly plug-ins, CLI, MCP, actions, `.atn` load/save (90 events); no .8BF, UXP/ExtendScript, Libraries | ~20% | estimated | | **Weighted** | 100% | | **~76%** | | ## Ready for real work: ~45% (weighted) @@ -83,7 +83,7 @@ Weights reflect what a working Photoshop user depends on (photographers, retouch | Hardware | 5% | 50% | 80–150 | GPU compositor (wgpu), pen pressure on all desktops but tilt/rotation not on Windows, Wayland pen broken (#2622), no HDR/EDR output, monitor profile auto-detected on macOS only | [hardware-parity.md](hardware-parity.md) | | Localization | 5% | 55% | 80–140 | 7 of the 12 key languages at 100% of measured UI keys; Hindi, Arabic, Vietnamese absent; no RTL UI; engine messages English | [localization-parity.md](localization-parity.md) | | Platforms | 5% | 80% | 30–60 | macOS (notarized), Windows (unsigned), Linux (AppImage/deb/rpm/Flatpak), web, FreeBSD partial; no Windows ARM64, no in-app updates | [scorecard.md](scorecard.md#distribution) | -| Ecosystem (plug-ins, scripting, presets, libraries) | 5% | 15% | 250–450 | No .8BF hosting (#2647), no UXP/ExtendScript, no .atn import; .abr/.grd/.pat/.aco/.ase/.cube supported | [gaps.md](gaps.md) | +| Ecosystem (plug-ins, scripting, presets, libraries) | 5% | 15% | 250–450 | No .8BF hosting (#2647), no UXP/ExtendScript; .atn load/save (2026-10-12, 90 events, the rest kept verbatim), .abr/.grd/.pat/.aco/.ase/.cube supported | [gaps.md](gaps.md) | | AI features | 5% | 10% | 300–600 | No ML runtime at all (scorecard AUTO-219-7); classical substitutes only. Needs an owner decision and openly licensed models (#41) | [gaps.md](gaps.md) | | **Weighted** | 100% | **~46%** | **2,340–4,100** | | | @@ -177,7 +177,7 @@ parity for the area and are already contained in the dimension rows above. | Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Missing anchor tools, on-canvas corner radius, path-point nudge; shape parity tracking issue #2496 | | Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, no spot plates (FILE-215-17), monitor profile only at launch | | Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking, calibration); generic lens profiles only | -| Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2), no .atn, no ExtendScript/UXP | +| Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (90 events mapped, the rest kept verbatim); no ExtendScript/UXP | | File I/O | 60% | 300–500 | See [file-format-parity.md](file-format-parity.md) | | Print, export, Save for Web | 50% | 60–100 | JPEG Options dialog missing (FILE-215-19), no Photoshop PDF, no separations, Save for Web settings not remembered | | Video / timeline, data-driven graphics | 30% | 100–200 | Menu items live; frame model persists in `.pcraft`; no PSD video-layer mapping | @@ -450,7 +450,7 @@ filled bar. This extends UI reachability and does not claim Photoshop-authored s | Stability | Never-crash lint series, crash guard, `panic_hunt` fuzzing in the gate | medium-high | No field crash data yet. | | Camera RAW | DNG, CR2, Sony ARW (lossless + compressed), Nikon NEF (lossless + lossy compressed), RW2, uncompressed ORF; 2026-10-09: uncompressed Fujifilm RAF, Bayer and X-Trans (edge-directed X-Trans demosaic; 26 MP X-Trans develop 306 ms on 12 cores) | medium | CR3, compressed RAF and Nikon "lossy after split" still open (#50). Non-DNG cameras have no calibration: their colour (matrix + tone curve) is fitted per file to the camera's embedded JPEG, with the neutral sRGB-primaries fallback when the fit doesn't clearly help (2026-10-08: 8 D3200 NEFs, mean ΔE vs the camera JPEG 11.6–26.0 → 1.0–7.5); measured per-model calibration still open. | | AI / generative | none | ~0% | Deferred by decision (#41). | -| Ecosystem | Sandboxed WebAssembly plug-ins instead of .8BF; no ExtendScript/UXP/.atn; no Adobe Fonts/Libraries/cloud docs | low | By design for 8BF; scripting compatibility open. | +| Ecosystem | Sandboxed WebAssembly plug-ins instead of .8BF; `.atn` actions load and save (2026-10-12); no ExtendScript/UXP; no Adobe Fonts/Libraries/cloud docs | low | By design for 8BF; scripting compatibility open. | | Platforms | macOS (notarized), Windows, Linux (AppImage/deb/rpm/Flatpak bundle), web | medium-high | Flathub later (#173); Windows signing material pending. | | Localisation | 2026-10-07: 10 UI languages; menu, `tl!`, blend mode, preference and brush-section coverage enforced by tests; live switching and scoped Preferences previews | medium | Engine errors/status messages still partly English; CJK web fonts, browser-locale detection, and RTL remain open. | @@ -563,6 +563,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Photoshop Actions `.atn` load and save (90 events mapped both ways); percentages unchanged | | 2026-10-10 | minor | Added measured Tilt-Shift computation/filtered-proxy gains and inconclusive cases; parity percentages and budgets unchanged | | 2026-10-10 | minor | Readiness-by-audience table: percent and hours for full, mainstream and essentials; full number confirmed as the additive weighted sum over dimensions (46%, reported ~45%) | | 2026-10-10 | minor | Added mainstream-practitioner (~43%) and essentials-user (~61%) numbers with written weights and discounts; full ready-for-real-work rechecked against its written weights (46%, reported ~45%), unchanged | diff --git a/scorecard/automation.toml b/scorecard/automation.toml index 000e13f944..2d1c9421c9 100644 --- a/scorecard/automation.toml +++ b/scorecard/automation.toml @@ -21,9 +21,9 @@ note = "actions.rs:47 copies raw journal entries with explicit layer ids" [[item]] id = "AUTO-219-2" target = "Actions panel: step and dialog toggles, reorder, delete a step, Stop, sets, .atn import" -status = "missing" +status = "partial" issue = 219 -note = "Panel deletes whole actions only (actions.rs:146)" +note = "2026-10-12: Load/Save Actions (.atn) in the panel menu, CLI batch and actions.load/save (atn_cmds.rs): 90 Photoshop events map to commands both ways, the rest are kept verbatim and fail at playback by name; step toggles, reorder and delete a step done; Stop steps play (actions.stopMessage) but there is no Insert Stop menu item; sets and dialog toggles are stored and round-trip but the panel neither groups by set nor edits the dialog toggle" [[item]] id = "AUTO-219-3"