Map the remaining frequent Photoshop action events (#219) (#4465)

- edit.transform gains the relative form (scaleX/scaleY %, rotate, dx/dy about the frame
  centre), as a Photoshop action records a Free Transform; image.applyImage takes a source
  layer by name.
- .atn events: transform (layer → translate or relative transform; selection →
  transformSelection), applyImageEvent, export (Save for Web, with the txtu path record
  decoded and written back; kept verbatim without a destination), newPlacedLayer /
  placedLayerEditContents / updatePlacedLayer, close with the Save answer → save + close,
  delete of the target channel → layer mask delete, Quick Mask on/off, polygon selections →
  select.lasso, Gradient Map (direct and as an adjustment layer). 92 → 105 events.
- Export tries every event listing a command id (Quick Mask on → set, off → clearEvent).
- Docs: event counts in the parity docs.
This commit is contained in:
Fabio Lissi authored and GitHub committed 2026-10-12 19:02:15 -04:00
1 parent 7015f2916d
commit 13f5c104f6
7 files changed
+689 -21

No files matched your search

+414 -6
View File
@@ -270,6 +270,31 @@ fn color_desc(hex: &str) -> Option<Descriptor> {
Some(desc("RGBC").with("Rd ", D::Double(ch(0)?)).with("Grn ", D::Double(ch(2)?)).with("Bl ", D::Double(ch(4)?)))
}
/// A `Pth ` value's text: Photoshop stores `"txtu"`, a little-endian total length, a
/// little-endian UTF-16 unit count and the units (NUL-terminated).
fn path_text(v: &D) -> Option<String> {
let D::Path(b) = v else { return None };
if b.get(0..4)? != b"txtu" {
return None;
}
let n = u32::from_le_bytes(b.get(8..12)?.try_into().ok()?) as usize;
let units: Vec<u16> = b.get(12..12usize.checked_add(n.checked_mul(2)?)?)?.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect();
let text = String::from_utf16_lossy(&units);
let text = text.trim_end_matches('\0');
(!text.is_empty()).then(|| text.to_string())
}
fn path_value(text: &str) -> D {
let units: Vec<u16> = text.encode_utf16().chain(std::iter::once(0)).collect();
let mut b = b"txtu".to_vec();
b.extend_from_slice(&((12 + units.len() * 2) as u32).to_le_bytes());
b.extend_from_slice(&(units.len() as u32).to_le_bytes());
for u in units {
b.extend_from_slice(&u.to_le_bytes());
}
D::Path(b)
}
/// Photoshop's `BlnM` enumeration ↔ PhotoCraft's blend mode labels.
const BLEND_MODES: &[(&str, &str)] = &[
("Nrml", "Normal"),
@@ -713,6 +738,60 @@ fn selective_to(p: &Value) -> Option<Descriptor> {
)
}
/// Gradient Map: the gradient's colour stops at 0..4096 → `stops: [[0..1, "#rrggbb"], …]`.
fn gradient_map_from(d: &Descriptor) -> Option<Value> {
let grad = get_obj(d, "Grad")?;
let stops: Option<Vec<Value>> = get_list(grad, "Clrs")?
.iter()
.map(|v| match v {
D::Descriptor(stop) => Some(json!([(get_num(stop, "Lctn")? / 4096.0).clamp(0.0, 1.0), color_hex(get_obj(stop, "Clr ")?)?])),
_ => None,
})
.collect();
let mut out = json!({"stops": stops?});
if let Some(r) = get_bool(d, "Rvrs") {
out["reverse"] = json!(r);
}
if let Some(dd) = get_bool(d, "Dthr") {
out["dither"] = json!(dd);
}
Some(out)
}
fn gradient_map_to(p: &Value) -> Option<Descriptor> {
let stops: Option<Vec<D>> = p
.get("stops")?
.as_array()?
.iter()
.map(|st| {
let a = st.as_array()?;
Some(D::Descriptor(
desc("Clrt")
.with("Clr ", D::Descriptor(color_desc(a.get(1)?.as_str()?)?))
.with("Type", enm("Clry", "UsrS"))
.with("Lctn", long((a.first()?.as_f64()? * 4096.0).round()))
.with("Mdpn", long(50.0)),
))
})
.collect();
let transparency = D::List(vec![
D::Descriptor(desc("TrnS").with("Opct", pct(100.0)).with("Lctn", long(0.0)).with("Mdpn", long(50.0))),
D::Descriptor(desc("TrnS").with("Opct", pct(100.0)).with("Lctn", long(4096.0)).with("Mdpn", long(50.0))),
]);
let grad = desc("Grdn")
.with("Nm ", text("Custom"))
.with("GrdF", enm("GrdF", "CstS"))
.with("Intr", D::Double(4096.0))
.with("Clrs", D::List(stops?))
.with("Trns", transparency);
Some(
desc("GdMp")
.with("Grad", D::Descriptor(grad))
.with("Rvrs", D::Boolean(jbool(p, "reverse").unwrap_or(false)))
.with("Dthr", D::Boolean(jbool(p, "dither").unwrap_or(false))),
)
}
/// An adjustment: its Photoshop class (also the direct event's code), the command suffix (`image.adjustments.<suffix>`, `layer.newAdjustmentLayer.<suffix>`), and
/// the converters between the adjustment descriptor and the command params.
struct Adjustment {
@@ -743,6 +822,7 @@ const ADJUSTMENTS: &[Adjustment] = &[
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 },
Adjustment { class: "GdMp", suffix: "gradientMap", from: gradient_map_from, to: gradient_map_to },
];
fn adjustment_by_class(c: &str) -> Option<&'static Adjustment> {
@@ -1028,6 +1108,20 @@ fn set_from(d: &Descriptor) -> Mapped {
(Some("Chnl"), Target::Name(n)) => step("select.loadSelection", json!({"channel": n})),
_ => unsupported,
},
Some(D::Descriptor(t)) if t.class_id.is("Plgn") => {
let pts: Option<Vec<Value>> = get_list(t, "Pts ")
.unwrap_or(&[])
.iter()
.map(|v| match v {
D::Descriptor(pt) => Some(json!([get_num(pt, "Hrzn")?, get_num(pt, "Vrtc")?])),
_ => None,
})
.collect();
match pts {
Some(pts) if pts.len() >= 3 => step("select.lasso", json!({"points": pts})),
_ => 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 {
@@ -1041,6 +1135,8 @@ fn set_from(d: &Descriptor) -> Mapped {
}
_ => unsupported,
},
// Quick Mask mode on (`set` of the document's quick-mask property); `clearEvent` turns it off.
(Some("Prpr"), Target::Property(p)) if p == "QucM" => step("select.editInQuickMaskMode", json!({"on": true})),
(Some("Lyr"), _) => {
let Some(mut p) = layer_target(r) else { return unsupported };
let Some(t) = get_obj(d, "T ") else { return unsupported };
@@ -1095,6 +1191,24 @@ fn set_to(id: &str, p: &Value) -> Option<Descriptor> {
"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.lasso" => {
let pts: Option<Vec<D>> = p
.get("points")?
.as_array()?
.iter()
.map(|pt| {
let a = pt.as_array()?;
Some(D::Descriptor(desc("Pnt ").with("Hrzn", px(a.first()?.as_f64()?)).with("Vrtc", px(a.get(1)?.as_f64()?))))
})
.collect();
sel(D::Descriptor(desc("Plgn").with("Pts ", D::List(pts?))))
}
"select.editInQuickMaskMode" => {
if jbool(p, "on") != Some(true) {
return None;
}
desc("null").with("null", quick_mask_ref())
}
"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" };
@@ -1195,14 +1309,279 @@ fn delete_from(d: &Descriptor) -> Mapped {
Some(p) => step("layer.delete", Value::Object(p)),
None => Mapped::Unsupported { harmless: false },
},
// The targeted channel of a layer is its mask (what Photoshop records after making an
// adjustment layer over a selection and discarding the mask).
Some(r) if ref_class(r).as_deref() == Some("Chnl") && matches!(target(r), Target::Ordinal(o) if o == "Trgt") => none("layer.layerMask.delete"),
Some(_) => Mapped::Unsupported { harmless: false },
}
}
fn quick_mask_ref() -> D {
D::Reference(vec![
ReferenceItem::Property { class: class("Prpr"), key: code("QucM") },
ReferenceItem::Enumerated { class: class("Dcmn"), type_id: code("Ordn"), value: code("Trgt") },
])
}
fn clear_from(d: &Descriptor) -> Mapped {
match get_ref(d, "null").map(target) {
Some(Target::Property(p)) if p == "QucM" => step("select.editInQuickMaskMode", json!({"on": false})),
None => none("edit.clear"),
_ => Mapped::Unsupported { harmless: false },
}
}
fn clear_to(id: &str, p: &Value) -> Option<Descriptor> {
match id {
"select.editInQuickMaskMode" if jbool(p, "on") == Some(false) => Some(desc("null").with("null", quick_mask_ref())),
_ => None,
}
}
/// `close`: Photoshop's Save prompt answer becomes a Save step before the Close.
fn close_from(d: &Descriptor) -> Mapped {
if get_enum(d, "Svng").as_deref() == Some("Ys") { steps(vec![("file.save", json!({})), ("file.close", json!({}))]) } else { none("file.close") }
}
fn close_to(_: &str, _: &Value) -> Option<Descriptor> {
Some(desc("null").with("Svng", enm("YsN ", "N ")))
}
/// `transform`: Photoshop records a Free Transform relative to the frame (offset of its centre,
/// width/height in percent, angle). A layer moved only becomes `layer.translate`; anything
/// else is `edit.transform`'s relative form; the selection's frame is `select.transformSelection`.
fn transform_from(d: &Descriptor) -> Mapped {
let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } };
let unsupported = Mapped::Unsupported { harmless: false };
let mut p = Map::new();
if let Some(o) = get_obj(d, "Ofst") {
let in_px = |k: &str| matches!(o.get(k), Some(D::UnitFloat { unit: PX, .. }));
if !in_px("Hrzn") || !in_px("Vrtc") {
return unsupported;
}
if let Some(x) = get_num(o, "Hrzn") {
p.insert("dx".into(), x.into());
}
if let Some(y) = get_num(o, "Vrtc") {
p.insert("dy".into(), y.into());
}
}
for (key, param) in [("Wdth", "scaleX"), ("Hght", "scaleY"), ("Angl", "rotate")] {
if let Some(v) = get_num(d, key) {
p.insert(param.into(), v.into());
}
}
if d.get("Skew").is_some() || d.get("Usng").is_some() {
return unsupported;
}
let geometry_only = p.keys().all(|k| k == "dx" || k == "dy");
match (ref_class(r).as_deref(), target(r)) {
(Some("Chnl"), Target::Property(prop)) if prop == "fsel" => step("select.transformSelection", Value::Object(p)),
(Some("Lyr"), _) => {
let Some(mut lp) = layer_target(r) else { return unsupported };
if geometry_only {
lp.insert("dx".into(), p.get("dx").and_then(Value::as_f64).unwrap_or(0.0).round().into());
lp.insert("dy".into(), p.get("dy").and_then(Value::as_f64).unwrap_or(0.0).round().into());
return step("layer.translate", Value::Object(lp));
}
lp.extend(p);
if let Some(i) = get_enum(d, "Intr").and_then(|i| lookup(INTERPOLATION, &i)).filter(|i| matches!(*i, "bicubic" | "bilinear" | "nearest")) {
lp.insert("interpolation".into(), i.into());
}
step("edit.transform", Value::Object(lp))
}
_ => unsupported,
}
}
fn transform_to(id: &str, p: &Value) -> Option<Descriptor> {
if p.get("quad").is_some() || p.get("matrix").is_some() || p.get("rect").is_some() {
return None;
}
let target = match id {
"edit.transform" => layer_ref(p),
"select.transformSelection" => D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: code("fsel") }]),
_ => return None,
};
let mut d = desc("null").with("null", target).with("FTcs", enm("QCSt", "Qcsa"));
d = d.with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(jnum(p, "dx").unwrap_or(0.0))).with("Vrtc", px(jnum(p, "dy").unwrap_or(0.0)))));
for (key, param) in [("Wdth", "scaleX"), ("Hght", "scaleY")] {
if let Some(v) = jnum(p, param) {
d = d.with(key, pct(v));
}
}
if let Some(a) = jnum(p, "rotate") {
d = d.with("Angl", ang(a));
}
if let Some(i) = jstr(p, "interpolation").and_then(|i| lookup_back(INTERPOLATION, i)) {
d = d.with("Intr", enm("Intp", i));
}
Some(d)
}
/// `Clcn` (Apply Image / Calculations blending) ↔ `image.applyImage`'s `blending`.
const CALC_BLEND: &[(&str, &str)] = &[
("Nrml", "normal"),
("Mltp", "multiply"),
("Scrn", "screen"),
("Ovrl", "overlay"),
("SftL", "softLight"),
("HrdL", "hardLight"),
("CDdg", "colorDodge"),
("CBrn", "colorBurn"),
("Drkn", "darken"),
("Lghn", "lighten"),
("Dfrn", "difference"),
("Xclu", "exclusion"),
("linearBurn", "linearBurn"),
("linearDodge", "linearDodge"),
("Add ", "add"),
("Sbtr", "subtract"),
];
const SOURCE_CHANNELS: &[(&str, &str)] =
&[("RGB", "composite"), ("CMYK", "composite"), ("Lab", "composite"), ("Trsp", "transparency"), ("Msk", "mask"), ("fsel", "selection")];
fn apply_image_from(d: &Descriptor) -> Mapped {
let unsupported = Mapped::Unsupported { harmless: false };
let Some(with) = get_obj(d, "With") else { return unsupported };
let Some(r) = get_ref(with, "T ") else { return unsupported };
let mut source = Map::new();
for item in r {
match item {
ReferenceItem::Enumerated { class, value, .. } if class.class_id.is("Chnl") => {
let v = trimmed(value);
let Some(channel) = lookup(SOURCE_CHANNELS, &v).or_else(|| lookup(CHANNELS, &v)) else { return unsupported };
source.insert("channel".into(), channel.into());
}
ReferenceItem::Name { class, name } if class.class_id.is("Lyr ") => {
source.insert("layer".into(), name.to_string_lossy().into());
}
ReferenceItem::Enumerated { class, value, .. } if class.class_id.is("Lyr ") && trimmed(value) == "Mrgd" => {
source.insert("layer".into(), "merged".into());
}
_ => return unsupported,
}
}
if let Some(i) = get_bool(with, "Invr") {
source.insert("invert".into(), i.into());
}
let mut p = json!({"source": source});
if let Some(c) = get_enum(with, "Clcl") {
match lookup(CALC_BLEND, &c) {
Some(b) => p["blending"] = json!(b),
None => return unsupported,
}
}
for (key, param) in [("Scl ", "scale"), ("Ofst", "offset"), ("Opct", "opacity")] {
if let Some(v) = get_num(with, key) {
p[param] = json!(v);
}
}
if let Some(t) = get_bool(with, "PrsT") {
p["preserveTransparency"] = json!(t);
}
step("image.applyImage", p)
}
fn apply_image_to(_: &str, p: &Value) -> Option<Descriptor> {
let source = p.get("source").cloned().unwrap_or(json!({}));
let channel = jstr(&source, "channel").unwrap_or("composite");
let chan = lookup_back(SOURCE_CHANNELS, channel).or_else(|| lookup_back(CHANNELS, channel))?;
let mut items = vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code(chan) }];
match jstr(&source, "layer") {
Some("merged") | None => items.push(ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code("Mrgd") }),
Some(name) => items.push(ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul(name) }),
}
let mut with = desc("null").with("T ", D::Reference(items));
if let Some(i) = jbool(&source, "invert") {
with = with.with("Invr", D::Boolean(i));
}
with = with.with("Clcl", enm("Clcn", lookup_back(CALC_BLEND, jstr(p, "blending").unwrap_or("multiply"))?));
if let Some(v) = jnum(p, "opacity") {
with = with.with("Opct", pct(v));
}
if let Some(v) = jnum(p, "scale") {
with = with.with("Scl ", D::Double(v));
}
if let Some(v) = jnum(p, "offset") {
with = with.with("Ofst", D::Double(v));
}
if let Some(t) = jbool(p, "preserveTransparency") {
with = with.with("PrsT", D::Boolean(t));
}
Some(desc("null").with("With", D::Descriptor(with)))
}
const WEB_FORMATS: &[(&str, &str)] = &[("PN24", "png24"), ("PN8", "png8"), ("JPEG", "jpeg"), ("GIFf", "gif"), ("WBMP", "wbmp")];
/// `export` (Save for Web): needs the destination path (`In`), as the command writes a file.
fn export_from(d: &Descriptor) -> Mapped {
let unsupported = Mapped::Unsupported { harmless: false };
let Some(u) = get_obj(d, "Usng") else { return unsupported };
let Some(path) = d.get("In ").and_then(path_text) else { return unsupported };
let mut p = json!({"path": path});
if let Some(f) = get_enum(u, "Fmt ") {
match lookup(WEB_FORMATS, &f) {
Some(f) => p["format"] = json!(f),
None => return unsupported,
}
}
for (key, param) in [("Intr", "interlaced"), ("Trns", "transparency"), ("EICC", "embedIcc"), ("Prgr", "progressive"), ("Optm", "optimized")] {
if let Some(b) = get_bool(u, key) {
p[param] = json!(b);
}
}
for (key, param) in [("Qlty", "quality"), ("NCol", "colors")] {
if let Some(v) = get_num(u, key) {
p[param] = json!(v);
}
}
if get_bool(u, "Mtt ") == Some(false) {
p["matte"] = json!("none");
} else if let (Some(r), Some(g), Some(b)) = (get_num(u, "MttR"), get_num(u, "MttG"), get_num(u, "MttB")) {
p["matte"] =
json!(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));
}
step("file.export.saveForWebLegacy", p)
}
fn export_to(_: &str, p: &Value) -> Option<Descriptor> {
let path = jstr(p, "path")?;
let mut u = desc("SaveForWeb").with("Op ", enm("SWOp", "OpSa")).with("Fmt ", enm("IRFm", lookup_back(WEB_FORMATS, jstr(p, "format").unwrap_or("png24"))?));
for (key, param, default) in [
("Intr", "interlaced", false),
("Trns", "transparency", true),
("EICC", "embedIcc", false),
("Prgr", "progressive", false),
("Optm", "optimized", true),
] {
u = u.with(key, D::Boolean(jbool(p, param).unwrap_or(default)));
}
if let Some(q) = jnum(p, "quality") {
u = u.with("Qlty", long(q));
}
if let Some(c) = jnum(p, "colors") {
u = u.with("NCol", long(c));
}
match jstr(p, "matte") {
Some("none") => u = u.with("Mtt ", D::Boolean(false)),
matte => {
let c = color_desc(matte.unwrap_or("#ffffff"))?;
u = u
.with("Mtt ", D::Boolean(true))
.with("MttR", get_num(&c, "Rd ").map(long)?)
.with("MttG", get_num(&c, "Grn ").map(long)?)
.with("MttB", get_num(&c, "Bl ").map(long)?);
}
}
Some(desc("null").with("Usng", D::Descriptor(u)).with("In ", path_value(path)))
}
fn delete_to(id: &str, p: &Value) -> Option<Descriptor> {
Some(match id {
"edit.clear" => desc("null"),
"layer.delete" => desc("null").with("null", layer_ref(p)),
"layer.layerMask.delete" => desc("null").with("null", target_ref("Chnl")),
_ => return None,
})
}
@@ -1866,6 +2245,17 @@ const EVENTS: &[Event] = &[
to: |_, p| adjustment_by_class("BlnW").and_then(|a| (a.to)(p)),
},
adjustment_event!("ChnM", "ChnM", "channelMixer", "Channel Mixer"),
Event {
code: "GdMp",
name: "gradientMapEvent",
label: "Gradient Map",
ids: &["image.adjustments.gradientMap"],
from: |d| match adjustment_by_class("GdMp") {
Some(a) => adjustment_steps(a, "image.adjustments.", Some(d)),
None => Mapped::Unsupported { harmless: false },
},
to: |_, p| adjustment_by_class("GdMp").and_then(|a| (a.to)(p)),
},
adjustment_event!("SlcC", "SlcC", "selectiveColor", "Selective Color"),
ev!("Dstt", "desaturate", "Desaturate", "image.adjustments.desaturate", &[]),
ev!("Eqlz", "equalize", "Equalize", "image.adjustments.equalize", &[]),
@@ -1961,6 +2351,7 @@ const EVENTS: &[Event] = &[
"layer.newAdjustmentLayer.blackWhite",
"layer.newAdjustmentLayer.channelMixer",
"layer.newAdjustmentLayer.selectiveColor",
"layer.newAdjustmentLayer.gradientMap",
"file.new",
"file.newFromClipboard",
],
@@ -1977,6 +2368,8 @@ const EVENTS: &[Event] = &[
"select.reselect",
"select.loadSelection",
"select.rect",
"select.lasso",
"select.editInQuickMaskMode",
"layer.setProps",
"layer.renameLayer",
"layer.setLabelColor",
@@ -2001,7 +2394,20 @@ const EVENTS: &[Event] = &[
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: "Dlt ", name: "delete", label: "Delete", ids: &["layer.delete", "edit.clear", "layer.layerMask.delete"], from: delete_from, to: delete_to },
Event { code: "", name: "clearEvent", label: "Clear", ids: &["select.editInQuickMaskMode"], from: clear_from, to: clear_to },
Event {
code: "Trnf",
name: "transform",
label: "Transform",
ids: &["edit.transform", "select.transformSelection"],
from: transform_from,
to: transform_to,
},
Event { code: "AppI", name: "applyImageEvent", label: "Apply Image", ids: &["image.applyImage"], from: apply_image_from, to: apply_image_to },
ev!("", "newPlacedLayer", "Convert to Smart Object", "layer.smartObjects.convertToSmartObject", &[]),
ev!("", "placedLayerEditContents", "Edit Contents", "layer.smartObjects.editContents", &[]),
ev!("", "updatePlacedLayer", "Update Smart Objects", "layer.smartObjects.updateModifiedContent", &[]),
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 {
@@ -2086,7 +2492,8 @@ const EVENTS: &[Event] = &[
to: profile_to,
},
// File and actions.
ev!("Cls ", "close", "Close", "file.close", &[]),
Event { code: "Cls ", name: "close", label: "Close", ids: &["file.close"], from: close_from, to: close_to },
Event { code: "Expr", name: "export", label: "Export", ids: &["file.export.saveForWebLegacy"], from: export_from, to: export_to },
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 },
@@ -2098,8 +2505,10 @@ fn event_for(item: &AtnEvent) -> Option<&'static Event> {
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))
/// The event `id` exports as: the first event listing it whose converter accepts the params
/// (Quick Mask on is a `set`, off a `clearEvent`).
fn event_and_descriptor(id: &str, params: &Value) -> Option<(&'static Event, Descriptor)> {
EVENTS.iter().filter(|e| e.ids.contains(&id)).find_map(|e| (e.to)(id, params).map(|d| (e, d)))
}
// ------------------------------------------------------------------ import
@@ -2220,12 +2629,11 @@ fn export_item(id: &str, params: &Value, set: &str) -> Option<AtnItem> {
};
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, &params)?;
let (e, descriptor) = event_and_descriptor(id, &params)?;
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() })
}
+192
View File
@@ -261,11 +261,41 @@ fn every_mapped_command_exports_and_imports_back() {
("edit.assignProfile", json!({"profile": "srgb"})),
("file.close", json!({})),
("file.save", json!({})),
("layer.layerMask.delete", json!({})),
("select.lasso", json!({"points": [[1, 2], [30, 2], [15, 40]]})),
("select.editInQuickMaskMode", json!({"on": true})),
("select.editInQuickMaskMode", json!({"on": false})),
("edit.transform", json!({"dx": 10, "dy": -5, "scaleX": 50, "scaleY": 75, "rotate": 12.5, "interpolation": "bicubic"})),
("edit.transform", json!({"layer": "Shadow", "dx": 0, "dy": 0, "scaleX": 200})),
("select.transformSelection", json!({"dx": 3, "dy": 4, "scaleX": 110, "scaleY": 110, "rotate": -30})),
(
"image.applyImage",
json!({"source": {"channel": "composite", "layer": "High-Frequency"}, "blending": "subtract", "scale": 2, "offset": 128, "preserveTransparency": true}),
),
("image.applyImage", json!({"source": {"channel": "red", "layer": "merged", "invert": true}, "blending": "multiply", "opacity": 50})),
(
"file.export.saveForWebLegacy",
json!({"path": "/tmp/out/banner.png", "format": "png24", "interlaced": false, "transparency": true, "embedIcc": false, "progressive": false, "optimized": true, "matte": "#ffffff"}),
),
(
"file.export.saveForWebLegacy",
json!({"path": "C:\\shots\\a.jpg", "format": "jpeg", "interlaced": false, "transparency": false, "embedIcc": true, "progressive": true, "optimized": true, "quality": 80, "matte": "none"}),
),
("image.adjustments.gradientMap", json!({"stops": [[0, "#000000"], [0.5, "#ff8000"], [1, "#ffffff"]], "reverse": true, "dither": false})),
("layer.newAdjustmentLayer.gradientMap", json!({"stops": [[0, "#102030"], [1, "#f0e0d0"]], "reverse": false, "dither": true})),
("layer.smartObjects.convertToSmartObject", json!({})),
("layer.smartObjects.editContents", json!({})),
("layer.smartObjects.updateModifiedContent", json!({})),
("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"));
if *id == "file.close" {
// Close exports "don't save" and imports as a plain close; the Save answer is tested below.
assert_eq!(item.event.id(), "Cls ");
continue;
}
let e = event_for(&item.event).unwrap_or_else(|| panic!("{id} exported as an unknown event {}", item.event.id()));
match (e.from)(item.descriptor.as_ref().unwrap()) {
Mapped::Steps(steps) => {
@@ -486,3 +516,165 @@ fn colours_convert_both_ways() {
assert_eq!(color_desc("#12ab9"), None);
assert_eq!(color_desc("#ééééé"), None);
}
#[test]
fn remaining_events_import_in_their_photoshop_shapes() {
// Close with the Save answer becomes Save then Close.
let close_yes = item("Cls ", "Close", Some(desc("null").with("Svng", enm("YsN ", "Ys "))));
let close_no = item("close", "Close", Some(desc("null").with("Svng", enm("YsN ", "N "))));
// A layer only moved is a translation; scaled, it is the relative Free Transform; the
// selection's frame goes to Transform Selection; points (not pixels) stay unsupported.
let moved = desc("null")
.with("null", target_ref("Lyr "))
.with("FTcs", enm("QCSt", "Qcsa"))
.with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(-57.4)).with("Vrtc", px(12.0))))
.with("Intr", enm("Intp", "Bcbc"));
let scaled = desc("null")
.with("null", target_ref("Lyr "))
.with("FTcs", enm("QCSt", "Qcsa"))
.with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(10.0)).with("Vrtc", px(0.0))))
.with("Wdth", pct(50.0))
.with("Hght", pct(50.0))
.with("Lnkd", D::Boolean(true))
.with("Intr", enm("Intp", "Bcbc"));
let sel = desc("null")
.with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }]))
.with("FTcs", enm("QCSt", "Qcs0"))
.with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(1014.0)).with("Vrtc", px(0.0))));
let points = desc("null").with("null", target_ref("Lyr ")).with(
"Ofst",
D::Descriptor(desc("Ofst").with("Hrzn", D::UnitFloat { unit: *b"#Rlt", value: 3.0 }).with("Vrtc", D::UnitFloat { unit: *b"#Rlt", value: 1.0 })),
);
// Apply Image as recorded by the frequency-separation action.
let apply = desc("null").with(
"With",
D::Descriptor(
desc("null")
.with(
"T ",
D::Reference(vec![
ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Chnl"), value: Id::new("RGB ") },
ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul("High-Frequency") },
]),
)
.with("Clcl", enm("Clcn", "Sbtr"))
.with("Scl ", D::Double(2.0))
.with("Ofst", D::Double(128.0))
.with("PrsT", D::Boolean(true)),
),
);
// Save for Web with and without a destination.
let web = desc("null")
.with(
"Usng",
D::Descriptor(
desc("SaveForWeb")
.with("Fmt ", enm("IRFm", "PN24"))
.with("Intr", D::Boolean(false))
.with("Trns", D::Boolean(true))
.with("Mtt ", D::Boolean(true))
.with("MttR", long(255.0))
.with("MttG", long(255.0))
.with("MttB", long(255.0))
.with("EICC", D::Boolean(false)),
),
)
.with("In ", path_value("/Users/me/Desktop/out.png"));
let web_nowhere = desc("null").with("Usng", D::Descriptor(desc("SaveForWeb").with("Fmt ", enm("IRFm", "PN24"))));
// Quick Mask on/off, a polygon selection, the mask of the target layer deleted.
let qm_on = desc("null").with("null", quick_mask_ref());
let polygon = desc("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }])).with(
"T ",
D::Descriptor(desc("Plgn").with(
"Pts ",
D::List(vec![
D::Descriptor(desc("Pnt ").with("Hrzn", px(1.0)).with("Vrtc", px(2.0))),
D::Descriptor(desc("Pnt ").with("Hrzn", px(30.0)).with("Vrtc", px(2.0))),
D::Descriptor(desc("Pnt ").with("Hrzn", px(15.0)).with("Vrtc", px(40.0))),
]),
)),
);
let mask_delete = desc("null").with("null", target_ref("Chnl"));
let items = vec![
item("GsnB", "Gaussian Blur", Some(desc("null").with("Rds ", px(1.0)))),
close_yes,
close_no,
item("transform", "Transform", Some(moved)),
item("Trnf", "Transform", Some(scaled)),
item("transform", "Transform", Some(sel)),
item("transform", "Transform", Some(points)),
item("applyImageEvent", "Apply Image", Some(apply)),
item("export", "Export", Some(web)),
item("export", "Export", Some(web_nowhere)),
item("setd", "Set", Some(qm_on.clone())),
item("clearEvent", "Clear", Some(qm_on.clone())),
item("set", "Set", Some(polygon)),
item("delete", "Delete", Some(mask_delete)),
item("newPlacedLayer", "Convert to Smart Object", None),
item("placedLayerEditContents", "Edit Contents", None),
item("updatePlacedLayer", "Update Smart Objects", None),
];
let file = AtnFile {
name: UnicodeString::new_nul("More"),
expanded: false,
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }],
warnings: vec![],
};
let imp = import(&atn::write(&file).unwrap()).unwrap();
let steps = &imp.actions[0].steps;
let ids: Vec<&str> = steps.iter().map(|(id, _)| id.as_str()).collect();
assert_eq!(
ids,
[
"filter.blur.gaussianBlur",
"file.save",
"file.close",
"file.close",
"layer.translate",
"edit.transform",
"select.transformSelection",
PHOTOSHOP_STEP,
"image.applyImage",
"file.export.saveForWebLegacy",
PHOTOSHOP_STEP,
"select.editInQuickMaskMode",
"select.editInQuickMaskMode",
"select.lasso",
"layer.layerMask.delete",
"layer.smartObjects.convertToSmartObject",
"layer.smartObjects.editContents",
"layer.smartObjects.updateModifiedContent",
]
);
assert_eq!(steps[4].1, json!({"dx": -57, "dy": 12}));
assert_eq!(steps[5].1, json!({"dx": 10, "dy": 0, "scaleX": 50, "scaleY": 50, "interpolation": "bicubic"}));
assert_eq!(steps[6].1, json!({"dx": 1014, "dy": 0}));
assert_eq!(
steps[8].1,
json!({"source": {"channel": "composite", "layer": "High-Frequency"}, "blending": "subtract", "scale": 2, "offset": 128, "preserveTransparency": true})
);
assert_eq!(
steps[9].1,
json!({"path": "/Users/me/Desktop/out.png", "format": "png24", "interlaced": false, "transparency": true, "embedIcc": false, "matte": "#ffffff"})
);
assert_eq!(steps[11].1, json!({"on": true}));
assert_eq!(steps[12].1, json!({"on": false}));
assert_eq!(steps[13].1, json!({"points": [[1, 2], [30, 2], [15, 40]]}));
assert_eq!(imp.unsupported.len(), 2);
// The Photoshop path record round-trips and rejects other shapes.
assert_eq!(path_text(&path_value("/a/b c.png")).as_deref(), Some("/a/b c.png"));
assert_eq!(path_text(&D::Path(b"txtu".to_vec())), None);
assert_eq!(path_text(&D::Path(vec![])), None);
assert_eq!(path_text(&D::Text(UnicodeString::new("x"))), None);
assert_eq!(path_text(&path_value("")), None);
// Apply Image by layer name runs on a document; a missing name is a clear error.
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("layer.new.layer", json!({"name": "High-Frequency"})).unwrap();
s.execute("layer.new.layer", json!({"name": "Top"})).unwrap();
s.execute("image.applyImage", steps[8].1.clone()).unwrap();
let e = s.execute("image.applyImage", json!({"source": {"layer": "Nope"}})).unwrap_err().to_string();
assert!(e.contains("Nope"), "{e}");
}
+16 -5
View File
@@ -328,9 +328,20 @@ fn native_pixels(doc: &Document, layer: Option<LayerId>) -> Result<(Vec<f32>, us
}
}
/// `"layer"` param of a source: a layer id, or `"merged"` (default).
fn source_layer(p: &Value) -> Option<LayerId> {
p.get("layer").and_then(Value::as_u64).map(LayerId)
/// `"layer"` param of a source: a layer id, a layer name (the topmost of that name, as a loaded
/// Photoshop action refers to it), or `"merged"` (default). An unknown name is an error.
fn source_layer(doc: &Document, p: &Value, cmd: &str) -> Result<Option<LayerId>> {
match p.get("layer") {
Some(Value::Number(n)) => Ok(n.as_u64().map(LayerId)),
Some(Value::String(name)) if !name.is_empty() && name != "merged" => doc
.walk()
.into_iter()
.rev()
.find(|(_, _, l)| l.name == *name)
.map(|(_, _, l)| Some(l.id))
.ok_or_else(|| bad(cmd, format!("no source layer named \"{name}\""))),
_ => Ok(None),
}
}
/// Planes of channel `r` from document `doc` (`layer` = None: merged). A composite gives one
@@ -425,7 +436,7 @@ fn source(s: &Session, p: &Value, cmd: &str, gray: bool) -> Result<Vec<Vec<f32>>
return Err(bad(cmd, format!("source document \"{}\" is {}×{}; it must match the target size", st.doc.name, st.doc.size.width, st.doc.size.height)));
}
let r = p.get("channel").map_or(Some(ChanRef::Composite), |v| parse_ref(v, &st.doc)).ok_or_else(|| bad(cmd, "unknown `channel`"))?;
let layer = source_layer(p);
let layer = source_layer(&st.doc, p, cmd)?;
let mut planes = if gray { vec![gray_plane(&st.doc, layer, st.active_layer, r)?] } else { ref_planes(&st.doc, layer, st.active_layer, r)? };
// Guard every caller (`.remove(0)` / `src[0]`) against an empty source.
if planes.is_empty() {
@@ -1716,7 +1727,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Image…",
["Image"],
None,
r##"{"source":{"document":index?,"layer":id|"merged"="merged","channel":"composite"|"red|…"|index|"selection"|"transparency"|"mask"="composite","invert":bool=false},"blending":"normal|multiply|screen|overlay|softLight|hardLight|colorDodge|colorBurn|darken|lighten|difference|exclusion|linearBurn|linearDodge|add|subtract|…"="multiply","opacity":0..100=100,"scale":1..2=1,"offset":-255..255=0,"preserveTransparency":bool=false,"mask":{"document","layer","channel","invert"}?,"sourceChannel|sourceDocument|sourceLayer|sourceInvert|maskChannel|…":flat form of source/mask?}"##,
r##"{"source":{"document":index?,"layer":id|name|"merged"="merged","channel":"composite"|"red|…"|index|"selection"|"transparency"|"mask"="composite","invert":bool=false},"blending":"normal|multiply|screen|overlay|softLight|hardLight|colorDodge|colorBurn|darken|lighten|difference|exclusion|linearBurn|linearDodge|add|subtract|…"="multiply","opacity":0..100=100,"scale":1..2=1,"offset":-255..255=0,"preserveTransparency":bool=false,"mask":{"document","layer","channel","invert"}?,"sourceChannel|sourceDocument|sourceLayer|sourceInvert|maskChannel|…":flat form of source/mask?}"##,
has_apply_target,
apply_image
),
+57 -2
View File
@@ -397,6 +397,33 @@ pub(crate) fn composite_over(dst: &mut Surface, top: &Surface) {
dst.write_region(b, &d);
}
/// The relative form (`scaleX`, `scaleY` in percent, `rotate` in degrees clockwise, `dx`, `dy`
/// in pixels), as Photoshop records a Free Transform: scale and rotate about the frame's
/// centre, then move. `None` when none of them is given.
fn relative_matrix(p: &Value, rect: [f64; 4]) -> Result<Option<Homography>> {
let num = |k: &str| -> Result<Option<f64>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_f64().filter(|f| f.is_finite()).map(Some).ok_or_else(|| bad(format!("`{k}` must be a finite number"))),
}
};
let (sx, sy, rot, dx, dy) = (num("scaleX")?, num("scaleY")?, num("rotate")?, num("dx")?, num("dy")?);
if sx.is_none() && sy.is_none() && rot.is_none() && dx.is_none() && dy.is_none() {
return Ok(None);
}
let (sx, sy) = (sx.unwrap_or(100.0) / 100.0, sy.unwrap_or(100.0) / 100.0);
if sx.abs() < 1e-6 || sy.abs() < 1e-6 {
return Err(bad("`scaleX` and `scaleY` must not be 0"));
}
let (cx, cy) = ((rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0);
let (sin, cos) = rot.unwrap_or(0.0).to_radians().sin_cos();
// (x, y) → translate(-c) → scale → rotate → translate(c + d).
let (a, b, c, d) = (sx * cos, sx * sin, -sy * sin, sy * cos);
let e = cx + dx.unwrap_or(0.0) - a * cx - c * cy;
let f = cy + dy.unwrap_or(0.0) - b * cx - d * cy;
Ok(Some(Homography([a, c, e, b, d, f, 0.0, 0.0, 1.0])))
}
fn quad_param(p: &Value) -> Option<[[f64; 2]; 4]> {
let a = p.get("quad")?.as_array()?;
if a.len() != 4 {
@@ -459,8 +486,10 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
}
// Affine [a b c d e f] maps (x, y) → (a·x + c·y + e, b·x + d·y + f).
Homography([v[0], v[2], v[4], v[1], v[3], v[5], 0.0, 0.0, 1.0])
} else if let Some(m) = relative_matrix(p, rect)? {
m
} else {
return Err(bad("pass `quad` (where the rect's corners go) or `matrix`"));
return Err(bad("pass `quad` (where the rect's corners go), `matrix`, or `scaleX`/`scaleY`/`rotate`/`dx`/`dy`"));
};
if h.inverse().is_none() {
return Err(bad("the transform collapses the layer"));
@@ -515,7 +544,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Free Transform",
menu: &[],
shortcut: None,
params: r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (source frame; default = layer content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"interpolation":"bicubic|bilinear|nearest"="bicubic","target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##,
params: r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (source frame; default = layer content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"scaleX":%?,"scaleY":%?,"rotate":deg?,"dx":px?,"dy":px? (relative alternative: scale and rotate about the frame's centre, then move, as a Photoshop action records a Free Transform),"interpolation":"bicubic|bilinear|nearest"="bicubic","target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##,
enabled: has_layer,
journal: true,
run: transform,
@@ -993,4 +1022,30 @@ mod tests {
s.execute("layer.setProps", json!({"locks": {"position": true}})).unwrap();
assert!(s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 0, 0]})).is_err(), "locked layer refuses identity transform");
}
#[test]
fn relative_params_scale_and_rotate_about_the_frame_centre() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 100, "height": 100})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("select.rect", json!({"x": 20, "y": 30, "width": 40, "height": 20})).unwrap();
s.execute("edit.fill", json!({"contents": "black"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
// 50% about the centre (40, 40), then 10 px right: the content ends at x 40..60, y 35..45.
let r = s.execute("edit.transform", json!({"scaleX": 50, "scaleY": 50, "dx": 10})).unwrap();
let rect: Vec<f64> = serde_json::from_value(r["rect"].clone()).unwrap();
assert_eq!(rect, [20.0, 30.0, 60.0, 50.0], "the reported frame is the source frame");
let bounds = |s: &Session| {
let d = s.active().unwrap();
let b = transform_bounds(&d.doc, d.doc.layer(d.active_layer.unwrap()).unwrap());
(b.x0, b.y0, b.x1, b.y1)
};
assert_eq!(bounds(&s), (40, 35, 60, 45));
// A quarter turn about the centre (50, 40) swaps the frame's sides.
s.execute("edit.transform", json!({"rotate": 90})).unwrap();
assert_eq!(bounds(&s), (45, 30, 55, 50));
for bad in [json!({"scaleX": 0}), json!({"rotate": "x"}), json!({"dx": f64::NAN})] {
assert!(s.execute("edit.transform", bad.clone()).is_err(), "{bad}");
}
}
}
+1 -1
View File
@@ -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 | 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) |
| Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (105 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 | |
+6 -4
View File
@@ -89,11 +89,13 @@ what an agent can't finish alone.
### G11. Actions and scripting
- **Missing:** document-independent action recording (AUTO-219-1), 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.
mapped events (Place, Image Size in percent, paths, conditionals, 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`; 92 Photoshop events map to commands both ways, Play and
Stop steps included; layer steps replay by layer name in any document. The panel groups actions
and `actions.load`/`actions.save`; 105 Photoshop events map to commands both ways (Transform,
Apply Image, Save for Web, smart-object edits, Gradient Map, Quick Mask and polygon selections
since the third PR), Play and Stop steps included; layer steps replay by layer name in any
document. The panel groups actions
in set folders, has the dialog toggle column (playback pauses on the step's dialog and resumes
on OK) and Insert Stop…; `actions.insert`, `actions.dialog`, `actions.assignSet` for agents
(AUTO-219-2 done).
+3 -3
View File
@@ -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, `.atn` load/save (90 events); no .8BF, UXP/ExtendScript, Libraries | ~20% | estimated |
| Ecosystem (plug-ins, scripting, libraries) | 10% | WebAssembly plug-ins, CLI, MCP, actions, `.atn` load/save (105 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; .atn load/save (2026-10-12, 90 events, the rest kept verbatim), .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, 105 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); `.atn` load/save since 2026-10-12 (90 events mapped, the rest kept verbatim); 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 (105 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 |