facet_svg/
path.rs

1//! SVG path data parsing and structured representation.
2
3use facet::Facet;
4
5/// A single SVG path command
6#[derive(Debug, Clone, PartialEq, Facet)]
7#[repr(u8)]
8pub enum PathCommand {
9    /// Move to (absolute)
10    MoveTo { x: f64, y: f64 },
11    /// Move to (relative)
12    MoveToRel { dx: f64, dy: f64 },
13    /// Line to (absolute)
14    LineTo { x: f64, y: f64 },
15    /// Line to (relative)
16    LineToRel { dx: f64, dy: f64 },
17    /// Horizontal line to (absolute)
18    HorizontalLineTo { x: f64 },
19    /// Horizontal line to (relative)
20    HorizontalLineToRel { dx: f64 },
21    /// Vertical line to (absolute)
22    VerticalLineTo { y: f64 },
23    /// Vertical line to (relative)
24    VerticalLineToRel { dy: f64 },
25    /// Cubic Bezier curve (absolute)
26    CurveTo {
27        x1: f64,
28        y1: f64,
29        x2: f64,
30        y2: f64,
31        x: f64,
32        y: f64,
33    },
34    /// Cubic Bezier curve (relative)
35    CurveToRel {
36        dx1: f64,
37        dy1: f64,
38        dx2: f64,
39        dy2: f64,
40        dx: f64,
41        dy: f64,
42    },
43    /// Smooth cubic Bezier curve (absolute)
44    SmoothCurveTo { x2: f64, y2: f64, x: f64, y: f64 },
45    /// Smooth cubic Bezier curve (relative)
46    SmoothCurveToRel {
47        dx2: f64,
48        dy2: f64,
49        dx: f64,
50        dy: f64,
51    },
52    /// Quadratic Bezier curve (absolute)
53    QuadTo { x1: f64, y1: f64, x: f64, y: f64 },
54    /// Quadratic Bezier curve (relative)
55    QuadToRel {
56        dx1: f64,
57        dy1: f64,
58        dx: f64,
59        dy: f64,
60    },
61    /// Smooth quadratic Bezier curve (absolute)
62    SmoothQuadTo { x: f64, y: f64 },
63    /// Smooth quadratic Bezier curve (relative)
64    SmoothQuadToRel { dx: f64, dy: f64 },
65    /// Arc (absolute)
66    Arc {
67        rx: f64,
68        ry: f64,
69        x_rotation: f64,
70        large_arc: bool,
71        sweep: bool,
72        x: f64,
73        y: f64,
74    },
75    /// Arc (relative)
76    ArcRel {
77        rx: f64,
78        ry: f64,
79        x_rotation: f64,
80        large_arc: bool,
81        sweep: bool,
82        dx: f64,
83        dy: f64,
84    },
85    /// Close path
86    ClosePath,
87}
88
89/// Structured SVG path data
90#[derive(Debug, Clone, PartialEq, Default, Facet)]
91#[facet(traits(Default, Display))]
92pub struct PathData {
93    pub commands: Vec<PathCommand>,
94}
95
96impl PathData {
97    pub const fn new() -> Self {
98        Self {
99            commands: Vec::new(),
100        }
101    }
102
103    /// Move to absolute position (M command)
104    pub fn m(mut self, x: f64, y: f64) -> Self {
105        self.commands.push(PathCommand::MoveTo { x, y });
106        self
107    }
108
109    /// Move to relative position (m command)
110    pub fn m_rel(mut self, dx: f64, dy: f64) -> Self {
111        self.commands.push(PathCommand::MoveToRel { dx, dy });
112        self
113    }
114
115    /// Line to absolute position (L command)
116    pub fn l(mut self, x: f64, y: f64) -> Self {
117        self.commands.push(PathCommand::LineTo { x, y });
118        self
119    }
120
121    /// Line to relative position (l command)
122    pub fn l_rel(mut self, dx: f64, dy: f64) -> Self {
123        self.commands.push(PathCommand::LineToRel { dx, dy });
124        self
125    }
126
127    /// Horizontal line to absolute x position (H command)
128    pub fn h(mut self, x: f64) -> Self {
129        self.commands.push(PathCommand::HorizontalLineTo { x });
130        self
131    }
132
133    /// Horizontal line to relative x position (h command)
134    pub fn h_rel(mut self, dx: f64) -> Self {
135        self.commands.push(PathCommand::HorizontalLineToRel { dx });
136        self
137    }
138
139    /// Vertical line to absolute y position (V command)
140    pub fn v(mut self, y: f64) -> Self {
141        self.commands.push(PathCommand::VerticalLineTo { y });
142        self
143    }
144
145    /// Vertical line to relative y position (v command)
146    pub fn v_rel(mut self, dy: f64) -> Self {
147        self.commands.push(PathCommand::VerticalLineToRel { dy });
148        self
149    }
150
151    /// Cubic Bezier curve to absolute position (C command)
152    pub fn c(mut self, x1: f64, y1: f64, x2: f64, y2: f64, x: f64, y: f64) -> Self {
153        self.commands.push(PathCommand::CurveTo {
154            x1,
155            y1,
156            x2,
157            y2,
158            x,
159            y,
160        });
161        self
162    }
163
164    /// Cubic Bezier curve to relative position (c command)
165    pub fn c_rel(mut self, dx1: f64, dy1: f64, dx2: f64, dy2: f64, dx: f64, dy: f64) -> Self {
166        self.commands.push(PathCommand::CurveToRel {
167            dx1,
168            dy1,
169            dx2,
170            dy2,
171            dx,
172            dy,
173        });
174        self
175    }
176
177    /// Smooth cubic Bezier curve to absolute position (S command)
178    pub fn s(mut self, x2: f64, y2: f64, x: f64, y: f64) -> Self {
179        self.commands
180            .push(PathCommand::SmoothCurveTo { x2, y2, x, y });
181        self
182    }
183
184    /// Smooth cubic Bezier curve to relative position (s command)
185    pub fn s_rel(mut self, dx2: f64, dy2: f64, dx: f64, dy: f64) -> Self {
186        self.commands
187            .push(PathCommand::SmoothCurveToRel { dx2, dy2, dx, dy });
188        self
189    }
190
191    /// Quadratic Bezier curve to absolute position (Q command)
192    pub fn q(mut self, x1: f64, y1: f64, x: f64, y: f64) -> Self {
193        self.commands.push(PathCommand::QuadTo { x1, y1, x, y });
194        self
195    }
196
197    /// Quadratic Bezier curve to relative position (q command)
198    pub fn q_rel(mut self, dx1: f64, dy1: f64, dx: f64, dy: f64) -> Self {
199        self.commands
200            .push(PathCommand::QuadToRel { dx1, dy1, dx, dy });
201        self
202    }
203
204    /// Smooth quadratic Bezier curve to absolute position (T command)
205    pub fn t(mut self, x: f64, y: f64) -> Self {
206        self.commands.push(PathCommand::SmoothQuadTo { x, y });
207        self
208    }
209
210    /// Smooth quadratic Bezier curve to relative position (t command)
211    pub fn t_rel(mut self, dx: f64, dy: f64) -> Self {
212        self.commands.push(PathCommand::SmoothQuadToRel { dx, dy });
213        self
214    }
215
216    /// Arc to absolute position (A command)
217    #[allow(clippy::too_many_arguments)]
218    pub fn a(
219        mut self,
220        rx: f64,
221        ry: f64,
222        x_rotation: f64,
223        large_arc: bool,
224        sweep: bool,
225        x: f64,
226        y: f64,
227    ) -> Self {
228        self.commands.push(PathCommand::Arc {
229            rx,
230            ry,
231            x_rotation,
232            large_arc,
233            sweep,
234            x,
235            y,
236        });
237        self
238    }
239
240    /// Arc to relative position (a command)
241    #[allow(clippy::too_many_arguments)]
242    pub fn a_rel(
243        mut self,
244        rx: f64,
245        ry: f64,
246        x_rotation: f64,
247        large_arc: bool,
248        sweep: bool,
249        dx: f64,
250        dy: f64,
251    ) -> Self {
252        self.commands.push(PathCommand::ArcRel {
253            rx,
254            ry,
255            x_rotation,
256            large_arc,
257            sweep,
258            dx,
259            dy,
260        });
261        self
262    }
263
264    /// Close path (Z command)
265    pub fn z(mut self) -> Self {
266        self.commands.push(PathCommand::ClosePath);
267        self
268    }
269
270    /// Parse path data from a string
271    pub fn parse(s: &str) -> Result<Self, PathParseError> {
272        let mut commands = Vec::new();
273        let mut chars = s.chars().peekable();
274
275        while let Some(&c) = chars.peek() {
276            // Skip whitespace and commas
277            if c.is_whitespace() || c == ',' {
278                chars.next();
279                continue;
280            }
281
282            // Parse command
283            let cmd = chars.next().unwrap();
284            match cmd {
285                'M' => {
286                    let (x, y) = parse_coord_pair(&mut chars)?;
287                    commands.push(PathCommand::MoveTo { x, y });
288                    // Subsequent coordinate pairs are implicit LineTo
289                    while let Some((x, y)) = try_parse_coord_pair(&mut chars) {
290                        commands.push(PathCommand::LineTo { x, y });
291                    }
292                }
293                'm' => {
294                    let (dx, dy) = parse_coord_pair(&mut chars)?;
295                    commands.push(PathCommand::MoveToRel { dx, dy });
296                    while let Some((dx, dy)) = try_parse_coord_pair(&mut chars) {
297                        commands.push(PathCommand::LineToRel { dx, dy });
298                    }
299                }
300                'L' => {
301                    let (x, y) = parse_coord_pair(&mut chars)?;
302                    commands.push(PathCommand::LineTo { x, y });
303                    while let Some((x, y)) = try_parse_coord_pair(&mut chars) {
304                        commands.push(PathCommand::LineTo { x, y });
305                    }
306                }
307                'l' => {
308                    let (dx, dy) = parse_coord_pair(&mut chars)?;
309                    commands.push(PathCommand::LineToRel { dx, dy });
310                    while let Some((dx, dy)) = try_parse_coord_pair(&mut chars) {
311                        commands.push(PathCommand::LineToRel { dx, dy });
312                    }
313                }
314                'H' => {
315                    let x = parse_number(&mut chars)?;
316                    commands.push(PathCommand::HorizontalLineTo { x });
317                    while let Some(x) = try_parse_number(&mut chars) {
318                        commands.push(PathCommand::HorizontalLineTo { x });
319                    }
320                }
321                'h' => {
322                    let dx = parse_number(&mut chars)?;
323                    commands.push(PathCommand::HorizontalLineToRel { dx });
324                    while let Some(dx) = try_parse_number(&mut chars) {
325                        commands.push(PathCommand::HorizontalLineToRel { dx });
326                    }
327                }
328                'V' => {
329                    let y = parse_number(&mut chars)?;
330                    commands.push(PathCommand::VerticalLineTo { y });
331                    while let Some(y) = try_parse_number(&mut chars) {
332                        commands.push(PathCommand::VerticalLineTo { y });
333                    }
334                }
335                'v' => {
336                    let dy = parse_number(&mut chars)?;
337                    commands.push(PathCommand::VerticalLineToRel { dy });
338                    while let Some(dy) = try_parse_number(&mut chars) {
339                        commands.push(PathCommand::VerticalLineToRel { dy });
340                    }
341                }
342                'C' => {
343                    let (x1, y1) = parse_coord_pair(&mut chars)?;
344                    let (x2, y2) = parse_coord_pair(&mut chars)?;
345                    let (x, y) = parse_coord_pair(&mut chars)?;
346                    commands.push(PathCommand::CurveTo {
347                        x1,
348                        y1,
349                        x2,
350                        y2,
351                        x,
352                        y,
353                    });
354                }
355                'c' => {
356                    let (dx1, dy1) = parse_coord_pair(&mut chars)?;
357                    let (dx2, dy2) = parse_coord_pair(&mut chars)?;
358                    let (dx, dy) = parse_coord_pair(&mut chars)?;
359                    commands.push(PathCommand::CurveToRel {
360                        dx1,
361                        dy1,
362                        dx2,
363                        dy2,
364                        dx,
365                        dy,
366                    });
367                }
368                'S' => {
369                    let (x2, y2) = parse_coord_pair(&mut chars)?;
370                    let (x, y) = parse_coord_pair(&mut chars)?;
371                    commands.push(PathCommand::SmoothCurveTo { x2, y2, x, y });
372                }
373                's' => {
374                    let (dx2, dy2) = parse_coord_pair(&mut chars)?;
375                    let (dx, dy) = parse_coord_pair(&mut chars)?;
376                    commands.push(PathCommand::SmoothCurveToRel { dx2, dy2, dx, dy });
377                }
378                'Q' => {
379                    let (x1, y1) = parse_coord_pair(&mut chars)?;
380                    let (x, y) = parse_coord_pair(&mut chars)?;
381                    commands.push(PathCommand::QuadTo { x1, y1, x, y });
382                }
383                'q' => {
384                    let (dx1, dy1) = parse_coord_pair(&mut chars)?;
385                    let (dx, dy) = parse_coord_pair(&mut chars)?;
386                    commands.push(PathCommand::QuadToRel { dx1, dy1, dx, dy });
387                }
388                'T' => {
389                    let (x, y) = parse_coord_pair(&mut chars)?;
390                    commands.push(PathCommand::SmoothQuadTo { x, y });
391                }
392                't' => {
393                    let (dx, dy) = parse_coord_pair(&mut chars)?;
394                    commands.push(PathCommand::SmoothQuadToRel { dx, dy });
395                }
396                'A' => {
397                    let rx = parse_number(&mut chars)?;
398                    let ry = parse_number(&mut chars)?;
399                    let x_rotation = parse_number(&mut chars)?;
400                    let large_arc = parse_flag(&mut chars)?;
401                    let sweep = parse_flag(&mut chars)?;
402                    let (x, y) = parse_coord_pair(&mut chars)?;
403                    commands.push(PathCommand::Arc {
404                        rx,
405                        ry,
406                        x_rotation,
407                        large_arc,
408                        sweep,
409                        x,
410                        y,
411                    });
412                }
413                'a' => {
414                    let rx = parse_number(&mut chars)?;
415                    let ry = parse_number(&mut chars)?;
416                    let x_rotation = parse_number(&mut chars)?;
417                    let large_arc = parse_flag(&mut chars)?;
418                    let sweep = parse_flag(&mut chars)?;
419                    let (dx, dy) = parse_coord_pair(&mut chars)?;
420                    commands.push(PathCommand::ArcRel {
421                        rx,
422                        ry,
423                        x_rotation,
424                        large_arc,
425                        sweep,
426                        dx,
427                        dy,
428                    });
429                }
430                'Z' | 'z' => {
431                    commands.push(PathCommand::ClosePath);
432                }
433                _ => {
434                    return Err(PathParseError::UnknownCommand(cmd));
435                }
436            }
437        }
438
439        Ok(PathData { commands })
440    }
441
442    /// Serialize path data to a string
443    fn serialize(&self) -> String {
444        let mut result = String::new();
445        for cmd in &self.commands {
446            if !result.is_empty() {
447                // No separator needed - commands are self-delimiting
448            }
449            match cmd {
450                PathCommand::MoveTo { x, y } => {
451                    result.push_str(&format!("M{},{}", fmt_num(*x), fmt_num(*y)));
452                }
453                PathCommand::MoveToRel { dx, dy } => {
454                    result.push_str(&format!("m{},{}", fmt_num(*dx), fmt_num(*dy)));
455                }
456                PathCommand::LineTo { x, y } => {
457                    result.push_str(&format!("L{},{}", fmt_num(*x), fmt_num(*y)));
458                }
459                PathCommand::LineToRel { dx, dy } => {
460                    result.push_str(&format!("l{},{}", fmt_num(*dx), fmt_num(*dy)));
461                }
462                PathCommand::HorizontalLineTo { x } => {
463                    result.push_str(&format!("H{}", fmt_num(*x)));
464                }
465                PathCommand::HorizontalLineToRel { dx } => {
466                    result.push_str(&format!("h{}", fmt_num(*dx)));
467                }
468                PathCommand::VerticalLineTo { y } => {
469                    result.push_str(&format!("V{}", fmt_num(*y)));
470                }
471                PathCommand::VerticalLineToRel { dy } => {
472                    result.push_str(&format!("v{}", fmt_num(*dy)));
473                }
474                PathCommand::CurveTo {
475                    x1,
476                    y1,
477                    x2,
478                    y2,
479                    x,
480                    y,
481                } => {
482                    result.push_str(&format!(
483                        "C{},{} {},{} {},{}",
484                        fmt_num(*x1),
485                        fmt_num(*y1),
486                        fmt_num(*x2),
487                        fmt_num(*y2),
488                        fmt_num(*x),
489                        fmt_num(*y)
490                    ));
491                }
492                PathCommand::CurveToRel {
493                    dx1,
494                    dy1,
495                    dx2,
496                    dy2,
497                    dx,
498                    dy,
499                } => {
500                    result.push_str(&format!(
501                        "c{},{} {},{} {},{}",
502                        fmt_num(*dx1),
503                        fmt_num(*dy1),
504                        fmt_num(*dx2),
505                        fmt_num(*dy2),
506                        fmt_num(*dx),
507                        fmt_num(*dy)
508                    ));
509                }
510                PathCommand::SmoothCurveTo { x2, y2, x, y } => {
511                    result.push_str(&format!(
512                        "S{},{} {},{}",
513                        fmt_num(*x2),
514                        fmt_num(*y2),
515                        fmt_num(*x),
516                        fmt_num(*y)
517                    ));
518                }
519                PathCommand::SmoothCurveToRel { dx2, dy2, dx, dy } => {
520                    result.push_str(&format!(
521                        "s{},{} {},{}",
522                        fmt_num(*dx2),
523                        fmt_num(*dy2),
524                        fmt_num(*dx),
525                        fmt_num(*dy)
526                    ));
527                }
528                PathCommand::QuadTo { x1, y1, x, y } => {
529                    result.push_str(&format!(
530                        "Q{},{} {},{}",
531                        fmt_num(*x1),
532                        fmt_num(*y1),
533                        fmt_num(*x),
534                        fmt_num(*y)
535                    ));
536                }
537                PathCommand::QuadToRel { dx1, dy1, dx, dy } => {
538                    result.push_str(&format!(
539                        "q{},{} {},{}",
540                        fmt_num(*dx1),
541                        fmt_num(*dy1),
542                        fmt_num(*dx),
543                        fmt_num(*dy)
544                    ));
545                }
546                PathCommand::SmoothQuadTo { x, y } => {
547                    result.push_str(&format!("T{},{}", fmt_num(*x), fmt_num(*y)));
548                }
549                PathCommand::SmoothQuadToRel { dx, dy } => {
550                    result.push_str(&format!("t{},{}", fmt_num(*dx), fmt_num(*dy)));
551                }
552                PathCommand::Arc {
553                    rx,
554                    ry,
555                    x_rotation,
556                    large_arc,
557                    sweep,
558                    x,
559                    y,
560                } => {
561                    result.push_str(&format!(
562                        "A{},{} {} {} {} {},{}",
563                        fmt_num(*rx),
564                        fmt_num(*ry),
565                        fmt_num(*x_rotation),
566                        if *large_arc { 1 } else { 0 },
567                        if *sweep { 1 } else { 0 },
568                        fmt_num(*x),
569                        fmt_num(*y)
570                    ));
571                }
572                PathCommand::ArcRel {
573                    rx,
574                    ry,
575                    x_rotation,
576                    large_arc,
577                    sweep,
578                    dx,
579                    dy,
580                } => {
581                    result.push_str(&format!(
582                        "a{},{} {} {} {} {},{}",
583                        fmt_num(*rx),
584                        fmt_num(*ry),
585                        fmt_num(*x_rotation),
586                        if *large_arc { 1 } else { 0 },
587                        if *sweep { 1 } else { 0 },
588                        fmt_num(*dx),
589                        fmt_num(*dy)
590                    ));
591                }
592                PathCommand::ClosePath => {
593                    result.push('Z');
594                }
595            }
596        }
597        result
598    }
599}
600
601/// Format a number with up to 3 decimal places (sufficient for SVG coordinates).
602/// Trims trailing zeros and decimal point.
603fn fmt_num(v: f64) -> String {
604    let s = format!("{:.3}", v);
605    let s = s.trim_end_matches('0');
606    let s = s.trim_end_matches('.');
607    s.to_string()
608}
609
610/// Error parsing path data
611#[derive(Debug, Clone, PartialEq)]
612pub enum PathParseError {
613    UnknownCommand(char),
614    ExpectedNumber,
615    ExpectedFlag,
616    InvalidNumber(String),
617}
618
619impl std::fmt::Display for PathParseError {
620    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
621        match self {
622            PathParseError::UnknownCommand(c) => write!(f, "unknown path command: {}", c),
623            PathParseError::ExpectedNumber => write!(f, "expected number"),
624            PathParseError::ExpectedFlag => write!(f, "expected flag (0 or 1)"),
625            PathParseError::InvalidNumber(s) => write!(f, "invalid number: {}", s),
626        }
627    }
628}
629
630impl std::error::Error for PathParseError {}
631
632impl std::fmt::Display for PathData {
633    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
634        f.write_str(&self.serialize())
635    }
636}
637
638/// Proxy type for PathData - serializes as a string
639#[derive(Facet, Clone, Debug)]
640#[facet(transparent)]
641pub struct PathDataProxy(pub String);
642
643impl TryFrom<PathDataProxy> for PathData {
644    type Error = PathParseError;
645    fn try_from(proxy: PathDataProxy) -> Result<Self, Self::Error> {
646        PathData::parse(&proxy.0)
647    }
648}
649
650#[allow(clippy::infallible_try_from)]
651impl TryFrom<&PathData> for PathDataProxy {
652    type Error = std::convert::Infallible;
653    fn try_from(v: &PathData) -> Result<Self, Self::Error> {
654        Ok(PathDataProxy(v.to_string()))
655    }
656}
657
658// Option impls for facet proxy support
659impl From<PathDataProxy> for Option<PathData> {
660    fn from(proxy: PathDataProxy) -> Self {
661        PathData::parse(&proxy.0).ok()
662    }
663}
664
665#[allow(clippy::infallible_try_from)]
666impl TryFrom<&Option<PathData>> for PathDataProxy {
667    type Error = std::convert::Infallible;
668    fn try_from(v: &Option<PathData>) -> Result<Self, Self::Error> {
669        match v {
670            Some(data) => Ok(PathDataProxy(data.to_string())),
671            None => Ok(PathDataProxy(String::new())),
672        }
673    }
674}
675
676// Helper parsing functions
677
678fn skip_wsp_comma(chars: &mut std::iter::Peekable<std::str::Chars>) {
679    while let Some(&c) = chars.peek() {
680        if c.is_whitespace() || c == ',' {
681            chars.next();
682        } else {
683            break;
684        }
685    }
686}
687
688fn parse_number(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<f64, PathParseError> {
689    skip_wsp_comma(chars);
690    let mut num_str = String::new();
691
692    // Handle optional sign
693    if let Some(&c) = chars.peek()
694        && (c == '-' || c == '+')
695    {
696        num_str.push(chars.next().unwrap());
697    }
698
699    // Parse digits and decimal point
700    let mut has_digits = false;
701    while let Some(&c) = chars.peek() {
702        if c.is_ascii_digit() || c == '.' {
703            num_str.push(chars.next().unwrap());
704            has_digits = true;
705        } else if c == 'e' || c == 'E' {
706            // Scientific notation
707            num_str.push(chars.next().unwrap());
708            if let Some(&sign) = chars.peek()
709                && (sign == '-' || sign == '+')
710            {
711                num_str.push(chars.next().unwrap());
712            }
713            while let Some(&d) = chars.peek() {
714                if d.is_ascii_digit() {
715                    num_str.push(chars.next().unwrap());
716                } else {
717                    break;
718                }
719            }
720            break;
721        } else {
722            break;
723        }
724    }
725
726    if !has_digits {
727        return Err(PathParseError::ExpectedNumber);
728    }
729
730    num_str
731        .parse()
732        .map_err(|_| PathParseError::InvalidNumber(num_str))
733}
734
735fn try_parse_number(chars: &mut std::iter::Peekable<std::str::Chars>) -> Option<f64> {
736    skip_wsp_comma(chars);
737    if let Some(&c) = chars.peek()
738        && (c.is_ascii_digit() || c == '-' || c == '+' || c == '.')
739    {
740        return parse_number(chars).ok();
741    }
742    None
743}
744
745fn parse_coord_pair(
746    chars: &mut std::iter::Peekable<std::str::Chars>,
747) -> Result<(f64, f64), PathParseError> {
748    let x = parse_number(chars)?;
749    let y = parse_number(chars)?;
750    Ok((x, y))
751}
752
753fn try_parse_coord_pair(chars: &mut std::iter::Peekable<std::str::Chars>) -> Option<(f64, f64)> {
754    skip_wsp_comma(chars);
755    if let Some(&c) = chars.peek()
756        && (c.is_ascii_digit() || c == '-' || c == '+' || c == '.')
757        && let Ok(pair) = parse_coord_pair(chars)
758    {
759        return Some(pair);
760    }
761    None
762}
763
764fn parse_flag(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<bool, PathParseError> {
765    skip_wsp_comma(chars);
766    match chars.next() {
767        Some('0') => Ok(false),
768        Some('1') => Ok(true),
769        _ => Err(PathParseError::ExpectedFlag),
770    }
771}
772
773#[cfg(test)]
774mod tests {
775    use super::*;
776
777    #[test]
778    fn test_parse_simple_path() {
779        let path = PathData::parse("M10,20L30,40Z").unwrap();
780        assert_eq!(path.commands.len(), 3);
781        assert_eq!(path.commands[0], PathCommand::MoveTo { x: 10.0, y: 20.0 });
782        assert_eq!(path.commands[1], PathCommand::LineTo { x: 30.0, y: 40.0 });
783        assert_eq!(path.commands[2], PathCommand::ClosePath);
784    }
785
786    #[test]
787    fn test_parse_box_path() {
788        // C pikchr box format
789        let path =
790            PathData::parse("M118.239,208.239L226.239,208.239L226.239,136.239L118.239,136.239Z")
791                .unwrap();
792        assert_eq!(path.commands.len(), 5);
793    }
794
795    #[test]
796    fn test_roundtrip() {
797        let original = "M10,20L30,40Z";
798        let path = PathData::parse(original).unwrap();
799        let serialized = path.to_string();
800        let reparsed = PathData::parse(&serialized).unwrap();
801        assert_eq!(path.commands, reparsed.commands);
802    }
803
804    #[test]
805    fn test_arc() {
806        let path = PathData::parse("A10,10 0 0,1 20,20").unwrap();
807        assert_eq!(path.commands.len(), 1);
808        match &path.commands[0] {
809            PathCommand::Arc {
810                rx,
811                ry,
812                x_rotation,
813                large_arc,
814                sweep,
815                x,
816                y,
817            } => {
818                assert_eq!(*rx, 10.0);
819                assert_eq!(*ry, 10.0);
820                assert_eq!(*x_rotation, 0.0);
821                assert!(!*large_arc);
822                assert!(*sweep);
823                assert_eq!(*x, 20.0);
824                assert_eq!(*y, 20.0);
825            }
826            _ => panic!("expected Arc command"),
827        }
828    }
829
830    #[test]
831    fn test_float_tolerance_in_diff() {
832        use facet_assert::{SameOptions, SameReport, check_same_with_report};
833
834        // Simulate C vs Rust precision difference
835        // C: "118.239" parses to this f64
836        // Rust: "118.2387401575" parses to this f64
837        let c_path = PathData::parse("M118.239,208.239L226.239,208.239Z").unwrap();
838        let rust_path =
839            PathData::parse("M118.2387401575,208.2387401575L226.2387401575,208.2387401575Z")
840                .unwrap();
841
842        // The difference is about 0.0003, well under 0.002 tolerance
843        let tolerance = 0.002;
844        let options = SameOptions::new().float_tolerance(tolerance);
845
846        eprintln!("C path: {:?}", c_path);
847        eprintln!("Rust path: {:?}", rust_path);
848
849        // Check if they're the same within tolerance
850        let result = check_same_with_report(&c_path, &rust_path, options);
851
852        match &result {
853            SameReport::Same => eprintln!("Result: Same"),
854            SameReport::Different(report) => {
855                eprintln!("Result: Different");
856                eprintln!("XML diff:\n{}", report.render_ansi_xml());
857            }
858            SameReport::Opaque { type_name } => eprintln!("Result: Opaque({})", type_name),
859        }
860
861        assert!(
862            matches!(result, SameReport::Same),
863            "PathData values within float tolerance should be considered Same"
864        );
865    }
866}