mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
- 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:
1 parent
7015f2916d
commit
13f5c104f6
7 files changed
+689
-21
No files matched your search
@@ -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, ¶ms)?;
|
||||
let (e, descriptor) = event_and_descriptor(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() })
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user