From 74ad8ee5bc68929fd2a332d2340eef155ee6e1e1 Mon Sep 17 00:00:00 2001 From: Fabio Lissi Date: Mon, 12 Oct 2026 16:51:45 -0500 Subject: [PATCH] Pin a corpus of real Photoshop Actions sets and test the .atn parser and import on it (#219) - corpus/atn: 16 sets from seven public repositories (MIT, BSD, Unlicense), fetched at pinned commits by cargo xtask corpus --atn, verified against xtask/atn-corpus.sha256, licences next to the files, rows in ATTRIBUTION.md and docs/development.md. photocraft-corpus takes only our own generated files, so third-party sets are pins here, like psd-tools and ag-psd. - photocraft-psd corpus_atn: every set parses and rewrites byte for byte (floor 16 files). photocraft-engine corpus_atn: every set loads, saves and reloads to the same actions; the steps still kept as Photoshop steps are capped (517 of 5 296 measured; generative AI and scripts), and the first differing step is named on a mismatch. - What the corpus found: localised panel names are platform legacy bytes (Mac Roman), now kept verbatim with AtnItem::label from the descriptor's Unicode class name; exporters emitted Photoshop defaults the importer had not read (now symmetric); setAdjustment carries its kind; and 300 more steps map: transform without a reference, selection nudges, moves to the bottom, View menu items, toolbox colours, clipping masks, ungroup, copy/paste styles, collapse groups, layer comps. New example atn_roundtrip shows the first differing byte. --- ATTRIBUTION.md | 1 + crates/engine/src/atn_cmds.rs | 209 ++++++++++++++++++++------- crates/engine/src/atn_cmds/tests.rs | 65 ++++++++- crates/engine/tests/corpus_atn.rs | 89 ++++++++++++ crates/psd/examples/atn_dump.rs | 4 +- crates/psd/examples/atn_roundtrip.rs | 54 +++++++ crates/psd/src/atn.rs | 82 ++++++++--- crates/psd/tests/corpus_atn.rs | 59 ++++++++ docs/development.md | 7 + xtask/atn-corpus.sha256 | 16 ++ xtask/src/corpus.rs | 9 +- xtask/src/corpus_pins.rs | 69 ++++++++- xtask/src/main.rs | 2 +- 13 files changed, 587 insertions(+), 79 deletions(-) create mode 100644 crates/engine/tests/corpus_atn.rs create mode 100644 crates/psd/examples/atn_roundtrip.rs create mode 100644 crates/psd/tests/corpus_atn.rs create mode 100644 xtask/atn-corpus.sha256 diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 227aee8ac9..369aa638fe 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -111,6 +111,7 @@ next to the files: | `corpus/heif/` (9 files, 0.1 MB) | HEIC/HEIF test files: `heic-rs/` checkerboards, RGB strips and a grid-tiled photo with EXIF and XMP (synthetic pixels encoded by macOS `sips`, each `.ref.png` Apple's decode); `pillow-heif/` the 10-bit RGBA `RGBA_10__29x100.heif` and its source `RGBA_16__29x100.png` | Thomas Braun (heic-rs); Pillow-Heif contributors | [heic-rs `tests/fixtures`](https://github.com/tbraun96/heic-rs/tree/main/tests/fixtures), [pillow-heif `tests/images`](https://github.com/bigcat88/pillow_heif/tree/master/tests/images) | MIT OR Apache-2.0 (heic-rs); BSD-3-Clause (pillow-heif) | | `corpus/exr/` (5 files, 2.3 MB) | The deep OpenEXR test images `11`, `42`, `64`, `multivariate` and `objectid.deep.exr` | Contributors to the OpenEXR Project | [openexr `src/test/bin/test_images`](https://github.com/AcademySoftwareFoundation/openexr/tree/main/src/test/bin/test_images) | BSD-3-Clause | | `corpus/affinity/` (60 files) | Public Affinity documents and PNG references: `vector-art/` (9 documents), `afdesignload/` (9), `jac21/` (2), `asset-store-template/` (1), and `affinity-samples/` (18 CC0 documents plus exported references) | samuel-etver; Nick Beeuwsaert; Jeremy Cantu; DAIKI (eviltwo); satoshoe-dev | [vector-art `simple/`](https://github.com/samuel-etver/vector-art/tree/255f8add3c8f0740196e22bd59502b811b532f0b/simple), [AFDesignLoad `testDesigns/`](https://github.com/NickBeeuwsaert/AFDesignLoad/tree/a18dd50a7079fb861a28835eeefa0d015f03f4a1/testDesigns), [Jac21/Branding `Logos/JC/DesignerFiles/Affinity`](https://github.com/Jac21/Branding/tree/57ae3f45bf4637f6927c0b07de25b922c55f944f/Logos/JC/DesignerFiles/Affinity), [AssetStoreTemplate `AssetStoreTemplate/`](https://github.com/eviltwo/AssetStoreTemplate/tree/f671981ee89d15526285b3cc87b4393d3defe644/AssetStoreTemplate), [affinity-samples](https://github.com/satoshoe-dev/affinity-samples/tree/96aca6bd86bbe7a677f1cf0630bd832d23943be8) | CC0-1.0 (vector-art and affinity-samples); MIT, Copyright (c) 2015 Nick Beeuwsaert; MIT, Copyright (c) 2018 Jeremy Cantu; MIT, Copyright (c) 2024 DAIKI | +| `corpus/atn/` (16 files, 0.9 MB) | Photoshop Actions sets, one folder per repository: `sbaril/` (Frequency Separation, Firefly, Dimension, Harmonize, Photo, Quick Object Extraction, Generative Upscale), `bjango/` (Bjango Actions, Adjustment Actions, Scale Actions), `bkwld/` (Save layers for web), `atntools/` (test.atn), `trimmer/` (Trimmer), `alpha-utility/` (AlphaUtilityAdditions), `gif-creator/` (BannerActions) | Stéphane Baril; Bjango and Marc Edwards; BKWLD; Maurits Rijk; damsmcfly; UCLA Game Lab; overbyte | [sbaril/Photoshop-Scripts](https://github.com/sbaril/Photoshop-Scripts), [bjango/Bjango-Actions](https://github.com/bjango/Bjango-Actions), [BKWLD/photoshop-actions](https://github.com/BKWLD/photoshop-actions), [mrijk/atntools](https://github.com/mrijk/atntools), [damsmcfly/photoshop-cleaner-trimmer](https://github.com/damsmcfly/photoshop-cleaner-trimmer), [uclagamelab/AlphaUtilityAdditions](https://github.com/uclagamelab/AlphaUtilityAdditions), [overbyte/photoshop-gif-creator-script](https://github.com/overbyte/photoshop-gif-creator-script), pinned in [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | MIT (sbaril, BKWLD, atntools, gif-creator), BSD (bjango, per `Help/License.md`), BSD-2-Clause (alpha-utility), Unlicense (trimmer); each licence file sits next to its files | | `corpus/pngsuite/` | PngSuite | Willem van Schaik | | Public domain | | `corpus/pixls/` (7 files, 226 MB) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked | diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 391c21abd8..86e8075dd6 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -121,6 +121,7 @@ enum Target { Property(String), Class, Offset(i32), + Index(u32), Other, } @@ -131,6 +132,7 @@ fn target(r: &[ReferenceItem]) -> Target { Some(ReferenceItem::Property { key, .. }) => Target::Property(trimmed(key)), Some(ReferenceItem::Class(_)) => Target::Class, Some(ReferenceItem::Offset { offset, .. }) => Target::Offset(*offset), + Some(ReferenceItem::Index { index, .. }) => Target::Index(*index), _ => Target::Other, } } @@ -607,24 +609,30 @@ fn hue_entry(p: &Value, range: Option<(usize, &Value)>) -> Option { } } for (key, param) in [("Hue ", "hue"), ("Strt", "saturation"), ("Lght", "lightness")] { - e = e.with(key, long(jnum(p, param).unwrap_or(0.0))); + if let Some(v) = jnum(p, param) { + e = e.with(key, long(v)); + } } Some(e) } +// Exporters write only what the importer read, so a loaded set saves and reloads to the same +// steps; Photoshop fills in its own defaults for what is missing. fn hue_sat_to(p: &Value) -> Option { - let mut entries = vec![D::Descriptor(hue_entry(p, None)?)]; + let mut d = desc("HStr").with("presetKind", enm("presetKindType", "presetKindCustom")); + if let Some(c) = jbool(p, "colorize") { + d = d.with("Clrz", D::Boolean(c)); + } + let mut entries = Vec::new(); + if ["hue", "saturation", "lightness"].iter().any(|k| p.get(*k).is_some()) { + entries.push(D::Descriptor(hue_entry(p, None)?)); + } for (i, name) in HUE_RANGES.iter().enumerate() { if let Some(r) = p.get(*name).filter(|v| v.is_object()) { entries.push(D::Descriptor(hue_entry(r, Some((i, r)))?)); } } - Some( - desc("HStr") - .with("presetKind", enm("presetKindType", "presetKindCustom")) - .with("Clrz", D::Boolean(jbool(p, "colorize").unwrap_or(false))) - .with("Adjs", D::List(entries)), - ) + Some(d.with("Adjs", D::List(entries))) } fn triple(d: &Descriptor, k: &str) -> Option { @@ -653,9 +661,14 @@ fn color_balance_from(d: &Descriptor) -> Option { fn color_balance_to(p: &Value) -> Option { let mut d = desc("ClrB"); for (key, param) in [("ShdL", "shadows"), ("MdtL", "midtones"), ("HghL", "highlights")] { - d = d.with(key, triple_to(p, param).unwrap_or_else(|| D::List(vec![long(0.0), long(0.0), long(0.0)]))); + if let Some(t) = triple_to(p, param) { + d = d.with(key, t); + } } - Some(d.with("PrsL", D::Boolean(jbool(p, "preserveLuminosity").unwrap_or(true)))) + if let Some(b) = jbool(p, "preserveLuminosity") { + d = d.with("PrsL", D::Boolean(b)); + } + Some(d) } const BW: &[F] = &[ @@ -694,7 +707,10 @@ fn channel_mixer_to(p: &Value) -> Option { d = d.with(key, D::Descriptor(desc("ChMx").with("Rd ", pct(v(0))).with("Grn ", pct(v(1))).with("Bl ", pct(v(2))).with("Cnst", pct(v(3))))); } } - Some(d.with("Mnch", D::Boolean(jbool(p, "monochrome").unwrap_or(false)))) + if let Some(m) = jbool(p, "monochrome") { + d = d.with("Mnch", D::Boolean(m)); + } + Some(d) } const SELECTIVE_RANGES: &[(&str, &str)] = &[ @@ -733,12 +749,11 @@ fn selective_to(p: &Value) -> Option { )); } } - Some( - desc("SlcC") - .with("presetKind", enm("presetKindType", "presetKindCustom")) - .with("Mthd", enm("CrcM", if jstr(p, "method") == Some("absolute") { "Absl" } else { "Rltv" })) - .with("ClrC", D::List(list)), - ) + let mut d = desc("SlcC").with("presetKind", enm("presetKindType", "presetKindCustom")); + if let Some(m) = jstr(p, "method") { + d = d.with("Mthd", enm("CrcM", if m == "absolute" { "Absl" } else { "Rltv" })); + } + Some(d.with("ClrC", D::List(list))) } /// Gradient Map: the gradient's colour stops at 0..4096 → `stops: [[0..1, "#rrggbb"], …]`. @@ -787,12 +802,14 @@ fn gradient_map_to(p: &Value) -> Option { .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))), - ) + let mut d = desc("GdMp").with("Grad", D::Descriptor(grad)); + if let Some(r) = jbool(p, "reverse") { + d = d.with("Rvrs", D::Boolean(r)); + } + if let Some(dd) = jbool(p, "dither") { + d = d.with("Dthr", D::Boolean(dd)); + } + Some(d) } /// An adjustment: its Photoshop class (also the direct event's code), the command suffix (`image.adjustments.`, `layer.newAdjustmentLayer.`), and @@ -989,7 +1006,7 @@ fn make_from(d: &Descriptor) -> Mapped { let id = if get_bool(d, "Cpy ") == Some(true) { "layer.new.layerViaCopy" } else { "layer.new.layerViaCut" }; let mut v = vec![(id, json!({}))]; if let Some(n) = get_text(nw, "Nm ") { - v.push(("layer.renameLayer", json!({"name": n}))); + v.push(("layer.setProps", json!({"name": n}))); } return steps(v); } @@ -1227,6 +1244,11 @@ fn set_from(d: &Descriptor) -> Mapped { } steps(v) } + // The toolbox colours. + (Some("Clr"), Target::Property(p)) if p == "FrgC" || p == "BckC" => match get_obj(d, "T ").and_then(color_hex) { + Some(hex) => step("tools.setColors", json!({(if p == "FrgC" { "foreground" } else { "background" }): hex})), + None => unsupported, + }, (Some("Lyr"), _) => { let Some(mut p) = layer_target(r) else { return unsupported }; let Some(t) = get_obj(d, "T ") else { return unsupported }; @@ -1268,7 +1290,11 @@ fn set_from(d: &Descriptor) -> Mapped { (Some("AdjL"), Target::Ordinal(o)) if o == "Trgt" => { match get_obj(d, "T ").and_then(|t| adjustment_by_class(&trimmed(&t.class_id)).map(|a| (a, t))) { Some((a, t)) => match (a.from)(t) { - Some(p) => step("layer.setAdjustment", p), + // `kind` names the adjustment so Save Actions can write the class back. + Some(mut p) => { + p["kind"] = json!(a.suffix); + step("layer.setAdjustment", p) + } None => unsupported, }, None => unsupported, @@ -1308,6 +1334,17 @@ fn set_to(id: &str, p: &Value) -> Option { } desc("null").with("null", quick_mask_ref()) } + "tools.setColors" => { + // One colour per step: the foreground when given, else the background. + let (key, hex) = match (jstr(p, "foreground"), jstr(p, "background")) { + (Some(f), _) => ("FrgC", f), + (None, Some(b)) => ("BckC", b), + (None, None) => return None, + }; + desc("null") + .with("null", D::Reference(vec![ReferenceItem::Property { class: class("Clr "), key: code(key) }])) + .with("T ", D::Descriptor(color_desc(hex)?)) + } "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" }; @@ -1400,10 +1437,18 @@ fn select_from(d: &Descriptor) -> Mapped { } } (Some(c), Target::Class) if is_tool_class(c) => Mapped::Unsupported { harmless: true }, + // A menu item chosen while recording (`Mn`): the View menu's zoom items replay. + (Some("Mn"), Target::Ordinal(item)) => match lookup(MENU_ITEMS, &item) { + Some(id) => none(id), + None => Mapped::Unsupported { harmless: false }, + }, _ => Mapped::Unsupported { harmless: false }, } } +/// `MnIt` menu-item codes ↔ the shell's view commands. +const MENU_ITEMS: &[(&str, &str)] = &[("FtOn", "view.fitOnScreen"), ("ActP", "view.actualPixels"), ("ZmIn", "view.zoomIn"), ("ZmOt", "view.zoomOut")]; + fn select_to(id: &str, p: &Value) -> Option { let ord = |o: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code(o) }]); let enumerated = |cls: &str, ty: &str, v: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class(cls), type_id: code(ty), value: code(v) }]); @@ -1434,7 +1479,10 @@ fn select_to(id: &str, p: &Value) -> Option { "layer.addBelowToSelection" => desc("null").with("null", ord("Bckw")).with("selectionModifier", enm("selectionModifierType", "addToSelection")), "layer.selectTop" => desc("null").with("null", ord("Frnt")), "layer.selectBottom" => desc("null").with("null", ord("Back")), - _ => return None, + _ => { + let item = lookup_back(MENU_ITEMS, id)?; + desc("null").with("null", D::Reference(vec![ReferenceItem::Enumerated { class: class("Mn "), type_id: code("MnIt"), value: code(item) }])) + } }) } @@ -1550,7 +1598,9 @@ fn close_to(_: &str, _: &Value) -> Option { /// 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 } }; + // No reference (a smart object replaced in place, `replaceLayer`) means the target layer. + let target_layer = [ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code("Trgt") }]; + let r: &[ReferenceItem] = get_ref(d, "null").unwrap_or(&target_layer); let unsupported = Mapped::Unsupported { harmless: false }; let mut p = Map::new(); if let Some(o) = get_obj(d, "Ofst") { @@ -1695,7 +1745,9 @@ fn apply_image_to(_: &str, p: &Value) -> Option { 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(b) = jstr(p, "blending") { + with = with.with("Clcl", enm("Clcn", lookup_back(CALC_BLEND, b)?)); + } if let Some(v) = jnum(p, "opacity") { with = with.with("Opct", pct(v)); } @@ -1746,15 +1798,14 @@ fn export_from(d: &Descriptor) -> Mapped { fn export_to(_: &str, p: &Value) -> Option { 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))); + let mut u = desc("SaveForWeb").with("Op ", enm("SWOp", "OpSa")); + if let Some(f) = jstr(p, "format") { + u = u.with("Fmt ", enm("IRFm", lookup_back(WEB_FORMATS, f)?)); + } + for (key, param) in [("Intr", "interlaced"), ("Trns", "transparency"), ("EICC", "embedIcc"), ("Prgr", "progressive"), ("Optm", "optimized")] { + if let Some(b) = jbool(p, param) { + u = u.with(key, D::Boolean(b)); + } } if let Some(q) = jnum(p, "quality") { u = u.with("Qlty", long(q)); @@ -1764,14 +1815,15 @@ fn export_to(_: &str, p: &Value) -> Option { } match jstr(p, "matte") { Some("none") => u = u.with("Mtt ", D::Boolean(false)), - matte => { - let c = color_desc(matte.unwrap_or("#ffffff"))?; + Some(matte) => { + let c = color_desc(matte)?; 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)?); } + None => {} } Some(desc("null").with("Usng", D::Descriptor(u)).with("In ", path_value(path))) } @@ -1808,6 +1860,16 @@ fn visibility_to(_: &str, p: &Value) -> Option { fn move_from(d: &Descriptor) -> Mapped { let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; + // The selection outline nudged. + if ref_class(r).as_deref() == Some("Chnl") && matches!(target(r), Target::Property(p) if p == "fsel") { + return match get_obj(d, "T ") { + Some(t) if matches!(t.get("Hrzn"), Some(D::UnitFloat { unit: PX, .. })) => match (get_num(t, "Hrzn"), get_num(t, "Vrtc")) { + (Some(dx), Some(dy)) => step("select.transformSelection", json!({"dx": dx.round(), "dy": dy.round()})), + _ => Mapped::Unsupported { harmless: false }, + }, + _ => Mapped::Unsupported { harmless: false }, + }; + } if ref_class(r).as_deref() != Some("Lyr") { return Mapped::Unsupported { harmless: false }; } @@ -1843,6 +1905,8 @@ fn move_from(d: &Descriptor) -> Mapped { Target::Ordinal(o) if o == "Prvs" => "layer.arrange.sendBackward", Target::Offset(1) => "layer.arrange.bringForward", Target::Offset(-1) => "layer.arrange.sendBackward", + // Index 0 is the bottom of the stack. + Target::Index(0) => "layer.arrange.sendToBack", _ => return Mapped::Unsupported { harmless: false }, }; step(id, Value::Object(p)) @@ -1987,12 +2051,19 @@ fn fill_from(d: &Descriptor) -> Mapped { 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))); + if let Some(t) = jnum(p, "tolerance") { + d = d.with("Tlrn", long(t)); + } + if let Some(a) = jbool(p, "antiAlias") { + d = d.with("AntA", D::Boolean(a)); + } 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(c) = jbool(p, "contiguous") { + d = d.with("Cntg", D::Boolean(c)); + } if let Some(m) = jbool(p, "sampleAllLayers") { d = d.with("Mrgd", D::Boolean(m)); } @@ -2003,7 +2074,12 @@ fn fill_to(id: &str, p: &Value) -> Option { if contents == "color" { d = d.with("Clr ", D::Descriptor(color_desc(jstr(p, "color")?)?)); } - d = d.with("Opct", pct(jnum(p, "opacity").unwrap_or(100.0))).with("Md ", enm("BlnM", blend_ps(jstr(p, "mode").unwrap_or("Normal"))?)); + if let Some(o) = jnum(p, "opacity") { + d = d.with("Opct", pct(o)); + } + if let Some(m) = jstr(p, "mode") { + d = d.with("Md ", enm("BlnM", blend_ps(m)?)); + } if let Some(t) = jbool(p, "preserveTransparency") { d = d.with("PrsT", D::Boolean(t)); } @@ -2223,7 +2299,9 @@ fn canvas_size_to(_: &str, p: &Value) -> Option { if let Some(hh) = jnum(p, "height") { d = d.with("Hght", px(hh)); } - d = d.with("Hrzn", enm("HrzL", h)).with("Vrtc", enm("VrtL", v)); + if jstr(p, "anchor").is_some() { + d = d.with("Hrzn", enm("HrzL", h)).with("Vrtc", enm("VrtL", v)); + } if let Some(c) = jstr(p, "extensionColor") { d = match lookup_back(EXTENSION, c) { Some(e) => d.with("canvasExtensionColorType", enm("canvasExtensionColorType", e)), @@ -2325,7 +2403,10 @@ fn crop_to(_: &str, p: &Value) -> Option { if let Some(a) = jnum(p, "angle") { d = d.with("Angl", ang(a)); } - Some(d.with("Dlt ", D::Boolean(jbool(p, "deleteCroppedPixels").unwrap_or(true)))) + if let Some(del) = jbool(p, "deleteCroppedPixels") { + d = d.with("Dlt ", D::Boolean(del)); + } + Some(d) } fn stop_from(d: &Descriptor) -> Mapped { @@ -2733,6 +2814,7 @@ const EVENTS: &[Event] = &[ "select.rect", "select.lasso", "select.editInQuickMaskMode", + "tools.setColors", "layer.setProps", "layer.renameLayer", "layer.setLabelColor", @@ -2758,6 +2840,10 @@ const EVENTS: &[Event] = &[ "channel.target", "edit.undo", "edit.redo", + "view.fitOnScreen", + "view.actualPixels", + "view.zoomIn", + "view.zoomOut", ], from: select_from, to: select_to, @@ -2801,6 +2887,29 @@ const EVENTS: &[Event] = &[ ev!("", "rasterizeLayer", "Rasterize", "layer.rasterize.layer", &[]), ev!("", "selectAllLayers", "Select All Layers", "select.allLayers", &[]), ev!("", "selectNoLayers", "Deselect Layers", "select.deselectLayers", &[]), + ev!("", "groupEvent", "Create Clipping Mask", "layer.createClippingMask", &[]), + ev!("", "ungroupLayersEvent", "Ungroup Layers", "layer.ungroupLayers", &[]), + ev!("", "copyEffects", "Copy Layer Style", "layer.layerStyle.copyLayerStyle", &[]), + ev!("", "pasteEffects", "Paste Layer Style", "layer.layerStyle.pasteLayerStyle", &[]), + Event { + code: "", + name: "collapseAllGroupsEvent", + label: "Collapse All Groups", + ids: &["layer.setExpanded"], + from: |_| step("layer.setExpanded", json!({"all": true, "expanded": false})), + to: |_, p| (jbool(p, "all") == Some(true) && jbool(p, "expanded") == Some(false)).then(|| desc("null")), + }, + Event { + code: "", + name: "applyComp", + label: "Apply Layer Comp", + ids: &["layerComp.apply"], + from: |d| match get_ref(d, "null").map(target) { + Some(Target::Name(n)) => step("layerComp.apply", json!({"comp": n})), + _ => Mapped::Unsupported { harmless: false }, + }, + to: |_, p| Some(desc("null").with("null", name_ref("compsClass", jstr(p, "comp")?))), + }, ev!("Dfrg", "defringe", "Defringe", "layer.matting.defringe", &[F("Wdth", "width", Kind::Px)]), Event { code: "Algn", @@ -2907,7 +3016,7 @@ pub struct Imported { fn photoshop_step(item: &AtnItem) -> (String, Value) { let descriptor = item.descriptor.as_ref().map(|d| photocraft_paint::tile::b64_encode(&d.to_bytes())); - (PHOTOSHOP_STEP.into(), json!({"event": item.event.id(), "name": item.dictionary, "descriptor": descriptor})) + (PHOTOSHOP_STEP.into(), json!({"event": item.event.id(), "name": item.label(), "descriptor": descriptor})) } /// Integral floats become integers (`2.0` → `2`), as the commands' own docs and recordings @@ -2942,7 +3051,7 @@ fn convert_action(a: &AtnAction, set: &str, unsupported: &mut Vec) let (new_steps, enabled) = match mapped { Mapped::Steps(s) => (s, item.enabled), Mapped::Unsupported { harmless } => { - unsupported.push(Unsupported { action: name.clone(), step: i, event: item.event.id(), name: item.dictionary.clone() }); + unsupported.push(Unsupported { action: name.clone(), step: i, event: item.event.id(), name: item.label() }); (vec![photoshop_step(item)], item.enabled && !harmless) } }; @@ -2993,21 +3102,21 @@ fn export_item(id: &str, params: &Value, set: &str) -> Option { let event = jstr(params, "event")?; let event = match event.as_bytes() { [a, b, c, d] => AtnEvent::Code([*a, *b, *c, *d]), - _ => AtnEvent::Name(event.to_string()), + _ => AtnEvent::name(event), }; let descriptor = match jstr(params, "descriptor") { Some(b64) => Some(Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(b64)?).ok()?), None => None, }; - return Some(AtnItem { event, dictionary: jstr(params, "name").unwrap_or("").to_string(), descriptor, ..Default::default() }); + return Some(AtnItem { event, dictionary: jstr(params, "name").unwrap_or("").as_bytes().to_vec(), descriptor, ..Default::default() }); } let mut params = params.clone(); if (id == "actions.play" || id == "actions.conditional") && params.get("set").is_none() { params["set"] = json!(set); } 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() }) + let event = if e.code.is_empty() { AtnEvent::name(e.name) } else { AtnEvent::Code(e.code.as_bytes().try_into().ok()?) }; + Some(AtnItem { event, dictionary: e.label.as_bytes().to_vec(), descriptor: Some(descriptor), ..Default::default() }) } /// The `.atn` bytes of `actions` as the set `set`. `function_keys` gives each action's key diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index bdb839fa30..de4f7efc99 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -6,9 +6,9 @@ use serde_json::json; fn item(event: &str, label: &str, d: Option) -> AtnItem { let event = match event.as_bytes() { [a, b, c, dd] => AtnEvent::Code([*a, *b, *c, *dd]), - _ => AtnEvent::Name(event.into()), + _ => AtnEvent::name(event), }; - AtnItem { event, dictionary: label.into(), descriptor: d, ..Default::default() } + AtnItem { event, dictionary: label.as_bytes().to_vec(), descriptor: d, ..Default::default() } } fn b64(bytes: &[u8]) -> String { @@ -293,6 +293,16 @@ fn every_mapped_command_exports_and_imports_back() { ("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"})), + ("tools.setColors", json!({"foreground": "#102030"})), + ("tools.setColors", json!({"background": "#ffffff"})), + ("view.fitOnScreen", json!({})), + ("view.zoomIn", json!({})), + ("layer.createClippingMask", json!({})), + ("layer.ungroupLayers", json!({})), + ("layer.layerStyle.copyLayerStyle", json!({})), + ("layer.layerStyle.pasteLayerStyle", json!({})), + ("layer.setExpanded", json!({"all": true, "expanded": false})), + ("layerComp.apply", json!({"comp": "Original"})), ("actions.play", json!({"action": "Other", "set": "S"})), (STOP_MESSAGE, json!({"message": "Hi", "allowContinue": false})), // The TrueGrit Texture Supply sets (Debaser, Distress Press, Halftone Zine Machine). @@ -393,6 +403,8 @@ fn every_mapped_command_exports_and_imports_back() { "select.modify.smooth", "select.reselect", "select.similar", + "view.actualPixels", + "view.zoomOut", "tools.swapColors", "edit.cut", "edit.copy", @@ -422,7 +434,10 @@ fn every_mapped_command_exports_and_imports_back() { // Every command the table names exists. // (Save is the desktop shell's: recorded by it, replayed by it, written by Batch.) for id in EVENTS.iter().flat_map(|e| e.ids.iter()) { - assert!(crate::commands::find(id).is_some() || crate::actions_cmds::shell_save_command(id), "the .atn table names an unknown command `{id}`"); + assert!( + crate::commands::find(id).is_some() || crate::actions_cmds::shell_save_command(id) || crate::actions_cmds::shell_view_command(id), + "the .atn table names an unknown command `{id}`" + ); } } @@ -741,6 +756,50 @@ fn remaining_events_import_in_their_photoshop_shapes() { 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"]); + // A transform without a reference (a replaced smart object) is the target layer's; the + // selection outline nudged; a layer moved to index 0 (the bottom); a View menu item; the + // foreground colour. + let replaced = desc("null") + .with("FTcs", enm("QCSt", "Qcsa")) + .with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(4.0)).with("Vrtc", px(6.0)))) + .with("Wdth", pct(50.0)) + .with("Hght", pct(50.0)) + .with("replaceLayer", D::Descriptor(desc("null"))); + let nudged = desc("null") + .with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }])) + .with("T ", D::Descriptor(desc("Ofst").with("Hrzn", px(3.0)).with("Vrtc", px(-2.0)))); + let to_bottom = desc("null") + .with("null", target_ref("Lyr ")) + .with("T ", D::Reference(vec![ReferenceItem::Index { class: class("Lyr "), index: 0 }])) + .with("Adjs", D::Boolean(false)); + let fit = + desc("null").with("null", D::Reference(vec![ReferenceItem::Enumerated { class: class("Mn "), type_id: Id::new("MnIt"), value: Id::new("FtOn") }])); + let fg = desc("null") + .with("null", D::Reference(vec![ReferenceItem::Property { class: class("Clr "), key: Id::new("FrgC") }])) + .with("T ", D::Descriptor(desc("RGBC").with("Rd ", D::Double(0.0)).with("Grn ", D::Double(0.0)).with("Bl ", D::Double(0.0)))); + let items = vec![ + item("transform", "Transform", Some(replaced)), + item("move", "Move", Some(nudged)), + item("move", "Move", Some(to_bottom)), + item("select", "Select", Some(fit)), + item("set", "Set", Some(fg)), + ]; + let file = AtnFile { + name: UnicodeString::new_nul("More2"), + expanded: false, + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], + warnings: vec![], + }; + let more = import(&atn::write(&file).unwrap()).unwrap(); + let more_steps = &more.actions[0].steps; + assert_eq!( + more_steps.iter().map(|(id, _)| id.as_str()).collect::>(), + ["edit.transform", "select.transformSelection", "layer.arrange.sendToBack", "view.fitOnScreen", "tools.setColors"] + ); + assert_eq!(more_steps[0].1, json!({"dx": 4, "dy": 6, "scaleX": 50, "scaleY": 50})); + assert_eq!(more_steps[1].1, json!({"dx": 3, "dy": -2})); + assert_eq!(more_steps[4].1, json!({"foreground": "#000000"})); + assert!(more.unsupported.is_empty()); 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})); diff --git a/crates/engine/tests/corpus_atn.rs b/crates/engine/tests/corpus_atn.rs new file mode 100644 index 0000000000..1a0670f7a4 --- /dev/null +++ b/crates/engine/tests/corpus_atn.rs @@ -0,0 +1,89 @@ +//! Corpus test (feature `corpus`; run with `cargo xtask test-corpus`): every `.atn` set under +//! `corpus/atn/` loads through `actions.load`, exports through `actions.save`, and the export +//! loads back to the same actions. Steps without a PhotoCraft equivalent are counted against a +//! ceiling, so a mapping regression (or a new unmapped event in the corpus) shows up here. +#![cfg(feature = "corpus")] + +use std::path::{Path, PathBuf}; + +use photocraft_engine::Session; +use serde_json::json; + +fn collect(dir: &Path, out: &mut Vec) { + let Ok(rd) = std::fs::read_dir(dir) else { return }; + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + collect(&p, out); + } else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("atn")) { + out.push(p); + } + } +} + +/// Sets in the corpus, and the most steps that may still be kept Photoshop steps across all of +/// them (2026-10-12: 517 of 5 296; 404 are generative AI (`generativeUpscale`, `syntheticFill`, +/// `syntheticGenHarmonize`: deferred by decision) and `AdobeScriptAutomation` scripts, the rest +/// shape layers with strokes, snapshots, smart-filter and channel edits, frame animation). +/// Raise the first, lower the second, never the reverse. +const FLOOR_FILES: usize = 16; +const CEILING_UNSUPPORTED: usize = 540; + +#[test] +fn atn_corpus_loads_saves_and_reloads() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/atn"); + assert!(root.is_dir(), "{} is missing: run `cargo xtask corpus --atn`", root.display()); + let mut files = Vec::new(); + collect(&root, &mut files); + files.sort(); + let mut failures = Vec::new(); + let (mut steps, mut unsupported) = (0usize, 0usize); + for p in &files { + let name = p.strip_prefix(&root).unwrap_or(p).display().to_string(); + let data = photocraft_engine::paint::tile::b64_encode(&std::fs::read(p).unwrap()); + let mut s = Session::new(); + let loaded = match s.execute("actions.load", json!({"data": data})) { + Ok(v) => v, + Err(e) => { + failures.push(format!("{name}: load: {e}")); + continue; + } + }; + let n_steps = loaded["steps"].as_u64().unwrap_or(0) as usize; + let n_unsupported = loaded["unsupported"].as_array().map_or(0, Vec::len); + let events: Vec = + loaded["unsupported"].as_array().map_or(Vec::new(), |u| u.iter().filter_map(|x| x["event"].as_str().map(String::from)).collect()); + steps += n_steps; + unsupported += n_unsupported; + println!("{name}: {} actions, {n_steps} steps, {n_unsupported} kept Photoshop steps {events:?}", loaded["actions"].as_array().map_or(0, Vec::len)); + let first = s.actions.list.clone(); + let saved = match s.execute("actions.save", json!({"set": loaded["set"]})) { + Ok(v) => v, + Err(e) => { + failures.push(format!("{name}: save: {e}")); + continue; + } + }; + let bytes = photocraft_engine::paint::tile::b64_decode(saved["data"].as_str().unwrap_or("")).unwrap_or_default(); + let mut t = Session::new(); + match t.execute("actions.load", json!({"data": photocraft_engine::paint::tile::b64_encode(&bytes)})) { + Ok(_) if t.actions.list == first => {} + Ok(_) => { + // Name the first step that differs, so a mapping asymmetry is quick to find. + let detail = first + .iter() + .zip(&t.actions.list) + .flat_map(|(a, b)| a.steps.iter().zip(&b.steps).enumerate().map(move |(i, (x, y))| (a.name.as_str(), i, x, y))) + .find(|(_, _, x, y)| x != y) + .map(|(action, i, x, y)| format!("`{action}` step {}: {} {} → {} {}", i + 1, x.0, x.1, y.0, y.1)) + .unwrap_or_else(|| "the actions differ in count, flags or names".into()); + failures.push(format!("{name}: the saved set loads to different actions: {detail}")); + } + Err(e) => failures.push(format!("{name}: reload: {e}")), + } + } + println!("{} files, {steps} steps, {unsupported} kept Photoshop steps", files.len()); + assert!(files.len() >= FLOOR_FILES, "only {} .atn files; the corpus floor is {FLOOR_FILES}", files.len()); + assert!(unsupported <= CEILING_UNSUPPORTED, "{unsupported} steps have no PhotoCraft equivalent; the ceiling is {CEILING_UNSUPPORTED}"); + assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n")); +} diff --git a/crates/psd/examples/atn_dump.rs b/crates/psd/examples/atn_dump.rs index 8a39bbb121..04e268da94 100644 --- a/crates/psd/examples/atn_dump.rs +++ b/crates/psd/examples/atn_dump.rs @@ -66,8 +66,8 @@ fn main() { for item in &a.items { let flags = format!("{}{}", if item.enabled { "" } else { " off" }, if item.with_dialog { " dialog" } else { "" }); match &item.descriptor { - Some(d) => println!(" {:<22} {:<28}{flags} {}", item.event.id(), item.dictionary, keys(d)), - None => println!(" {:<22} {:<28}{flags}", item.event.id(), item.dictionary), + Some(d) => println!(" {:<22} {:<28}{flags} {}", item.event.id(), item.label(), keys(d)), + None => println!(" {:<22} {:<28}{flags}", item.event.id(), item.label()), } } } diff --git a/crates/psd/examples/atn_roundtrip.rs b/crates/psd/examples/atn_roundtrip.rs new file mode 100644 index 0000000000..b73151b745 --- /dev/null +++ b/crates/psd/examples/atn_roundtrip.rs @@ -0,0 +1,54 @@ +//! Check `.atn` files for byte-exact round trips: parse, write, compare, and show the first +//! difference with its context: `cargo run -p photocraft-psd --example atn_roundtrip -- set.atn…` +//! +//! Exit status 1 when any file differs or fails to parse. + +use photocraft_psd::atn; + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect::>().join(" ") +} + +fn main() { + let mut failed = false; + for path in std::env::args().skip(1) { + let bytes = match std::fs::read(&path) { + Ok(b) => b, + Err(e) => { + eprintln!("{path}: {e}"); + failed = true; + continue; + } + }; + let file = match atn::parse(&bytes) { + Ok(f) => f, + Err(e) => { + println!("{path}: parse failed: {e}"); + failed = true; + continue; + } + }; + let written = match atn::write(&file) { + Ok(w) => w, + Err(e) => { + println!("{path}: write failed: {e}"); + failed = true; + continue; + } + }; + if written == bytes { + println!("{path}: identical ({} bytes)", bytes.len()); + continue; + } + failed = true; + let at = bytes.iter().zip(&written).position(|(a, b)| a != b).unwrap_or(bytes.len().min(written.len())); + let lo = at.saturating_sub(24); + println!("{path}: differs at byte {at} (original {} bytes, rewritten {} bytes)", bytes.len(), written.len()); + println!(" original : {}", hex(bytes.get(lo..(at + 40).min(bytes.len())).unwrap_or(&[]))); + println!(" rewritten: {}", hex(written.get(lo..(at + 40).min(written.len())).unwrap_or(&[]))); + println!(" original : {}", String::from_utf8_lossy(bytes.get(lo..(at + 40).min(bytes.len())).unwrap_or(&[])).replace(|c: char| c.is_control(), ".")); + } + if failed { + std::process::exit(1); + } +} diff --git a/crates/psd/src/atn.rs b/crates/psd/src/atn.rs index 2d8bf9990f..6bb900d435 100644 --- a/crates/psd/src/atn.rs +++ b/crates/psd/src/atn.rs @@ -17,8 +17,10 @@ //! //! The descriptor is a plain ActionDescriptor body ([`Descriptor`], no version prefix): the //! event's parameters, exactly as Photoshop's own "Make", "Set", "Gaussian Blur"… events -//! carry them. Photoshop 5-era files (version 12) use a different, undocumented layout and are -//! rejected as unsupported. +//! carry them. The event name and the panel name are byte strings (ASCII, or the platform's +//! legacy encoding for a localised panel name: Mac Roman "Définir"), kept verbatim; +//! [`AtnItem::label`] gives a readable name. Photoshop 5-era files (version 12) use a +//! different, undocumented layout and are rejected as unsupported. //! //! [`parse`] never panics: every count is bounds-checked against the bytes left, descriptor //! nesting is bounded, and allocations are capped. [`write`] produces the same layout, so @@ -26,7 +28,7 @@ use crate::descriptor::{Descriptor, UnicodeString}; use crate::error::{PsdError, Result}; -use crate::io::{Reader, WriteExt}; +use crate::io::{Reader, WriteExt, decode_legacy_name}; /// The only version this module reads and writes (Photoshop 6 and every later release). pub const VERSION: u32 = 16; @@ -84,8 +86,9 @@ pub struct AtnItem { pub dialog_options: u8, /// The event. pub event: AtnEvent, - /// The name the Actions panel shows for the step ("Gaussian Blur", "Make", …). - pub dictionary: String, + /// The name the Actions panel shows for the step ("Gaussian Blur", "Make", …), as stored: + /// ASCII, or the authoring platform's legacy encoding when localised. See [`Self::label`]. + pub dictionary: Vec, /// The event's parameters; `None` for events without any. pub descriptor: Option, } @@ -98,49 +101,71 @@ impl Default for AtnItem { with_dialog: false, dialog_options: 0, event: AtnEvent::Code(*b" "), - dictionary: String::new(), + dictionary: Vec::new(), descriptor: None, } } } +impl AtnItem { + /// The panel name as text: UTF-8 when it is, otherwise decoded byte by byte (Latin-1). + pub fn dictionary(&self) -> String { + decode_legacy_name(&self.dictionary) + } + + /// A readable name for the step: the descriptor's class name when Photoshop stored one + /// (Unicode, so a localised name survives), else [`Self::dictionary`], else the event id. + pub fn label(&self) -> String { + let class = self.descriptor.as_ref().map(|d| d.name.to_string_lossy()).unwrap_or_default(); + if !class.trim().is_empty() { + return class; + } + let dict = self.dictionary(); + if dict.trim().is_empty() { self.event.id() } else { dict } + } +} + /// A Photoshop event id: a four-character code (`GsnB`) or a string name (`trim`). #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum AtnEvent { /// Stored as `long` + 4 bytes. Code([u8; 4]), - /// Stored as `TEXT` + length-prefixed ASCII. - Name(String), + /// Stored as `TEXT` + length-prefixed bytes (ASCII in every file seen). + Name(Vec), } impl AtnEvent { + /// A string-named event. + pub fn name(s: &str) -> Self { + AtnEvent::Name(s.as_bytes().to_vec()) + } /// The event id as text: the code's four characters, or the name. pub fn id(&self) -> String { match self { AtnEvent::Code(c) => String::from_utf8_lossy(c).into_owned(), - AtnEvent::Name(n) => n.clone(), + AtnEvent::Name(n) => String::from_utf8_lossy(n).into_owned(), } } /// `true` if the event is the code or name `s`. pub fn is(&self, s: &str) -> bool { match self { AtnEvent::Code(c) => c == s.as_bytes(), - AtnEvent::Name(n) => n == s, + AtnEvent::Name(n) => n == s.as_bytes(), } } } -/// Reads a u32 length + ASCII bytes (event and dictionary names). -fn read_ascii(r: &mut Reader<'_>) -> Result { +/// Reads a u32 length + bytes (event and dictionary names), verbatim. +fn read_ascii(r: &mut Reader<'_>) -> Result> { let n = r.u32()?; - Ok(String::from_utf8_lossy(r.bytes_u64(u64::from(n))?).into_owned()) + Ok(r.bytes_u64(u64::from(n))?.to_vec()) } -fn write_ascii(out: &mut Vec, s: &str) -> Result<()> { +fn write_ascii(out: &mut Vec, s: &[u8]) -> Result<()> { let n = u32::try_from(s.len()).map_err(|_| PsdError::LimitExceeded("action name too long"))?; out.put_u32(n); - out.put(s.as_bytes()); + out.put(s); Ok(()) } @@ -277,12 +302,12 @@ mod tests { with_dialog: true, dialog_options: 2, event: AtnEvent::Code(*b"GsnB"), - dictionary: "Gaussian Blur".into(), + dictionary: b"Gaussian Blur".to_vec(), descriptor: Some(Descriptor::new("null").with("Rds ", px(2.5))), }; let trim = AtnItem { - event: AtnEvent::Name("trim".into()), - dictionary: "Trim".into(), + event: AtnEvent::name("trim"), + dictionary: b"Trim".to_vec(), descriptor: Some( Descriptor { name: UnicodeString::new_nul("Trim"), class_id: crate::descriptor::Id::Str(b"trim".to_vec()), items: Vec::new() } .with( @@ -293,7 +318,7 @@ mod tests { ), ..Default::default() }; - let flatten = AtnItem { enabled: false, event: AtnEvent::Code(*b"Fltn"), dictionary: "Flatten Image".into(), ..Default::default() }; + let flatten = AtnItem { enabled: false, event: AtnEvent::Code(*b"Fltn"), dictionary: b"Flatten Image".to_vec(), ..Default::default() }; AtnFile { name: UnicodeString::new_nul("Set ✓"), expanded: true, @@ -324,6 +349,21 @@ mod tests { assert_eq!(parsed.actions[0].items[0].event.id(), "GsnB"); assert!(parsed.actions[1].items[0].event.is("trim")); assert_eq!(parsed.actions[1].items[0].event.id(), "trim"); + assert_eq!(parsed.actions[0].items[0].label(), "Gaussian Blur"); + assert_eq!(parsed.actions[1].items[0].label(), "Trim"); + // A localised panel name in the platform's legacy encoding survives a round trip and + // the Unicode class name is the readable label. + let localised = AtnItem { + event: AtnEvent::name("set"), + dictionary: b"D\x8efinir".to_vec(), + descriptor: Some(Descriptor { name: UnicodeString::new_nul("Définir"), class_id: crate::descriptor::Id::new("setd"), items: Vec::new() }), + ..Default::default() + }; + assert_eq!(localised.label(), "Définir"); + let f = AtnFile { actions: vec![AtnAction { items: vec![localised.clone()], ..Default::default() }], ..Default::default() }; + let bytes = write(&f).unwrap(); + assert_eq!(parse(&bytes).unwrap().actions[0].items[0], localised); + assert_eq!(AtnItem { event: AtnEvent::name("x"), ..Default::default() }.label(), "x"); } /// The layout Photoshop writes for a one-step Trim action, assembled by hand from the @@ -338,8 +378,8 @@ mod tests { expanded: true, items: vec![AtnItem { expanded: true, - event: AtnEvent::Name("trim".into()), - dictionary: "Trim".into(), + event: AtnEvent::name("trim"), + dictionary: b"Trim".to_vec(), descriptor: Some(Descriptor { name: UnicodeString::new_nul("Trim"), class_id: crate::descriptor::Id::Str(b"trim".to_vec()), diff --git a/crates/psd/tests/corpus_atn.rs b/crates/psd/tests/corpus_atn.rs new file mode 100644 index 0000000000..d3b4ace534 --- /dev/null +++ b/crates/psd/tests/corpus_atn.rs @@ -0,0 +1,59 @@ +//! Corpus test (feature `corpus`; run with `cargo xtask test-corpus`): every `.atn` under +//! `corpus/atn/` (gitignored, fetched by `cargo xtask corpus --atn`) parses, and writing it back +//! reproduces the bytes exactly. A missing corpus fails. +#![cfg(feature = "corpus")] + +use std::path::{Path, PathBuf}; + +use photocraft_psd::atn; + +fn collect(dir: &Path, out: &mut Vec) { + let Ok(rd) = std::fs::read_dir(dir) else { return }; + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + collect(&p, out); + } else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("atn")) { + out.push(p); + } + } +} + +/// Sets in the corpus; raise, never lower. +const FLOOR: usize = 16; + +#[test] +fn atn_corpus_parses_and_is_byte_stable() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/atn"); + assert!(root.is_dir(), "{} is missing: run `cargo xtask corpus --atn`", root.display()); + let mut files = Vec::new(); + collect(&root, &mut files); + files.sort(); + let mut failures = Vec::new(); + let (mut actions, mut steps) = (0usize, 0usize); + for p in &files { + let name = p.strip_prefix(&root).unwrap_or(p).display().to_string(); + let bytes = std::fs::read(p).unwrap(); + let file = match atn::parse(&bytes) { + Ok(f) => f, + Err(e) => { + failures.push(format!("{name}: {e}")); + continue; + } + }; + if !file.warnings.is_empty() { + failures.push(format!("{name}: warnings {:?}", file.warnings)); + } + actions += file.actions.len(); + steps += file.actions.iter().map(|a| a.items.len()).sum::(); + match atn::write(&file) { + Ok(written) if written == bytes => {} + Ok(written) => failures.push(format!("{name}: rewritten bytes differ ({} vs {} bytes)", written.len(), bytes.len())), + Err(e) => failures.push(format!("{name}: write failed: {e}")), + } + println!("{name}: {} actions, {} steps", file.actions.len(), file.actions.iter().map(|a| a.items.len()).sum::()); + } + println!("{} files, {actions} actions, {steps} steps", files.len()); + assert!(files.len() >= FLOOR, "only {} .atn files; the corpus floor is {FLOOR}", files.len()); + assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n")); +} diff --git a/docs/development.md b/docs/development.md index bb0de12690..9987b2d609 100644 --- a/docs/development.md +++ b/docs/development.md @@ -427,8 +427,14 @@ recorded) when adding an event to the table: ```sh cargo run -p photocraft-psd --example atn_dump -- "Default Actions.atn" +cargo run -p photocraft-psd --example atn_roundtrip -- "Default Actions.atn" # parse, write, diff ``` +The real-file oracles are `corpus/atn/` (16 sets from seven public repositories, see *Test +corpora*): `photocraft-psd`'s corpus test parses every file and rewrites it byte for byte; +`photocraft-engine`'s loads, saves and reloads every set and caps the steps that still have no +PhotoCraft equivalent. + Events outside the table load as `actions.photoshopStep` steps that keep the descriptor verbatim (so Save Actions writes them back) and fail at playback with the step's Photoshop name. @@ -468,6 +474,7 @@ against committed **sha256 manifests**. All pins are in one place: | `corpus/heif/` | 9 small HEIC/HEIF files (checkerboards, RGB strips, a grid-tiled photo with EXIF/XMP, each with Apple's decode as `.ref.png`; a 10-bit RGBA file with its source PNG), for the `heif` feature | heic-rs (MIT OR Apache-2.0) and pillow-heif (BSD-3-Clause) upstreams | `xtask/heif-corpus.sha256` | | `corpus/exr/` | the 5 deep OpenEXR test images (scanline deep data with half colour and u32 ID channels; 2.3 MB), checked against the ID manifests of their upstream sidecars | OpenEXR upstream at v3.5.2 (BSD-3-Clause) | `xtask/exr-corpus.sha256` | | `corpus/affinity/` | 21 public Affinity documents and the 18 Affinity 3 samples for #1606, with exported PNG references | vector-art and affinity-samples (CC0), AFDesignLoad, Jac21/Branding and AssetStoreTemplate (MIT) upstreams | `xtask/affinity-corpus.sha256` | +| `corpus/atn/` | 16 Photoshop Actions sets from seven public repositories (frequency separation, adjustments, Save for Web, generative workflows…), the real-file oracles of the `.atn` parser and import | sbaril, bjango, BKWLD, atntools, trimmer, alpha-utility, gif-creator upstreams (MIT, BSD, Unlicense) | `xtask/atn-corpus.sha256` | | `corpus/pngsuite/` | PngSuite | schaik.com release archive (public domain) | (fixed archive) | ```sh diff --git a/xtask/atn-corpus.sha256 b/xtask/atn-corpus.sha256 new file mode 100644 index 0000000000..b6cfea8f9d --- /dev/null +++ b/xtask/atn-corpus.sha256 @@ -0,0 +1,16 @@ +8b778f618f0219e18a1bf3994561c1d428acba42c4b3fc499bf8a7e334483d9a alpha-utility/AlphaUtilityAdditions.atn +6421c4001e6fc0f3593f55a523c5ec973eff56521cfeb2943c45a2e79ec584bf atntools/test.atn +3401a0d51699c8c9b19b3b228078ed7adc21385250b6bf23c1b5ea57662cd19f bjango/Bjango Actions.atn +b240cd0aa441071fbdba2898f3f6c72f1e1be2117a8c80f673d937ecae65510c bjango/Bjango Adjustment Actions.atn +f35e6109187fcab10a08d64a5eb58e147678643d4bd3225c43e6d80e00499da5 bjango/Bjango Scale Actions.atn +8f550c2b77d6ba8348c23092bb2d6df7fd27195c3f6218f7fa27feccf7516e74 bkwld/Save layers for web.atn +3d6b254532ec231f26145d62ecde018bab3ac7ba6c2380304e875158d787e689 gif-creator/BannerActions.atn +057374895b523bc1b9bf1f7a7b4fcd1bf4e66467fd783d8dc667a9a15b8939fe sbaril/Dimension-v01.atn +9b8ae4c7f0ebf103f9f45f1c051dffdfb67fc9c083d291be9c29b740023d85f2 sbaril/Firefly-Actions.atn +56ea2003be73018b81dfee524e0a5bc3c31b5b110045430793e2dd03406e66a3 sbaril/Frequency-Separation.atn +7e1dd8db08e98b74020fd61bc20cfecb25100062a723c4367de4eaa903ea1e29 sbaril/Generative-Upscale-Bypass-4096px-Limit/Generative-Upscale-Bypass-4096px-Limit.atn +46f372beac54a565cf7dde30afe9c2a247c43a991635823c68b91e378c863b61 sbaril/Generative-Upscale-Bypass-4096px-Limit/Generative-Upscale-Bypass-4K-Limit-Topaz.atn +1fed6e67ce15623d02e543aeffb76c2c14165eecfb4e363611a6e0ec3982d592 sbaril/Harmonize-Enhancement.atn +76a2704d6cc2269293e1aa221724fd769b0e77b5c8731777d7f06991f8c85366 sbaril/Photo-v01.atn +9b47dec313d23b342b668a85ac3d10fef953bc0c7c50d980de3025b6d861c12e sbaril/Quick-Object-Extraction.atn +418ec00d0c732c70d328f271cb79823af850514a746c8ff78df95e230522449e trimmer/Trimmer.atn diff --git a/xtask/src/corpus.rs b/xtask/src/corpus.rs index e6c929a1be..4272ad3de2 100644 --- a/xtask/src/corpus.rs +++ b/xtask/src/corpus.rs @@ -34,7 +34,7 @@ const CRITICAL: &[&str] = &[ "crates/affinity/", "crates/raw/", ]; -/// `cargo xtask corpus [--all | --pngsuite | --download | --psd | --psd-tools | --heif | --exr | --pixls | --affinity | --photoshop [--local]] [--update-manifest]` +/// `cargo xtask corpus [--all | --pngsuite | --download | --psd | --psd-tools | --heif | --exr | --pixls | --affinity | --atn | --photoshop [--local]] [--update-manifest]` pub fn cmd(args: &[&str]) -> Result<(), String> { let update = args.contains(&"--update-manifest"); let mut did = false; @@ -69,6 +69,10 @@ pub fn cmd(args: &[&str]) -> Result<(), String> { corpus_pins::AFFINITY.fetch(update)?; did = true; } + if args.contains(&"--atn") { + corpus_pins::ATN.fetch(update)?; + did = true; + } if args.contains(&"--photoshop") { if args.contains(&"--local") { corpus_pins::PHOTOSHOP.fetch_local(&local_clone()?, update)?; @@ -146,6 +150,8 @@ fn list() { manifest xtask/exr-corpus.sha256. Fetch: --exr corpus/affinity/ [{}] public Affinity documents and #1606 samples (CC0, MIT), with PNG references; manifest xtask/affinity-corpus.sha256. Fetch: --affinity + corpus/atn/ [{}] Photoshop Actions sets from seven public repositories (MIT, BSD, Unlicense); + manifest xtask/atn-corpus.sha256. Fetch: --atn corpus/pngsuite/ [{}] PngSuite (public domain), {PNGSUITE_URL}. Fetch: --pngsuite corpus/pixls/ [opt-in] real camera raws from raw.pixls.us (public domain), one per decode path plus the known-unsupported packed ORF. Fetch: --pixls (opt-in) @@ -163,6 +169,7 @@ Pins: xtask/src/corpus_pins.rs. Moving one: change it, then -- --update-ma status(corpus_pins::EXR.is_current()), &OPENEXR_COMMIT[..12], status(corpus_pins::AFFINITY.is_current()), + status(corpus_pins::ATN.is_current()), status(png_ok), ); } diff --git a/xtask/src/corpus_pins.rs b/xtask/src/corpus_pins.rs index 917412709a..3337dc894a 100644 --- a/xtask/src/corpus_pins.rs +++ b/xtask/src/corpus_pins.rs @@ -240,7 +240,74 @@ pub const AFFINITY: PinnedCorpus = PinnedCorpus { }, }; /// Every pinned corpus, in fetch order. -pub const ALL: &[&PinnedCorpus] = &[&PSD_MIXED, &PSD_TOOLS, &PHOTOSHOP, &HEIF, &EXR, &AFFINITY]; +/// Photoshop Actions files (`.atn`) from public repositories under permissive licences, the +/// real-file oracles of `photocraft-psd::atn` and the engine's `.atn` import (2026-10-12). +/// https://github.com/sbaril/Photoshop-Scripts (MIT): `Actions/`. +pub const SBARIL_COMMIT: &str = "622493c9c617c9d2ff38500e8e512c0ad421eed9"; +/// https://github.com/bjango/Bjango-Actions (BSD, `Help/License.md`): three sets at the root. +pub const BJANGO_COMMIT: &str = "0fdc790b4931fd36816e6056dd84c2aa0d3ff054"; +/// https://github.com/BKWLD/photoshop-actions (MIT). +pub const BKWLD_COMMIT: &str = "d1a5d24e02bf23f78659106473ef7d8c747bea00"; +/// https://github.com/mrijk/atntools (MIT): the fixture of a Rust `.atn` reader. +pub const ATNTOOLS_COMMIT: &str = "529db30b631f32bc7970121af0393853c2154fa0"; +/// https://github.com/damsmcfly/photoshop-cleaner-trimmer (Unlicense). +pub const TRIMMER_COMMIT: &str = "fd3814df2a625ed7a015633eb91d8fe2f5bbe9a0"; +/// https://github.com/uclagamelab/AlphaUtilityAdditions (BSD-2-Clause). +pub const ALPHA_UTILITY_COMMIT: &str = "7bdae950a7db4c6c2224bcaf08f4c17ec18958cb"; +/// https://github.com/overbyte/photoshop-gif-creator-script (MIT). +pub const GIF_CREATOR_COMMIT: &str = "9fb78e8c09a21678afc84ccb31a435bd242894df"; + +/// `corpus/atn`: Photoshop Actions sets from seven public repositories, one folder each. +pub const ATN: PinnedCorpus = PinnedCorpus { + name: "atn", + dest: "atn", + upstreams: &[ + Upstream { prefix: "sbaril/", repo: "sbaril/Photoshop-Scripts", commit: SBARIL_COMMIT, subdir: "Actions", extras: &[("LICENSE", "sbaril/LICENSE")] }, + Upstream { prefix: "bjango/", repo: "bjango/Bjango-Actions", commit: BJANGO_COMMIT, subdir: "", extras: &[("Help/License.md", "bjango/License.md")] }, + Upstream { prefix: "bkwld/", repo: "BKWLD/photoshop-actions", commit: BKWLD_COMMIT, subdir: "", extras: &[("LICENSE", "bkwld/LICENSE")] }, + Upstream { prefix: "atntools/", repo: "mrijk/atntools", commit: ATNTOOLS_COMMIT, subdir: "", extras: &[("LICENSE", "atntools/LICENSE")] }, + Upstream { + prefix: "trimmer/", + repo: "damsmcfly/photoshop-cleaner-trimmer", + commit: TRIMMER_COMMIT, + subdir: "", + extras: &[("LICENSE", "trimmer/LICENSE")], + }, + Upstream { + prefix: "alpha-utility/", + repo: "uclagamelab/AlphaUtilityAdditions", + commit: ALPHA_UTILITY_COMMIT, + subdir: "", + extras: &[("LICENSE", "alpha-utility/LICENSE")], + }, + Upstream { + prefix: "gif-creator/", + repo: "overbyte/photoshop-gif-creator-script", + commit: GIF_CREATOR_COMMIT, + subdir: "", + extras: &[("LICENSE", "gif-creator/LICENSE")], + }, + ], + manifest: "xtask/atn-corpus.sha256", + exts: &["atn"], + subset: false, + sources_md: |c| { + format!( + "# Photoshop Actions (.atn) corpus\n\nAction sets from public repositories, unmodified, each in its own folder with its licence: \ + `sbaril/` (https://github.com/sbaril/Photoshop-Scripts/tree/{SBARIL_COMMIT}/Actions, MIT), \ + `bjango/` (https://github.com/bjango/Bjango-Actions/tree/{BJANGO_COMMIT}, BSD), \ + `bkwld/` (https://github.com/BKWLD/photoshop-actions/tree/{BKWLD_COMMIT}, MIT), \ + `atntools/` (https://github.com/mrijk/atntools/tree/{ATNTOOLS_COMMIT}, MIT), \ + `trimmer/` (https://github.com/damsmcfly/photoshop-cleaner-trimmer/tree/{TRIMMER_COMMIT}, Unlicense), \ + `alpha-utility/` (https://github.com/uclagamelab/AlphaUtilityAdditions/tree/{ALPHA_UTILITY_COMMIT}, BSD-2-Clause), \ + `gif-creator/` (https://github.com/overbyte/photoshop-gif-creator-script/tree/{GIF_CREATOR_COMMIT}, MIT). \ + Fetched and sha256-verified by `cargo xtask corpus --atn` against `{}`. Gitignored; never commit these files.\n", + c.manifest + ) + }, +}; + +pub const ALL: &[&PinnedCorpus] = &[&PSD_MIXED, &PSD_TOOLS, &PHOTOSHOP, &HEIF, &EXR, &AFFINITY, &ATN]; /// `corpus/pixls`: real camera raws from (released into the public /// domain / CC0 by their photographers; see ATTRIBUTION.md). One file per decode path diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 6c0f016464..d849d58d55 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -25,7 +25,7 @@ commands: layers enforce the crate dependency layering (plan/architecture.md §3) wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates ci fmt --check, clippy -D warnings, test, layers, wasm (stops at first failure) - corpus [--all | --pngsuite | --psd | --psd-tools | --pixls | --photoshop] [--local] [--update-manifest] + corpus [--all | --pngsuite | --psd | --psd-tools | --pixls | --atn | --photoshop] [--local] [--update-manifest] show where test corpora live and their pins (xtask/src/corpus_pins.rs), or fetch them into corpus/ (pinned commits, sha256-verified; --all = every corpus but pixls, --photoshop --local copies from ../photocraft-corpus or $PHOTOCRAFT_CORPUS_REPO)