diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 3e4c34b81a..0cfbc31156 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -2691,8 +2691,8 @@ const EVENTS: &[Event] = &[ 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!("", "boxblur", "Box Blur", "filter.blur.boxBlur", &[F("Rds ", "radius", Kind::Px)]), + ev!("", "surfaceBlur", "Surface Blur", "filter.blur.surfaceBlur", &[F("Rds ", "radius", Kind::Px), F("Thsh", "threshold", Kind::Long)]), ev!( "SmrB", "smartBlur", @@ -2768,7 +2768,7 @@ const EVENTS: &[Event] = &[ adjustment_event!("Exps", "Exps", "exposure", "Exposure"), adjustment_event!("PhtF", "PhtF", "photoFilter", "Photo Filter"), Event { - code: "BlnW", + code: "", name: "blackAndWhite", label: "Black & White", ids: &["image.adjustments.blackWhite"], @@ -2960,7 +2960,7 @@ const EVENTS: &[Event] = &[ from: move_from, to: move_to, }, - ev!("Fltn", "flattenImage", "Flatten Image", "layer.flattenImage", &[]), + ev!("FltI", "flattenImage", "Flatten Image", "layer.flattenImage", &[]), Event { code: "MrgV", name: "mergeVisible", @@ -3046,9 +3046,20 @@ const EVENTS: &[Event] = &[ Event { code: "Stop", name: "stop", label: "Stop", ids: &[STOP_MESSAGE], from: stop_from, to: stop_to }, ]; +/// Four-char codes PhotoCraft once exported for events Photoshop only runs by name (or by +/// another code), with the name they stand for. Measured in Photoshop: `executeAction` with +/// these codes fails ("the command is not currently available"), and `Fltn` is the +/// `flatness` property, not the flatten event. Import keeps accepting them so actions saved +/// before still load; export never writes them. +const LEGACY_CODES: &[(&str, &str)] = &[("BoxB", "boxblur"), ("SrfB", "surfaceBlur"), ("Fltn", "flattenImage"), ("BlnW", "blackAndWhite")]; + 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) + let find = |id: &str| EVENTS.iter().find(|e| (!e.code.is_empty() && e.code.trim_end() == id.trim_end()) || e.name == id); + find(&id).or_else(|| match item { + AtnEvent::Code(_) => LEGACY_CODES.iter().find(|(code, _)| *code == id).and_then(|(_, name)| find(name)), + AtnEvent::Name(_) => None, + }) } /// The event `id` exports as: the first event listing it whose converter accepts the params diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 37744831cc..f22a153232 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -144,7 +144,7 @@ fn save_round_trips_through_photoshop_events() { 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_eq!(events, ["GsnB", "Mk ", "setd", "slct", "slct", "Stop", "syntheticFill", "Ply ", "FltI"]); assert!(file.actions[0].items[0].with_dialog); assert!(!file.actions[0].items[8].enabled); // The kept Photoshop step is written back byte for byte. @@ -1037,6 +1037,77 @@ fn remove_white_clears_the_targeted_layer_not_the_converted_background() { assert!(bottom.surface().unwrap().sample_channel(2, 2, 1) > 0.99, "and kept its pixels"); } +/// Measured in Photoshop: `executeAction` fails for the codes `BoxB`, `SrfB`, `Fltn` and +/// `BlnW` ("the command is not currently available"), while the names `boxblur`, +/// `surfaceBlur`, `flattenImage`, `blackAndWhite` and the code `FltI` run. +#[test] +fn events_photoshop_only_runs_by_name_export_by_name_and_old_codes_still_import() { + let bw = json!({"reds": 40, "yellows": 60, "greens": 40, "cyans": 60, "blues": 20, "magentas": 80, "tint": false, "tintColor": "#e4d5b0"}); + let cases = [ + ("filter.blur.boxBlur", json!({"radius": 3}), "boxblur", "BoxB"), + ("filter.blur.surfaceBlur", json!({"radius": 5, "threshold": 15}), "surfaceBlur", "SrfB"), + ("layer.flattenImage", json!({}), "FltI", "Fltn"), + ("image.adjustments.blackWhite", bw, "blackAndWhite", "BlnW"), + ]; + for (id, params, written, legacy) in &cases { + let exported = export_item(id, params, "S").unwrap_or_else(|| panic!("{id} does not export")); + assert_eq!(exported.event.id(), *written, "{id} exports under an id Photoshop runs"); + assert_eq!(matches!(exported.event, AtnEvent::Name(_)), written.len() != 4, "{id}: a name is written as a name"); + let d = exported.descriptor.clone(); + // The written id, the name for Flatten Image, and the old code all import to the command. + let name = if *id == "layer.flattenImage" { "flattenImage" } else { written }; + for event in [*written, name, *legacy] { + let e = event_for(&item(event, "", None).event).unwrap_or_else(|| panic!("{event} does not import")); + assert!(e.ids.contains(id), "{event} imports as {:?}, not {id}", e.ids); + match (e.from)(d.as_ref().unwrap()) { + Mapped::Steps(steps) => { + assert_eq!(steps.len(), 1, "{event}: {steps:?}"); + assert_eq!(steps[0].0, *id, "{event}"); + let mut back = steps[0].1.clone(); + normalise(&mut back); + assert_eq!(back, *params, "{event}"); + } + Mapped::Unsupported { .. } => panic!("{event} does not import back"), + } + } + } + + // A whole set of Photoshop-shaped steps (names, FltI) and old PhotoCraft-shaped ones + // (legacy codes) loads to the same commands. + let blur = |event: &str| item(event, "Box Blur", Some(desc("null").with("Rds ", px(3.0)))); + let surface = |event: &str| item(event, "Surface Blur", Some(desc("null").with("Rds ", px(5.0)).with("Thsh", long(15.0)))); + let photoshop = vec![blur("boxblur"), surface("surfaceBlur"), item("FltI", "Flatten Image", None), item("flattenImage", "Flatten Image", None)]; + let legacy = vec![blur("BoxB"), surface("SrfB"), item("Fltn", "Flatten Image", None)]; + let data = set_of("S", vec![("Photoshop", photoshop), ("Legacy", legacy)]); + let mut s = Session::new(); + let r = s.execute("actions.load", json!({"data": data})).unwrap(); + assert_eq!(r["unsupported"], json!([]), "{r}"); + let ids = |i: usize| s.actions.list[i].steps.iter().map(|(id, _)| id.clone()).collect::>(); + assert_eq!(ids(0), ["filter.blur.boxBlur", "filter.blur.surfaceBlur", "layer.flattenImage", "layer.flattenImage"]); + assert_eq!(ids(1), ["filter.blur.boxBlur", "filter.blur.surfaceBlur", "layer.flattenImage"]); + assert_eq!(s.actions.list[1].steps[1].1, json!({"radius": 5, "threshold": 15})); + // Saving them writes the ids Photoshop runs, never the old codes. + let saved = s.execute("actions.save", json!({"set": "S"})).unwrap(); + let file = atn::parse(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap(); + let events: Vec = file.actions.iter().flat_map(|a| a.items.iter().map(|i| i.event.id())).collect(); + assert_eq!(events, ["boxblur", "surfaceBlur", "FltI", "FltI", "boxblur", "surfaceBlur", "FltI"]); + + // Unknown ids, near misses and the legacy codes written as names stay Photoshop steps, + // never a panic or a wrong command. + for event in ["BoxBlur", "boxBlur", "BOXB", "Fltn ", "FLTI", "", "\u{0}\u{0}\u{0}\u{0}", "BlnWx"] { + let e = event_for(&item(event, "", None).event); + assert!(e.is_none(), "{event:?} maps to {:?}", e.map(|e| e.ids)); + } + // A legacy code with a broken descriptor is unsupported, not a crash. + let e = event_for(&item("SrfB", "", None).event).unwrap(); + assert!(matches!((e.from)(&desc("null").with("Rds ", D::Double(f64::NAN))), Mapped::Unsupported { .. } | Mapped::Steps(_))); + // A wrong-typed key is skipped like any other unreadable field: the step keeps the defaults. + match (e.from)(&desc("null").with("Rds ", text("x"))) { + Mapped::Steps(steps) => assert!(steps.iter().all(|(_, p)| p.get("radius").is_none()), "{steps:?}"), + Mapped::Unsupported { .. } => {} + } +} + /// Smart Blur's quality uses Photoshop's `SmBQ` codes (`SBQL`/`SBQM`/`SBQH`): measured in /// Photoshop, the old `Hgh `/`Mdm `/`Low ` play back as its fallback quality. Old codes still read. #[test]