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"