Skip to main content

bevy_react/svg/
protocol.rs

1//! Wire types for the JSX `<svg>` element and its shape children — owned by
2//! the svg module (the [`crate::canvas::DrawCmd`] precedent) and re-exported
3//! by [`crate::protocol`], which references them from `Props`.
4//!
5//! Decoding follows the protocol module's rule: wire strings parse **once, at
6//! the serde boundary**, and every malformed value **warns and drops the
7//! field** (via [`crate::protocol::decode_warn`]) — never failing the batch.
8//! Warn kinds emitted here: `"viewBox"`, `"shapePath"`, `"shapePoints"`,
9//! `"shapePaint"`, `"shapeEnum"`, `"shapeTransform"`, `"shapeTransition"`
10//! (each mirrored in `devtools.rs`' kind list and
11//! `js/src/devtools/warnings.ts`).
12
13use std::fmt;
14
15use bevy::color::Srgba;
16use bevy::math::Vec2;
17use serde::Deserialize;
18use serde::de::{self, Deserializer, Visitor};
19
20use crate::canvas::parse_css_color;
21use crate::protocol::{animatable::Animatable, decode_warn};
22
23mod path;
24#[cfg(test)]
25mod tests;
26
27pub use path::{PathData, PathSeg};
28
29/// The folded attribute object of one SVG shape child (`<circle>`, `<rect>`,
30/// `<line>`, `<polyline>`, `<polygon>`, `<path>`, `<g>`, …). All-`Option`:
31/// absent means "attribute not set", and the shape kind decides which fields
32/// it reads. On update the whole object **replaces atomically** (see
33/// [`crate::protocol::props::Props::merge_delta`]).
34///
35/// The **numeric** attrs (the [`NUMERIC_ATTRS`] set) accept the inline
36/// `{ animated: …, seed? }` wrapper ([`Animatable`], the style-field wire
37/// form): the binding derives an
38/// [`AnimatableProperty::ShapeAttr`](crate::animations::protocol::AnimatableProperty)
39/// entry and the animation driver writes the attr per frame. Consumers
40/// (paint/hit/walk) read these fields via
41/// [`static_or_seed`](crate::protocol::animatable::AnimatableField::static_or_seed): an
42/// animated attr with no `seed` reads as **absent** — the attr's own default
43/// (geometry `0`, `strokeWidth` `1`, `opacity` `1`) — until the driver
44/// writes; a `seed` renders as the static value in the wrapper's place.
45/// Every other field (`d`, `points`, paints, keywords, `transform`) is not
46/// animatable: a wrapper (or any object) arriving there warns with the
47/// field's own kind and drops the field.
48#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
49#[serde(rename_all = "camelCase", default)]
50pub struct ShapeAttrs {
51    // --- geometry (SVG user units) ---
52    pub x: Option<Animatable<f32>>,
53    pub y: Option<Animatable<f32>>,
54    pub width: Option<Animatable<f32>>,
55    pub height: Option<Animatable<f32>>,
56    pub cx: Option<Animatable<f32>>,
57    pub cy: Option<Animatable<f32>>,
58    pub r: Option<Animatable<f32>>,
59    pub rx: Option<Animatable<f32>>,
60    pub ry: Option<Animatable<f32>>,
61    pub x1: Option<Animatable<f32>>,
62    pub y1: Option<Animatable<f32>>,
63    pub x2: Option<Animatable<f32>>,
64    pub y2: Option<Animatable<f32>>,
65    /// `<polyline>`/`<polygon>` vertices. Wire: a flat number array
66    /// `[x0, y0, x1, y1, …]`, paired here; an odd count warns and drops.
67    #[serde(deserialize_with = "de_points")]
68    pub points: Option<Vec<Vec2>>,
69    /// `<path>` data, parsed into absolute segments (see [`PathData`]).
70    #[serde(deserialize_with = "de_path")]
71    pub d: Option<PathData>,
72
73    // --- paint ---
74    /// Interior paint. Absent falls back to the SVG default (black) — distinct
75    /// from an explicit `"none"`.
76    #[serde(deserialize_with = "de_paint")]
77    pub fill: Option<ShapePaint>,
78    /// Outline paint. Absent falls back to the SVG default (no stroke).
79    #[serde(deserialize_with = "de_paint")]
80    pub stroke: Option<ShapePaint>,
81    pub stroke_width: Option<Animatable<f32>>,
82    pub opacity: Option<Animatable<f32>>,
83    #[serde(deserialize_with = "de_fill_rule")]
84    pub fill_rule: Option<FillRuleKind>,
85    #[serde(deserialize_with = "de_linecap")]
86    pub stroke_linecap: Option<LinecapKind>,
87    #[serde(deserialize_with = "de_linejoin")]
88    pub stroke_linejoin: Option<LinejoinKind>,
89
90    /// SVG transform list, resolved to a 2D affine at decode.
91    #[serde(deserialize_with = "de_transform")]
92    pub transform: Option<ShapeTransform>,
93
94    /// Declarative easing for the **numeric** attrs: when a static numeric
95    /// attr changes, the transition engine eases the painted value instead of
96    /// snapping (see [`crate::transition`]'s shape channel). Config, not a
97    /// value: deliberately **outside** [`NUMERIC_ATTRS`], so the binding
98    /// deriver / paint / hit never see it — but it participates in
99    /// `PartialEq` like every field (a spec-only change is a real attrs
100    /// change; the atomic replace carries it). Boxed: the spec's inline
101    /// entry array (~0.7 KB) would otherwise bulk EVERY `ShapeAttrs` — and
102    /// ride every clone (props cache, `SvgShape`, the op-apply clones) — for
103    /// a field most shapes don't set.
104    #[serde(deserialize_with = "de_transition")]
105    pub transition: Option<Box<ShapeTransitionSpec>>,
106}
107
108/// Read accessor for one numeric attr of a [`ShapeAttrs`] (a
109/// [`NUMERIC_ATTRS`] row).
110pub(crate) type NumericAttrAccessor = fn(&ShapeAttrs) -> &Option<Animatable<f32>>;
111
112/// Mutable accessor twin of [`NumericAttrAccessor`] (the row's third column),
113/// for the animation apply stage's name→slot writes.
114pub(crate) type NumericAttrAccessorMut = fn(&mut ShapeAttrs) -> &mut Option<Animatable<f32>>;
115
116/// Wire name → field accessors (read, mut) for every **numeric** (and
117/// therefore animatable) shape attr — the single source both for the binding
118/// deriver (`crate::style_bindings::derive_shape_bindings`, which emits
119/// `AnimatableProperty::ShapeAttr { name }` per animated field) and for the
120/// animation apply stage that resolves a bound name back to its field
121/// ([`numeric_attr_mut`]). Wire names are the camelCase serde names
122/// ([`ShapeAttrs`] is `rename_all = "camelCase"` — only `strokeWidth` differs
123/// from its field).
124pub(crate) const NUMERIC_ATTR_COUNT: usize = 15;
125pub(crate) const NUMERIC_ATTRS: [(&str, NumericAttrAccessor, NumericAttrAccessorMut);
126    NUMERIC_ATTR_COUNT] = [
127    ("x", |a| &a.x, |a| &mut a.x),
128    ("y", |a| &a.y, |a| &mut a.y),
129    ("width", |a| &a.width, |a| &mut a.width),
130    ("height", |a| &a.height, |a| &mut a.height),
131    ("cx", |a| &a.cx, |a| &mut a.cx),
132    ("cy", |a| &a.cy, |a| &mut a.cy),
133    ("r", |a| &a.r, |a| &mut a.r),
134    ("rx", |a| &a.rx, |a| &mut a.rx),
135    ("ry", |a| &a.ry, |a| &mut a.ry),
136    ("x1", |a| &a.x1, |a| &mut a.x1),
137    ("y1", |a| &a.y1, |a| &mut a.y1),
138    ("x2", |a| &a.x2, |a| &mut a.x2),
139    ("y2", |a| &a.y2, |a| &mut a.y2),
140    ("strokeWidth", |a| &a.stroke_width, |a| &mut a.stroke_width),
141    ("opacity", |a| &a.opacity, |a| &mut a.opacity),
142];
143
144/// The mutable slot of one numeric attr by **wire name** ([`NUMERIC_ATTRS`],
145/// the one table — never a parallel name→field match), or `None` for a name
146/// outside the numeric set (a stale binding; the apply stage warns).
147pub(crate) fn numeric_attr_mut<'a>(
148    attrs: &'a mut ShapeAttrs,
149    name: &str,
150) -> Option<&'a mut Option<Animatable<f32>>> {
151    NUMERIC_ATTRS
152        .iter()
153        .find(|(n, _, _)| *n == name)
154        .map(|(_, _, m)| m(attrs))
155}
156
157/// The read-only slot of one numeric attr by wire name — the read twin of
158/// [`numeric_attr_mut`], for the apply stage's compare-before-write phase
159/// (reading must not tick change detection).
160pub(crate) fn numeric_attr<'a>(
161    attrs: &'a ShapeAttrs,
162    name: &str,
163) -> Option<&'a Option<Animatable<f32>>> {
164    NUMERIC_ATTRS
165        .iter()
166        .find(|(n, _, _)| *n == name)
167        .map(|(_, r, _)| r(attrs))
168}
169
170/// Shorthand for a static numeric attr in test fixtures (struct-literal
171/// `ShapeAttrs` construction predates the [`Animatable`] field type).
172#[cfg(test)]
173pub(crate) fn st(v: f32) -> Option<Animatable<f32>> {
174    Some(Animatable::Static(v))
175}
176
177/// The shape `transition` spec: per-attr easing timing, keyed by the
178/// [`NUMERIC_ATTRS`] wire names (shapes have no `style`, so the spec rides
179/// the shape object itself — no `all` fallback, no non-numeric channels).
180/// Entries are stored positionally in [`NUMERIC_ATTRS`] order; reuse of
181/// [`ChannelTransition`] (the style-transition timing type) is verbatim —
182/// same wire shape (`duration`/`easing`/`delay`/springs), same driver.
183#[derive(Debug, Clone, Default, PartialEq)]
184pub struct ShapeTransitionSpec {
185    entries: [Option<crate::transition::ChannelTransition>; NUMERIC_ATTR_COUNT],
186}
187
188impl ShapeTransitionSpec {
189    /// The timing for one numeric attr by **wire name**; `None` when the
190    /// spec has no entry for it (that attr snaps).
191    pub fn for_attr(&self, name: &str) -> Option<&crate::transition::ChannelTransition> {
192        NUMERIC_ATTRS
193            .iter()
194            .position(|(n, _, _)| *n == name)
195            .and_then(|i| self.entries[i].as_ref())
196    }
197
198    /// The timing at one [`NUMERIC_ATTRS`] index (the engine's positional
199    /// twin of [`Self::for_attr`]).
200    pub(crate) fn at(&self, index: usize) -> Option<&crate::transition::ChannelTransition> {
201        self.entries[index].as_ref()
202    }
203}
204
205/// `deserialize_with` for [`ShapeAttrs::transition`]: an object keyed by
206/// numeric attr wire names, each value a [`ChannelTransition`]. Unknown /
207/// non-numeric keys (nothing else is easeable) and malformed spec values
208/// warn (`"shapeTransition"`) and drop **that key**; a non-object value
209/// warns and drops the whole field. Decodes through [`serde_json::Value`]
210/// (specs are tiny and rare — not a hot path) so no wire type can ever
211/// hard-error the batch.
212fn de_transition<'de, D: Deserializer<'de>>(
213    d: D,
214) -> Result<Option<Box<ShapeTransitionSpec>>, D::Error> {
215    let Some(value) = Option::<serde_json::Value>::deserialize(d)? else {
216        return Ok(None);
217    };
218    let serde_json::Value::Object(map) = value else {
219        if !value.is_null() {
220            decode_warn(
221                "shapeTransition",
222                &value.to_string(),
223                "transition takes an object of per-attr timing specs; dropping",
224            );
225        }
226        return Ok(None);
227    };
228    let mut spec = ShapeTransitionSpec::default();
229    for (key, entry) in map {
230        let Some(i) = NUMERIC_ATTRS.iter().position(|(n, _, _)| *n == key) else {
231            decode_warn(
232                "shapeTransition",
233                &key,
234                &format!("`{key}` is not a numeric shape attr (only those ease); dropping"),
235            );
236            continue;
237        };
238        match serde_json::from_value(entry) {
239            Ok(timing) => spec.entries[i] = Some(timing),
240            Err(e) => {
241                decode_warn(
242                    "shapeTransition",
243                    &key,
244                    &format!("invalid transition spec for `{key}`: {e}; dropping"),
245                );
246            }
247        }
248    }
249    Ok(Some(Box::new(spec)))
250}
251
252/// A resolved SVG paint: the explicit `"none"` keyword (don't paint — the web
253/// meaning of `fill="none"`, distinct from an *absent* paint, which uses the
254/// SVG defaults: fill black, stroke none) or a CSS color.
255#[derive(Debug, Clone, Copy, PartialEq)]
256pub enum ShapePaint {
257    None,
258    Color(Srgba),
259}
260
261/// `fill-rule` keyword.
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263pub enum FillRuleKind {
264    NonZero,
265    EvenOdd,
266}
267
268/// `stroke-linecap` keyword.
269#[derive(Debug, Clone, Copy, PartialEq, Eq)]
270pub enum LinecapKind {
271    Butt,
272    Round,
273    Square,
274}
275
276/// `stroke-linejoin` keyword.
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub enum LinejoinKind {
279    Miter,
280    Round,
281    Bevel,
282}
283
284/// An SVG transform list resolved to a 2D affine, in SVG matrix order
285/// `[a, b, c, d, e, f]`: `x' = a·x + c·y + e`, `y' = b·x + d·y + f`.
286///
287/// Stored as a plain matrix rather than a `tiny_skia::Transform` so the wire
288/// type stays raster-agnostic (the protocol layer never names the raster
289/// backend); the painter's `From<&ShapeTransform>` impl (in `svg::paint`)
290/// converts via `Transform::from_row` — the same field order.
291///
292/// v1 scope: `translate(x [y])`, `scale(s [sy])`, `rotate(deg [cx cy])`,
293/// composed in list order. Anything else (`matrix`/`skewX`/`skewY`, or a
294/// parse error) warns with kind `"shapeTransform"` and drops the field.
295#[derive(Debug, Clone, Copy, PartialEq)]
296pub struct ShapeTransform(pub [f32; 6]);
297
298impl Default for ShapeTransform {
299    fn default() -> Self {
300        ShapeTransform([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])
301    }
302}
303
304const IDENTITY: [f64; 6] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
305
306/// Affine concat `a · b` (apply `b` first, then `a`) — transform-list order
307/// is left-to-right, so the running matrix post-multiplies each new function.
308fn mul(a: [f64; 6], b: [f64; 6]) -> [f64; 6] {
309    [
310        a[0] * b[0] + a[2] * b[1],
311        a[1] * b[0] + a[3] * b[1],
312        a[0] * b[2] + a[2] * b[3],
313        a[1] * b[2] + a[3] * b[3],
314        a[0] * b[4] + a[2] * b[5] + a[4],
315        a[1] * b[4] + a[3] * b[5] + a[5],
316    ]
317}
318
319impl ShapeTransform {
320    /// Parse an SVG transform-list string into a resolved affine. `svgtypes`
321    /// splits `rotate(a cx cy)` into translate·rotate·translate tokens, so
322    /// the rotate-about-a-point form arrives here as supported primitives.
323    pub(crate) fn parse(s: &str) -> Result<ShapeTransform, String> {
324        use svgtypes::{TransformListParser, TransformListToken as T};
325        let mut m = IDENTITY;
326        for token in TransformListParser::from(s) {
327            let token = token.map_err(|e| format!("invalid transform {s:?}: {e}"))?;
328            let t = match token {
329                T::Translate { tx, ty } => [1.0, 0.0, 0.0, 1.0, tx, ty],
330                T::Scale { sx, sy } => [sx, 0.0, 0.0, sy, 0.0, 0.0],
331                T::Rotate { angle } => {
332                    let (sin, cos) = angle.to_radians().sin_cos();
333                    [cos, sin, -sin, cos, 0.0, 0.0]
334                }
335                T::Matrix { .. } | T::SkewX { .. } | T::SkewY { .. } => {
336                    return Err(format!(
337                        "unsupported transform function in {s:?} \
338                         (v1 supports translate/scale/rotate)"
339                    ));
340                }
341            };
342            m = mul(m, t);
343        }
344        Ok(ShapeTransform(m.map(|v| v as f32)))
345    }
346}
347
348/// The `<svg>` element's `viewBox`: the user-unit rectangle mapped onto the
349/// element's layout box.
350#[derive(Debug, Clone, Copy, PartialEq)]
351pub struct ViewBox {
352    pub min: Vec2,
353    pub size: Vec2,
354}
355
356impl ViewBox {
357    /// Parse the `"minX minY width height"` form (whitespace/comma separated,
358    /// per the SVG spec). A non-positive size is invalid (`svgtypes` checks).
359    pub(crate) fn parse(s: &str) -> Result<ViewBox, String> {
360        let vb: svgtypes::ViewBox = s
361            .parse()
362            .map_err(|e| format!("invalid viewBox {s:?}: {e}"))?;
363        Ok(ViewBox {
364            min: Vec2::new(vb.x as f32, vb.y as f32),
365            size: Vec2::new(vb.w as f32, vb.h as f32),
366        })
367    }
368}
369
370/// `deserialize_with` for [`crate::protocol::props::Props::view_box`]: warn-and-drop
371/// on a malformed string — or on an object (`viewBox` is not animatable and
372/// takes no `{ animated }` wrapper) — like every other wire decode.
373pub(crate) fn de_view_box<'de, D: Deserializer<'de>>(d: D) -> Result<Option<ViewBox>, D::Error> {
374    struct V;
375    impl<'de> Visitor<'de> for V {
376        type Value = Option<ViewBox>;
377        fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
378            f.write_str("a viewBox string \"minX minY width height\"")
379        }
380        fn visit_str<E: de::Error>(self, s: &str) -> Result<Self::Value, E> {
381            Ok(match ViewBox::parse(s) {
382                Ok(vb) => Some(vb),
383                Err(e) => {
384                    decode_warn("viewBox", s, &e);
385                    None
386                }
387            })
388        }
389        fn visit_map<A: de::MapAccess<'de>>(self, map: A) -> Result<Self::Value, A::Error> {
390            warn_object_dropped(map, "viewBox").map(|()| None)
391        }
392        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
393            Ok(None)
394        }
395        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
396            Ok(None)
397        }
398    }
399    d.deserialize_any(V)
400}
401
402/// Consume an unexpected JSON **object** on a non-animatable field — most
403/// likely an `{ animated }` wrapper (only the numeric attrs accept those) —
404/// warn with the field's own kind, and drop the field. Keeps the module's
405/// never-fail-the-batch rule: without this arm the visitors would hard-error
406/// on any object, aborting the whole op batch.
407fn warn_object_dropped<'de, A: de::MapAccess<'de>>(
408    map: A,
409    kind: &'static str,
410) -> Result<(), A::Error> {
411    let v = serde_json::Value::deserialize(de::value::MapAccessDeserializer::new(map))?;
412    let hint = if v.get("animated").is_some() {
413        " (only numeric shape attrs accept { animated } bindings)"
414    } else {
415        ""
416    };
417    decode_warn(
418        kind,
419        &v.to_string(),
420        &format!("unexpected object value{hint}; dropping"),
421    );
422    Ok(())
423}
424
425fn de_path<'de, D: Deserializer<'de>>(d: D) -> Result<Option<PathData>, D::Error> {
426    struct V;
427    impl<'de> Visitor<'de> for V {
428        type Value = Option<PathData>;
429        fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
430            f.write_str("an SVG path data string")
431        }
432        fn visit_str<E: de::Error>(self, s: &str) -> Result<Self::Value, E> {
433            Ok(match PathData::parse(s) {
434                Ok(p) => Some(p),
435                Err(e) => {
436                    decode_warn("shapePath", s, &e);
437                    None
438                }
439            })
440        }
441        fn visit_map<A: de::MapAccess<'de>>(self, map: A) -> Result<Self::Value, A::Error> {
442            warn_object_dropped(map, "shapePath").map(|()| None)
443        }
444        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
445            Ok(None)
446        }
447        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
448            Ok(None)
449        }
450    }
451    d.deserialize_any(V)
452}
453
454fn de_points<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Vec<Vec2>>, D::Error> {
455    struct V;
456    impl<'de> Visitor<'de> for V {
457        type Value = Option<Vec<Vec2>>;
458        fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
459            f.write_str("a flat number array [x0, y0, x1, y1, …]")
460        }
461        fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
462            let mut nums = Vec::with_capacity(seq.size_hint().unwrap_or(0));
463            while let Some(n) = seq.next_element::<f32>()? {
464                nums.push(n);
465            }
466            if nums.len() % 2 != 0 {
467                decode_warn(
468                    "shapePoints",
469                    &format!("[{} numbers]", nums.len()),
470                    &format!(
471                        "points needs an even number of coordinates, got {}; dropping",
472                        nums.len()
473                    ),
474                );
475                return Ok(None);
476            }
477            Ok(Some(
478                nums.chunks_exact(2)
479                    .map(|p| Vec2::new(p[0], p[1]))
480                    .collect(),
481            ))
482        }
483        fn visit_map<A: de::MapAccess<'de>>(self, map: A) -> Result<Self::Value, A::Error> {
484            warn_object_dropped(map, "shapePoints").map(|()| None)
485        }
486        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
487            Ok(None)
488        }
489        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
490            Ok(None)
491        }
492    }
493    d.deserialize_any(V)
494}
495
496fn de_paint<'de, D: Deserializer<'de>>(d: D) -> Result<Option<ShapePaint>, D::Error> {
497    struct V;
498    impl<'de> Visitor<'de> for V {
499        type Value = Option<ShapePaint>;
500        fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
501            f.write_str("a CSS color string or the keyword \"none\"")
502        }
503        fn visit_str<E: de::Error>(self, s: &str) -> Result<Self::Value, E> {
504            if s == "none" {
505                return Ok(Some(ShapePaint::None));
506            }
507            Ok(match parse_css_color(s) {
508                Some(c) => Some(ShapePaint::Color(c)),
509                None => {
510                    decode_warn("shapePaint", s, &format!("unrecognized paint {s:?}"));
511                    None
512                }
513            })
514        }
515        fn visit_map<A: de::MapAccess<'de>>(self, map: A) -> Result<Self::Value, A::Error> {
516            warn_object_dropped(map, "shapePaint").map(|()| None)
517        }
518        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
519            Ok(None)
520        }
521        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
522            Ok(None)
523        }
524    }
525    d.deserialize_any(V)
526}
527
528fn de_transform<'de, D: Deserializer<'de>>(d: D) -> Result<Option<ShapeTransform>, D::Error> {
529    struct V;
530    impl<'de> Visitor<'de> for V {
531        type Value = Option<ShapeTransform>;
532        fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
533            f.write_str("an SVG transform list string")
534        }
535        fn visit_str<E: de::Error>(self, s: &str) -> Result<Self::Value, E> {
536            Ok(match ShapeTransform::parse(s) {
537                Ok(t) => Some(t),
538                Err(e) => {
539                    decode_warn("shapeTransform", s, &e);
540                    None
541                }
542            })
543        }
544        fn visit_map<A: de::MapAccess<'de>>(self, map: A) -> Result<Self::Value, A::Error> {
545            warn_object_dropped(map, "shapeTransform").map(|()| None)
546        }
547        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
548            Ok(None)
549        }
550        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
551            Ok(None)
552        }
553    }
554    d.deserialize_any(V)
555}
556
557/// Keyword deserializers with the shared `"shapeEnum"` warn kind: an
558/// unrecognized keyword warns and **drops the field** (unlike the style
559/// `keyword_fields!`, which falls back to the bevy default — a shape enum has
560/// no "bevy default" to fall to; absent means the SVG default).
561macro_rules! shape_keywords {
562    ($( fn $fn_name:ident($ty:ident) { $($kw:literal => $variant:ident),+ $(,)? } )+) => { $(
563        fn $fn_name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<$ty>, D::Error> {
564            struct V;
565            impl<'de> Visitor<'de> for V {
566                type Value = Option<$ty>;
567                fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
568                    f.write_str(concat!("a `", stringify!($ty), "` keyword"))
569                }
570                fn visit_str<E: de::Error>(self, s: &str) -> Result<Self::Value, E> {
571                    Ok(match s {
572                        $( $kw => Some(<$ty>::$variant), )+
573                        _ => {
574                            decode_warn(
575                                "shapeEnum",
576                                s,
577                                &format!(
578                                    concat!("unrecognized ", stringify!($ty), " keyword {:?}"),
579                                    s
580                                ),
581                            );
582                            None
583                        }
584                    })
585                }
586                fn visit_map<A: de::MapAccess<'de>>(self, map: A) -> Result<Self::Value, A::Error> {
587                    warn_object_dropped(map, "shapeEnum").map(|()| None)
588                }
589                fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
590                    Ok(None)
591                }
592                fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
593                    Ok(None)
594                }
595            }
596            d.deserialize_any(V)
597        }
598    )+ };
599}
600
601shape_keywords! {
602    fn de_fill_rule(FillRuleKind) {
603        "nonzero" => NonZero, "evenodd" => EvenOdd,
604    }
605    fn de_linecap(LinecapKind) {
606        "butt" => Butt, "round" => Round, "square" => Square,
607    }
608    fn de_linejoin(LinejoinKind) {
609        "miter" => Miter, "round" => Round, "bevel" => Bevel,
610    }
611}