From 9f2db12ec22463c4d8c4ab0d92b24b74ffafc433 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 07:24:55 -0800 Subject: [PATCH] Plug-ins: a painted live effect's paint keys are checked at every level A key that a paint does not have, such as `opacity` on a solid fill, was dropped when the paint was read, and the fill was drawn opaque. The result is now refused and `lastError` names the field, for keys on the paint and on its colors, gradient, stops, placement and freeform points. --- crates/plugins/src/painted.rs | 78 ++++++++++++++++++++++++++++- crates/plugins/src/painted/tests.rs | 61 ++++++++++++++++++++++ docs/plugins.md | 3 +- 3 files changed, 139 insertions(+), 3 deletions(-) diff --git a/crates/plugins/src/painted.rs b/crates/plugins/src/painted.rs index 700936ff0..7df197ce9 100644 --- a/crates/plugins/src/painted.rs +++ b/crates/plugins/src/painted.rs @@ -47,8 +47,8 @@ pub enum Scope { /// Parses and checks a painted result: `{"objects": [...]}`, or `{"error": "message"}` when the /// plug-in declined. `rule` is the fill rule a piece without `fillRule` takes. Any other top-level -/// key, an unknown key in a piece or stroke, a list or count past its limit, a number out of range -/// or a paint of the wrong form refuses the whole result, naming the field. +/// key, an unknown key in a piece, stroke or paint, a list or count past its limit, a number out of +/// range or a paint of the wrong form refuses the whole result, naming the field. pub fn decode(bytes: &[u8], scope: Scope, rule: FillRule) -> Result> { let v: Value = serde_json::from_slice(bytes).map_err(|e| out_err("output", format!("not JSON: {e}")))?; if let Some(e) = crate::objects::declined(&v) { @@ -162,12 +162,86 @@ fn color(c: &Color, at: &str) -> Result<()> { } } +/// Refuses a key of object `v` that is not in `keys`, naming it after `at`. A value that is not an +/// object passes, and deserializing it reports the fault. +fn only_keys(v: &Value, at: &str, keys: &[&str]) -> Result<()> { + match v.as_object().and_then(|o| o.keys().find(|k| !keys.contains(&k.as_str()))) { + Some(k) => Err(out_err(&format!("{at}.{k}"), format!("unknown key `{k}`; the keys here are {}", keys.join(", ")))), + None => Ok(()), + } +} + +/// [`only_keys`] for a color, whose keys follow its `model`. An unknown model passes, and +/// deserializing it reports the fault. +fn color_keys(v: &Value, at: &str) -> Result<()> { + let keys: &[&str] = match v.get("model").and_then(Value::as_str) { + Some("rgb") => &["model", "r", "g", "b"], + Some("cmyk") => &["model", "c", "m", "y", "k"], + Some("gray") => &["model", "k"], + Some("lab") => &["model", "l", "a", "b"], + _ => return Ok(()), + }; + only_keys(v, at, keys) +} + +/// [`only_keys`] for a point `{x, y}`. +fn point_keys(v: Option<&Value>, at: &str) -> Result<()> { + v.map_or(Ok(()), |v| only_keys(v, at, &["x", "y"])) +} + +/// Calls `f` on each entry of the array at `v`, named `at[i]`. +fn each(v: Option<&Value>, at: &str, f: impl Fn(&Value, &str) -> Result<()>) -> Result<()> { + v.and_then(Value::as_array).into_iter().flatten().enumerate().try_for_each(|(i, e)| f(e, &format!("{at}[{i}]"))) +} + +/// Refuses an unknown key at any level of paint `v`: the paint itself, its colors, a gradient's +/// stops, placement and freeform points. Deserializing a paint drops unknown keys, so a key such +/// as `opacity` on a solid paint would otherwise be accepted and do nothing. +fn paint_keys(v: &Value, at: &str) -> Result<()> { + match v.get("type").and_then(Value::as_str) { + Some("none") => only_keys(v, at, &["type"]), + Some("solid") => { + only_keys(v, at, &["type", "color", "swatch", "tint"])?; + v.get("color").map_or(Ok(()), |c| color_keys(c, &format!("{at}.color"))) + } + Some("pattern") => only_keys(v, at, &["type", "pattern", "xf"]), + Some("gradient") => { + only_keys(v, at, &["type", "gradient", "geom", "angle", "swatch", "freeform"])?; + if let Some(g) = v.get("gradient") { + let at = format!("{at}.gradient"); + only_keys(g, &at, &["kind", "stops", "dither", "method"])?; + each(g.get("stops"), &format!("{at}.stops"), |s, at| { + only_keys(s, at, &["offset", "color", "opacity", "midpoint", "swatch", "tint", "plates"])?; + s.get("color").map_or(Ok(()), |c| color_keys(c, &format!("{at}.color"))) + })?; + } + if let Some(geom) = v.get("geom") { + let at = format!("{at}.geom"); + only_keys(geom, &at, &["start", "end", "aspect", "focal"])?; + ["start", "end", "focal"].into_iter().try_for_each(|k| point_keys(geom.get(k), &format!("{at}.{k}")))?; + } + if let Some(ff) = v.get("freeform") { + let at = format!("{at}.freeform"); + only_keys(ff, &at, &["points", "lines", "mode"])?; + each(ff.get("points"), &format!("{at}.points"), |p, at| { + only_keys(p, at, &["at", "color", "opacity", "spread"])?; + point_keys(p.get("at"), &format!("{at}.at"))?; + p.get("color").map_or(Ok(()), |c| color_keys(c, &format!("{at}.color"))) + })?; + } + Ok(()) + } + _ => Ok(()), + } +} + /// A paint as the filter form has it (`null`: none), checked rather than clamped: colors, tints, /// stops, opacities, midpoints and placements in range. fn paint(v: &Value, at: &str) -> Result { if v.is_null() { return Ok(Paint::None); } + paint_keys(v, at)?; let p: Paint = serde_json::from_value(v.clone()).map_err(|e| out_err(at, e))?; let unit = |x: f32, at: &str, what: &str| in_range(x, 0.0, 1.0, at, what); match &p { diff --git a/crates/plugins/src/painted/tests.rs b/crates/plugins/src/painted/tests.rs index ff72d330f..cd1c36404 100644 --- a/crates/plugins/src/painted/tests.rs +++ b/crates/plugins/src/painted/tests.rs @@ -123,6 +123,67 @@ fn faults_refuse_the_whole_result_by_field() { assert_eq!(declined, Error::Failed("nothing to do".into())); } +/// A key a paint does not have, at any level of it, refuses the result and is named; it is not +/// dropped. +#[test] +fn unknown_keys_inside_a_paint_are_refused_by_field() { + let gray = json!({"model": "gray", "k": 0}); + let stop = |extra: Value| { + let mut s = json!({"offset": 0, "color": gray}); + s.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + json!({"type": "gradient", "gradient": {"kind": "Linear", "stops": [s]}}) + }; + let point = json!({"x": 0, "y": 0}); + for (fill, at) in [ + (json!({"type": "solid", "color": {"model": "rgb", "r": 1, "g": 0, "b": 0}, "opacity": 0.5}), "objects[0].fills[0].opacity"), + (json!({"type": "solid", "color": {"model": "gray", "k": 0, "alpha": 0.5}}), "objects[0].fills[0].color.alpha"), + (json!({"type": "none", "color": gray}), "objects[0].fills[0].color"), + (json!({"type": "pattern", "pattern": "Dots", "scale": 2}), "objects[0].fills[0].scale"), + (stop(json!({"blend": "multiply"})), "objects[0].fills[0].gradient.stops[0].blend"), + (stop(json!({"color": {"model": "lab", "l": 50, "a": 0, "b": 0, "c": 1}})), "objects[0].fills[0].gradient.stops[0].color.c"), + ( + json!({"type": "gradient", "gradient": {"kind": "Linear", "stops": [{"offset": 0, "color": gray}], "opacity": 0.5}}), + "objects[0].fills[0].gradient.opacity", + ), + ( + json!({"type": "gradient", "gradient": {"kind": "Linear", "stops": [{"offset": 0, "color": gray}]}, "opacity": 0.5}), + "objects[0].fills[0].opacity", + ), + ( + json!({"type": "gradient", "gradient": {"kind": "Radial", "stops": [{"offset": 0, "color": gray}]}, + "geom": {"start": point, "end": {"x": 1, "y": 0, "z": 0}}}), + "objects[0].fills[0].geom.end.z", + ), + ( + json!({"type": "gradient", "gradient": {"kind": "Freeform", "stops": [{"offset": 0, "color": gray}]}, + "freeform": {"points": [{"at": point, "color": gray, "glow": 1}]}}), + "objects[0].fills[0].freeform.points[0].glow", + ), + ] { + let e = decode(&out(json!([{"path": square(), "fills": [fill]}])), Scope::Object, FillRule::NonZero).unwrap_err().to_string(); + assert!(e.contains(&format!("{at}:")), "{at}: {e}"); + } + let e = decode( + &out(json!([{"path": square(), "strokes": [{"paint": {"type": "solid", "color": gray, "opacity": 0.5}}]}])), + Scope::Object, + FillRule::NonZero, + ) + .unwrap_err() + .to_string(); + assert!(e.contains("objects[0].strokes[0].paint.opacity:"), "{e}"); + // Every key a paint does have is still taken. + let full = json!({"type": "gradient", "angle": 30, "swatch": "Ramp", + "gradient": {"kind": "Freeform", "dither": true, "method": "Perceptual", + "stops": [{"offset": 0, "color": gray, "opacity": 1, "midpoint": 0.5, "swatch": "Ink", "tint": 0.5, "plates": [0, 0, 0, 0]}]}, + "geom": {"start": point, "end": {"x": 1, "y": 0}, "aspect": 1, "focal": point}, + "freeform": {"points": [{"at": point, "color": gray, "opacity": 1, "spread": 0.5}], "lines": [[0]], "mode": "lines"}}); + let solid = json!({"type": "solid", "color": {"model": "cmyk", "c": 0, "m": 0, "y": 0, "k": 1}, "swatch": "Ink", "tint": 0.5}); + let pattern = json!({"type": "pattern", "pattern": "Dots", "xf": [1, 0, 0, 1, 0, 0]}); + assert!( + decode(&out(json!([{"path": square(), "fills": [full, solid, pattern, {"type": "none"}, null]}])), Scope::Object, FillRule::NonZero).is_ok() + ); +} + /// The swatches and patterns a piece's paints name must be in the document. #[test] fn named_swatches_and_patterns_must_exist() { diff --git a/docs/plugins.md b/docs/plugins.md index 59a31be97..88bc10065 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -413,7 +413,8 @@ is a piece drawn by itself, in the order returned, with paints of its own: - `path` is required; `fillRule` defaults to the object's. `type` and `bounds` may be handed back as given and are ignored. Any other key (`opacity`, `effects`, `guide`, `locked`, `name`, a top-level `documentData`…) refuses the - result. + result, as does a key that a paint, its color, a gradient stop, a gradient's placement or a freeform point does not + have, such as `opacity` on a solid paint. - `fills` and `strokes` take the forms a filter returns ([Input](#input)). A piece without `fills` (or `strokes`) is painted with the ones the effect was given; an empty list paints none. Given ones replace the object's in order, as a filter's output sets them: an existing fill or stroke keeps its own effects, opacity and blending, and a new stroke