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.
This commit is contained in:
dotCooCoo
2026-11-08 07:24:55 -08:00
parent d8afed1562
commit 9f2db12ec2
3 changed files with 139 additions and 3 deletions
+76 -2
View File
@@ -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<Vec<Piece>> {
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<Paint> {
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 {
+61
View File
@@ -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() {
+2 -1
View File
@@ -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