Skip to main content

rust_motion_paths/
parser.rs

1//! SVG path parser and serializer.
2
3use crate::types::Instruction;
4use thiserror::Error;
5
6#[derive(Error, Debug, PartialEq)]
7pub enum PathParseError {
8    #[error("Invalid SVG command '{0}' at index {1}")]
9    InvalidCommand(char, usize),
10    #[error("Unexpected end of path string")]
11    UnexpectedEnd,
12    #[error("Failed to parse number '{0}'")]
13    ParseNumberError(String),
14}
15
16/// Parses an SVG path `d` string into a list of [`Instruction`]s.
17pub fn parse_path(d: &str) -> Result<Vec<Instruction>, PathParseError> {
18    let mut instructions = Vec::new();
19    let tokens = tokenize(d)?;
20    let mut idx = 0;
21
22    let mut current_x = 0.0;
23    let mut current_y = 0.0;
24    let mut start_x = 0.0;
25    let mut start_y = 0.0;
26    let mut last_control_point: Option<(f64, f64)> = None;
27    let mut last_cmd: Option<char> = None;
28
29    while idx < tokens.len() {
30        match tokens[idx].as_str() {
31            "M" => {
32                idx += 1;
33                let x = parse_num(&tokens, &mut idx)?;
34                let y = parse_num(&tokens, &mut idx)?;
35                current_x = x;
36                current_y = y;
37                start_x = x;
38                start_y = y;
39                instructions.push(Instruction::MoveTo { x, y });
40                last_cmd = None;
41                last_control_point = None;
42
43                while idx < tokens.len() && is_num_token(&tokens[idx]) {
44                    let x = parse_num(&tokens, &mut idx)?;
45                    let y = parse_num(&tokens, &mut idx)?;
46                    current_x = x;
47                    current_y = y;
48                    instructions.push(Instruction::LineTo { x, y });
49                }
50            }
51            "m" => {
52                idx += 1;
53                let dx = parse_num(&tokens, &mut idx)?;
54                let dy = parse_num(&tokens, &mut idx)?;
55                current_x += dx;
56                current_y += dy;
57                start_x = current_x;
58                start_y = current_y;
59                instructions.push(Instruction::MoveTo {
60                    x: current_x,
61                    y: current_y,
62                });
63                last_cmd = None;
64                last_control_point = None;
65
66                while idx < tokens.len() && is_num_token(&tokens[idx]) {
67                    let dx = parse_num(&tokens, &mut idx)?;
68                    let dy = parse_num(&tokens, &mut idx)?;
69                    current_x += dx;
70                    current_y += dy;
71                    instructions.push(Instruction::LineTo {
72                        x: current_x,
73                        y: current_y,
74                    });
75                }
76            }
77            "L" => {
78                idx += 1;
79                last_cmd = None;
80                last_control_point = None;
81                while idx < tokens.len() && is_num_token(&tokens[idx]) {
82                    let x = parse_num(&tokens, &mut idx)?;
83                    let y = parse_num(&tokens, &mut idx)?;
84                    current_x = x;
85                    current_y = y;
86                    instructions.push(Instruction::LineTo { x, y });
87                }
88            }
89            "l" => {
90                idx += 1;
91                last_cmd = None;
92                last_control_point = None;
93                while idx < tokens.len() && is_num_token(&tokens[idx]) {
94                    let dx = parse_num(&tokens, &mut idx)?;
95                    let dy = parse_num(&tokens, &mut idx)?;
96                    current_x += dx;
97                    current_y += dy;
98                    instructions.push(Instruction::LineTo {
99                        x: current_x,
100                        y: current_y,
101                    });
102                }
103            }
104            "H" => {
105                idx += 1;
106                last_cmd = None;
107                last_control_point = None;
108                while idx < tokens.len() && is_num_token(&tokens[idx]) {
109                    let x = parse_num(&tokens, &mut idx)?;
110                    current_x = x;
111                    instructions.push(Instruction::LineTo {
112                        x: current_x,
113                        y: current_y,
114                    });
115                }
116            }
117            "h" => {
118                idx += 1;
119                last_cmd = None;
120                last_control_point = None;
121                while idx < tokens.len() && is_num_token(&tokens[idx]) {
122                    let dx = parse_num(&tokens, &mut idx)?;
123                    current_x += dx;
124                    instructions.push(Instruction::LineTo {
125                        x: current_x,
126                        y: current_y,
127                    });
128                }
129            }
130            "V" => {
131                idx += 1;
132                last_cmd = None;
133                last_control_point = None;
134                while idx < tokens.len() && is_num_token(&tokens[idx]) {
135                    let y = parse_num(&tokens, &mut idx)?;
136                    current_y = y;
137                    instructions.push(Instruction::LineTo {
138                        x: current_x,
139                        y: current_y,
140                    });
141                }
142            }
143            "v" => {
144                idx += 1;
145                last_cmd = None;
146                last_control_point = None;
147                while idx < tokens.len() && is_num_token(&tokens[idx]) {
148                    let dy = parse_num(&tokens, &mut idx)?;
149                    current_y += dy;
150                    instructions.push(Instruction::LineTo {
151                        x: current_x,
152                        y: current_y,
153                    });
154                }
155            }
156            "C" => {
157                idx += 1;
158                while idx < tokens.len() && is_num_token(&tokens[idx]) {
159                    let x1 = parse_num(&tokens, &mut idx)?;
160                    let y1 = parse_num(&tokens, &mut idx)?;
161                    let x2 = parse_num(&tokens, &mut idx)?;
162                    let y2 = parse_num(&tokens, &mut idx)?;
163                    let x = parse_num(&tokens, &mut idx)?;
164                    let y = parse_num(&tokens, &mut idx)?;
165                    current_x = x;
166                    current_y = y;
167                    last_control_point = Some((x2, y2));
168                    last_cmd = Some('C');
169                    instructions.push(Instruction::CubicCurveTo {
170                        x1,
171                        y1,
172                        x2,
173                        y2,
174                        x,
175                        y,
176                    });
177                }
178            }
179            "c" => {
180                idx += 1;
181                while idx < tokens.len() && is_num_token(&tokens[idx]) {
182                    let dx1 = parse_num(&tokens, &mut idx)?;
183                    let dy1 = parse_num(&tokens, &mut idx)?;
184                    let dx2 = parse_num(&tokens, &mut idx)?;
185                    let dy2 = parse_num(&tokens, &mut idx)?;
186                    let dx = parse_num(&tokens, &mut idx)?;
187                    let dy = parse_num(&tokens, &mut idx)?;
188                    let x1 = current_x + dx1;
189                    let y1 = current_y + dy1;
190                    let x2 = current_x + dx2;
191                    let y2 = current_y + dy2;
192                    let x = current_x + dx;
193                    let y = current_y + dy;
194                    current_x = x;
195                    current_y = y;
196                    last_control_point = Some((x2, y2));
197                    last_cmd = Some('C');
198                    instructions.push(Instruction::CubicCurveTo {
199                        x1,
200                        y1,
201                        x2,
202                        y2,
203                        x,
204                        y,
205                    });
206                }
207            }
208            "S" => {
209                idx += 1;
210                while idx < tokens.len() && is_num_token(&tokens[idx]) {
211                    let x2 = parse_num(&tokens, &mut idx)?;
212                    let y2 = parse_num(&tokens, &mut idx)?;
213                    let x = parse_num(&tokens, &mut idx)?;
214                    let y = parse_num(&tokens, &mut idx)?;
215                    let (x1, y1) = match (last_cmd, last_control_point) {
216                        (Some('C'), Some((px2, py2))) => {
217                            (2.0 * current_x - px2, 2.0 * current_y - py2)
218                        }
219                        _ => (current_x, current_y),
220                    };
221                    current_x = x;
222                    current_y = y;
223                    last_control_point = Some((x2, y2));
224                    last_cmd = Some('C');
225                    instructions.push(Instruction::CubicCurveTo {
226                        x1,
227                        y1,
228                        x2,
229                        y2,
230                        x,
231                        y,
232                    });
233                }
234            }
235            "s" => {
236                idx += 1;
237                while idx < tokens.len() && is_num_token(&tokens[idx]) {
238                    let dx2 = parse_num(&tokens, &mut idx)?;
239                    let dy2 = parse_num(&tokens, &mut idx)?;
240                    let dx = parse_num(&tokens, &mut idx)?;
241                    let dy = parse_num(&tokens, &mut idx)?;
242                    let (x1, y1) = match (last_cmd, last_control_point) {
243                        (Some('C'), Some((px2, py2))) => {
244                            (2.0 * current_x - px2, 2.0 * current_y - py2)
245                        }
246                        _ => (current_x, current_y),
247                    };
248                    let x2 = current_x + dx2;
249                    let y2 = current_y + dy2;
250                    let x = current_x + dx;
251                    let y = current_y + dy;
252                    current_x = x;
253                    current_y = y;
254                    last_control_point = Some((x2, y2));
255                    last_cmd = Some('C');
256                    instructions.push(Instruction::CubicCurveTo {
257                        x1,
258                        y1,
259                        x2,
260                        y2,
261                        x,
262                        y,
263                    });
264                }
265            }
266            "Q" => {
267                idx += 1;
268                while idx < tokens.len() && is_num_token(&tokens[idx]) {
269                    let x1 = parse_num(&tokens, &mut idx)?;
270                    let y1 = parse_num(&tokens, &mut idx)?;
271                    let x = parse_num(&tokens, &mut idx)?;
272                    let y = parse_num(&tokens, &mut idx)?;
273                    current_x = x;
274                    current_y = y;
275                    last_control_point = Some((x1, y1));
276                    last_cmd = Some('Q');
277                    instructions.push(Instruction::QuadCurveTo { x1, y1, x, y });
278                }
279            }
280            "q" => {
281                idx += 1;
282                while idx < tokens.len() && is_num_token(&tokens[idx]) {
283                    let dx1 = parse_num(&tokens, &mut idx)?;
284                    let dy1 = parse_num(&tokens, &mut idx)?;
285                    let dx = parse_num(&tokens, &mut idx)?;
286                    let dy = parse_num(&tokens, &mut idx)?;
287                    let x1 = current_x + dx1;
288                    let y1 = current_y + dy1;
289                    let x = current_x + dx;
290                    let y = current_y + dy;
291                    current_x = x;
292                    current_y = y;
293                    last_control_point = Some((x1, y1));
294                    last_cmd = Some('Q');
295                    instructions.push(Instruction::QuadCurveTo { x1, y1, x, y });
296                }
297            }
298            "T" => {
299                idx += 1;
300                while idx < tokens.len() && is_num_token(&tokens[idx]) {
301                    let x = parse_num(&tokens, &mut idx)?;
302                    let y = parse_num(&tokens, &mut idx)?;
303                    let (x1, y1) = match (last_cmd, last_control_point) {
304                        (Some('Q'), Some((px1, py1))) => {
305                            (2.0 * current_x - px1, 2.0 * current_y - py1)
306                        }
307                        _ => (current_x, current_y),
308                    };
309                    current_x = x;
310                    current_y = y;
311                    last_control_point = Some((x1, y1));
312                    last_cmd = Some('Q');
313                    instructions.push(Instruction::QuadCurveTo { x1, y1, x, y });
314                }
315            }
316            "t" => {
317                idx += 1;
318                while idx < tokens.len() && is_num_token(&tokens[idx]) {
319                    let dx = parse_num(&tokens, &mut idx)?;
320                    let dy = parse_num(&tokens, &mut idx)?;
321                    let (x1, y1) = match (last_cmd, last_control_point) {
322                        (Some('Q'), Some((px1, py1))) => {
323                            (2.0 * current_x - px1, 2.0 * current_y - py1)
324                        }
325                        _ => (current_x, current_y),
326                    };
327                    let x = current_x + dx;
328                    let y = current_y + dy;
329                    current_x = x;
330                    current_y = y;
331                    last_control_point = Some((x1, y1));
332                    last_cmd = Some('Q');
333                    instructions.push(Instruction::QuadCurveTo { x1, y1, x, y });
334                }
335            }
336            "A" => {
337                idx += 1;
338                last_cmd = None;
339                last_control_point = None;
340                while idx < tokens.len() && is_num_token(&tokens[idx]) {
341                    let rx = parse_num(&tokens, &mut idx)?;
342                    let ry = parse_num(&tokens, &mut idx)?;
343                    let x_axis_rotation = parse_num(&tokens, &mut idx)?;
344                    let large_arc_flag = parse_num(&tokens, &mut idx)? != 0.0;
345                    let sweep_flag = parse_num(&tokens, &mut idx)? != 0.0;
346                    let x = parse_num(&tokens, &mut idx)?;
347                    let y = parse_num(&tokens, &mut idx)?;
348                    current_x = x;
349                    current_y = y;
350                    instructions.push(Instruction::ArcTo {
351                        rx: rx.abs(),
352                        ry: ry.abs(),
353                        x_axis_rotation,
354                        large_arc_flag,
355                        sweep_flag,
356                        x,
357                        y,
358                    });
359                }
360            }
361            "a" => {
362                idx += 1;
363                last_cmd = None;
364                last_control_point = None;
365                while idx < tokens.len() && is_num_token(&tokens[idx]) {
366                    let rx = parse_num(&tokens, &mut idx)?;
367                    let ry = parse_num(&tokens, &mut idx)?;
368                    let x_axis_rotation = parse_num(&tokens, &mut idx)?;
369                    let large_arc_flag = parse_num(&tokens, &mut idx)? != 0.0;
370                    let sweep_flag = parse_num(&tokens, &mut idx)? != 0.0;
371                    let dx = parse_num(&tokens, &mut idx)?;
372                    let dy = parse_num(&tokens, &mut idx)?;
373                    let x = current_x + dx;
374                    let y = current_y + dy;
375                    current_x = x;
376                    current_y = y;
377                    instructions.push(Instruction::ArcTo {
378                        rx: rx.abs(),
379                        ry: ry.abs(),
380                        x_axis_rotation,
381                        large_arc_flag,
382                        sweep_flag,
383                        x,
384                        y,
385                    });
386                }
387            }
388            "Z" | "z" => {
389                idx += 1;
390                current_x = start_x;
391                current_y = start_y;
392                last_cmd = None;
393                last_control_point = None;
394                instructions.push(Instruction::ClosePath);
395            }
396            other => {
397                return Err(PathParseError::ParseNumberError(other.to_string()));
398            }
399        }
400    }
401
402    Ok(instructions)
403}
404
405fn is_svg_cmd(c: char) -> bool {
406    matches!(
407        c,
408        'M' | 'm'
409            | 'L'
410            | 'l'
411            | 'H'
412            | 'h'
413            | 'V'
414            | 'v'
415            | 'C'
416            | 'c'
417            | 'S'
418            | 's'
419            | 'Q'
420            | 'q'
421            | 'T'
422            | 't'
423            | 'A'
424            | 'a'
425            | 'Z'
426            | 'z'
427    )
428}
429
430fn is_num_token(s: &str) -> bool {
431    s.parse::<f64>().is_ok()
432}
433
434fn tokenize(d: &str) -> Result<Vec<String>, PathParseError> {
435    let mut tokens = Vec::new();
436    let mut current_token = String::new();
437
438    let chars: Vec<char> = d.chars().collect();
439    let mut i = 0;
440
441    while i < chars.len() {
442        let c = chars[i];
443        if is_svg_cmd(c) {
444            if !current_token.trim().is_empty() {
445                tokens.push(current_token.trim().to_string());
446                current_token = String::new();
447            }
448            tokens.push(c.to_string());
449            i += 1;
450        } else if c == ',' || c.is_whitespace() {
451            if !current_token.trim().is_empty() {
452                tokens.push(current_token.trim().to_string());
453                current_token = String::new();
454            }
455            i += 1;
456        } else if ((c == '-' || c == '+')
457            && !current_token.is_empty()
458            && !current_token.ends_with('e')
459            && !current_token.ends_with('E'))
460            || (c == '.' && current_token.contains('.'))
461        {
462            tokens.push(current_token.trim().to_string());
463            current_token = String::new();
464            current_token.push(c);
465            i += 1;
466        } else {
467            current_token.push(c);
468            i += 1;
469        }
470    }
471
472    if !current_token.trim().is_empty() {
473        tokens.push(current_token.trim().to_string());
474    }
475
476    Ok(tokens)
477}
478
479fn parse_num(tokens: &[String], idx: &mut usize) -> Result<f64, PathParseError> {
480    if *idx >= tokens.len() {
481        return Err(PathParseError::UnexpectedEnd);
482    }
483    let val = tokens[*idx]
484        .parse::<f64>()
485        .map_err(|_| PathParseError::ParseNumberError(tokens[*idx].clone()))?;
486    *idx += 1;
487    Ok(val)
488}
489
490/// Serializes instructions back into a standardized SVG `d` path string.
491pub fn serialize_instructions(instructions: &[Instruction]) -> String {
492    let mut out = Vec::new();
493
494    for inst in instructions {
495        match inst {
496            Instruction::MoveTo { x, y } => out.push(format!("M {x:.4} {y:.4}")),
497            Instruction::LineTo { x, y } => out.push(format!("L {x:.4} {y:.4}")),
498            Instruction::CubicCurveTo {
499                x1,
500                y1,
501                x2,
502                y2,
503                x,
504                y,
505            } => out.push(format!("C {x1:.4} {y1:.4} {x2:.4} {y2:.4} {x:.4} {y:.4}")),
506            Instruction::QuadCurveTo { x1, y1, x, y } => {
507                out.push(format!("Q {x1:.4} {y1:.4} {x:.4} {y:.4}"))
508            }
509            Instruction::ArcTo {
510                rx,
511                ry,
512                x_axis_rotation,
513                large_arc_flag,
514                sweep_flag,
515                x,
516                y,
517            } => out.push(format!(
518                "A {rx:.4} {ry:.4} {x_axis_rotation:.4} {} {} {x:.4} {y:.4}",
519                if *large_arc_flag { 1 } else { 0 },
520                if *sweep_flag { 1 } else { 0 }
521            )),
522            Instruction::ClosePath => out.push("Z".to_string()),
523        }
524    }
525
526    out.join(" ")
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    #[test]
534    fn test_parse_and_serialize_line() {
535        let d = "M 10 20 L 100 200 Z";
536        let insts = parse_path(d).unwrap();
537        assert_eq!(insts.len(), 3);
538        assert_eq!(insts[0], Instruction::MoveTo { x: 10.0, y: 20.0 });
539        assert_eq!(insts[1], Instruction::LineTo { x: 100.0, y: 200.0 });
540        assert_eq!(insts[2], Instruction::ClosePath);
541
542        let res = serialize_instructions(&insts);
543        assert_eq!(res, "M 10.0000 20.0000 L 100.0000 200.0000 Z");
544    }
545
546    #[test]
547    fn test_parse_arc() {
548        let d = "M 100 100 L 100 0 A 100 100 0 0 1 200 100 Z";
549        let insts = parse_path(d).unwrap();
550        assert_eq!(insts.len(), 4);
551        assert_eq!(insts[0], Instruction::MoveTo { x: 100.0, y: 100.0 });
552        assert_eq!(insts[1], Instruction::LineTo { x: 100.0, y: 0.0 });
553        assert_eq!(
554            insts[2],
555            Instruction::ArcTo {
556                rx: 100.0,
557                ry: 100.0,
558                x_axis_rotation: 0.0,
559                large_arc_flag: false,
560                sweep_flag: true,
561                x: 200.0,
562                y: 100.0,
563            }
564        );
565        assert_eq!(insts[3], Instruction::ClosePath);
566
567        let res = serialize_instructions(&insts);
568        assert_eq!(
569            res,
570            "M 100.0000 100.0000 L 100.0000 0.0000 A 100.0000 100.0000 0.0000 0 1 200.0000 100.0000 Z"
571        );
572    }
573
574    #[test]
575    fn test_parse_relative_and_hv() {
576        let d = "m 10 20 h 30 v 40 l -10 -10 z";
577        let insts = parse_path(d).unwrap();
578        assert_eq!(insts.len(), 5);
579        assert_eq!(insts[0], Instruction::MoveTo { x: 10.0, y: 20.0 });
580        assert_eq!(insts[1], Instruction::LineTo { x: 40.0, y: 20.0 });
581        assert_eq!(insts[2], Instruction::LineTo { x: 40.0, y: 60.0 });
582        assert_eq!(insts[3], Instruction::LineTo { x: 30.0, y: 50.0 });
583        assert_eq!(insts[4], Instruction::ClosePath);
584    }
585
586    #[test]
587    fn test_parse_chained_beziers_and_smooth() {
588        let d = "M 0 0 C 10 20 30 40 50 60 S 70 80 90 100 Q 110 120 130 140 T 150 160";
589        let insts = parse_path(d).unwrap();
590        assert_eq!(insts.len(), 5);
591        assert_eq!(insts[0], Instruction::MoveTo { x: 0.0, y: 0.0 });
592        assert_eq!(
593            insts[1],
594            Instruction::CubicCurveTo {
595                x1: 10.0,
596                y1: 20.0,
597                x2: 30.0,
598                y2: 40.0,
599                x: 50.0,
600                y: 60.0,
601            }
602        );
603        // S reflects (30, 40) across (50, 60): 2*50 - 30 = 70, 2*60 - 40 = 80
604        assert_eq!(
605            insts[2],
606            Instruction::CubicCurveTo {
607                x1: 70.0,
608                y1: 80.0,
609                x2: 70.0,
610                y2: 80.0,
611                x: 90.0,
612                y: 100.0,
613            }
614        );
615        assert_eq!(
616            insts[3],
617            Instruction::QuadCurveTo {
618                x1: 110.0,
619                y1: 120.0,
620                x: 130.0,
621                y: 140.0,
622            }
623        );
624        // T reflects (110, 120) across (130, 140): 2*130 - 110 = 150, 2*140 - 120 = 160
625        assert_eq!(
626            insts[4],
627            Instruction::QuadCurveTo {
628                x1: 150.0,
629                y1: 160.0,
630                x: 150.0,
631                y: 160.0,
632            }
633        );
634    }
635
636    #[test]
637    fn test_parse_exponential_and_compact_coordinates() {
638        let d = "M1e1-2e1L.5.25";
639        let insts = parse_path(d).unwrap();
640        assert_eq!(insts.len(), 2);
641        assert_eq!(insts[0], Instruction::MoveTo { x: 10.0, y: -20.0 });
642        assert_eq!(insts[1], Instruction::LineTo { x: 0.5, y: 0.25 });
643    }
644
645    #[test]
646    fn test_parse_error_invalid_command() {
647        let res = parse_path("X 10 20");
648        assert!(res.is_err());
649    }
650}