Integration: .atn Oil Paint/Smart Sharpen clamp on import, Smart Sharpen maps the Gaussian form (#10627)

Out-of-range Oil Paint and Smart Sharpen settings kept the step as a
Photoshop step; the reference application clamps them when the step
plays, so the import now clamps (stylization 10.5 -> 10, Amnt 500.5 % ->
500, Rds 0.05 px -> 0.1). Export still refuses out-of-range params.

Oil Paint's light direction (LghD) had no range: LghD 500 mapped and
saved back as 500. It now clamps to the command's -180..180 on import
(500 -> 180) and export refuses {"angle": 181}.

Smart Sharpen mapped the legacy Lens Blur form, which the command's
Gaussian sharpen does not play, and wrote every step as legacy Lens
Blur. It now follows the PSD smart-filter mapping (photocraft-io
smart_map): only useLegacy false + blur GsnB maps (noiseReduction ->
reduceNoise); the reference default (useLegacy true, lensBlur), Motion
Blur and legacy steps stay Photoshop steps; export writes useLegacy
false, Amnt, Rds, noiseReduction, blur GsnB.

Co-authored-by: hectorviov <1501713+hectorviov@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed 2026-11-02 02:13:01 +01:00
1 parent 8988b6bfd0
commit 6ab24508d8
3 files changed
+201 -115

No files matched your search

+54 -35
View File
@@ -541,21 +541,32 @@ const OIL_PAINT: &[(&str, &str, f64, f64)] = &[
("microBrush", "bristleDetail", 10.0, 5.0),
];
/// `filter.stylize.oilPaint`'s ranges. The command clamps a value outside them when it plays,
/// so such a step keeps the Photoshop step on import and is refused on export, rather than
/// mapping a step that plays clamped and saves back unchanged.
const OIL_PAINT_LIMITS: &[(&str, f64, f64)] =
&[("stylization", 0.1, 10.0), ("cleanliness", 0.0, 10.0), ("scale", 0.1, 10.0), ("bristleDetail", 0.0, 10.0), ("shine", 0.0, 10.0)];
/// `filter.stylize.oilPaint`'s ranges (the light direction's is the command's -180..180). Photoshop
/// clamps a recorded value outside them when the step plays, so the import clamps it too; export
/// refuses one, as the dialog cannot hold it.
const OIL_PAINT_LIMITS: &[(&str, f64, f64)] = &[
("stylization", 0.1, 10.0),
("cleanliness", 0.0, 10.0),
("scale", 0.1, 10.0),
("bristleDetail", 0.0, 10.0),
("angle", -180.0, 180.0),
("shine", 0.0, 10.0),
];
/// Whether `n` is within `filter.stylize.oilPaint`'s range for `param` (a param without one is).
fn in_oil_paint_limits(param: &str, n: f64) -> bool {
OIL_PAINT_LIMITS.iter().all(|(p, min, max)| *p != param || (*min..=*max).contains(&n))
}
/// `n` clamped to `filter.stylize.oilPaint`'s range for `param` (unchanged for a param without one).
fn oil_paint_clamped(param: &str, n: f64) -> f64 {
OIL_PAINT_LIMITS.iter().find(|(p, ..)| *p == param).map_or(n, |(_, min, max)| n.clamp(*min, *max))
}
/// Oil Paint (`oilPaint`). Missing keys take Photoshop's defaults, which differ from the
/// command's for cleanliness, scale, bristle detail and shine, so they are written out. A
/// setting of another number type, with a unit, or outside the command's ranges keeps the
/// Photoshop step.
/// setting of another number type or with a unit keeps the Photoshop step; one outside the
/// command's ranges is clamped, as Photoshop clamps it.
pub(super) fn oil_paint_from(d: &Descriptor) -> Mapped {
match oil_paint_params(d) {
Some(p) => step("filter.stylize.oilPaint", Value::Object(p)),
@@ -571,10 +582,10 @@ fn oil_paint_params(d: &Descriptor) -> Option<Map<String, Value>> {
let mut p = Map::new();
p.insert("lighting".into(), setting(d, "lightingOn", boolean, true)?.into());
for (key, param, ps, _) in OIL_PAINT {
p.insert((*param).into(), setting(d, key, double, *ps).filter(|n| in_oil_paint_limits(param, *n))?.into());
p.insert((*param).into(), oil_paint_clamped(param, setting(d, key, double, *ps)?).into());
}
p.insert("angle".into(), setting(d, "LghD", |v| integer(v).map(f64::from), -60.0)?.into());
p.insert("shine".into(), setting(d, "specularity", double, 1.299_999_952_316_28).filter(|n| in_oil_paint_limits("shine", *n))?.into());
p.insert("angle".into(), oil_paint_clamped("angle", setting(d, "LghD", |v| integer(v).map(f64::from), -60.0)?).into());
p.insert("shine".into(), oil_paint_clamped("shine", setting(d, "specularity", double, 1.299_999_952_316_28)?).into());
Some(p)
}
@@ -613,16 +624,17 @@ fn in_unit(unit: [u8; 4]) -> impl Fn(&D) -> Option<f64> {
}
}
/// `filter.sharpen.smartSharpen`'s amount and radius limits, in `smart_sharpen_params`' key order.
/// The command rejects a value outside them, so such a step keeps the Photoshop step on import
/// and is refused on export, rather than mapping a step that fails when it plays.
const SMART_SHARPEN_LIMITS: &[(&str, f64, f64)] = &[("amount", 1.0, 500.0), ("radius", 0.1, 64.0)];
/// `filter.sharpen.smartSharpen`'s amount, radius and noise reduction limits, in
/// `smart_sharpen_params`' key order. Photoshop clamps a recorded value outside them when the step
/// plays, so the import clamps it too; export refuses one, as the dialog cannot hold it.
const SMART_SHARPEN_LIMITS: &[(&str, f64, f64)] = &[("amount", 1.0, 500.0), ("radius", 0.1, 64.0), ("reduceNoise", 0.0, 100.0)];
/// Smart Sharpen (`smartSharpen`), as Photoshop records it without its dialog: the legacy
/// filter, removing Lens Blur, with no noise reduction (so `reduceNoise` is 0). A step that
/// asks for what the command does not do (another blur, More Accurate, the newer filter, a
/// preset), or an amount or radius in another unit or outside the command's limits, keeps it
/// as a Photoshop step.
/// Smart Sharpen (`smartSharpen`) in the one form the command plays, the same form PSD smart
/// filters map (`photocraft-io`'s `smart_map`): the newer filter (`useLegacy` false) removing a
/// Gaussian blur (`blur` `GsnB`), both recorded. The legacy filter (Photoshop's default, which
/// removes Lens Blur), Lens Blur, Motion Blur, a preset other than Custom or any other key, or an
/// amount, radius or noise reduction in another unit, keeps it as a Photoshop step. A missing
/// amount, radius or noise reduction takes Photoshop's default (200 %, 1 px, 10 %).
pub(super) fn smart_sharpen_from(d: &Descriptor) -> Mapped {
match smart_sharpen_params(d) {
Some(p) => step("filter.sharpen.smartSharpen", Value::Object(p)),
@@ -630,29 +642,37 @@ pub(super) fn smart_sharpen_from(d: &Descriptor) -> Mapped {
}
}
/// A `blurType` enumeration's value (`enumerated` alone does not check the type).
fn blur_type(v: &D) -> Option<String> {
match v {
D::Enumerated { type_id, .. } if trimmed(type_id) == "blurType" => enumerated(v),
_ => None,
}
}
fn smart_sharpen_params(d: &Descriptor) -> Option<Map<String, Value>> {
let keys = ["presetKind", "useLegacy", "Amnt", "Rds", "moreAccurate", "blur"];
let as_recorded = only_keys(d, &keys)
let keys = ["presetKind", "useLegacy", "Amnt", "Rds", "noiseReduction", "blur"];
let gaussian = only_keys(d, &keys)
&& setting(d, "presetKind", enumerated, "presetKindCustom".into())? == "presetKindCustom"
&& setting(d, "useLegacy", boolean, true)?
&& !setting(d, "moreAccurate", boolean, false)?
&& setting(d, "blur", enumerated, "lensBlur".into())? == "lensBlur";
if !as_recorded {
&& !setting(d, "useLegacy", boolean, true)?
&& setting(d, "blur", blur_type, "lensBlur".into())? == "GsnB";
if !gaussian {
return None;
}
let mut p = Map::new();
for ((key, unit, default), (param, min, max)) in [("Amnt", *b"#Prc", 200.0), ("Rds", PX, 1.0)].into_iter().zip(SMART_SHARPEN_LIMITS) {
let n = setting(d, key, in_unit(unit), default).filter(|n| (*min..=*max).contains(n))?;
let read = [("Amnt", *b"#Prc", 200.0), ("Rds", PX, 1.0), ("noiseReduction", *b"#Prc", 10.0)];
for ((key, unit, default), (param, min, max)) in read.into_iter().zip(SMART_SHARPEN_LIMITS) {
let n = setting(d, key, in_unit(unit), default)?.clamp(*min, *max);
p.insert((*param).into(), n.into());
}
p.insert("reduceNoise".into(), 0.into());
Some(p)
}
/// Every key in recorded order. The legacy filter has no noise reduction, so only a step that
/// sets `reduceNoise` to 0 is written (the command reduces noise by 10 when it is left out).
/// The form `smart_sharpen_params` reads, with the keys in the order Photoshop records them (as
/// PSD smart filters write it; `presetKind` is left out, as Photoshop leaves it out of a step with
/// explicit values). The command's defaults fill what the step leaves out.
pub(super) fn smart_sharpen_to(_: &str, p: &Value) -> Option<Descriptor> {
if !pixel_target(p) || !sets_only(p, &["target", "amount", "radius", "reduceNoise"]) || p.get("reduceNoise").and_then(Value::as_f64) != Some(0.0) {
if !pixel_target(p) || !sets_only(p, &["target", "amount", "radius", "reduceNoise"]) {
return None;
}
let num = |k: &str, default: f64| match p.get(k) {
@@ -661,11 +681,10 @@ pub(super) fn smart_sharpen_to(_: &str, p: &Value) -> Option<Descriptor> {
};
Some(
desc("null")
.with("presetKind", enm("presetKindType", "presetKindCustom"))
.with("useLegacy", D::Boolean(true))
.with("useLegacy", D::Boolean(false))
.with("Amnt", pct(num("amount", 100.0)?))
.with("Rds ", px(num("radius", 1.0)?))
.with("moreAccurate", D::Boolean(false))
.with("blur", enm("blurType", "lensBlur")),
.with("noiseReduction", pct(num("reduceNoise", 10.0)?))
.with("blur", enm("blurType", "GsnB")),
)
}
+146 -79
View File
@@ -3210,16 +3210,21 @@ fn newer_feature_steps_load_with_photoshops_defaults() {
"bristleDetail": 10.0, "angle": -60.0, "shine": 1.299_999_952_316_28});
assert_eq!(mapped(newer("oilPaint", oil.clone())), ("filter.stylize.oilPaint".into(), expected_oil.clone()));
assert_eq!(mapped(newer("oilPaint", desc("null"))).1, expected_oil, "a missing key takes Photoshop's default");
// Smart Sharpen in the form the command plays (the newer filter, removing a Gaussian blur).
let sharpen = desc("null")
.with("presetKind", enm("presetKindType", "presetKindCustom"))
.with("useLegacy", D::Boolean(true))
.with("Amnt", pct(200.0))
.with("Rds ", px(1.0))
.with("moreAccurate", D::Boolean(false))
.with("blur", enm("blurType", "lensBlur"));
.with("useLegacy", D::Boolean(false))
.with("Amnt", pct(150.0))
.with("Rds ", px(2.5))
.with("noiseReduction", pct(20.0))
.with("blur", enm("blurType", "GsnB"));
assert_eq!(
mapped(newer("smartSharpen", sharpen.clone())),
("filter.sharpen.smartSharpen".into(), json!({"amount": 200.0, "radius": 1.0, "reduceNoise": 0}))
("filter.sharpen.smartSharpen".into(), json!({"amount": 150.0, "radius": 2.5, "reduceNoise": 20.0}))
);
assert_eq!(
mapped(newer("smartSharpen", gaussian_sharpen(&[]))).1,
json!({"amount": 200.0, "radius": 1.0, "reduceNoise": 10.0}),
"a missing amount, radius or noise reduction takes Photoshop's default"
);
assert_eq!(mapped(newer("selectSky", desc("null"))), ("select.sky".into(), json!({"sampleAllLayers": false})));
assert_eq!(mapped(newer("focusMask", desc("null"))), ("select.focusArea".into(), json!({})));
@@ -3228,7 +3233,9 @@ fn newer_feature_steps_load_with_photoshops_defaults() {
// Written back, they carry the same keys in the same order.
let back = |id: &str, p: Value| export_item(id, &p, "S").unwrap_or_else(|| panic!("{id} does not export")).descriptor.unwrap();
assert_eq!(back("filter.stylize.oilPaint", expected_oil).items, oil.items);
assert_eq!(back("filter.sharpen.smartSharpen", json!({"amount": 200, "radius": 1, "reduceNoise": 0})).items, sharpen.items);
assert_eq!(back("filter.sharpen.smartSharpen", json!({"amount": 150, "radius": 2.5, "reduceNoise": 20})).items, sharpen.items);
let (id, params) = mapped(newer("smartSharpen", sharpen.clone()));
assert_eq!(back(&id, params).items, sharpen.items, "load -> save changed the step");
// Oil Paint and Smart Sharpen play.
let mut s = Session::new();
@@ -3238,7 +3245,7 @@ fn newer_feature_steps_load_with_photoshops_defaults() {
"Paint",
vec![
("filter.stylize.oilPaint".into(), mapped(newer("oilPaint", desc("null"))).1),
("filter.sharpen.smartSharpen".into(), mapped(newer("smartSharpen", desc("null"))).1),
("filter.sharpen.smartSharpen".into(), mapped(newer("smartSharpen", gaussian_sharpen(&[]))).1),
],
);
s.actions.list.push(action);
@@ -3251,10 +3258,26 @@ fn newer_feature_steps_load_with_photoshops_defaults() {
#[test]
fn newer_feature_steps_the_commands_cannot_play_are_kept() {
let kept = |m: Mapped| matches!(m, Mapped::Unsupported { harmless: false });
assert!(kept(newer("smartSharpen", desc("null").with("blur", enm("blurType", "motionBlur")))));
assert!(kept(newer("smartSharpen", desc("null").with("moreAccurate", D::Boolean(true)))));
assert!(kept(newer("smartSharpen", desc("null").with("useLegacy", D::Boolean(false)))));
assert!(kept(newer("smartSharpen", desc("null").with("presetKind", enm("presetKindType", "presetKindDefault")))));
// Photoshop's own default step: the legacy filter removing Lens Blur, which the command's
// Gaussian sharpen does not play.
let legacy = desc("null")
.with("presetKind", enm("presetKindType", "presetKindCustom"))
.with("useLegacy", D::Boolean(true))
.with("Amnt", pct(200.0))
.with("Rds ", px(1.0))
.with("moreAccurate", D::Boolean(false))
.with("blur", enm("blurType", "lensBlur"));
assert!(kept(newer("smartSharpen", legacy)));
assert!(kept(newer("smartSharpen", desc("null"))), "an empty step is the legacy filter");
assert!(kept(newer("smartSharpen", desc("null").with("useLegacy", D::Boolean(false)))), "no blur recorded is Lens Blur");
assert!(kept(newer("smartSharpen", desc("null").with("blur", enm("blurType", "GsnB")))), "no useLegacy recorded is the legacy filter");
for blur in ["lensBlur", "MtnB", "motionBlur"] {
assert!(kept(newer("smartSharpen", gaussian_sharpen(&[("blur", enm("blurType", blur))]))), "{blur}");
}
assert!(kept(newer("smartSharpen", gaussian_sharpen(&[("useLegacy", D::Boolean(true))]))));
assert!(kept(newer("smartSharpen", gaussian_sharpen(&[("blur", enm("Ordn", "GsnB"))]))), "a blur of another type");
assert!(kept(newer("smartSharpen", gaussian_sharpen(&[("moreAccurate", D::Boolean(false))]))));
assert!(kept(newer("smartSharpen", gaussian_sharpen(&[("presetKind", enm("presetKindType", "presetKindDefault"))]))));
assert!(kept(newer("focusMask", desc("null").with("Rng ", D::Double(3.0)))));
assert!(kept(newer("removeBackground", desc("null").with("Lyr ", D::Integer(1)))));
assert!(kept(newer("selectSky", desc("null").with("smartRadius", D::Boolean(true)))));
@@ -3265,8 +3288,8 @@ fn newer_feature_steps_the_commands_cannot_play_are_kept() {
#[test]
fn newer_feature_steps_that_cannot_be_written_are_refused() {
for (id, params) in [
("filter.sharpen.smartSharpen", json!({"amount": 150})),
("filter.sharpen.smartSharpen", json!({"amount": 150, "reduceNoise": 10})),
("filter.sharpen.smartSharpen", json!({"amount": 150, "blur": "lens"})),
("filter.sharpen.smartSharpen", json!({"target": "mask"})),
("filter.stylize.oilPaint", json!({"angle": 10.5})),
("filter.stylize.oilPaint", json!({"target": "mask"})),
("select.focusArea", json!({"range": 0.3})),
@@ -3279,6 +3302,28 @@ fn newer_feature_steps_that_cannot_be_written_are_refused() {
assert!(export_item("select.sky", &json!({"mode": "replace", "threshold": 50, "softness": 50}), "S").is_some());
}
/// A Smart Sharpen step in the form the command plays (`useLegacy` false, `blur` `GsnB`) plus
/// `settings`, which replace those two when they name them.
fn gaussian_sharpen(settings: &[(&str, D)]) -> Descriptor {
let named = |k: &str| settings.iter().any(|(key, _)| key.trim_end() == k);
let mut d = desc("null");
if !named("useLegacy") {
d = d.with("useLegacy", D::Boolean(false));
}
if !named("blur") {
d = d.with("blur", enm("blurType", "GsnB"));
}
settings.iter().fold(d, |d, (k, v)| d.with(k, v.clone()))
}
/// `kept_when_wrong_typed` for Smart Sharpen, on the form it would otherwise map.
fn sharpen_kept_when(settings: &[(&str, D)]) {
for (key, v) in settings {
let m = newer("smartSharpen", gaussian_sharpen(&[(key, v.clone())]));
assert!(matches!(m, Mapped::Unsupported { harmless: false }), "smartSharpen with {key:?} = {v:?} was mapped");
}
}
/// A recorded setting of the wrong type is the step's own setting, not a missing one: the
/// mapper must not play it with a default but keep the Photoshop step (#10610).
fn kept_when_wrong_typed(name: &str, settings: &[(&str, D)]) {
@@ -3311,26 +3356,23 @@ fn oil_paint_with_a_wrong_typed_setting_is_kept() {
#[test]
fn smart_sharpen_with_a_wrong_typed_setting_is_kept() {
kept_when_wrong_typed(
"smartSharpen",
&[
("presetKind", text("presetKindDefault")),
("useLegacy", D::Integer(0)),
("moreAccurate", D::Integer(1)),
("blur", text("motionBlur")),
("Amnt", D::Boolean(true)),
("Rds ", text("2")),
("Rds", text("2")),
],
);
sharpen_kept_when(&[
("presetKind", text("presetKindDefault")),
("useLegacy", D::Integer(0)),
("blur", text("GsnB")),
("Amnt", D::Boolean(true)),
("Rds ", text("2")),
("Rds", text("2")),
("noiseReduction", text("10")),
]);
}
/// `only_keys` admits a code without its space padding, so the mapper reads it too: an
/// unpadded `Rds` radius is the step's radius, not the default 1 (#10610).
#[test]
fn smart_sharpen_reads_an_unpadded_radius() {
let d = desc("null").with("Amnt", pct(150.0)).with("Rds", px(2.5));
assert_eq!(mapped(newer("smartSharpen", d)), ("filter.sharpen.smartSharpen".into(), json!({"amount": 150.0, "radius": 2.5, "reduceNoise": 0})));
let d = gaussian_sharpen(&[("Amnt", pct(150.0)), ("Rds", px(2.5))]);
assert_eq!(mapped(newer("smartSharpen", d)), ("filter.sharpen.smartSharpen".into(), json!({"amount": 150.0, "radius": 2.5, "reduceNoise": 10.0})));
}
/// A step recording one of its keys twice, padded or not, can't be saved back with both values,
@@ -3344,9 +3386,9 @@ fn newer_feature_steps_with_a_repeated_key_are_kept() {
("smartSharpen", "Rds ", "Rds", px(2.0), px(3.0)),
("smartSharpen", "Rds", "Rds", px(2.0), px(2.0)),
("smartSharpen", "presetKind", "presetKind", enm("presetKindType", "presetKindCustom"), enm("presetKindType", "presetKindCustom")),
("smartSharpen", "useLegacy", "useLegacy", D::Boolean(true), D::Boolean(true)),
("smartSharpen", "moreAccurate", "moreAccurate", D::Boolean(false), D::Boolean(false)),
("smartSharpen", "blur", "blur", enm("blurType", "lensBlur"), enm("blurType", "lensBlur")),
("smartSharpen", "useLegacy", "useLegacy", D::Boolean(false), D::Boolean(false)),
("smartSharpen", "noiseReduction", "noiseReduction", pct(10.0), pct(20.0)),
("smartSharpen", "blur", "blur", enm("blurType", "GsnB"), enm("blurType", "GsnB")),
("oilPaint", "lightingOn", "lightingOn", D::Boolean(true), D::Boolean(false)),
("oilPaint", "stylization", "stylization", D::Double(4.0), D::Double(6.0)),
("oilPaint", "cleanliness", "cleanliness", D::Double(2.0), D::Double(3.0)),
@@ -3357,7 +3399,14 @@ fn newer_feature_steps_with_a_repeated_key_are_kept() {
("selectSky", "sampleAllLayers", "sampleAllLayers", D::Boolean(false), D::Boolean(true)),
]
.into_iter()
.filter(|(name, key, again, a, b)| !kept(newer(name, desc("null").with(key, a.clone()).with(again, b.clone()))))
.filter(|(name, key, again, a, b)| {
let d = if *name == "smartSharpen" {
gaussian_sharpen(&[(key, a.clone()), (again, b.clone())])
} else {
desc("null").with(key, a.clone()).with(again, b.clone())
};
!kept(newer(name, d))
})
.map(|(name, key, again, ..)| format!("{name} {key:?} {again:?}"))
.collect::<Vec<_>>();
assert!(mapped.is_empty(), "mapped with a repeated key: {mapped:?}");
@@ -3387,47 +3436,59 @@ fn oil_paint_setting_with_a_unit_or_another_number_type_is_kept() {
/// step stays a Photoshop step, whether the radius key is padded or not (#10610).
#[test]
fn smart_sharpen_amount_or_radius_in_another_unit_is_kept() {
kept_when_wrong_typed(
"smartSharpen",
&[
("Amnt", px(150.0)),
("Amnt", D::Double(150.0)),
("Amnt", D::Integer(150)),
("Rds ", pct(2.0)),
("Rds ", D::Double(2.0)),
("Rds", D::UnitFloat { unit: *b"#Mlm", value: 2.0 }),
("Rds", D::Double(2.0)),
],
);
sharpen_kept_when(&[
("Amnt", px(150.0)),
("Amnt", D::Double(150.0)),
("Amnt", D::Integer(150)),
("Rds ", pct(2.0)),
("Rds ", D::Double(2.0)),
("Rds", D::UnitFloat { unit: *b"#Mlm", value: 2.0 }),
("Rds", D::Double(2.0)),
("noiseReduction", px(10.0)),
("noiseReduction", D::Double(10.0)),
]);
for rds in ["Rds ", "Rds"] {
let d = desc("null").with("Amnt", pct(150.0)).with(rds, px(2.5));
let d = gaussian_sharpen(&[("Amnt", pct(150.0)), (rds, px(2.5))]);
assert_eq!(
mapped(newer("smartSharpen", d)),
("filter.sharpen.smartSharpen".into(), json!({"amount": 150.0, "radius": 2.5, "reduceNoise": 0})),
("filter.sharpen.smartSharpen".into(), json!({"amount": 150.0, "radius": 2.5, "reduceNoise": 10.0})),
"{rds:?}"
);
}
}
/// `filter.sharpen.smartSharpen` takes an amount of 1..500 % and a radius of 0.1..64 px and
/// rejects anything else. A step outside them would map and then fail when it plays, so it
/// stays a Photoshop step, and a step outside them is not written either (#10610).
/// `filter.sharpen.smartSharpen` takes an amount of 1..500 %, a radius of 0.1..64 px and a noise
/// reduction of 0..100 %. Photoshop clamps a recorded value outside them when the step plays,
/// so the import clamps it too; a step outside them is not written (#10610).
#[test]
fn smart_sharpen_outside_the_commands_limits_is_kept_and_refused() {
kept_when_wrong_typed("smartSharpen", &[("Amnt", pct(0.5)), ("Amnt", pct(500.5)), ("Rds ", px(0.05)), ("Rds ", px(64.5)), ("Rds", px(1000.0))]);
for (amount, radius) in [(1.0, 0.1), (500.0, 64.0)] {
let d = desc("null").with("Amnt", pct(amount)).with("Rds ", px(radius));
fn smart_sharpen_outside_the_commands_limits_is_clamped_on_import_and_refused_on_export() {
for (key, v, param, want) in [
("Amnt", pct(0.5), "amount", 1.0),
("Amnt", pct(500.5), "amount", 500.0),
("Rds ", px(0.05), "radius", 0.1),
("Rds ", px(64.5), "radius", 64.0),
("Rds", px(1000.0), "radius", 64.0),
("noiseReduction", pct(-5.0), "reduceNoise", 0.0),
("noiseReduction", pct(150.0), "reduceNoise", 100.0),
] {
let (id, p) = mapped(newer("smartSharpen", gaussian_sharpen(&[(key, v.clone())])));
assert_eq!((id.as_str(), &p[param]), ("filter.sharpen.smartSharpen", &json!(want)), "{key:?} = {v:?}");
}
for (amount, radius, noise) in [(1.0, 0.1, 0.0), (500.0, 64.0, 100.0)] {
let d = gaussian_sharpen(&[("Amnt", pct(amount)), ("Rds ", px(radius)), ("noiseReduction", pct(noise))]);
assert_eq!(
mapped(newer("smartSharpen", d)),
("filter.sharpen.smartSharpen".into(), json!({"amount": amount, "radius": radius, "reduceNoise": 0})),
("filter.sharpen.smartSharpen".into(), json!({"amount": amount, "radius": radius, "reduceNoise": noise})),
"{amount} {radius}"
);
}
for params in [
json!({"amount": 0.5, "reduceNoise": 0}),
json!({"amount": 501, "reduceNoise": 0}),
json!({"radius": 0.05, "reduceNoise": 0}),
json!({"radius": 65, "reduceNoise": 0}),
json!({"amount": 0.5}),
json!({"amount": 501}),
json!({"radius": 0.05}),
json!({"radius": 65}),
json!({"reduceNoise": -1}),
json!({"reduceNoise": 101}),
] {
assert!(export_item("filter.sharpen.smartSharpen", &params, "S").is_none(), "{params} exported");
}
@@ -3495,27 +3556,30 @@ fn newer_feature_selection_steps_play() {
assert!(mask.value(20, 15) < 0.2, "Remove Background revealed the sky");
}
/// `filter.stylize.oilPaint` takes a stylization and scale of 0.1..10 and a cleanliness, bristle
/// detail and shine of 0..10, and clamps anything else when it plays. A step outside them would
/// play clamped and save back unchanged, so it stays a Photoshop step, and a step outside them
/// is not written either (#10610).
/// `filter.stylize.oilPaint` takes a stylization and scale of 0.1..10, a cleanliness, bristle
/// detail and shine of 0..10 and a light direction of -180..180. Photoshop clamps a recorded
/// value outside them when the step plays, so the import clamps it too; a step outside them is
/// not written (#10610).
#[test]
fn oil_paint_outside_the_commands_ranges_is_kept_and_refused() {
kept_when_wrong_typed(
"oilPaint",
&[
("stylization", D::Double(0.05)),
("stylization", D::Double(10.5)),
("cleanliness", D::Double(-0.5)),
("cleanliness", D::Double(10.5)),
("brushScale", D::Double(0.05)),
("brushScale", D::Double(10.5)),
("microBrush", D::Double(-1.0)),
("microBrush", D::Double(10.5)),
("specularity", D::Double(-0.5)),
("specularity", D::Double(10.5)),
],
);
fn oil_paint_outside_the_commands_ranges_is_clamped_on_import_and_refused_on_export() {
for (key, v, param, want) in [
("stylization", D::Double(0.05), "stylization", 0.1),
("stylization", D::Double(10.5), "stylization", 10.0),
("cleanliness", D::Double(-0.5), "cleanliness", 0.0),
("cleanliness", D::Double(10.5), "cleanliness", 10.0),
("brushScale", D::Double(0.05), "scale", 0.1),
("brushScale", D::Double(10.5), "scale", 10.0),
("microBrush", D::Double(-1.0), "bristleDetail", 0.0),
("microBrush", D::Double(10.5), "bristleDetail", 10.0),
("specularity", D::Double(-0.5), "shine", 0.0),
("specularity", D::Double(10.5), "shine", 10.0),
("LghD", D::Integer(500), "angle", 180.0),
("LghD", D::Integer(-500), "angle", -180.0),
("LghD", D::Integer(181), "angle", 180.0),
] {
let (id, p) = mapped(newer("oilPaint", desc("null").with(key, v.clone())));
assert_eq!((id.as_str(), &p[param]), ("filter.stylize.oilPaint", &json!(want)), "{key:?} = {v:?}");
}
for (low, high) in [(0.1, 0.0), (10.0, 10.0)] {
let d = desc("null")
.with("stylization", D::Double(low))
@@ -3543,6 +3607,9 @@ fn oil_paint_outside_the_commands_ranges_is_kept_and_refused() {
json!({"bristleDetail": 11}),
json!({"shine": -0.5}),
json!({"shine": 10.5}),
json!({"angle": 181}),
json!({"angle": -181}),
json!({"angle": 1000}),
] {
assert!(export_item("filter.stylize.oilPaint", &params, "S").is_none(), "{params} exported");
}
+1 -1
View File
@@ -148,7 +148,7 @@ Embedded SVG and PDF-compatible AI/PDF/EPS payloads can use an explicit VectorCr
| Date | Change | Summary |
|---|---|---|
| 2026-10-31 | minor | `.atn`: Oil Paint (`oilPaint`), Smart Sharpen (`smartSharpen`), Focus Area (`focusMask`), Select Sky (`selectSky`) and Remove Background (`removeBackground`) map to their commands with the keys and defaults Photoshop records (Oil Paint's and Select Sky's defaults written out where they differ from the commands'); Smart Sharpen maps the legacy Lens Blur form and keeps the others as Photoshop steps (#10610) |
| 2026-10-31 | minor | `.atn`: Oil Paint (`oilPaint`), Smart Sharpen (`smartSharpen`), Focus Area (`focusMask`), Select Sky (`selectSky`) and Remove Background (`removeBackground`) map to their commands with the keys and defaults Photoshop records (Oil Paint's and Select Sky's defaults written out where they differ from the commands'); Smart Sharpen maps the newer filter's Gaussian form, as PSD smart filters do, and keeps the legacy filter, Lens Blur and Motion Blur as Photoshop steps; out-of-range Oil Paint and Smart Sharpen settings are clamped on import (#10610) |
| 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) |