Integration: Custom import takes whole numbers only, export refuses non-object params (#10631)

A Custom (Cstm) step with a fractional Scl/Ofst/cell, e.g. Scl Double 1.5,
was mapped to filter.other.custom with scale 1.5, but export writes integers
and refuses the fraction, so Save Actions failed on a step that used to stay
a kept Photoshop step and round-trip. `within` now requires a whole number;
an integral Double such as 2.0 still maps.

`sets_only` accepted any non-object params, so custom_to(json!([1])) wrote an
identity Custom and bare_to(json!(5)) an empty descriptor for params no
command can play. Only an object or null passes now.

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:
David Mann
2026-11-02 01:59:05 +01:00
co-authored by hectorviov Claude Opus 5.5
parent 4ed3f5b591
commit 632a62b554
2 changed files with 19 additions and 6 deletions
@@ -23,9 +23,10 @@ fn setting<T>(d: &Descriptor, key: &str, read: impl Fn(&D) -> Option<T>, default
}
}
/// Whether `p` sets nothing but `allowed` (a `null` counts as unset).
/// Whether `p` sets nothing but `allowed` (a `null` counts as unset). Params that are neither an
/// object nor `null` are no command's params, so they set something no event can hold.
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())))
p.is_null() || p.as_object().is_some_and(|m| m.iter().all(|(k, v)| v.is_null() || allowed.contains(&k.as_str())))
}
/// NTSC Colors, Average and Remove Black / White Matte: Photoshop runs them with an empty
@@ -91,15 +92,15 @@ const CUSTOM_SCALE: RangeInclusive<f64> = 1.0..=9999.0;
const CUSTOM_OFFSET: RangeInclusive<f64> = -9999.0..=9999.0;
const CUSTOM_CELL: RangeInclusive<f64> = -999.0..=999.0;
/// A number within `range`.
/// A whole number within `range`.
fn within(range: &RangeInclusive<f64>) -> impl Fn(&D) -> Option<f64> + '_ {
move |v| num(v).filter(|n| range.contains(n))
move |v| num(v).filter(|n| n.fract() == 0.0 && range.contains(n))
}
/// Custom (`Cstm`): `Mtrx` is the 5 × 5 kernel, row by row. Photoshop takes exactly 25 cells (a
/// 3 × 3 list makes it fail), so any other matrix keeps the Photoshop step, as does a `Scl `,
/// `Ofst` or cell that is not a number, or one outside the command's range (it would play
/// clamped).
/// `Ofst` or cell that is not a number, one that is not a whole number (export writes integers,
/// so Save Actions would refuse it), or one outside the command's range (it would play clamped).
pub(super) fn custom_from(d: &Descriptor) -> Mapped {
let unsupported = Mapped::Unsupported { harmless: false };
if !only_keys(d, &["Scl", "Ofst", "Mtrx"]) {
+12
View File
@@ -3297,9 +3297,14 @@ fn video_other_and_matting_steps_that_cannot_be_written_are_refused() {
("filter.blur.average", json!({"target": "mask"})),
("filter.other.hsbHsl", json!({"inputMode": "lab"})),
("filter.video.deInterlace", json!({"eliminate": "allFields"})),
("filter.other.custom", json!([1])),
("filter.blur.average", json!(5)),
("filter.other.hsbHsl", json!("hsl")),
] {
assert!(export_item(id, &params, "S").is_none(), "{id} {params} exported");
}
// `null` params set nothing.
assert!(export_item("filter.blur.average", &Value::Null, "S").is_some());
// A Custom step without a kernel is the command's identity kernel.
let d = export_item("filter.other.custom", &json!({}), "S").unwrap().descriptor.unwrap();
let cells = get_list(&d, "Mtrx").unwrap();
@@ -3352,6 +3357,10 @@ fn custom_outside_the_commands_range_is_kept_and_refused() {
desc("null").with("Mtrx", kernel(-1000)),
desc("null").with("Mtrx", D::List((0..26).map(|_| D::Integer(0)).collect())),
desc("null").with("Mtrx", D::List((0..1000).map(|_| D::Integer(0)).collect())),
// Fractions: export writes integers, so Save Actions would refuse the mapped step.
desc("null").with("Mtrx", identity.clone()).with("Scl ", D::Double(1.5)),
desc("null").with("Mtrx", identity.clone()).with("Ofst", D::Double(2.5)),
desc("null").with("Mtrx", D::List((0..25).map(|i| if i == 12 { D::Double(0.5) } else { D::Integer(0) }).collect())),
] {
assert!(kept(other_step("Cstm", d.clone())), "{:?} was mapped", d.items);
}
@@ -3363,6 +3372,9 @@ fn custom_outside_the_commands_range_is_kept_and_refused() {
(&json!(f64::from(scale)), &json!(f64::from(offset)), &json!(f64::from(cell)))
);
}
// An integral Double is a whole number and still maps.
let Mapped::Steps(steps) = other_step("Cstm", desc("null").with("Mtrx", identity.clone()).with("Scl ", D::Double(2.0))) else { panic!("Scl 2.0 kept") };
assert_eq!(steps[0].1["scale"], json!(2.0));
let mut cells = vec![json!(0); 25];
cells[12] = json!(1000);
for params in [json!({"scale": 0}), json!({"scale": 10_000}), json!({"offset": -10_000}), json!({"offset": 10_000}), json!({"kernel": cells})] {