Skip to main content

bevy_react/svg/protocol/
path.rs

1//! SVG path-data (`d`) parsing: the wire string is parsed **once, at the
2//! serde boundary** (the [`crate::protocol`] rule) into a flat list of
3//! **absolute-coordinate** segments, so replaying the path at paint time is a
4//! plain loop — no re-parsing, no relative/shorthand bookkeeping downstream.
5//!
6//! The grammar work is done by `svgtypes`' [`PathParser`] (the resvg-family
7//! parser); this module normalizes what it yields: relative segments become
8//! absolute, `H`/`V` become full `LineTo`s, and the smooth shorthands (`S`/`T`)
9//! are expanded to full curves via the SVG control-point reflection rule.
10//!
11//! Elliptical arcs (`A`/`a`) are **unsupported in v1**: `svgtypes` offers no
12//! arc→cubic conversion helper, and hand-rolling the endpoint-to-center math
13//! is out of scope, so a path containing an arc fails as a whole (the caller
14//! warns with kind `"shapePath"` and drops the field — never a partial path).
15
16use svgtypes::{PathParser, PathSegment};
17
18/// One normalized path segment. Coordinates are always absolute, in the SVG
19/// user-unit space of the enclosing `<svg>`'s `viewBox`.
20#[derive(Debug, Clone, Copy, PartialEq)]
21pub enum PathSeg {
22    MoveTo {
23        x: f32,
24        y: f32,
25    },
26    LineTo {
27        x: f32,
28        y: f32,
29    },
30    /// Quadratic Bézier: one control point, then the endpoint. (`c1*` like
31    /// `CubicTo`'s scheme — and deliberately NOT `cx`/`cy`, which would shadow
32    /// the parse loop's current-point locals of the same name.)
33    QuadTo {
34        c1x: f32,
35        c1y: f32,
36        x: f32,
37        y: f32,
38    },
39    /// Cubic Bézier: two control points, then the endpoint.
40    CubicTo {
41        c1x: f32,
42        c1y: f32,
43        c2x: f32,
44        c2y: f32,
45        x: f32,
46        y: f32,
47    },
48    Close,
49}
50
51/// A parsed `d` attribute: the normalized segment list (possibly empty — an
52/// empty `d` string is a valid, paint-nothing path).
53#[derive(Debug, Clone, Default, PartialEq)]
54pub struct PathData(pub Vec<PathSeg>);
55
56impl PathData {
57    /// Parse a `d` string into normalized absolute segments. `Err` carries the
58    /// warn message; the whole path is dropped on any error (a half-parsed
59    /// path would silently paint the wrong shape).
60    pub(crate) fn parse(d: &str) -> Result<PathData, String> {
61        let mut segs = Vec::new();
62        // Normalization state, kept in f64 (the parser's unit) so long chains
63        // of relative segments don't accumulate f32 rounding.
64        let (mut cx, mut cy) = (0.0f64, 0.0f64); // current point
65        let (mut sx, mut sy) = (0.0f64, 0.0f64); // current subpath start
66        // The reflection sources for the smooth shorthands: the previous
67        // segment's last control point, `Some` only when that segment was of
68        // the matching family (SVG's "if the previous command was not a
69        // C/S (resp. Q/T), the control point is the current point" rule).
70        let mut prev_cubic: Option<(f64, f64)> = None;
71        let mut prev_quad: Option<(f64, f64)> = None;
72        for seg in PathParser::from(d) {
73            let seg = seg.map_err(|e| format!("invalid path data {d:?}: {e}"))?;
74            // Resolve a possibly-relative endpoint against the current point.
75            let abs = |is_abs: bool, x: f64, y: f64| {
76                if is_abs { (x, y) } else { (cx + x, cy + y) }
77            };
78            match seg {
79                PathSegment::MoveTo { abs: a, x, y } => {
80                    (cx, cy) = abs(a, x, y);
81                    (sx, sy) = (cx, cy);
82                    (prev_cubic, prev_quad) = (None, None);
83                    segs.push(PathSeg::MoveTo {
84                        x: cx as f32,
85                        y: cy as f32,
86                    });
87                }
88                PathSegment::LineTo { abs: a, x, y } => {
89                    (cx, cy) = abs(a, x, y);
90                    (prev_cubic, prev_quad) = (None, None);
91                    segs.push(PathSeg::LineTo {
92                        x: cx as f32,
93                        y: cy as f32,
94                    });
95                }
96                PathSegment::HorizontalLineTo { abs: a, x } => {
97                    cx = if a { x } else { cx + x };
98                    (prev_cubic, prev_quad) = (None, None);
99                    segs.push(PathSeg::LineTo {
100                        x: cx as f32,
101                        y: cy as f32,
102                    });
103                }
104                PathSegment::VerticalLineTo { abs: a, y } => {
105                    cy = if a { y } else { cy + y };
106                    (prev_cubic, prev_quad) = (None, None);
107                    segs.push(PathSeg::LineTo {
108                        x: cx as f32,
109                        y: cy as f32,
110                    });
111                }
112                PathSegment::CurveTo {
113                    abs: a,
114                    x1,
115                    y1,
116                    x2,
117                    y2,
118                    x,
119                    y,
120                } => {
121                    let (c1x, c1y) = abs(a, x1, y1);
122                    let (c2x, c2y) = abs(a, x2, y2);
123                    (cx, cy) = abs(a, x, y);
124                    (prev_cubic, prev_quad) = (Some((c2x, c2y)), None);
125                    segs.push(PathSeg::CubicTo {
126                        c1x: c1x as f32,
127                        c1y: c1y as f32,
128                        c2x: c2x as f32,
129                        c2y: c2y as f32,
130                        x: cx as f32,
131                        y: cy as f32,
132                    });
133                }
134                PathSegment::SmoothCurveTo {
135                    abs: a,
136                    x2,
137                    y2,
138                    x,
139                    y,
140                } => {
141                    // First control = reflection of the previous cubic's
142                    // second control about the current point.
143                    let (px, py) = prev_cubic.unwrap_or((cx, cy));
144                    let (c1x, c1y) = (2.0 * cx - px, 2.0 * cy - py);
145                    let (c2x, c2y) = abs(a, x2, y2);
146                    (cx, cy) = abs(a, x, y);
147                    (prev_cubic, prev_quad) = (Some((c2x, c2y)), None);
148                    segs.push(PathSeg::CubicTo {
149                        c1x: c1x as f32,
150                        c1y: c1y as f32,
151                        c2x: c2x as f32,
152                        c2y: c2y as f32,
153                        x: cx as f32,
154                        y: cy as f32,
155                    });
156                }
157                PathSegment::Quadratic {
158                    abs: a,
159                    x1,
160                    y1,
161                    x,
162                    y,
163                } => {
164                    let (qx, qy) = abs(a, x1, y1);
165                    (cx, cy) = abs(a, x, y);
166                    (prev_cubic, prev_quad) = (None, Some((qx, qy)));
167                    segs.push(PathSeg::QuadTo {
168                        c1x: qx as f32,
169                        c1y: qy as f32,
170                        x: cx as f32,
171                        y: cy as f32,
172                    });
173                }
174                PathSegment::SmoothQuadratic { abs: a, x, y } => {
175                    let (px, py) = prev_quad.unwrap_or((cx, cy));
176                    let (qx, qy) = (2.0 * cx - px, 2.0 * cy - py);
177                    (cx, cy) = abs(a, x, y);
178                    (prev_cubic, prev_quad) = (None, Some((qx, qy)));
179                    segs.push(PathSeg::QuadTo {
180                        c1x: qx as f32,
181                        c1y: qy as f32,
182                        x: cx as f32,
183                        y: cy as f32,
184                    });
185                }
186                PathSegment::EllipticalArc { .. } => {
187                    return Err(format!("arc segments unsupported in v1 in path data {d:?}"));
188                }
189                PathSegment::ClosePath { .. } => {
190                    (cx, cy) = (sx, sy);
191                    (prev_cubic, prev_quad) = (None, None);
192                    segs.push(PathSeg::Close);
193                }
194            }
195        }
196        Ok(PathData(segs))
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::{PathData, PathSeg};
203
204    /// Mixed absolute/relative input normalizes to the exact absolute segment
205    /// list: `l` adds to the current point, `q`/`c` absolute-ize control
206    /// points and endpoint, `z` closes.
207    #[test]
208    fn mixed_relative_absolute_normalizes() {
209        let d = PathData::parse("M10 10 l10 0 q5 5 10 0 c1 2 3 4 5 6 z").expect("valid path");
210        assert_eq!(
211            d.0,
212            vec![
213                PathSeg::MoveTo { x: 10.0, y: 10.0 },
214                PathSeg::LineTo { x: 20.0, y: 10.0 },
215                PathSeg::QuadTo {
216                    c1x: 25.0,
217                    c1y: 15.0,
218                    x: 30.0,
219                    y: 10.0
220                },
221                PathSeg::CubicTo {
222                    c1x: 31.0,
223                    c1y: 12.0,
224                    c2x: 33.0,
225                    c2y: 14.0,
226                    x: 35.0,
227                    y: 16.0
228                },
229                PathSeg::Close,
230            ]
231        );
232    }
233
234    /// `H`/`V` (and their relative forms) become full `LineTo`s; the segment
235    /// after a `z` continues from the subpath start.
236    #[test]
237    fn h_v_and_close_normalize() {
238        let d = PathData::parse("M1 2 H5 v3 h-2 Z l1 1").expect("valid path");
239        assert_eq!(
240            d.0,
241            vec![
242                PathSeg::MoveTo { x: 1.0, y: 2.0 },
243                PathSeg::LineTo { x: 5.0, y: 2.0 },
244                PathSeg::LineTo { x: 5.0, y: 5.0 },
245                PathSeg::LineTo { x: 3.0, y: 5.0 },
246                PathSeg::Close,
247                // After Close the current point is the subpath start (1, 2).
248                PathSeg::LineTo { x: 2.0, y: 3.0 },
249            ]
250        );
251    }
252
253    /// `S` reflects the previous cubic's second control point about the
254    /// current point; `T` reflects the previous quadratic control. When the
255    /// previous segment is not of the matching family, the control is the
256    /// current point.
257    #[test]
258    fn smooth_shorthands_expand_via_reflection() {
259        let d = PathData::parse("M0 0 C1 1 2 1 3 0 S5 -1 6 0").expect("valid path");
260        assert_eq!(
261            d.0[2],
262            PathSeg::CubicTo {
263                // Reflection of (2, 1) about (3, 0) = (4, -1).
264                c1x: 4.0,
265                c1y: -1.0,
266                c2x: 5.0,
267                c2y: -1.0,
268                x: 6.0,
269                y: 0.0
270            }
271        );
272        let d = PathData::parse("M0 0 Q1 2 2 0 T4 0").expect("valid path");
273        assert_eq!(
274            d.0[2],
275            PathSeg::QuadTo {
276                // Reflection of (1, 2) about (2, 0) = (3, -2).
277                c1x: 3.0,
278                c1y: -2.0,
279                x: 4.0,
280                y: 0.0
281            }
282        );
283        // `T` with no preceding Q/T: control collapses to the current point.
284        let d = PathData::parse("M5 5 T9 9").expect("valid path");
285        assert_eq!(
286            d.0[1],
287            PathSeg::QuadTo {
288                c1x: 5.0,
289                c1y: 5.0,
290                x: 9.0,
291                y: 9.0
292            }
293        );
294    }
295
296    /// Garbage input fails as a whole — the caller warns and drops the field.
297    #[test]
298    fn garbage_input_errors() {
299        assert!(PathData::parse("M10 10 L nope").is_err());
300        // Paths must start with a moveto.
301        assert!(PathData::parse("L10 10").is_err());
302    }
303
304    /// Arcs are unsupported in v1: the whole path is rejected, with a message
305    /// naming the limitation.
306    #[test]
307    fn arcs_are_rejected_whole() {
308        let err = PathData::parse("M0 0 A5 5 0 0 1 10 10").expect_err("arc must be rejected");
309        assert!(err.contains("arc segments unsupported"), "{err}");
310    }
311
312    /// An empty `d` is a valid, paint-nothing path (not an error).
313    #[test]
314    fn empty_input_is_an_empty_path() {
315        assert_eq!(PathData::parse("").expect("valid"), PathData::default());
316    }
317}