diff --git a/crates/engine/src/cmd/atn_cmds.rs b/crates/engine/src/cmd/atn_cmds.rs index 42a4722479..e2c37009e8 100644 --- a/crates/engine/src/cmd/atn_cmds.rs +++ b/crates/engine/src/cmd/atn_cmds.rs @@ -32,6 +32,7 @@ mod descriptors; mod events; mod fields; mod files; +mod steps_distort; mod steps_doc; mod steps_image; mod steps_layer; diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index 5723c93c59..b64e33c05b 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -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)]), diff --git a/crates/engine/src/cmd/atn_cmds/steps_distort.rs b/crates/engine/src/cmd/atn_cmds/steps_distort.rs new file mode 100644 index 0000000000..b1ad578cc8 --- /dev/null +++ b/crates/engine/src/cmd/atn_cmds/steps_distort.rs @@ -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> { + 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 { + let params: Vec<&str> = keys.iter().map(Key::param).chain(extra.iter().copied()).chain(["target"]).collect(); + if !pixel_target(p) || !sets_only(p, ¶ms) { + 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 { + 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))) +} diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index f9e633244c..8a1c3e2e1c 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -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, ¶ms), "{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, ¶ms, "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")); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5ef09a2e7e..b0a3c346d7 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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) |