diff --git a/crates/engine/src/cmd/atn_cmds/steps_image.rs b/crates/engine/src/cmd/atn_cmds/steps_image.rs index 7ae8b37328..df33969d92 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_image.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_image.rs @@ -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> { 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 { } } -/// `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 { + match v { + D::Enumerated { type_id, .. } if trimmed(type_id) == "blurType" => enumerated(v), + _ => None, + } +} + fn smart_sharpen_params(d: &Descriptor) -> Option> { - 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 { - 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 { }; 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")), ) } diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 8495847dea..ec2f9aa22e 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -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::>(); 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", ¶ms, "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", ¶ms, "S").is_none(), "{params} exported"); } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 093f8ee93f..50d62d667f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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) |