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}