Skip to main content

oxideav_scene/
svg_path.rs

1//! Minimal SVG-path-data parser for [`Shape::Path`](crate::object::Shape::Path).
2//!
3//! The SVG 1.1 "path data" mini-grammar is widely-published commodity
4//! syntax — each command is a single ASCII letter (case picks absolute
5//! / relative coordinates) followed by a list of `f32` arguments
6//! separated by whitespace and / or commas. The supported subset here
7//! covers everything `oxideav-core::Path` can express:
8//!
9//! | Cmd        | Args                  | Meaning                               |
10//! |------------|-----------------------|---------------------------------------|
11//! | `M` / `m`  | `(x y)+`              | Move-to. Extra coord pairs become     |
12//! |            |                       | implicit line-to commands.            |
13//! | `L` / `l`  | `(x y)+`              | Line-to.                              |
14//! | `H` / `h`  | `x+`                  | Horizontal line-to.                   |
15//! | `V` / `v`  | `y+`                  | Vertical line-to.                     |
16//! | `C` / `c`  | `(x1 y1 x2 y2 x y)+`  | Cubic Bezier.                         |
17//! | `S` / `s`  | `(x2 y2 x y)+`        | Smooth cubic (reflect prev control).  |
18//! | `Q` / `q`  | `(x1 y1 x y)+`        | Quadratic Bezier.                     |
19//! | `T` / `t`  | `(x y)+`              | Smooth quadratic (reflect previous).  |
20//! | `Z` / `z`  | —                     | Close sub-path.                       |
21//!
22//! Arc commands (`A` / `a`) are **not** parsed — `oxideav-core::Path`
23//! exposes only line / quad / cubic primitives, and converting an
24//! elliptical arc into a cubic-spline approximation is its own design
25//! decision (number of segments, error bound). Calling code with an
26//! arc-using path gets [`SvgPathError::UnsupportedCommand`] so the
27//! caller can decide whether to drop the object, log, or supply a
28//! pre-flattened path.
29//!
30//! Numeric tokens accept SVG's standard forms: integers, decimals with
31//! optional leading sign, leading-dot decimals (`.5`), trailing-dot
32//! decimals (`5.`), and scientific notation (`1e3`, `1.5E-2`). Consecutive
33//! coordinate pairs may be separated by whitespace and / or a single
34//! comma.
35
36use oxideav_core::{Path, Point};
37
38/// Reasons the path data could not be lowered to a [`Path`].
39#[derive(Clone, Debug, PartialEq)]
40pub enum SvgPathError {
41    /// A command letter outside the supported set (currently arcs).
42    UnsupportedCommand(char),
43    /// A command was expected but the input ended (or contained a
44    /// non-command, non-whitespace, non-digit character at top level).
45    UnexpectedChar(char),
46    /// A numeric argument could not be parsed.
47    InvalidNumber,
48    /// A command had too few arguments (e.g. `M 1` with no `y`).
49    Truncated,
50    /// The path data did not start with a move-to (`M` / `m`). Other
51    /// commands implicitly need a current point and the SVG spec
52    /// requires `M` / `m` to be the first command.
53    NotStartedWithMove,
54}
55
56/// Parse SVG path data into an [`oxideav_core::Path`].
57///
58/// See the module docs for the supported grammar subset. Empty input
59/// produces an empty `Path`.
60pub fn parse_path(data: &str) -> Result<Path, SvgPathError> {
61    let parser = Parser::new(data);
62    parser.parse()
63}
64
65/// Axis-aligned bounding box (min_x, min_y, max_x, max_y) of every
66/// anchor / control point referenced by an SVG path-data string.
67///
68/// Returns `None` when the input is empty or unparseable. The bound
69/// is conservative for curves — it uses the convex-hull-of-control-
70/// points approximation (an exact Bezier tight-bound would walk the
71/// derivative roots; the hull is a strict superset, which is what a
72/// scene-layer "content size" wants for layout). Stroke half-widths
73/// are not included.
74pub fn parse_bbox(data: &str) -> Option<(f32, f32, f32, f32)> {
75    let path = parse_path(data).ok()?;
76    if path.commands.is_empty() {
77        return None;
78    }
79    let mut min_x = f32::INFINITY;
80    let mut min_y = f32::INFINITY;
81    let mut max_x = f32::NEG_INFINITY;
82    let mut max_y = f32::NEG_INFINITY;
83    let mut hit = false;
84    let mut push = |x: f32, y: f32, hit: &mut bool| {
85        *hit = true;
86        if x < min_x {
87            min_x = x;
88        }
89        if y < min_y {
90            min_y = y;
91        }
92        if x > max_x {
93            max_x = x;
94        }
95        if y > max_y {
96            max_y = y;
97        }
98    };
99    use oxideav_core::PathCommand as C;
100    for c in &path.commands {
101        match *c {
102            C::MoveTo(p) | C::LineTo(p) => push(p.x, p.y, &mut hit),
103            C::QuadCurveTo { control, end } => {
104                push(control.x, control.y, &mut hit);
105                push(end.x, end.y, &mut hit);
106            }
107            C::CubicCurveTo { c1, c2, end } => {
108                push(c1.x, c1.y, &mut hit);
109                push(c2.x, c2.y, &mut hit);
110                push(end.x, end.y, &mut hit);
111            }
112            C::Close => {}
113            _ => {}
114        }
115    }
116    if !hit {
117        return None;
118    }
119    Some((min_x, min_y, max_x, max_y))
120}
121
122// --------------------------------------------------------------------
123// Implementation
124// --------------------------------------------------------------------
125
126struct Parser<'a> {
127    bytes: &'a [u8],
128    pos: usize,
129    out: Path,
130    // Current pen position (the "current point" in SVG terms).
131    cx: f32,
132    cy: f32,
133    // Start of the current sub-path, restored by `Z`.
134    start_x: f32,
135    start_y: f32,
136    // Previous quadratic / cubic control point reflection target. Reset
137    // to the current point when the previous command wasn't a matching
138    // curve type (per SVG §8.3.6 / §8.3.7).
139    prev_cubic_ctrl: Option<(f32, f32)>,
140    prev_quad_ctrl: Option<(f32, f32)>,
141    started: bool,
142}
143
144impl<'a> Parser<'a> {
145    fn new(data: &'a str) -> Self {
146        Self {
147            bytes: data.as_bytes(),
148            pos: 0,
149            out: Path::new(),
150            cx: 0.0,
151            cy: 0.0,
152            start_x: 0.0,
153            start_y: 0.0,
154            prev_cubic_ctrl: None,
155            prev_quad_ctrl: None,
156            started: false,
157        }
158    }
159
160    fn parse(mut self) -> Result<Path, SvgPathError> {
161        self.skip_ws_comma();
162        while self.pos < self.bytes.len() {
163            let cmd = self.bytes[self.pos] as char;
164            if !is_command(cmd) {
165                return Err(SvgPathError::UnexpectedChar(cmd));
166            }
167            self.pos += 1;
168            if !self.started && !matches!(cmd, 'M' | 'm') {
169                return Err(SvgPathError::NotStartedWithMove);
170            }
171            self.dispatch(cmd)?;
172            self.skip_ws_comma();
173        }
174        Ok(self.out)
175    }
176
177    fn dispatch(&mut self, cmd: char) -> Result<(), SvgPathError> {
178        let abs = cmd.is_ascii_uppercase();
179        match cmd.to_ascii_uppercase() {
180            'M' => self.cmd_move(abs)?,
181            'L' => self.cmd_line(abs)?,
182            'H' => self.cmd_hline(abs)?,
183            'V' => self.cmd_vline(abs)?,
184            'C' => self.cmd_cubic(abs)?,
185            'S' => self.cmd_smooth_cubic(abs)?,
186            'Q' => self.cmd_quad(abs)?,
187            'T' => self.cmd_smooth_quad(abs)?,
188            'Z' => self.cmd_close(),
189            // Arcs are deliberately unsupported (see module doc).
190            'A' => return Err(SvgPathError::UnsupportedCommand(cmd)),
191            other => return Err(SvgPathError::UnsupportedCommand(other)),
192        }
193        // Clear smooth-curve reflection state for non-curve commands.
194        match cmd.to_ascii_uppercase() {
195            'C' | 'S' => self.prev_quad_ctrl = None,
196            'Q' | 'T' => self.prev_cubic_ctrl = None,
197            _ => {
198                self.prev_cubic_ctrl = None;
199                self.prev_quad_ctrl = None;
200            }
201        }
202        Ok(())
203    }
204
205    fn cmd_move(&mut self, abs: bool) -> Result<(), SvgPathError> {
206        // First coord pair after M/m is a moveto; subsequent pairs are
207        // implicit line-tos (with matching abs / rel sense).
208        let (mut x, mut y) = self.read_pair()?;
209        if !abs {
210            x += self.cx;
211            y += self.cy;
212        }
213        self.cx = x;
214        self.cy = y;
215        self.start_x = x;
216        self.start_y = y;
217        self.out.move_to(Point::new(x, y));
218        self.started = true;
219        // Greedy follow-on line-tos.
220        while self.peek_number().is_some() {
221            let (mut nx, mut ny) = self.read_pair()?;
222            if !abs {
223                nx += self.cx;
224                ny += self.cy;
225            }
226            self.cx = nx;
227            self.cy = ny;
228            self.out.line_to(Point::new(nx, ny));
229        }
230        Ok(())
231    }
232
233    fn cmd_line(&mut self, abs: bool) -> Result<(), SvgPathError> {
234        let mut got_any = false;
235        while self.peek_number().is_some() {
236            let (mut x, mut y) = self.read_pair()?;
237            if !abs {
238                x += self.cx;
239                y += self.cy;
240            }
241            self.cx = x;
242            self.cy = y;
243            self.out.line_to(Point::new(x, y));
244            got_any = true;
245        }
246        if !got_any {
247            return Err(SvgPathError::Truncated);
248        }
249        Ok(())
250    }
251
252    fn cmd_hline(&mut self, abs: bool) -> Result<(), SvgPathError> {
253        let mut got_any = false;
254        while self.peek_number().is_some() {
255            let mut x = self.read_number()?;
256            if !abs {
257                x += self.cx;
258            }
259            self.cx = x;
260            self.out.line_to(Point::new(x, self.cy));
261            got_any = true;
262        }
263        if !got_any {
264            return Err(SvgPathError::Truncated);
265        }
266        Ok(())
267    }
268
269    fn cmd_vline(&mut self, abs: bool) -> Result<(), SvgPathError> {
270        let mut got_any = false;
271        while self.peek_number().is_some() {
272            let mut y = self.read_number()?;
273            if !abs {
274                y += self.cy;
275            }
276            self.cy = y;
277            self.out.line_to(Point::new(self.cx, y));
278            got_any = true;
279        }
280        if !got_any {
281            return Err(SvgPathError::Truncated);
282        }
283        Ok(())
284    }
285
286    fn cmd_cubic(&mut self, abs: bool) -> Result<(), SvgPathError> {
287        let mut got_any = false;
288        while self.peek_number().is_some() {
289            let (mut x1, mut y1) = self.read_pair()?;
290            let (mut x2, mut y2) = self.read_pair()?;
291            let (mut x, mut y) = self.read_pair()?;
292            if !abs {
293                x1 += self.cx;
294                y1 += self.cy;
295                x2 += self.cx;
296                y2 += self.cy;
297                x += self.cx;
298                y += self.cy;
299            }
300            self.out
301                .cubic_to(Point::new(x1, y1), Point::new(x2, y2), Point::new(x, y));
302            self.prev_cubic_ctrl = Some((x2, y2));
303            self.cx = x;
304            self.cy = y;
305            got_any = true;
306        }
307        if !got_any {
308            return Err(SvgPathError::Truncated);
309        }
310        Ok(())
311    }
312
313    fn cmd_smooth_cubic(&mut self, abs: bool) -> Result<(), SvgPathError> {
314        let mut got_any = false;
315        while self.peek_number().is_some() {
316            // First control is the reflection of the previous cubic's
317            // second control through the current point. If the previous
318            // command wasn't a cubic, the reflection collapses to the
319            // current point (SVG §8.3.6).
320            let (rx, ry) = match self.prev_cubic_ctrl {
321                Some((px, py)) => (2.0 * self.cx - px, 2.0 * self.cy - py),
322                None => (self.cx, self.cy),
323            };
324            let (mut x2, mut y2) = self.read_pair()?;
325            let (mut x, mut y) = self.read_pair()?;
326            if !abs {
327                x2 += self.cx;
328                y2 += self.cy;
329                x += self.cx;
330                y += self.cy;
331            }
332            self.out
333                .cubic_to(Point::new(rx, ry), Point::new(x2, y2), Point::new(x, y));
334            self.prev_cubic_ctrl = Some((x2, y2));
335            self.cx = x;
336            self.cy = y;
337            got_any = true;
338        }
339        if !got_any {
340            return Err(SvgPathError::Truncated);
341        }
342        Ok(())
343    }
344
345    fn cmd_quad(&mut self, abs: bool) -> Result<(), SvgPathError> {
346        let mut got_any = false;
347        while self.peek_number().is_some() {
348            let (mut x1, mut y1) = self.read_pair()?;
349            let (mut x, mut y) = self.read_pair()?;
350            if !abs {
351                x1 += self.cx;
352                y1 += self.cy;
353                x += self.cx;
354                y += self.cy;
355            }
356            self.out.quad_to(Point::new(x1, y1), Point::new(x, y));
357            self.prev_quad_ctrl = Some((x1, y1));
358            self.cx = x;
359            self.cy = y;
360            got_any = true;
361        }
362        if !got_any {
363            return Err(SvgPathError::Truncated);
364        }
365        Ok(())
366    }
367
368    fn cmd_smooth_quad(&mut self, abs: bool) -> Result<(), SvgPathError> {
369        let mut got_any = false;
370        while self.peek_number().is_some() {
371            let (rx, ry) = match self.prev_quad_ctrl {
372                Some((px, py)) => (2.0 * self.cx - px, 2.0 * self.cy - py),
373                None => (self.cx, self.cy),
374            };
375            let (mut x, mut y) = self.read_pair()?;
376            if !abs {
377                x += self.cx;
378                y += self.cy;
379            }
380            self.out.quad_to(Point::new(rx, ry), Point::new(x, y));
381            self.prev_quad_ctrl = Some((rx, ry));
382            self.cx = x;
383            self.cy = y;
384            got_any = true;
385        }
386        if !got_any {
387            return Err(SvgPathError::Truncated);
388        }
389        Ok(())
390    }
391
392    fn cmd_close(&mut self) {
393        self.out.close();
394        self.cx = self.start_x;
395        self.cy = self.start_y;
396    }
397
398    // ---- low-level helpers ----
399
400    fn skip_ws_comma(&mut self) {
401        // SVG path grammar: whitespace = space / tab / CR / LF / FF;
402        // a single comma is a separator between numbers.
403        let mut seen_comma = false;
404        while self.pos < self.bytes.len() {
405            let c = self.bytes[self.pos];
406            match c {
407                b' ' | b'\t' | b'\r' | b'\n' | 0x0C => self.pos += 1,
408                b',' if !seen_comma => {
409                    seen_comma = true;
410                    self.pos += 1;
411                }
412                _ => break,
413            }
414        }
415    }
416
417    fn peek_number(&mut self) -> Option<u8> {
418        self.skip_ws_comma();
419        if self.pos >= self.bytes.len() {
420            return None;
421        }
422        let c = self.bytes[self.pos];
423        if c.is_ascii_digit() || c == b'+' || c == b'-' || c == b'.' {
424            Some(c)
425        } else {
426            None
427        }
428    }
429
430    fn read_number(&mut self) -> Result<f32, SvgPathError> {
431        self.skip_ws_comma();
432        if self.pos >= self.bytes.len() {
433            return Err(SvgPathError::Truncated);
434        }
435        let start = self.pos;
436        // sign
437        if matches!(self.bytes[self.pos], b'+' | b'-') {
438            self.pos += 1;
439        }
440        // integer part
441        while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_digit() {
442            self.pos += 1;
443        }
444        // fractional part
445        if self.pos < self.bytes.len() && self.bytes[self.pos] == b'.' {
446            self.pos += 1;
447            while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_digit() {
448                self.pos += 1;
449            }
450        }
451        // exponent
452        if self.pos < self.bytes.len() && matches!(self.bytes[self.pos], b'e' | b'E') {
453            self.pos += 1;
454            if self.pos < self.bytes.len() && matches!(self.bytes[self.pos], b'+' | b'-') {
455                self.pos += 1;
456            }
457            while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_digit() {
458                self.pos += 1;
459            }
460        }
461        if self.pos == start {
462            return Err(SvgPathError::Truncated);
463        }
464        let raw = std::str::from_utf8(&self.bytes[start..self.pos])
465            .map_err(|_| SvgPathError::InvalidNumber)?;
466        raw.parse::<f32>().map_err(|_| SvgPathError::InvalidNumber)
467    }
468
469    fn read_pair(&mut self) -> Result<(f32, f32), SvgPathError> {
470        let x = self.read_number()?;
471        let y = self.read_number()?;
472        Ok((x, y))
473    }
474}
475
476fn is_command(c: char) -> bool {
477    matches!(
478        c,
479        'M' | 'm'
480            | 'L'
481            | 'l'
482            | 'H'
483            | 'h'
484            | 'V'
485            | 'v'
486            | 'C'
487            | 'c'
488            | 'S'
489            | 's'
490            | 'Q'
491            | 'q'
492            | 'T'
493            | 't'
494            | 'Z'
495            | 'z'
496            | 'A'
497            | 'a'
498    )
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    #[test]
506    fn empty_input_is_empty_path() {
507        let p = parse_path("").unwrap();
508        assert_eq!(p.commands.len(), 0);
509    }
510
511    #[test]
512    fn whitespace_only_is_empty_path() {
513        let p = parse_path("   \t\n  ").unwrap();
514        assert_eq!(p.commands.len(), 0);
515    }
516
517    #[test]
518    fn move_then_line_absolute() {
519        let p = parse_path("M 10 20 L 30 40").unwrap();
520        // 1 move + 1 line.
521        assert_eq!(p.commands.len(), 2);
522    }
523
524    #[test]
525    fn move_then_line_relative() {
526        // m 10,20 means move to (10,20); l 5,5 should reach (15,25).
527        let p = parse_path("m 10,20 l 5,5").unwrap();
528        assert_eq!(p.commands.len(), 2);
529    }
530
531    #[test]
532    fn must_start_with_move() {
533        let err = parse_path("L 10 10").unwrap_err();
534        assert_eq!(err, SvgPathError::NotStartedWithMove);
535    }
536
537    #[test]
538    fn comma_or_space_separator() {
539        let a = parse_path("M0,0L1,1L2,2").unwrap();
540        let b = parse_path("M 0 0 L 1 1 L 2 2").unwrap();
541        assert_eq!(a.commands.len(), b.commands.len());
542    }
543
544    #[test]
545    fn implicit_line_after_moveto() {
546        // M 0 0 1 1 → moveto (0,0) then implicit line to (1,1)
547        let p = parse_path("M 0 0 1 1 2 2").unwrap();
548        // 1 move + 2 implicit lines.
549        assert_eq!(p.commands.len(), 3);
550    }
551
552    #[test]
553    fn close_command() {
554        let p = parse_path("M 0 0 L 1 0 L 1 1 Z").unwrap();
555        // 1 move + 2 lines + 1 close.
556        assert_eq!(p.commands.len(), 4);
557    }
558
559    #[test]
560    fn horizontal_and_vertical() {
561        let p = parse_path("M 5 5 H 10 V 12 h -3 v 4").unwrap();
562        // 1 move + 4 lines.
563        assert_eq!(p.commands.len(), 5);
564    }
565
566    #[test]
567    fn cubic_bezier() {
568        let p = parse_path("M 0 0 C 10 0 10 10 20 10").unwrap();
569        assert_eq!(p.commands.len(), 2);
570    }
571
572    #[test]
573    fn smooth_cubic_reflects_prev_control() {
574        let p = parse_path("M 0 0 C 10 0 10 10 20 10 S 30 0 40 10").unwrap();
575        // 1 move + 2 cubic.
576        assert_eq!(p.commands.len(), 3);
577    }
578
579    #[test]
580    fn quadratic_and_smooth_quadratic() {
581        let p = parse_path("M 0 0 Q 5 5 10 0 T 20 0").unwrap();
582        assert_eq!(p.commands.len(), 3);
583    }
584
585    #[test]
586    fn scientific_notation_number() {
587        let p = parse_path("M 1e2 2.5e-1 L 1.5E1 0.0").unwrap();
588        assert_eq!(p.commands.len(), 2);
589    }
590
591    #[test]
592    fn negative_after_implicit_pair_no_separator() {
593        // SVG allows `1-2` to read as `1` and `-2` (sign restarts a
594        // number). Make sure we don't gobble the minus.
595        let p = parse_path("M 1-2 L 3-4").unwrap();
596        assert_eq!(p.commands.len(), 2);
597    }
598
599    #[test]
600    fn leading_dot_decimal() {
601        let p = parse_path("M .5 .5 L 1 1").unwrap();
602        assert_eq!(p.commands.len(), 2);
603    }
604
605    #[test]
606    fn unsupported_arc_returns_error() {
607        let err = parse_path("M 0 0 A 5 5 0 0 0 10 10").unwrap_err();
608        assert!(matches!(err, SvgPathError::UnsupportedCommand('A')));
609    }
610
611    #[test]
612    fn unexpected_char_at_top_level() {
613        let err = parse_path("M 0 0 X 1 1").unwrap_err();
614        assert!(matches!(err, SvgPathError::UnexpectedChar('X')));
615    }
616
617    #[test]
618    fn truncated_after_command_letter() {
619        let err = parse_path("M").unwrap_err();
620        assert!(matches!(err, SvgPathError::Truncated));
621    }
622
623    #[test]
624    fn close_restores_current_point_to_subpath_start() {
625        // After M 10 20 L 30 40 Z, the pen should be back at (10,20),
626        // so a following m 0,0 starts there. We can't peek at internal
627        // state through the public Path API, but we can verify the
628        // resulting command count is what we expect (3: M, L, Z) and
629        // that a follow-up move parses without error.
630        let p = parse_path("M 10 20 L 30 40 Z M 1 1").unwrap();
631        // M L Z M = 4 commands.
632        assert_eq!(p.commands.len(), 4);
633    }
634}