Actions: map Photoshop's Distort filter steps (#10605)

Pinch, Polar Coordinates, Spherize, Twirl, Wave and ZigZag steps load
as filter.distort.*, play, and save back with every key in recorded
order. Out-of-range values are clamped as Photoshop clamps them and
missing keys take Photoshop's defaults (Wave's undefined areas are
Repeat Edge Pixels). A Wave scaled below 100 % stays a Photoshop step,
as the command has no scale. Shear is still kept verbatim: its curve
points need a step recorded through its dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hector VillarrealandClaude Opus 5.5 committed 2026-10-31 21:04:42 -06:00
1 parent 1f984bffb3
commit a03120e8b4
5 files changed
+329 -1

No files matched your search

+1
View File
@@ -32,6 +32,7 @@ mod descriptors;
mod events;
mod fields;
mod files;
mod steps_distort;
mod steps_doc;
mod steps_image;
mod steps_layer;
+43
View File
@@ -2,6 +2,7 @@ const DIFFUSE_MODE: &[(&str, &str)] = &[("Nrml", "normal"), ("DrkO", "darkenOnly
use super::adjustments::{adjustment_by_class, adjustment_steps, levels_from, levels_to, shadows_highlights_from, shadows_highlights_to};
use super::descriptors::{desc, get_bool, get_ref, jbool, layer_ref, layer_target, ref_class};
use super::fields::{F, Kind, Mapped, fields_from, fields_to, none, step};
use super::steps_distort::{PINCH, POLAR, SPHERIZE, TWIRL, ZIGZAG, keys_from, keys_to, wave_from, wave_to};
use super::steps_doc::{
align_from, align_to, canvas_size_from, canvas_size_to, close_from, close_to, conditional_from, conditional_to, convert_mode_from, convert_mode_to,
crop_from, crop_to, export_from, export_to, image_size_from, image_size_to, merge_visible_from, merge_visible_to, open_from, open_to, paste_from, paste_to,
@@ -247,6 +248,48 @@ pub(super) const EVENTS: &[Event] = &[
"filter.other.offset",
&[F("Hrzn", "horizontal", Kind::Long), F("Vrtc", "vertical", Kind::Long), F("Fl ", "undefinedAreas", Kind::Enum("FlMd", OFFSET_FILL))]
),
// Distort (#10605): every key in recorded order, Photoshop's limits and defaults.
Event {
code: "Pnch",
name: "pinch",
label: "Pinch",
ids: &["filter.distort.pinch"],
from: |d| keys_from(d, "filter.distort.pinch", PINCH),
to: |_, p| keys_to(p, PINCH, &[("amount", json!(50))], &[]),
},
Event {
code: "Plr ",
name: "polar",
label: "Polar Coordinates",
ids: &["filter.distort.polarCoordinates"],
from: |d| keys_from(d, "filter.distort.polarCoordinates", POLAR),
to: |_, p| keys_to(p, POLAR, &[("mode", json!("rectangularToPolar"))], &[]),
},
Event {
code: "Sphr",
name: "spherize",
label: "Spherize",
ids: &["filter.distort.spherize"],
from: |d| keys_from(d, "filter.distort.spherize", SPHERIZE),
to: |_, p| keys_to(p, SPHERIZE, &[("amount", json!(100)), ("mode", json!("normal"))], &[]),
},
Event {
code: "Twrl",
name: "twirl",
label: "Twirl",
ids: &["filter.distort.twirl"],
from: |d| keys_from(d, "filter.distort.twirl", TWIRL),
to: |_, p| keys_to(p, TWIRL, &[("angle", json!(50))], &[]),
},
Event { code: "Wave", name: "wave", label: "Wave", ids: &["filter.distort.wave"], from: wave_from, to: wave_to },
Event {
code: "ZgZg",
name: "zigZag",
label: "ZigZag",
ids: &["filter.distort.zigZag"],
from: |d| keys_from(d, "filter.distort.zigZag", ZIGZAG),
to: |_, p| keys_to(p, ZIGZAG, &[("amount", json!(10)), ("ridges", json!(5)), ("style", json!("pondRipples"))], &[]),
},
ev!("Rple", "ripple", "Ripple", "filter.distort.ripple", &[F("Amnt", "amount", Kind::ExactLong), F("RplS", "size", Kind::Enum("RplS", RIPPLE_SIZE))]),
ev!("Crst", "crystallize", "Crystallize", "filter.pixelate.crystallize", &[F("ClSz", "cellSize", Kind::ExactLong)]),
ev!("Dfs ", "diffuse", "Diffuse", "filter.stylize.diffuse", &[F("Md ", "mode", Kind::Enum("DfsM", DIFFUSE_MODE)), F("FlRs", "seed", Kind::Long)]),
@@ -0,0 +1,168 @@
//! Photoshop's Distort filter steps (#10605): Pinch, Polar Coordinates, Spherize, Twirl, Wave and
//! ZigZag, each key in recorded order with the limits and defaults Photoshop measures.
use super::descriptors::{desc, enm, get_enum, get_num, trimmed};
use super::events::pixel_target;
use super::fields::{Mapped, step};
use super::tables::{lookup, lookup_back};
use super::{D, Descriptor, Map, Value, json};
/// Whether every key of `d` is one of `keys` (codes compare without their space padding).
fn only_keys(d: &Descriptor, keys: &[&str]) -> bool {
d.items.iter().all(|(k, _)| keys.contains(&trimmed(k).as_str()))
}
/// Whether `p` sets nothing but `allowed` (a `null` counts as unset).
fn sets_only(p: &Value, allowed: &[&str]) -> bool {
p.as_object().is_none_or(|m| m.iter().all(|(k, v)| v.is_null() || allowed.contains(&k.as_str())))
}
/// One key of a Distort filter's step, in recorded order.
pub(super) enum Key {
/// An integer clamped to `min..=max` on import, as Photoshop clamps it; `default` when the
/// step leaves it out.
Int { key: &'static str, param: &'static str, min: i32, max: i32, default: i32 },
/// An enumeration (`ty`, Photoshop ↔ PhotoCraft values); `default` is Photoshop's value when
/// the step leaves it out. An unknown value keeps the step.
Enum { key: &'static str, param: &'static str, ty: &'static str, table: &'static [(&'static str, &'static str)], default: &'static str },
}
impl Key {
fn param(&self) -> &'static str {
match self {
Key::Int { param, .. } | Key::Enum { param, .. } => param,
}
}
fn key(&self) -> &'static str {
match self {
Key::Int { key, .. } | Key::Enum { key, .. } => key,
}
}
}
/// A step with exactly `keys` (any missing): each value clamped or looked up, Photoshop's
/// default for what it leaves out. Any other key keeps the step.
pub(super) fn keys_from(d: &Descriptor, id: &str, keys: &[Key]) -> Mapped {
keys_map(d, keys, &[]).map_or(Mapped::Unsupported { harmless: false }, |m| step(id, Value::Object(m)))
}
fn keys_map(d: &Descriptor, keys: &[Key], extra: &[&'static str]) -> Option<Map<String, Value>> {
let known: Vec<&str> = keys.iter().map(Key::key).chain(extra.iter().copied()).map(str::trim_end).collect();
if !only_keys(d, &known) {
return None;
}
let mut m = Map::new();
for k in keys {
let v = match k {
Key::Int { key, min, max, default, .. } => {
Value::from(get_num(d, key).map_or(*default, |n| n.round().clamp(f64::from(*min), f64::from(*max)) as i32))
}
Key::Enum { key, table, default, .. } => Value::from(lookup(table, &get_enum(d, key).unwrap_or_else(|| (*default).to_string()))?),
};
m.insert(k.param().into(), v);
}
Some(m)
}
/// Every key in recorded order, the command's default for what the step leaves out
/// (`ours`, by param). An integer key refuses a fractional value, which would replay rounded.
pub(super) fn keys_to(p: &Value, keys: &[Key], ours: &[(&str, Value)], extra: &[&'static str]) -> Option<Descriptor> {
let params: Vec<&str> = keys.iter().map(Key::param).chain(extra.iter().copied()).chain(["target"]).collect();
if !pixel_target(p) || !sets_only(p, &params) {
return None;
}
let value = |param: &str| match p.get(param) {
None | Some(Value::Null) => ours.iter().find(|(k, _)| *k == param).map(|(_, v)| v.clone()),
Some(v) => Some(v.clone()),
};
let mut d = desc("null");
for k in keys {
let v = value(k.param())?;
d = match k {
Key::Int { key, .. } => {
let n = v.as_f64()?;
if n.fract() != 0.0 || !(f64::from(i32::MIN)..=f64::from(i32::MAX)).contains(&n) {
return None;
}
d.with(key, D::Integer(n as i32))
}
Key::Enum { key, ty, table, .. } => d.with(key, enm(ty, lookup_back(table, v.as_str()?)?)),
};
}
Some(d)
}
pub(super) const PINCH: &[Key] = &[Key::Int { key: "Amnt", param: "amount", min: -100, max: 100, default: 50 }];
pub(super) const TWIRL: &[Key] = &[Key::Int { key: "Angl", param: "angle", min: -999, max: 999, default: 50 }];
pub(super) const POLAR: &[Key] =
&[Key::Enum { key: "Cnvr", param: "mode", ty: "Cnvr", table: &[("RctP", "rectangularToPolar"), ("PlrR", "polarToRectangular")], default: "RctP" }];
pub(super) const SPHERIZE: &[Key] = &[
Key::Int { key: "Amnt", param: "amount", min: -100, max: 100, default: 100 },
Key::Enum { key: "SphM", param: "mode", ty: "SphM", table: &[("Nrml", "normal"), ("HrzO", "horizontalOnly"), ("VrtO", "verticalOnly")], default: "Nrml" },
];
pub(super) const ZIGZAG: &[Key] = &[
Key::Int { key: "Amnt", param: "amount", min: -100, max: 100, default: 10 },
Key::Int { key: "NmbR", param: "ridges", min: 0, max: 20, default: 5 },
Key::Enum {
key: "ZZTy",
param: "style",
ty: "ZZTy",
table: &[("PndR", "pondRipples"), ("OtFr", "outFromCenter"), ("ArnC", "aroundCenter")],
default: "PndR",
},
];
/// Wave's keys before its scale, undefined areas and seed (handled in `wave_from` / `wave_to`).
const WAVE: &[Key] = &[
Key::Enum { key: "Wvtp", param: "type", ty: "Wvtp", table: &[("WvSn", "sine"), ("WvSq", "square"), ("WvTr", "triangle")], default: "WvSn" },
Key::Int { key: "NmbG", param: "generators", min: 1, max: 999, default: 5 },
Key::Int { key: "WLMn", param: "wavelengthMin", min: 1, max: 999, default: 10 },
Key::Int { key: "WLMx", param: "wavelengthMax", min: 1, max: 999, default: 120 },
Key::Int { key: "AmMn", param: "amplitudeMin", min: 1, max: 999, default: 5 },
Key::Int { key: "AmMx", param: "amplitudeMax", min: 1, max: 999, default: 35 },
];
const WAVE_UNDEFINED: &[(&str, &str)] = &[("RptE", "repeat"), ("WrpA", "wrap")];
/// Wave (`Wave`). The command has no horizontal or vertical scale, so a step that scales the
/// waves below 100 % keeps the Photoshop step. Missing undefined areas are Photoshop's Repeat
/// Edge Pixels (the command's own default is wrap); `RndS` keeps its 32 bits as the seed.
pub(super) fn wave_from(d: &Descriptor) -> Mapped {
let unsupported = Mapped::Unsupported { harmless: false };
let Some(mut m) = keys_map(d, WAVE, &["SclH", "SclV", "UndA", "RndS"]) else { return unsupported };
if ["SclH", "SclV"].iter().any(|k| get_num(d, k).is_some_and(|n| n.round().clamp(1.0, 100.0) != 100.0)) {
return unsupported;
}
let Some(undefined) = lookup(WAVE_UNDEFINED, &get_enum(d, "UndA").unwrap_or_else(|| "RptE".into())) else { return unsupported };
m.insert("undefinedAreas".into(), undefined.into());
if let Some(seed) = get_num(d, "RndS") {
m.insert("seed".into(), Value::from(seed as i32 as u32));
}
step("filter.distort.wave", Value::Object(m))
}
pub(super) fn wave_to(_: &str, p: &Value) -> Option<Descriptor> {
let ours = [
("type", json!("sine")),
("generators", json!(5)),
("wavelengthMin", json!(10)),
("wavelengthMax", json!(120)),
("amplitudeMin", json!(5)),
("amplitudeMax", json!(35)),
];
let d = keys_to(p, WAVE, &ours, &["undefinedAreas", "seed"])?;
let undefined = match p.get("undefinedAreas") {
None | Some(Value::Null) => "WrpA",
Some(v) => lookup_back(WAVE_UNDEFINED, v.as_str()?)?,
};
let seed = match p.get("seed") {
None | Some(Value::Null) => 0,
Some(v) => u32::try_from(v.as_u64()?).ok()? as i32,
};
Some(d.with("SclH", D::Integer(100)).with("SclV", D::Integer(100)).with("UndA", enm("UndA", undefined)).with("RndS", D::Integer(seed)))
}
+115
View File
@@ -485,6 +485,16 @@ fn every_mapped_command_exports_and_imports_back() {
("filter.blur.smartBlur", json!({"radius": 2.5, "threshold": 40, "quality": "medium", "mode": "overlayEdge"})),
("filter.other.offset", json!({"horizontal": 10, "vertical": -5, "undefinedAreas": "wrap"})),
("filter.blur.blur", json!({})),
("filter.distort.pinch", json!({"amount": -30})),
("filter.distort.polarCoordinates", json!({"mode": "polarToRectangular"})),
("filter.distort.spherize", json!({"amount": -50, "mode": "verticalOnly"})),
("filter.distort.twirl", json!({"angle": -200})),
(
"filter.distort.wave",
json!({"type": "square", "generators": 3, "wavelengthMin": 20, "wavelengthMax": 200, "amplitudeMin": 4, "amplitudeMax": 40,
"undefinedAreas": "wrap", "seed": 4_000_000_000_u32}),
),
("filter.distort.zigZag", json!({"amount": -20, "ridges": 12, "style": "aroundCenter"})),
(
"image.adjustments.levels",
json!({"inBlack": 5, "inWhite": 250, "gamma": 0.9, "outBlack": 0, "outWhite": 255, "red": {"inBlack": 2, "inWhite": 255, "gamma": 1, "outBlack": 0, "outWhite": 255}}),
@@ -3175,3 +3185,108 @@ fn long_names_are_clipped_instead_of_copied_into_every_diagnostic() {
assert_eq!(imp.unsupported.len(), 50);
assert!(imp.unsupported.iter().all(|u| u.action.chars().count() == MAX_NAME_CHARS));
}
/// The step `d` of Photoshop event `code`, as PhotoCraft loads it.
fn distort(code: &str, d: Descriptor) -> Mapped {
let event = AtnEvent::Code(code.as_bytes().try_into().unwrap());
(events::event_for(&event).unwrap_or_else(|| panic!("{code} is unknown")).from)(&d)
}
fn one_step(m: Mapped) -> (String, Value) {
match m {
Mapped::Steps(mut s) if s.len() == 1 => s.remove(0),
_ => panic!("not one step"),
}
}
/// Photoshop's Distort filter steps load with its defaults for missing keys and its clamping for
/// out-of-range values, play, and save back with every key in recorded order (#10605).
#[test]
fn distort_filter_steps_load_clamp_play_and_save_back() {
// Photoshop's defaults, as its steps record them.
let wave = desc("null")
.with("Wvtp", enm("Wvtp", "WvSn"))
.with("NmbG", D::Integer(5))
.with("WLMn", D::Integer(10))
.with("WLMx", D::Integer(120))
.with("AmMn", D::Integer(5))
.with("AmMx", D::Integer(35))
.with("SclH", D::Integer(100))
.with("SclV", D::Integer(100))
.with("UndA", enm("UndA", "RptE"))
.with("RndS", D::Integer(-7));
let zigzag = desc("null").with("Amnt", D::Integer(10)).with("NmbR", D::Integer(5)).with("ZZTy", enm("ZZTy", "PndR"));
let cases = [
("Pnch", desc("null").with("Amnt", D::Integer(50)), "filter.distort.pinch", json!({"amount": 50})),
("Plr ", desc("null").with("Cnvr", enm("Cnvr", "RctP")), "filter.distort.polarCoordinates", json!({"mode": "rectangularToPolar"})),
(
"Sphr",
desc("null").with("Amnt", D::Integer(100)).with("SphM", enm("SphM", "Nrml")),
"filter.distort.spherize",
json!({"amount": 100, "mode": "normal"}),
),
("Twrl", desc("null").with("Angl", D::Integer(50)), "filter.distort.twirl", json!({"angle": 50})),
(
"Wave",
wave,
"filter.distort.wave",
json!({"type": "sine", "generators": 5, "wavelengthMin": 10, "wavelengthMax": 120, "amplitudeMin": 5, "amplitudeMax": 35,
"undefinedAreas": "repeat", "seed": 4_294_967_289_u32}),
),
("ZgZg", zigzag, "filter.distort.zigZag", json!({"amount": 10, "ridges": 5, "style": "pondRipples"})),
];
let mut s = Session::new();
s.execute("file.new", json!({"width": 24, "height": 24})).unwrap();
s.execute("filter.render.clouds", json!({})).unwrap();
for (code, d, id, params) in cases {
let (got, p) = one_step(distort(code, d.clone()));
assert_eq!((got.as_str(), &p), (id, &params), "{code}");
// Every key, in the order Photoshop records them.
assert_eq!(export_item(id, &p, "S").unwrap().descriptor.unwrap().items, d.items, "{code}");
s.actions.list.push(Action::new(code, vec![(id.to_string(), p)]));
let played = s.execute("actions.play", json!({"action": code})).unwrap();
assert_eq!(played["ran"], 1, "{code}: {played}");
}
// A step that leaves its keys out takes Photoshop's defaults: Wave's undefined areas are
// Repeat Edge Pixels, where the command's own default is wrap.
assert_eq!(one_step(distort("Wave", desc("null"))).1["undefinedAreas"], "repeat");
assert_eq!(one_step(distort("Sphr", desc("null"))).1, json!({"amount": 100, "mode": "normal"}));
// Out-of-range values are clamped, as Photoshop clamps them.
assert_eq!(one_step(distort("Pnch", desc("null").with("Amnt", D::Integer(500)))).1, json!({"amount": 100}));
assert_eq!(one_step(distort("Twrl", desc("null").with("Angl", D::Integer(-5000)))).1, json!({"angle": -999}));
assert_eq!(one_step(distort("ZgZg", desc("null").with("NmbR", D::Integer(40)))).1["ridges"], 20);
assert_eq!(one_step(distort("Wave", desc("null").with("NmbG", D::Integer(0)))).1["generators"], 1);
}
/// What the commands cannot play keeps the Photoshop step: an unknown enumeration value, a Wave
/// scaled below 100 % (the command has no scale), or a key the event does not have (#10605).
#[test]
fn distort_filter_steps_the_commands_cannot_play_are_kept() {
let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false });
assert!(kept(distort("Sphr", desc("null").with("SphM", enm("SphM", "Dgnl")))));
assert!(kept(distort("Plr ", desc("null").with("Cnvr", enm("Cnvr", "Sprl")))));
assert!(kept(distort("Wave", desc("null").with("SclH", D::Integer(50)))));
assert!(kept(distort("Wave", desc("null").with("UndA", enm("UndA", "Trns")))));
assert!(kept(distort("Pnch", desc("null").with("Rds ", D::Integer(3)))));
// A scale of 100 % (or above, which Photoshop clamps to 100) plays.
assert!(!kept(distort("Wave", desc("null").with("SclH", D::Integer(100)).with("SclV", D::Integer(250)))));
}
/// A setting the events cannot hold refuses the step instead of replaying differently: a
/// fractional amount (the keys are integers), a mask target, a seed past 32 bits (#10605).
#[test]
fn distort_filter_steps_that_cannot_be_written_are_refused() {
for (id, params) in [
("filter.distort.pinch", json!({"amount": 12.5})),
("filter.distort.twirl", json!({"angle": 10, "target": "mask"})),
("filter.distort.zigZag", json!({"ridges": 2.5})),
("filter.distort.wave", json!({"seed": 4_294_967_296_u64})),
("filter.distort.wave", json!({"undefinedAreas": "transparent"})),
("filter.distort.spherize", json!({"mode": "diagonal"})),
] {
assert!(export_item(id, &params, "S").is_none(), "{id} {params} exported");
}
// Left out, a setting is written as the command replays it: Wave wraps.
let wave = export_item("filter.distort.wave", &json!({}), "S").unwrap().descriptor.unwrap();
assert_eq!(get_enum(&wave, "UndA").as_deref(), Some("WrpA"));
}
+2 -1
View File
@@ -1,6 +1,6 @@
# File-format parity
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-26 · **Change:** minor (TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-31 · **Change:** minor (`.atn`: Pinch, Polar Coordinates, Spherize, Twirl, Wave and ZigZag steps load, play and save back with Photoshop's limits and defaults, #10605; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026
**PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import.
@@ -148,6 +148,7 @@ Embedded SVG and PDF-compatible AI/PDF/EPS payloads can use an explicit VectorCr
| Date | Change | Summary |
|---|---|---|
| 2026-10-31 | minor | `.atn`: Pinch (`Pnch`), Polar Coordinates (`Plr `), Spherize (`Sphr`), Twirl (`Twrl`), Wave (`Wave`) and ZigZag (`ZgZg`) map to `filter.distort.*` with every key in recorded order; out-of-range values clamped and missing keys given Photoshop's defaults on import; a Wave scaled below 100 % stays a Photoshop step (the command has no scale); Shear still kept verbatim (#10605) |
| 2026-10-26 | minor | Embedded AI/PDF/EPS save-back retains native format and a separate source-bound display preview; original sources remain undoable. Native Adobe interoperability and standalone EPS/DCS remain unverified; no corpus floor or percentage changed. |
| 2026-10-22 | minor | Embedded PSD vectors gain explicit bounded SVG editor/save-back; standalone EPS/DCS and native-format fidelity remain open. |
| 2026-10-22 | minor | TIFF: flat pages up to 300,000 px a side open (was 2^30 px, about 32,768²): `codecs::TiffBands` decodes a group of rows at a time into the layer's tiles. The 117,504 × 22,464 px banner from #8456 opens in 3.3 s at 11.45 GB peak for a 10.56 GB layer; 30,000² 6.8 s / 6.43 GB → 1.1 s / 3.86 GB; 36 MP 220 → 52 ms. Layered TIFFs still decode whole (#8169) |