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.
This commit is contained in:
Fabio Lissi
2026-10-12 16:51:45 -05:00
parent 6f85054288
commit 74ad8ee5bc
13 changed files with 587 additions and 79 deletions
+1
View File
@@ -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 | <http://www.schaik.com/pngsuite/> | 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 |
+159 -50
View File
@@ -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<Descriptor> {
}
}
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<Descriptor> {
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<Value> {
@@ -653,9 +661,14 @@ fn color_balance_from(d: &Descriptor) -> Option<Value> {
fn color_balance_to(p: &Value) -> Option<Descriptor> {
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<Descriptor> {
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<Descriptor> {
));
}
}
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<Descriptor> {
.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.<suffix>`, `layer.newAdjustmentLayer.<suffix>`), 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<Descriptor> {
}
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<Descriptor> {
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<Descriptor> {
"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<Descriptor> {
/// 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<Descriptor> {
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<Descriptor> {
let path = jstr(p, "path")?;
let mut u = desc("SaveForWeb").with("Op ", enm("SWOp", "OpSa")).with("Fmt ", enm("IRFm", lookup_back(WEB_FORMATS, jstr(p, "format").unwrap_or("png24"))?));
for (key, param, default) in [
("Intr", "interlaced", false),
("Trns", "transparency", true),
("EICC", "embedIcc", false),
("Prgr", "progressive", false),
("Optm", "optimized", true),
] {
u = u.with(key, D::Boolean(jbool(p, param).unwrap_or(default)));
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<Descriptor> {
}
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<Descriptor> {
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<Descriptor> {
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<Descriptor> {
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<Descriptor> {
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<Descriptor> {
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<Unsupported>)
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<AtnItem> {
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, &params)?;
let event = if e.code.is_empty() { AtnEvent::Name(e.name.to_string()) } else { AtnEvent::Code(e.code.as_bytes().try_into().ok()?) };
Some(AtnItem { event, dictionary: e.label.to_string(), descriptor: Some(descriptor), ..Default::default() })
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
+62 -3
View File
@@ -6,9 +6,9 @@ use serde_json::json;
fn item(event: &str, label: &str, d: Option<Descriptor>) -> 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::<Vec<_>>(),
["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}));
+89
View File
@@ -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<PathBuf>) {
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<String> =
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"));
}
+2 -2
View File
@@ -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()),
}
}
}
+54
View File
@@ -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::<Vec<_>>().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);
}
}
+61 -21
View File
@@ -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<u8>,
/// The event's parameters; `None` for events without any.
pub descriptor: Option<Descriptor>,
}
@@ -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<u8>),
}
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<String> {
/// Reads a u32 length + bytes (event and dictionary names), verbatim.
fn read_ascii(r: &mut Reader<'_>) -> Result<Vec<u8>> {
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<u8>, s: &str) -> Result<()> {
fn write_ascii(out: &mut Vec<u8>, 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()),
+59
View File
@@ -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<PathBuf>) {
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::<usize>();
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::<usize>());
}
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"));
}
+7
View File
@@ -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
+16
View File
@@ -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
+8 -1
View File
@@ -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 --<name> --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),
);
}
+68 -1
View File
@@ -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 <https://raw.pixls.us> (released into the public
/// domain / CC0 by their photographers; see ATTRIBUTION.md). One file per decode path
+1 -1
View File
@@ -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)