Actions: export Box Blur, Surface Blur, Flatten Image and Black & White under ids Photoshop runs (#6330) (#6331)

Save Actions wrote these four steps under the four-char codes BoxB, SrfB,
Fltn and BlnW, which Photoshop does not run: executeAction fails with "the
command is not currently available" (measured in Photoshop on a document
with a filled pixel layer; Fltn resolves to the flatness property, not the
flatten event). The names boxblur, surfaceBlur and blackAndWhite and the
code FltI run.

- Box Blur, Surface Blur and Black & White now export by name
  (boxblur, surfaceBlur, blackAndWhite); Flatten Image exports as FltI.
  The descriptors are unchanged.
- Import accepts the names, FltI and flattenImage, and still accepts the
  old codes (a LEGACY_CODES table consulted only for four-char codes), so
  actions PhotoCraft saved before keep loading.
- Tests: each step exports the new id and imports back to the same command
  and params under the new id, the name and the legacy code; a set mixing
  Photoshop-shaped and legacy steps loads and re-saves with the new ids;
  near-miss ids (case, padding, empty, NULs) stay Photoshop steps; broken
  descriptors don't panic.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
David Mann
2026-10-19 14:18:18 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 7200f64bf1
commit dfe94d6ab9
2 changed files with 88 additions and 6 deletions
+16 -5
View File
@@ -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
+72 -1
View File
@@ -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<String> = 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::<Vec<_>>();
assert_eq!(ids(0), ["filter.blur.boxBlur", "filter.blur.surfaceBlur", "layer.flattenImage", "layer.flattenImage"]);
assert_eq!(ids(1), ["filter.blur.boxBlur", "filter.blur.surfaceBlur", "layer.flattenImage"]);
assert_eq!(s.actions.list[1].steps[1].1, json!({"radius": 5, "threshold": 15}));
// Saving them writes the ids Photoshop runs, never the old codes.
let saved = s.execute("actions.save", json!({"set": "S"})).unwrap();
let file = atn::parse(&photocraft_paint::tile::b64_decode(saved["data"].as_str().unwrap()).unwrap()).unwrap();
let events: Vec<String> = file.actions.iter().flat_map(|a| a.items.iter().map(|i| i.event.id())).collect();
assert_eq!(events, ["boxblur", "surfaceBlur", "FltI", "FltI", "boxblur", "surfaceBlur", "FltI"]);
// Unknown ids, near misses and the legacy codes written as names stay Photoshop steps,
// never a panic or a wrong command.
for event in ["BoxBlur", "boxBlur", "BOXB", "Fltn ", "FLTI", "", "\u{0}\u{0}\u{0}\u{0}", "BlnWx"] {
let e = event_for(&item(event, "", None).event);
assert!(e.is_none(), "{event:?} maps to {:?}", e.map(|e| e.ids));
}
// A legacy code with a broken descriptor is unsupported, not a crash.
let e = event_for(&item("SrfB", "", None).event).unwrap();
assert!(matches!((e.from)(&desc("null").with("Rds ", D::Double(f64::NAN))), Mapped::Unsupported { .. } | Mapped::Steps(_)));
// A wrong-typed key is skipped like any other unreadable field: the step keeps the defaults.
match (e.from)(&desc("null").with("Rds ", text("x"))) {
Mapped::Steps(steps) => assert!(steps.iter().all(|(_, p)| p.get("radius").is_none()), "{steps:?}"),
Mapped::Unsupported { .. } => {}
}
}
/// Smart Blur's quality uses Photoshop's `SmBQ` codes (`SBQL`/`SBQM`/`SBQH`): measured in
/// Photoshop, the old `Hgh `/`Mdm `/`Low ` play back as its fallback quality. Old codes still read.
#[test]