From 90c42f5555b1ff1cbc708bb6f8bbf28c7387895d Mon Sep 17 00:00:00 2001 From: Fabio Lissi Date: Mon, 12 Oct 2026 18:12:35 -0500 Subject: [PATCH] Map Place, Open, Image Size in percent, the Paint Bucket and Insert Conditional (#219) (#4470) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - image.imageSize takes percent (both sides; an explicit side wins). - actions.conditional {if, then, else}: Photoshop's Insert Conditional as a step, 33 conditions on the document (shape, mode, depth, selection, layers, alpha channels, unsaved) and the active layer (kind, locks, visibility, masks, effects); a failing branch stops the caller. - .atn events: placeEvent → file.placeEmbedded/placeLinked (path, scale), open → file.openAs, imageSize in percent, fill from a point → paint.bucket (pixels), conditional. 105 → 108. - Panel labels conditional steps as 'If cond → then / else'. --- crates/engine/src/actions_cmds.rs | 2 +- crates/engine/src/actions_edit_cmds.rs | 189 +++++++++++++++++++++++++ crates/engine/src/atn_cmds.rs | 137 ++++++++++++++++-- crates/engine/src/atn_cmds/tests.rs | 76 +++++++++- crates/engine/src/image_cmds.rs | 12 +- crates/ui-egui/src/actions.rs | 5 + docs/control-protocol.md | 5 +- docs/file-format-parity.md | 2 +- docs/gaps.md | 11 +- docs/target-app-parity.md | 6 +- 10 files changed, 418 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index edadde2117..3ee242d7d6 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -353,7 +353,7 @@ pub fn finish_playback(s: &mut Session, recording: Option<(usize, usize)>, actio /// Nested playback returns a structured failure even though the command itself succeeded. /// Promote it to the caller's failed step so no later parent steps run after a child fails. pub fn nested_failure(id: &str, value: &Value) -> Option { - (id == "actions.play").then(|| value.get("failed")).flatten().map(|failure| { + (id == "actions.play" || id == "actions.conditional").then(|| value.get("failed")).flatten().map(|failure| { format!( "action `{}` step {} failed: {}", value.get("action").and_then(Value::as_str).unwrap_or(""), diff --git a/crates/engine/src/actions_edit_cmds.rs b/crates/engine/src/actions_edit_cmds.rs index c7cde6869f..1f9cce3431 100644 --- a/crates/engine/src/actions_edit_cmds.rs +++ b/crates/engine/src/actions_edit_cmds.rs @@ -126,6 +126,123 @@ fn assign_set(s: &mut Session, p: &Value) -> Result { Ok(result) } +/// The conditions of Photoshop's Insert Conditional, by their string ids. +pub const CONDITIONS: &[&str] = &[ + "always", + "never", + "isOpen", + "landscape", + "square", + "portrait", + "modeIsRGB", + "modeIsCMYK", + "modeIsGrayscale", + "modeIsLab", + "modeIsBitmap", + "modeIsIndexed", + "modeIsDuotone", + "modeIsMultichannel", + "depth8", + "depth16", + "depth32", + "unsaved", + "hasSelection", + "hasLayers", + "hasAlphaChannels", + "isBackground", + "isPixelLayer", + "isAdjustmentLayer", + "isShapeLayer", + "isTypeLayer", + "isSmartObject", + "isLayerGroup", + "isLocked", + "isVisible", + "hasPixelMask", + "hasVectorMask", + "hasEffects", +]; + +/// Whether `condition` holds for the active document and layer. Layer conditions are false +/// without a document or an active layer; `isOpen` is whether a document is open. +pub fn evaluate(s: &Session, condition: &str) -> Result { + use photocraft_color::ColorMode; + use photocraft_doc::LayerContent; + if !CONDITIONS.contains(&condition) { + return Err(bad("actions.conditional", format!("unknown condition `{condition}` ({})", CONDITIONS.join("|")))); + } + let Some(d) = s.active() else { return Ok(condition == "always") }; + let doc = &d.doc; + let layer = d.active_layer.and_then(|id| doc.layer(id)); + let (w, h) = (doc.size.width, doc.size.height); + Ok(match condition { + "always" | "isOpen" => true, + "never" => false, + "landscape" => w > h, + "square" => w == h, + "portrait" => h > w, + "modeIsRGB" => doc.mode == ColorMode::Rgb, + "modeIsCMYK" => doc.mode == ColorMode::Cmyk, + "modeIsGrayscale" => doc.mode == ColorMode::Grayscale, + "modeIsLab" => doc.mode == ColorMode::Lab, + "modeIsBitmap" => doc.mode == ColorMode::Bitmap, + "modeIsIndexed" => doc.mode == ColorMode::Indexed, + "modeIsDuotone" => doc.mode == ColorMode::Duotone, + "modeIsMultichannel" => doc.mode == ColorMode::Multichannel, + "depth8" => doc.depth == photocraft_color::SampleType::U8, + "depth16" => doc.depth == photocraft_color::SampleType::U16, + "depth32" => doc.depth == photocraft_color::SampleType::F32, + "unsaved" => d.is_dirty(), + "hasSelection" => doc.selection.is_some(), + // More than a lone Background: any layer that isn't the background. + "hasLayers" => doc.walk().iter().any(|(_, _, l)| !doc.is_background(l.id)), + "hasAlphaChannels" => !doc.channels.is_empty(), + "isBackground" => layer.is_some_and(|l| doc.is_background(l.id)), + "isPixelLayer" => layer.is_some_and(|l| matches!(l.content, LayerContent::Raster(_)) && !doc.is_background(l.id)), + "isAdjustmentLayer" => layer.is_some_and(|l| matches!(l.content, LayerContent::Adjustment(_))), + "isShapeLayer" => layer.is_some_and(|l| matches!(l.content, LayerContent::Shape(_))), + "isTypeLayer" => layer.is_some_and(|l| matches!(l.content, LayerContent::Text(_))), + "isSmartObject" => layer.is_some_and(|l| matches!(l.content, LayerContent::Smart(_))), + "isLayerGroup" => layer.is_some_and(|l| l.is_group()), + "isLocked" => layer.is_some_and(|l| l.locks.all || l.locks.pixels || l.locks.position || l.locks.transparency), + "isVisible" => layer.is_some_and(|l| l.visible), + "hasPixelMask" => layer.is_some_and(|l| l.mask.is_some()), + "hasVectorMask" => layer.is_some_and(|l| l.vector_mask.is_some()), + "hasEffects" => layer.is_some_and(|l| !l.effects.items.is_empty()), + _ => false, + }) +} + +/// Photoshop's Insert Conditional as a step: test the condition, play `then` or `else`. +fn conditional(s: &mut Session, p: &Value) -> Result { + let cmd = "actions.conditional"; + let condition = match p.get("if") { + Some(Value::String(c)) => c.clone(), + _ => return Err(bad(cmd, format!("needs `if`, one of {}", CONDITIONS.join("|")))), + }; + let branch = |key: &str| -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(n)) if !n.trim().is_empty() => Ok(Some(n.clone())), + Some(Value::String(_)) => Ok(None), + Some(_) => Err(bad(cmd, format!("`{key}` must be an action name or null"))), + } + }; + let (then, otherwise) = (branch("then")?, branch("else")?); + let holds = evaluate(s, &condition)?; + let played = if holds { then } else { otherwise }; + let mut out = json!({"if": condition, "holds": holds, "played": played}); + if let Some(name) = played { + let r = s.execute("actions.play", json!({"action": name}))?; + if let Some(f) = r.get("failed") { + out["failed"] = f.clone(); + } + out["ran"] = r.get("ran").cloned().unwrap_or(json!(0)); + out["action"] = json!(name); + } + Ok(out) +} + pub fn specs() -> Vec { vec![ CommandSpec { @@ -148,6 +265,16 @@ pub fn specs() -> Vec { run: dialog, journal: false, }, + CommandSpec { + id: "actions.conditional", + label: "Conditional", + menu: &[], + shortcut: None, + params: r##"{"if":condition, "then":action name|null, "else":action name|null} → {if, holds, played, ran?, failed?}. Photoshop's Insert Conditional: conditions always|never|isOpen|landscape|square|portrait|modeIsRGB|modeIsCMYK|modeIsGrayscale|modeIsLab|modeIsBitmap|modeIsIndexed|modeIsDuotone|modeIsMultichannel|depth8|depth16|depth32|unsaved|hasSelection|hasLayers|hasAlphaChannels|isBackground|isPixelLayer|isAdjustmentLayer|isShapeLayer|isTypeLayer|isSmartObject|isLayerGroup|isLocked|isVisible|hasPixelMask|hasVectorMask|hasEffects (layer conditions test the active layer). The branch plays as actions.play does; its failure stops the calling action."##, + enabled: always, + run: conditional, + journal: true, + }, CommandSpec { id: "actions.assignSet", label: "Move to Set", @@ -223,6 +350,68 @@ mod tests { assert_eq!(s.actions.list[0].dialogs.iter().copied().collect::>(), [0]); } + #[test] + fn conditional_tests_the_document_and_plays_a_branch() { + let mut s = session_with(&[]); + s.actions.list.push(Action::new("Yes", vec![("layer.new.layer".into(), json!({"name": "yes"}))])); + s.actions.list.push(Action::new("No", vec![("layer.new.layer".into(), json!({"name": "no"}))])); + let names = |s: &Session| s.active().unwrap().doc.walk().iter().map(|(_, _, l)| l.name.clone()).collect::>(); + // 8 × 8: square, not landscape; one Background: no layers yet. + for (cond, expect) in [ + ("square", true), + ("landscape", false), + ("portrait", false), + ("modeIsRGB", true), + ("modeIsCMYK", false), + ("depth8", true), + ("hasLayers", false), + ("hasSelection", false), + ("isBackground", true), + ("isPixelLayer", false), + ("hasEffects", false), + ("always", true), + ("never", false), + ("isOpen", true), + ] { + assert_eq!(evaluate(&s, cond).unwrap(), expect, "{cond}"); + } + let r = s.execute("actions.conditional", json!({"if": "square", "then": "Yes", "else": "No"})).unwrap(); + assert_eq!((r["holds"].as_bool(), r["played"].as_str(), r["ran"].as_u64()), (Some(true), Some("Yes"), Some(1))); + assert_eq!(names(&s).last().map(String::as_str), Some("yes")); + for (cond, expect) in [("hasLayers", true), ("isPixelLayer", true), ("isBackground", false), ("isVisible", true)] { + assert_eq!(evaluate(&s, cond).unwrap(), expect, "{cond}"); + } + let r = s.execute("actions.conditional", json!({"if": "landscape", "then": "Yes", "else": "No"})).unwrap(); + assert_eq!((r["holds"].as_bool(), r["played"].as_str()), (Some(false), Some("No"))); + assert_eq!(names(&s).last().map(String::as_str), Some("no")); + // No branch for the outcome: nothing plays, no error. + let r = s.execute("actions.conditional", json!({"if": "landscape", "then": "Yes"})).unwrap(); + assert!(r["played"].is_null()); + // A failing branch reports like a nested Play, and stops a calling action. + s.actions.list.push(Action::new("Bad", vec![("layer.delete".into(), json!({"layer": 999}))])); + let r = s.execute("actions.conditional", json!({"if": "always", "then": "Bad"})).unwrap(); + assert!(r["failed"].is_object(), "{r}"); + assert!(crate::actions_cmds::nested_failure("actions.conditional", &r).is_some()); + s.actions.list.push(Action::new( + "Caller", + vec![("actions.conditional".into(), json!({"if": "always", "then": "Bad"})), ("layer.new.layer".into(), json!({"name": "after"}))], + )); + let played = s.execute("actions.play", json!({"action": "Caller"})).unwrap(); + assert_eq!(played["failed"]["step"], 0, "{played}"); + assert_ne!(names(&s).last().map(String::as_str), Some("after")); + // Without a document only always/never answer; layer conditions are false. + let t = Session::new(); + assert!(evaluate(&t, "always").unwrap()); + assert!(!evaluate(&t, "isOpen").unwrap()); + assert!(!evaluate(&t, "isBackground").unwrap()); + for p in [json!({}), json!({"if": "sometimes"}), json!({"if": 3}), json!({"if": "always", "then": 5}), json!({"if": "always", "then": "Nope"})] { + assert!( + s.execute("actions.conditional", p.clone()).is_err() || s.execute("actions.conditional", p.clone()).unwrap().get("failed").is_some(), + "{p}" + ); + } + } + #[test] fn assign_set_moves_an_action_between_folders() { let mut s = session_with(&["select.all"]); diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index fa9e649c0b..e5c8e74674 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -164,8 +164,9 @@ fn class(c: &str) -> Class { Class { name: UnicodeString::new_nul(""), class_id: code(c) } } -/// The pixel unit of a `UntF` value. +/// The pixel and percent units of a `UntF` value. const PX: [u8; 4] = *b"#Pxl"; +const PCT: [u8; 4] = *b"#Prc"; fn px(v: f64) -> D { D::UnitFloat { unit: PX, value: v } @@ -1338,6 +1339,69 @@ fn clear_to(id: &str, p: &Value) -> Option { } } +/// `placeEvent`: File › Place Embedded / Linked, with the file and the recorded scale. +fn place_from(d: &Descriptor) -> Mapped { + let Some(path) = d.get("null").and_then(path_text) else { return Mapped::Unsupported { harmless: false } }; + let mut p = json!({"path": path}); + if let Some(w) = get_num(d, "Wdth") { + p["scale"] = json!(w); + } + step(if get_bool(d, "Lnkd") == Some(true) { "file.placeLinked" } else { "file.placeEmbedded" }, p) +} + +fn place_to(id: &str, p: &Value) -> Option { + let mut d = desc("null").with("null", path_value(jstr(p, "path")?)).with("FTcs", enm("QCSt", "Qcsa")); + if let Some(s) = jnum(p, "scale") { + d = d.with("Wdth", pct(s)).with("Hght", pct(s)); + } + Some(d.with("Lnkd", D::Boolean(id == "file.placeLinked")).with("Antl", D::Boolean(true))) +} + +/// `open`: File › Open of a recorded path. +fn open_from(d: &Descriptor) -> Mapped { + match d.get("null").and_then(path_text) { + Some(path) => step("file.openAs", json!({"path": path})), + None => Mapped::Unsupported { harmless: false }, + } +} + +fn open_to(_: &str, p: &Value) -> Option { + Some(desc("null").with("null", path_value(jstr(p, "path")?))) +} + +/// Photoshop's four-character condition codes that differ from the string ids. +const CONDITION_CODES: &[(&str, &str)] = &[("Lyrs", "hasLayers")]; + +/// `conditional`: Insert Conditional, with its Then and Else actions by name. +fn conditional_from(d: &Descriptor) -> Mapped { + let Some(c) = get_enum(d, "null") else { return Mapped::Unsupported { harmless: false } }; + let condition = lookup(CONDITION_CODES, &c).unwrap_or(c.as_str()); + if !crate::actions_edit_cmds::CONDITIONS.contains(&condition) { + return Mapped::Unsupported { harmless: false }; + } + let branch = |key: &str| match get_ref(d, key).map(target) { + Some(Target::Name(n)) => Some(n), + _ => None, + }; + step("actions.conditional", json!({"if": condition, "then": branch("then"), "else": branch("else")})) +} + +fn conditional_to(_: &str, p: &Value) -> Option { + let condition = jstr(p, "if")?; + let code = lookup_back(CONDITION_CODES, condition).unwrap_or(condition); + let mut d = desc("null").with("null", enm("Cndt", code)); + for key in ["then", "else"] { + if let Some(name) = jstr(p, key) { + let mut items = vec![ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul(name) }]; + if let Some(set) = jstr(p, "set") { + items.push(ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul(set) }); + } + d = d.with(key, D::Reference(items)); + } + } + Some(d) +} + /// `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") } @@ -1712,9 +1776,36 @@ fn play_to(_: &str, p: &Value) -> Option { } fn fill_from(d: &Descriptor) -> Mapped { - if d.get("From").is_some() { - // The Paint Bucket tool's click, not Edit › Fill. - return Mapped::Unsupported { harmless: false }; + if let Some(from) = get_obj(d, "From") { + // The Paint Bucket tool's click: its point is in pixels, or in percent of the canvas + // (not replayable without the document size). + let in_px = |k: &str| matches!(from.get(k), Some(D::UnitFloat { unit: PX, .. })); + let (Some(x), Some(y)) = (get_num(from, "Hrzn"), get_num(from, "Vrtc")) else { return Mapped::Unsupported { harmless: false } }; + if !in_px("Hrzn") || !in_px("Vrtc") { + return Mapped::Unsupported { harmless: false }; + } + let mut p = json!({"x": x.round(), "y": y.round()}); + match get_enum(d, "Usng").as_deref() { + Some("FrgC") | None => {} + Some("Pttn") => p["contents"] = json!("pattern"), + Some(_) => return Mapped::Unsupported { harmless: false }, + } + if let Some(t) = get_num(d, "Tlrn") { + p["tolerance"] = json!(t); + } + if let Some(c) = get_bool(d, "Cntg") { + p["contiguous"] = json!(c); + } + if let Some(a) = get_bool(d, "AntA") { + p["antiAlias"] = json!(a); + } + if let Some(m) = get_bool(d, "Mrgd") { + p["sampleAllLayers"] = json!(m); + } + if let Some(o) = get_num(d, "Opct") { + p["opacity"] = json!(o); + } + return step("paint.bucket", p); } let Some(contents) = get_enum(d, "Usng").and_then(|u| lookup(FILL_CONTENTS, &u)) else { return Mapped::Unsupported { harmless: false } }; let mut p = json!({"contents": contents}); @@ -1739,7 +1830,20 @@ fn fill_from(d: &Descriptor) -> Mapped { step("edit.fill", p) } -fn fill_to(_: &str, p: &Value) -> Option { +fn fill_to(id: &str, p: &Value) -> Option { + if id == "paint.bucket" { + let mut d = desc("null").with("From", D::Descriptor(desc("Pnt ").with("Hrzn", px(jnum(p, "x")?)).with("Vrtc", px(jnum(p, "y")?)))); + d = d.with("Tlrn", long(jnum(p, "tolerance").unwrap_or(32.0))).with("AntA", D::Boolean(jbool(p, "antiAlias").unwrap_or(true))); + d = d.with("Usng", enm("FlCn", if jstr(p, "contents") == Some("pattern") { "Pttn" } else { "FrgC" })); + if let Some(o) = jnum(p, "opacity") { + d = d.with("Opct", pct(o)); + } + d = d.with("Cntg", D::Boolean(jbool(p, "contiguous").unwrap_or(true))); + if let Some(m) = jbool(p, "sampleAllLayers") { + d = d.with("Mrgd", D::Boolean(m)); + } + return Some(d); + } let contents = jstr(p, "contents").unwrap_or("color"); let mut d = desc("null").with("Usng", enm("FlCn", lookup_back(FILL_CONTENTS, contents)?)); if contents == "color" { @@ -1935,16 +2039,25 @@ fn canvas_size_to(_: &str, p: &Value) -> Option { fn image_size_from(d: &Descriptor) -> Mapped { let mut p = Map::new(); + let mut percent: Option = None; for (k, param) in [("Wdth", "width"), ("Hght", "height")] { match d.get(k) { Some(D::UnitFloat { unit: PX, value }) => { p.insert(param.into(), value.round().into()); } - // Percent or physical units need the document's size: not replayable headlessly. + // Percent scales both sides at playback; two different percentages, or physical + // units, need the document's size and resolution and stay unsupported. + Some(D::UnitFloat { unit: PCT, value }) => match percent { + Some(other) if (other - value).abs() > 1e-9 => return Mapped::Unsupported { harmless: false }, + _ => percent = Some(*value), + }, Some(_) => return Mapped::Unsupported { harmless: false }, None => {} } } + if let Some(pc) = percent { + p.insert("percent".into(), pc.into()); + } if let Some(r) = get_num(d, "Rslt") { p.insert("resolution".into(), r.into()); } @@ -1955,7 +2068,7 @@ fn image_size_from(d: &Descriptor) -> Mapped { } None => return Mapped::Unsupported { harmless: false }, } - } else if p.contains_key("resolution") && !p.contains_key("width") && !p.contains_key("height") { + } else if p.contains_key("resolution") && !p.contains_key("width") && !p.contains_key("height") && !p.contains_key("percent") { p.insert("resample".into(), "none".into()); } if p.is_empty() { @@ -1966,6 +2079,9 @@ fn image_size_from(d: &Descriptor) -> Mapped { fn image_size_to(_: &str, p: &Value) -> Option { let mut d = desc("null"); + if let Some(pc) = jnum(p, "percent") { + d = d.with("Wdth", pct(pc)).with("Hght", pct(pc)).with("CnsP", D::Boolean(true)); + } if let Some(w) = jnum(p, "width") { d = d.with("Wdth", px(w)); } @@ -2459,7 +2575,7 @@ const EVENTS: &[Event] = &[ ev!("Grow", "grow", "Grow", "select.grow", &[F("Tlrn", "tolerance", Kind::Long)]), ev!("Smlr", "similar", "Similar", "select.similar", &[F("Tlrn", "tolerance", Kind::Long)]), // Edit. - Event { code: "Fl ", name: "fill", label: "Fill", ids: &["edit.fill"], from: fill_from, to: fill_to }, + Event { code: "Fl ", name: "fill", label: "Fill", ids: &["edit.fill", "paint.bucket"], from: fill_from, to: fill_to }, ev!("Strk", "stroke", "Stroke", "edit.stroke", STROKE), ev!("copy", "copyEvent", "Copy", "edit.copy", &[]), ev!("CpyM", "copyMerged", "Copy Merged", "edit.copyMerged", &[]), @@ -2494,6 +2610,9 @@ const EVENTS: &[Event] = &[ // File and actions. 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 }, + Event { code: "Plc ", name: "placeEvent", label: "Place", ids: &["file.placeEmbedded", "file.placeLinked"], from: place_from, to: place_to }, + Event { code: "Opn ", name: "open", label: "Open", ids: &["file.openAs"], from: open_from, to: open_to }, + Event { code: "", name: "conditional", label: "If", ids: &["actions.conditional"], from: conditional_from, to: conditional_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 }, @@ -2630,7 +2749,7 @@ fn export_item(id: &str, params: &Value, set: &str) -> Option { return Some(AtnItem { event, dictionary: jstr(params, "name").unwrap_or("").to_string(), descriptor, ..Default::default() }); } let mut params = params.clone(); - if id == "actions.play" && params.get("set").is_none() { + if (id == "actions.play" || id == "actions.conditional") && params.get("set").is_none() { params["set"] = json!(set); } let (e, descriptor) = event_and_descriptor(id, ¶ms)?; diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 6d6f7bb68e..fc73a18412 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -286,6 +286,13 @@ fn every_mapped_command_exports_and_imports_back() { ("layer.smartObjects.convertToSmartObject", json!({})), ("layer.smartObjects.editContents", json!({})), ("layer.smartObjects.updateModifiedContent", json!({})), + ("paint.bucket", json!({"x": 10, "y": 20, "tolerance": 32, "antiAlias": true, "contiguous": false, "opacity": 100})), + ("image.imageSize", json!({"percent": 200, "resample": "bicubicAutomatic"})), + ("file.placeEmbedded", json!({"path": "/tmp/logo.png", "scale": 50})), + ("file.placeLinked", json!({"path": "/tmp/logo.png"})), + ("file.openAs", json!({"path": "/tmp/a.psd"})), + ("actions.conditional", json!({"if": "hasLayers", "then": "FS8-Layers", "else": "FS8-No-Layers", "set": "S"})), + ("actions.conditional", json!({"if": "landscape", "then": "Wide", "else": null, "set": "S"})), ("actions.play", json!({"action": "Other", "set": "S"})), (STOP_MESSAGE, json!({"message": "Hi", "allowContinue": false})), ]; @@ -303,7 +310,7 @@ fn every_mapped_command_exports_and_imports_back() { assert_eq!(steps[0].0, *id, "{id} came back as another command"); let mut back = steps[0].1.clone(); normalise(&mut back); - if *id == "actions.play" { + if *id == "actions.play" || *id == "actions.conditional" { back["set"] = json!("S"); } assert_eq!(back, *params, "{id}"); @@ -401,10 +408,15 @@ fn every_mapped_command_exports_and_imports_back() { #[test] fn unsupported_shapes_become_photoshop_steps_not_errors() { - // Percent image size, a Paint Bucket fill, a move in points, an unknown adjustment class. + // Image size in two different percentages, a Paint Bucket click in percent of the canvas, a + // move in points, an unknown adjustment class. let items = vec![ - item("ImgS", "Image Size", Some(desc("null").with("Wdth", pct(200.0)))), - item("Fl ", "Fill", Some(desc("null").with("From", D::Descriptor(desc("Pnt "))).with("Usng", enm("FlCn", "FrgC")))), + item("ImgS", "Image Size", Some(desc("null").with("Wdth", pct(200.0)).with("Hght", pct(150.0)))), + item( + "Fl ", + "Fill", + Some(desc("null").with("From", D::Descriptor(desc("Pnt ").with("Hrzn", pct(0.0)).with("Vrtc", pct(0.0)))).with("Usng", enm("FlCn", "FrgC"))), + ), item( "move", "Move", @@ -662,6 +674,62 @@ fn remaining_events_import_in_their_photoshop_shapes() { assert_eq!(steps[13].1, json!({"points": [[1, 2], [30, 2], [15, 40]]})); assert_eq!(imp.unsupported.len(), 2); + // The frequency-separation set's own conditional, a percent Image Size, a Paint Bucket + // click in pixels (percent stays unsupported) and a Place. + let cond = desc("null") + .with("null", enm("Cndt", "Lyrs")) + .with( + "then", + D::Reference(vec![ + ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul("FS8-Layers") }, + ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul("FS") }, + ]), + ) + .with("else", D::Reference(vec![ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul("FS8-No-Layers") }])); + let size = desc("null") + .with("Wdth", pct(200.0)) + .with("scaleStyles", D::Boolean(true)) + .with("CnsP", D::Boolean(true)) + .with("Intr", enm("Intp", "bicubicAutomatic")); + let bucket_px = desc("null") + .with("From", D::Descriptor(desc("Pnt ").with("Hrzn", px(0.0)).with("Vrtc", px(0.0)))) + .with("Tlrn", long(32.0)) + .with("Usng", enm("FlCn", "FrgC")) + .with("Cntg", D::Boolean(false)); + let bucket_pct = desc("null") + .with("From", D::Descriptor(desc("Pnt ").with("Hrzn", pct(0.0)).with("Vrtc", pct(0.0)))) + .with("Tlrn", long(32.0)) + .with("Usng", enm("FlCn", "FrgC")); + let place = desc("null") + .with("null", path_value("/Users/me/logo.png")) + .with("FTcs", enm("QCSt", "Qcsa")) + .with("Wdth", pct(50.0)) + .with("Hght", pct(50.0)) + .with("Lnkd", D::Boolean(false)); + let items = vec![ + item("conditional", "If", Some(cond)), + item("imageSize", "Image Size", Some(size)), + item("fill", "Fill", Some(bucket_px)), + item("fill", "Fill", Some(bucket_pct)), + item("placeEvent", "Place", Some(place)), + item("Opn ", "Open", Some(desc("null").with("null", path_value("/Users/me/in.psd")))), + ]; + let file = AtnFile { + name: UnicodeString::new_nul("Rest"), + expanded: false, + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], + warnings: vec![], + }; + let rest = import(&atn::write(&file).unwrap()).unwrap(); + let rest_steps = &rest.actions[0].steps; + let rest_ids: Vec<&str> = rest_steps.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(rest_ids, ["actions.conditional", "image.imageSize", "paint.bucket", PHOTOSHOP_STEP, "file.placeEmbedded", "file.openAs"]); + assert_eq!(rest_steps[0].1, json!({"if": "hasLayers", "then": "FS8-Layers", "else": "FS8-No-Layers"})); + assert_eq!(rest_steps[1].1, json!({"percent": 200, "resample": "bicubicAutomatic"})); + assert_eq!(rest_steps[2].1, json!({"x": 0, "y": 0, "tolerance": 32, "contiguous": false})); + assert_eq!(rest_steps[4].1, json!({"path": "/Users/me/logo.png", "scale": 50})); + assert_eq!(rest_steps[5].1, json!({"path": "/Users/me/in.psd"})); + // 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); diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 84474d57d1..d26d87e517 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -165,8 +165,14 @@ fn image_size(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let (ow, oh) = (d.doc.size.width, d.doc.size.height); let resample = parse_resample(p.get("resample").and_then(Value::as_str).unwrap_or("bicubic")); - let pw = p.get("width").and_then(Value::as_f64); - let ph = p.get("height").and_then(Value::as_f64); + let mut pw = p.get("width").and_then(Value::as_f64); + let mut ph = p.get("height").and_then(Value::as_f64); + // `percent` scales both sides (what a Photoshop action records as "200%"); an explicit side wins. + if let Some(pc) = p.get("percent") { + let pc = pc.as_f64().filter(|v| v.is_finite() && *v > 0.0).ok_or_else(|| bad("image.imageSize", "`percent` must be a positive number"))?; + pw = pw.or(Some(ow as f64 * pc / 100.0)); + ph = ph.or(Some(oh as f64 * pc / 100.0)); + } // Missing dimension keeps the aspect ratio. let (nw, nh) = match (pw, ph) { (Some(w), Some(h)) => (w, h), @@ -713,7 +719,7 @@ pub fn specs() -> Vec { "image.imageSize", "Image Size…", ["Image"], - r##"{"width":px,"height":px,"resolution":ppi,"resample":"automatic|preserveDetails|bicubicSmoother|bicubicSharper|bicubicAutomatic|bicubic|nearest|bilinear|lanczos|none"="bicubic"} → {width,height} (the document's size after the command; "none" changes the resolution only)"##, + r##"{"width":px,"height":px,"percent":%? (both sides; an explicit side wins),"resolution":ppi,"resample":"automatic|preserveDetails|bicubicSmoother|bicubicSharper|bicubicAutomatic|bicubic|nearest|bilinear|lanczos|none"="bicubic"} → {width,height} (the document's size after the command; "none" changes the resolution only)"##, has_doc, image_size ), diff --git a/crates/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index 997770b5f6..7902a39bba 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -248,6 +248,11 @@ fn step_label(id: &str, params: &Value) -> String { if id == "actions.play" { let target = params.get("action").map(|v| v.as_str().map(str::to_owned).unwrap_or_else(|| v.to_string())).unwrap_or_default(); format!("{}: {target}", label_of(id)) + } else if id == "actions.conditional" { + // "If hasLayers: FS8-Layers / FS8-No-Layers" + let cond = params.get("if").and_then(Value::as_str).unwrap_or("?"); + let branch = |k: &str| params.get(k).and_then(Value::as_str).unwrap_or("–").to_string(); + format!("{}: {cond} → {} / {}", label_of(id), branch("then"), branch("else")) } else if id == photocraft_engine::atn_cmds::STOP_MESSAGE { format!("{}: {}", label_of(id), params.get("message").and_then(Value::as_str).unwrap_or_default()) } else if id == photocraft_engine::atn_cmds::PHOTOSHOP_STEP { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b775728aff..f23745efdb 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -535,7 +535,10 @@ step with its dialog on opens that command's dialog seeded with the recorded val the play result carries `paused: {step, id, dialog}`, OK runs the dialog's values and continues from the next step, Cancel stops the action. Agents (`automation_input`) play headlessly with the recorded values. The panel menu's **Insert Stop…** adds an `actions.stopMessage` step after the -selected step (`actions.insert` takes any command id and params). +selected step (`actions.insert` takes any command id and params). `actions.conditional {if, then, +else}` is Photoshop's Insert Conditional as a step: it tests the document or active layer +(`always`, `landscape`, `modeIsRGB`, `hasLayers`, `isBackground`, `hasPixelMask`… see its params) +and plays the matching action by name, failing the caller when that action fails. Layer-panel **Quick Export as PNG** exports every selected layer as a separate PNG. A single Save dialog chooses the first filename; remaining files are numbered siblings with sanitized diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index bc892c0ed3..d6e3bef284 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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` (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) | +| Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (108 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 | | diff --git a/docs/gaps.md b/docs/gaps.md index c5e1172415..d18a8655c2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -89,12 +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 - mapped events (Place, Image Size in percent, paths, conditionals, Generative Fill…) load as - kept Photoshop steps that fail at playback by name. + mapped events (paths, scripts, Generative Fill, offsets in points…) 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`; 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 + and `actions.load`/`actions.save`; 108 Photoshop events map to commands both ways (Transform, + Apply Image, Save for Web, smart-object edits, Gradient Map, Quick Mask, polygon selections, + Place, Open, Image Size in percent, the Paint Bucket and Insert Conditional since the third + and fourth PRs), Play, Stop and conditional 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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ae076b3208..b5e8dd8a04 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -66,7 +66,7 @@ architecture and the PSD writer are the serial parts. | Filters and adjustments | 10% | every Filter and Image › Adjustments item live; Filter Gallery 47 looks | 100% | measured (menu catalog) | | File formats (usage-weighted) | 15% | 13 of 31 Photoshop formats supported in Photoshop's directions, 4 partial; the common ones (PSD/PSB, TIFF, JPEG, PNG, WebP) all present | ~80% (48% by count) | measured list, estimated weights ([file-format-parity.md](file-format-parity.md)) | | AI / generative | 10% | classical Select Subject/Sky, Remove tool, Content-Aware; no Generative Fill/Expand, Neural Filters, Sky Replacement, Super Zoom | ~15% | estimated | -| Ecosystem (plug-ins, scripting, libraries) | 10% | WebAssembly plug-ins, CLI, MCP, actions, `.atn` load/save (105 events); no .8BF, UXP/ExtendScript, Libraries | ~20% | estimated | +| Ecosystem (plug-ins, scripting, libraries) | 10% | WebAssembly plug-ins, CLI, MCP, actions, `.atn` load/save (108 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, 105 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, 108 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 (105 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 (108 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 |