Skip to main content

azul_core/
path_parser.rs

1//! SVG `d=""` path data parser.
2//!
3//! Parses the `d` attribute of SVG `<path>` elements into `SvgMultiPolygon`
4//! geometry, supporting all 14 SVG path commands (M/m, L/l, H/h, V/v,
5//! C/c, S/s, Q/q, T/t, A/a, Z/z).
6
7use alloc::{string::String, vec::Vec};
8use azul_css::props::basic::{SvgCubicCurve, SvgPoint, SvgQuadraticCurve};
9
10use crate::svg::{
11    SvgLine, SvgMultiPolygon, SvgPath, SvgPathElement, SvgPathElementVec, SvgPathVec,
12};
13
14/// Bezier approximation constant for quarter-circle arcs.
15const KAPPA: f32 = 0.552_284_8;
16
17/// Tolerance for treating two points as coincident (used in closepath and arc degeneracy checks).
18const POINT_EPSILON: f32 = 1e-6;
19
20/// Tolerance for snapping a closepath line (slightly larger to avoid micro-segments).
21const CLOSEPATH_EPSILON: f32 = 0.001;
22
23/// Tolerance for treating a vector length as zero in angle computation.
24const ZERO_LENGTH_EPSILON: f32 = 1e-10;
25
26/// Small offset added to PI/2 when splitting arcs to avoid exact-boundary floating-point issues.
27const ARC_SPLIT_FUDGE: f32 = 0.001;
28
29/// Decode the UTF-8 character starting at `pos` in `input`.
30///
31/// `input` is always the byte view of a valid `&str` and `pos` is always at a
32/// char boundary (only whole ASCII tokens are consumed), so the UTF-8 decode
33/// succeeds; a corrupt offset falls back to the replacement character rather
34/// than panicking. Used so error messages report the real Unicode char instead
35/// of a Latin-1 reinterpretation of a single UTF-8 byte (`b as char`).
36fn char_at(input: &[u8], pos: usize) -> char {
37    input
38        .get(pos..)
39        .and_then(|rest| core::str::from_utf8(rest).ok())
40        .and_then(|s| s.chars().next())
41        .unwrap_or(char::REPLACEMENT_CHARACTER)
42}
43
44/// Errors that can occur during SVG path parsing.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum SvgPathParseError {
47    /// The path string is empty.
48    EmptyPath,
49    /// Unexpected character encountered at the given byte offset.
50    UnexpectedChar { pos: usize, ch: char },
51    /// Expected a number but found something else.
52    ExpectedNumber { pos: usize },
53    /// Invalid arc flag (must be 0 or 1).
54    InvalidArcFlag { pos: usize },
55}
56
57/// Human-readable error messages for SVG path parse failures.
58impl core::fmt::Display for SvgPathParseError {
59    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
60        match self {
61            Self::EmptyPath => write!(f, "empty path"),
62            Self::UnexpectedChar { pos, ch } => {
63                write!(f, "unexpected char '{ch}' at byte {pos}")
64            }
65            Self::ExpectedNumber { pos } => write!(f, "expected number at byte {pos}"),
66            Self::InvalidArcFlag { pos } => write!(f, "invalid arc flag at byte {pos}"),
67        }
68    }
69}
70
71/// Internal parser state.
72struct PathParser<'a> {
73    input: &'a [u8],
74    pos: usize,
75    current: SvgPoint,
76    subpath_start: SvgPoint,
77    last_control: Option<SvgPoint>,
78    last_command: u8,
79}
80
81impl<'a> PathParser<'a> {
82    const fn new(input: &'a [u8]) -> Self {
83        Self {
84            input,
85            pos: 0,
86            current: SvgPoint { x: 0.0, y: 0.0 },
87            subpath_start: SvgPoint { x: 0.0, y: 0.0 },
88            last_control: None,
89            last_command: 0,
90        }
91    }
92
93    const fn at_end(&self) -> bool {
94        self.pos >= self.input.len()
95    }
96
97    fn peek(&self) -> Option<u8> {
98        self.input.get(self.pos).copied()
99    }
100
101    fn skip_whitespace_and_commas(&mut self) {
102        while let Some(&b) = self.input.get(self.pos) {
103            if b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' || b == b',' {
104                self.pos += 1;
105            } else {
106                break;
107            }
108        }
109    }
110
111    fn skip_whitespace(&mut self) {
112        while let Some(&b) = self.input.get(self.pos) {
113            if b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' {
114                self.pos += 1;
115            } else {
116                break;
117            }
118        }
119    }
120
121    /// Returns true if the current position looks like the start of a number.
122    fn has_number(&self) -> bool {
123        match self.input.get(self.pos) {
124            Some(b'+' | b'-' | b'.') => true,
125            Some(b) if b.is_ascii_digit() => true,
126            _ => false,
127        }
128    }
129
130    fn parse_number(&mut self) -> Result<f32, SvgPathParseError> {
131        self.skip_whitespace_and_commas();
132        let start = self.pos;
133
134        // Optional sign
135        if let Some(&b) = self.input.get(self.pos) {
136            if b == b'+' || b == b'-' {
137                self.pos += 1;
138            }
139        }
140
141        let mut has_digits = false;
142
143        // Integer part
144        while let Some(&b) = self.input.get(self.pos) {
145            if b.is_ascii_digit() {
146                self.pos += 1;
147                has_digits = true;
148            } else {
149                break;
150            }
151        }
152
153        // Decimal part
154        if self.input.get(self.pos) == Some(&b'.') {
155            self.pos += 1;
156            while let Some(&b) = self.input.get(self.pos) {
157                if b.is_ascii_digit() {
158                    self.pos += 1;
159                    has_digits = true;
160                } else {
161                    break;
162                }
163            }
164        }
165
166        if !has_digits {
167            return Err(SvgPathParseError::ExpectedNumber { pos: start });
168        }
169
170        // Exponent
171        if let Some(&b) = self.input.get(self.pos) {
172            if b == b'e' || b == b'E' {
173                self.pos += 1;
174                if let Some(&b) = self.input.get(self.pos) {
175                    if b == b'+' || b == b'-' {
176                        self.pos += 1;
177                    }
178                }
179                while let Some(&b) = self.input.get(self.pos) {
180                    if b.is_ascii_digit() {
181                        self.pos += 1;
182                    } else {
183                        break;
184                    }
185                }
186            }
187        }
188
189        let s = core::str::from_utf8(&self.input[start..self.pos])
190            .map_err(|_| SvgPathParseError::ExpectedNumber { pos: start })?;
191        s.parse::<f32>()
192            .map_err(|_| SvgPathParseError::ExpectedNumber { pos: start })
193    }
194
195    fn parse_flag(&mut self) -> Result<bool, SvgPathParseError> {
196        self.skip_whitespace_and_commas();
197        match self.input.get(self.pos) {
198            Some(b'0') => {
199                self.pos += 1;
200                Ok(false)
201            }
202            Some(b'1') => {
203                self.pos += 1;
204                Ok(true)
205            }
206            _ => Err(SvgPathParseError::InvalidArcFlag { pos: self.pos }),
207        }
208    }
209
210    fn parse_coordinate_pair(&mut self) -> Result<(f32, f32), SvgPathParseError> {
211        let x = self.parse_number()?;
212        let y = self.parse_number()?;
213        Ok((x, y))
214    }
215
216    fn make_absolute(&self, x: f32, y: f32, relative: bool) -> SvgPoint {
217        if relative {
218            SvgPoint {
219                x: self.current.x + x,
220                y: self.current.y + y,
221            }
222        } else {
223            SvgPoint { x, y }
224        }
225    }
226
227    fn handle_line_to(
228        &mut self,
229        relative: bool,
230        elements: &mut Vec<SvgPathElement>,
231    ) -> Result<(), SvgPathParseError> {
232        let (x, y) = self.parse_coordinate_pair()?;
233        let end = self.make_absolute(x, y, relative);
234        elements.push(SvgPathElement::Line(SvgLine {
235            start: self.current,
236            end,
237        }));
238        self.current = end;
239        self.last_control = None;
240        Ok(())
241    }
242
243    fn handle_horizontal_to(
244        &mut self,
245        relative: bool,
246        elements: &mut Vec<SvgPathElement>,
247    ) -> Result<(), SvgPathParseError> {
248        let x = self.parse_number()?;
249        let abs_x = if relative { self.current.x + x } else { x };
250        let end = SvgPoint {
251            x: abs_x,
252            y: self.current.y,
253        };
254        elements.push(SvgPathElement::Line(SvgLine {
255            start: self.current,
256            end,
257        }));
258        self.current = end;
259        self.last_control = None;
260        Ok(())
261    }
262
263    fn handle_vertical_to(
264        &mut self,
265        relative: bool,
266        elements: &mut Vec<SvgPathElement>,
267    ) -> Result<(), SvgPathParseError> {
268        let y = self.parse_number()?;
269        let abs_y = if relative { self.current.y + y } else { y };
270        let end = SvgPoint {
271            x: self.current.x,
272            y: abs_y,
273        };
274        elements.push(SvgPathElement::Line(SvgLine {
275            start: self.current,
276            end,
277        }));
278        self.current = end;
279        self.last_control = None;
280        Ok(())
281    }
282
283    #[allow(clippy::similar_names)] // domain-standard coordinate/control-point names
284    fn handle_cubic_to(
285        &mut self,
286        relative: bool,
287        elements: &mut Vec<SvgPathElement>,
288    ) -> Result<(), SvgPathParseError> {
289        let (c1x, c1y) = self.parse_coordinate_pair()?;
290        let (c2x, c2y) = self.parse_coordinate_pair()?;
291        let (ex, ey) = self.parse_coordinate_pair()?;
292        let ctrl_1 = self.make_absolute(c1x, c1y, relative);
293        let ctrl_2 = self.make_absolute(c2x, c2y, relative);
294        let end = self.make_absolute(ex, ey, relative);
295        elements.push(SvgPathElement::CubicCurve(SvgCubicCurve {
296            start: self.current,
297            ctrl_1,
298            ctrl_2,
299            end,
300        }));
301        self.last_control = Some(ctrl_2);
302        self.current = end;
303        Ok(())
304    }
305
306    #[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
307    #[allow(clippy::similar_names)] // domain-standard coordinate/control-point names
308    fn handle_smooth_cubic_to(
309        &mut self,
310        relative: bool,
311        elements: &mut Vec<SvgPathElement>,
312    ) -> Result<(), SvgPathParseError> {
313        let ctrl_1 = match self.last_control {
314            Some(lc) if matches!(self.last_command.to_ascii_uppercase(), b'C' | b'S') => SvgPoint {
315                x: 2.0 * self.current.x - lc.x,
316                y: 2.0 * self.current.y - lc.y,
317            },
318            _ => self.current,
319        };
320        let (c2x, c2y) = self.parse_coordinate_pair()?;
321        let (ex, ey) = self.parse_coordinate_pair()?;
322        let ctrl_2 = self.make_absolute(c2x, c2y, relative);
323        let end = self.make_absolute(ex, ey, relative);
324        elements.push(SvgPathElement::CubicCurve(SvgCubicCurve {
325            start: self.current,
326            ctrl_1,
327            ctrl_2,
328            end,
329        }));
330        self.last_control = Some(ctrl_2);
331        self.current = end;
332        Ok(())
333    }
334
335    fn handle_quadratic_to(
336        &mut self,
337        relative: bool,
338        elements: &mut Vec<SvgPathElement>,
339    ) -> Result<(), SvgPathParseError> {
340        let (cx, cy) = self.parse_coordinate_pair()?;
341        let (ex, ey) = self.parse_coordinate_pair()?;
342        let ctrl = self.make_absolute(cx, cy, relative);
343        let end = self.make_absolute(ex, ey, relative);
344        elements.push(SvgPathElement::QuadraticCurve(SvgQuadraticCurve {
345            start: self.current,
346            ctrl,
347            end,
348        }));
349        self.last_control = Some(ctrl);
350        self.current = end;
351        Ok(())
352    }
353
354    #[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
355    fn handle_smooth_quadratic_to(
356        &mut self,
357        relative: bool,
358        elements: &mut Vec<SvgPathElement>,
359    ) -> Result<(), SvgPathParseError> {
360        let ctrl = match self.last_control {
361            Some(lc) if matches!(self.last_command.to_ascii_uppercase(), b'Q' | b'T') => SvgPoint {
362                x: 2.0 * self.current.x - lc.x,
363                y: 2.0 * self.current.y - lc.y,
364            },
365            _ => self.current,
366        };
367        let (ex, ey) = self.parse_coordinate_pair()?;
368        let end = self.make_absolute(ex, ey, relative);
369        elements.push(SvgPathElement::QuadraticCurve(SvgQuadraticCurve {
370            start: self.current,
371            ctrl,
372            end,
373        }));
374        self.last_control = Some(ctrl);
375        self.current = end;
376        Ok(())
377    }
378
379    fn handle_arc_to(
380        &mut self,
381        relative: bool,
382        elements: &mut Vec<SvgPathElement>,
383    ) -> Result<(), SvgPathParseError> {
384        let rx = self.parse_number()?.abs();
385        let ry = self.parse_number()?.abs();
386        let x_rotation = self.parse_number()?;
387        let large_arc = self.parse_flag()?;
388        let sweep = self.parse_flag()?;
389        let (ex, ey) = self.parse_coordinate_pair()?;
390        let end = self.make_absolute(ex, ey, relative);
391        arc_to_cubics(
392            self.current,
393            end,
394            rx,
395            ry,
396            x_rotation,
397            large_arc,
398            sweep,
399            elements,
400        );
401        self.current = end;
402        self.last_control = None;
403        Ok(())
404    }
405}
406
407/// Parse an SVG path `d` attribute string into a `SvgMultiPolygon`.
408///
409/// Each M/m command starts a new subpath (ring). All 14 SVG path commands are
410/// supported including arcs (converted to cubic beziers).
411///
412/// # Panics
413///
414/// Panics if the path tokenizer signals a command but then yields no token
415/// (an internal parser invariant that should not occur for any input).
416#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
417#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose parser/builder/dispatch (one branch per input variant)
418/// # Errors
419///
420/// Returns an error if `d` is not a valid SVG path-data string.
421pub fn parse_svg_path_d(d: &str) -> Result<SvgMultiPolygon, SvgPathParseError> {
422    let d = d.trim();
423    if d.is_empty() {
424        return Err(SvgPathParseError::EmptyPath);
425    }
426
427    let mut parser = PathParser::new(d.as_bytes());
428    let mut rings: Vec<SvgPath> = Vec::new();
429    let mut current_elements: Vec<SvgPathElement> = Vec::new();
430
431    parser.skip_whitespace();
432
433    while !parser.at_end() {
434        parser.skip_whitespace_and_commas();
435        if parser.at_end() {
436            break;
437        }
438
439        let b = parser.peek().unwrap();
440
441        // Determine if this is a command letter or an implicit repeat
442        let cmd = if b.is_ascii_alphabetic() {
443            parser.pos += 1;
444            b
445        } else if parser.last_command != 0 {
446            // Implicit repeat: after M/m, implicit commands become L/l
447            match parser.last_command {
448                b'M' => b'L',
449                b'm' => b'l',
450                // AUDIT 2026-07-08: a `Z`/`z` closepath takes no arguments, so it
451                // cannot be implicitly repeated. Reaching here means a stray
452                // non-command byte followed a closepath (e.g. the `5` in "M0 0Z5").
453                // The old `other => other` fell through to the `Z` arm, which
454                // consumes zero bytes, so `pos` never advanced -> 100% CPU infinite
455                // loop. Reject it as an unexpected character instead.
456                b'Z' | b'z' => {
457                    return Err(SvgPathParseError::UnexpectedChar {
458                        pos: parser.pos,
459                        ch: char_at(parser.input, parser.pos),
460                    });
461                }
462                other => other,
463            }
464        } else {
465            return Err(SvgPathParseError::UnexpectedChar {
466                pos: parser.pos,
467                ch: char_at(parser.input, parser.pos),
468            });
469        };
470
471        let relative = cmd.is_ascii_lowercase();
472        let cmd_upper = cmd.to_ascii_uppercase();
473
474        match cmd_upper {
475            b'M' => {
476                // Flush current subpath
477                if !current_elements.is_empty() {
478                    rings.push(SvgPath {
479                        items: SvgPathElementVec::from_vec(core::mem::take(&mut current_elements)),
480                    });
481                }
482                let (x, y) = parser.parse_coordinate_pair()?;
483                let pt = parser.make_absolute(x, y, relative);
484                parser.current = pt;
485                parser.subpath_start = pt;
486                parser.last_control = None;
487                parser.last_command = cmd;
488            }
489            b'L' => {
490                parser.handle_line_to(relative, &mut current_elements)?;
491                parser.last_command = cmd;
492            }
493            b'H' => {
494                parser.handle_horizontal_to(relative, &mut current_elements)?;
495                parser.last_command = cmd;
496            }
497            b'V' => {
498                parser.handle_vertical_to(relative, &mut current_elements)?;
499                parser.last_command = cmd;
500            }
501            b'C' => {
502                parser.handle_cubic_to(relative, &mut current_elements)?;
503                parser.last_command = cmd;
504            }
505            b'S' => {
506                parser.handle_smooth_cubic_to(relative, &mut current_elements)?;
507                parser.last_command = cmd;
508            }
509            b'Q' => {
510                parser.handle_quadratic_to(relative, &mut current_elements)?;
511                parser.last_command = cmd;
512            }
513            b'T' => {
514                parser.handle_smooth_quadratic_to(relative, &mut current_elements)?;
515                parser.last_command = cmd;
516            }
517            b'A' => {
518                parser.handle_arc_to(relative, &mut current_elements)?;
519                parser.last_command = cmd;
520            }
521            b'Z' => {
522                // Close subpath
523                let dx = parser.current.x - parser.subpath_start.x;
524                let dy = parser.current.y - parser.subpath_start.y;
525                if dx * dx + dy * dy > CLOSEPATH_EPSILON * CLOSEPATH_EPSILON {
526                    current_elements.push(SvgPathElement::Line(SvgLine {
527                        start: parser.current,
528                        end: parser.subpath_start,
529                    }));
530                }
531                parser.current = parser.subpath_start;
532                parser.last_control = None;
533                parser.last_command = cmd;
534
535                // Flush current subpath
536                if !current_elements.is_empty() {
537                    rings.push(SvgPath {
538                        items: SvgPathElementVec::from_vec(core::mem::take(&mut current_elements)),
539                    });
540                }
541            }
542            _ => {
543                return Err(SvgPathParseError::UnexpectedChar {
544                    pos: parser.pos - 1,
545                    ch: cmd as char,
546                });
547            }
548        }
549
550        // After processing one argument group, try to consume more
551        // argument groups for the same command (implicit repeats)
552        if cmd_upper != b'M' && cmd_upper != b'Z' {
553            loop {
554                parser.skip_whitespace_and_commas();
555                if parser.at_end() {
556                    break;
557                }
558                let next = parser.peek().unwrap();
559                if next.is_ascii_alphabetic() {
560                    break; // Next command letter
561                }
562                if !parser.has_number() {
563                    break;
564                }
565
566                // Implicit repeat of the same command
567                match cmd_upper {
568                    b'L' => parser.handle_line_to(relative, &mut current_elements)?,
569                    b'H' => parser.handle_horizontal_to(relative, &mut current_elements)?,
570                    b'V' => parser.handle_vertical_to(relative, &mut current_elements)?,
571                    b'C' => parser.handle_cubic_to(relative, &mut current_elements)?,
572                    b'S' => parser.handle_smooth_cubic_to(relative, &mut current_elements)?,
573                    b'Q' => parser.handle_quadratic_to(relative, &mut current_elements)?,
574                    b'T' => parser.handle_smooth_quadratic_to(relative, &mut current_elements)?,
575                    b'A' => parser.handle_arc_to(relative, &mut current_elements)?,
576                    _ => break,
577                }
578            }
579        }
580    }
581
582    // Flush any remaining elements
583    if !current_elements.is_empty() {
584        rings.push(SvgPath {
585            items: SvgPathElementVec::from_vec(current_elements),
586        });
587    }
588
589    // A `d` made up solely of comma/whitespace filler (e.g. ",") consumes to EOF
590    // without ever reading a command and used to be accepted as an empty Ok. The SVG
591    // path grammar requires a moveto to start; a bare separator is only valid BETWEEN
592    // commands, never as the whole string.
593    if parser.last_command == 0 && rings.is_empty() {
594        return Err(SvgPathParseError::UnexpectedChar {
595            pos: 0,
596            ch: char_at(parser.input, 0),
597        });
598    }
599
600    Ok(SvgMultiPolygon {
601        rings: SvgPathVec::from_vec(rings),
602    })
603}
604
605/// Convert an SVG arc to 1–4 cubic bezier curves.
606///
607/// Implements the SVG spec arc endpoint-to-center parameterization (Appendix F.6).
608#[allow(clippy::suboptimal_flops)]
609// mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
610// n_segs is a tiny arc-quadrant count (<= ~6) and its loop index; the float<->usize
611// casts are exact for these bounded values.
612#[allow(
613    clippy::cast_possible_truncation,
614    clippy::cast_precision_loss,
615    clippy::cast_sign_loss
616)]
617#[allow(clippy::similar_names)] // domain-standard coordinate/control-point names
618fn arc_to_cubics(
619    start: SvgPoint,
620    end: SvgPoint,
621    mut rx: f32,
622    mut ry: f32,
623    x_rotation_deg: f32,
624    large_arc: bool,
625    sweep: bool,
626    out: &mut Vec<SvgPathElement>,
627) {
628    // Degenerate cases
629    if (start.x - end.x).abs() < POINT_EPSILON && (start.y - end.y).abs() < POINT_EPSILON {
630        return;
631    }
632    if rx < POINT_EPSILON || ry < POINT_EPSILON {
633        out.push(SvgPathElement::Line(SvgLine { start, end }));
634        return;
635    }
636
637    let phi = x_rotation_deg.to_radians();
638    let cos_phi = phi.cos();
639    let sin_phi = phi.sin();
640
641    // Step 1: Compute (x1', y1')
642    let dx = (start.x - end.x) / 2.0;
643    let dy = (start.y - end.y) / 2.0;
644    let x1p = cos_phi * dx + sin_phi * dy;
645    let y1p = -sin_phi * dx + cos_phi * dy;
646
647    // Step 2: Compute (cx', cy') - correct radii if too small
648    let x1p2 = x1p * x1p;
649    let y1p2 = y1p * y1p;
650    let mut rx2 = rx * rx;
651    let mut ry2 = ry * ry;
652
653    let lambda = x1p2 / rx2 + y1p2 / ry2;
654    if lambda > 1.0 {
655        let sqrt_lambda = lambda.sqrt();
656        rx *= sqrt_lambda;
657        ry *= sqrt_lambda;
658        rx2 = rx * rx;
659        ry2 = ry * ry;
660    }
661
662    let num = (rx2 * ry2 - rx2 * y1p2 - ry2 * x1p2).max(0.0);
663    let den = rx2 * y1p2 + ry2 * x1p2;
664    let sq = if den > 0.0 { (num / den).sqrt() } else { 0.0 };
665
666    let sign = if large_arc == sweep { -1.0 } else { 1.0 };
667    let cxp = sign * sq * (rx * y1p / ry);
668    let cyp = sign * sq * -(ry * x1p / rx);
669
670    // Step 3: Compute (cx, cy) from (cx', cy')
671    let mx = f32::midpoint(start.x, end.x);
672    let my = f32::midpoint(start.y, end.y);
673    let cx = cos_phi * cxp - sin_phi * cyp + mx;
674    let cy = sin_phi * cxp + cos_phi * cyp + my;
675
676    // Step 4: Compute theta1 and dtheta
677    let theta1 = angle_between(1.0, 0.0, (x1p - cxp) / rx, (y1p - cyp) / ry);
678    let mut dtheta = angle_between(
679        (x1p - cxp) / rx,
680        (y1p - cyp) / ry,
681        (-x1p - cxp) / rx,
682        (-y1p - cyp) / ry,
683    );
684
685    if !sweep && dtheta > 0.0 {
686        dtheta -= core::f32::consts::TAU;
687    } else if sweep && dtheta < 0.0 {
688        dtheta += core::f32::consts::TAU;
689    }
690
691    // Split into segments of at most PI/2
692    let n_segs = (dtheta.abs() / (core::f32::consts::FRAC_PI_2 + ARC_SPLIT_FUDGE)).ceil() as usize;
693    let n_segs = n_segs.max(1);
694    let seg_angle = dtheta / n_segs as f32;
695
696    let mut prev = start;
697    for i in 0..n_segs {
698        let t1 = theta1 + seg_angle * i as f32;
699        let t2 = theta1 + seg_angle * (i + 1) as f32;
700
701        let (c1, c2, ep) = arc_segment_to_cubic(cx, cy, rx, ry, cos_phi, sin_phi, t1, t2);
702
703        let seg_end = if i + 1 == n_segs { end } else { ep };
704        out.push(SvgPathElement::CubicCurve(SvgCubicCurve {
705            start: prev,
706            ctrl_1: c1,
707            ctrl_2: c2,
708            end: seg_end,
709        }));
710        prev = seg_end;
711    }
712}
713
714/// Compute the angle between two vectors.
715#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
716fn angle_between(ux: f32, uy: f32, vx: f32, vy: f32) -> f32 {
717    let dot = ux * vx + uy * vy;
718    let len = ((ux * ux + uy * uy) * (vx * vx + vy * vy)).sqrt();
719    if len < ZERO_LENGTH_EPSILON {
720        return 0.0;
721    }
722    let cos_val = (dot / len).clamp(-1.0, 1.0);
723    let angle = cos_val.acos();
724    if ux * vy - uy * vx < 0.0 {
725        -angle
726    } else {
727        angle
728    }
729}
730
731/// Convert a single arc segment (<=90 degrees) to a cubic bezier.
732#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
733#[allow(clippy::similar_names)] // domain-standard coordinate/control-point names
734fn arc_segment_to_cubic(
735    cx: f32,
736    cy: f32,
737    rx: f32,
738    ry: f32,
739    cos_phi: f32,
740    sin_phi: f32,
741    theta1: f32,
742    theta2: f32,
743) -> (SvgPoint, SvgPoint, SvgPoint) {
744    let alpha = 4.0 / 3.0 * ((theta2 - theta1) / 4.0).tan();
745
746    let cos1 = theta1.cos();
747    let sin1 = theta1.sin();
748    let cos2 = theta2.cos();
749    let sin2 = theta2.sin();
750
751    // Control point 1 (relative to unit circle)
752    let dx1 = rx * (cos1 - alpha * sin1);
753    let dy1 = ry * (sin1 + alpha * cos1);
754    // Control point 2
755    let dx2 = rx * (cos2 + alpha * sin2);
756    let dy2 = ry * (sin2 - alpha * cos2);
757    // End point
758    let dx3 = rx * cos2;
759    let dy3 = ry * sin2;
760
761    let c1 = SvgPoint {
762        x: cos_phi * dx1 - sin_phi * dy1 + cx,
763        y: sin_phi * dx1 + cos_phi * dy1 + cy,
764    };
765    let c2 = SvgPoint {
766        x: cos_phi * dx2 - sin_phi * dy2 + cx,
767        y: sin_phi * dx2 + cos_phi * dy2 + cy,
768    };
769    let ep = SvgPoint {
770        x: cos_phi * dx3 - sin_phi * dy3 + cx,
771        y: sin_phi * dx3 + cos_phi * dy3 + cy,
772    };
773
774    (c1, c2, ep)
775}
776
777/// Approximate a circle with 4 cubic bezier curves.
778///
779/// Uses the standard kappa constant (0.5522847498) for quarter-arc approximation.
780#[must_use]
781pub fn svg_circle_to_paths(cx: f32, cy: f32, r: f32) -> SvgPath {
782    let k = r * KAPPA;
783
784    let elements = vec![
785        // Top to right
786        SvgPathElement::CubicCurve(SvgCubicCurve {
787            start: SvgPoint { x: cx, y: cy - r },
788            ctrl_1: SvgPoint {
789                x: cx + k,
790                y: cy - r,
791            },
792            ctrl_2: SvgPoint {
793                x: cx + r,
794                y: cy - k,
795            },
796            end: SvgPoint { x: cx + r, y: cy },
797        }),
798        // Right to bottom
799        SvgPathElement::CubicCurve(SvgCubicCurve {
800            start: SvgPoint { x: cx + r, y: cy },
801            ctrl_1: SvgPoint {
802                x: cx + r,
803                y: cy + k,
804            },
805            ctrl_2: SvgPoint {
806                x: cx + k,
807                y: cy + r,
808            },
809            end: SvgPoint { x: cx, y: cy + r },
810        }),
811        // Bottom to left
812        SvgPathElement::CubicCurve(SvgCubicCurve {
813            start: SvgPoint { x: cx, y: cy + r },
814            ctrl_1: SvgPoint {
815                x: cx - k,
816                y: cy + r,
817            },
818            ctrl_2: SvgPoint {
819                x: cx - r,
820                y: cy + k,
821            },
822            end: SvgPoint { x: cx - r, y: cy },
823        }),
824        // Left to top
825        SvgPathElement::CubicCurve(SvgCubicCurve {
826            start: SvgPoint { x: cx - r, y: cy },
827            ctrl_1: SvgPoint {
828                x: cx - r,
829                y: cy - k,
830            },
831            ctrl_2: SvgPoint {
832                x: cx - k,
833                y: cy - r,
834            },
835            end: SvgPoint { x: cx, y: cy - r },
836        }),
837    ];
838
839    SvgPath {
840        items: SvgPathElementVec::from_vec(elements),
841    }
842}
843
844/// Convert an SVG `<rect>` to a path with optional rounded corners.
845///
846/// If `rx` and `ry` are both 0, produces 4 line segments.
847/// Otherwise, produces lines for straight edges and cubic curves for corners.
848#[must_use]
849// builds the rounded-rect path segment-by-segment with a matching capacity hint;
850// a `vec![..]` literal of the 8 multi-line elements would be less readable here.
851#[allow(clippy::vec_init_then_push)]
852#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose parser/builder/dispatch (one branch per input variant)
853pub fn svg_rect_to_path(x: f32, y: f32, w: f32, h: f32, rx: f32, ry: f32) -> SvgPath {
854    let rx = rx.min(w / 2.0);
855    let ry = ry.min(h / 2.0);
856
857    if rx < CLOSEPATH_EPSILON && ry < CLOSEPATH_EPSILON {
858        // Simple rectangle: 4 lines
859        let tl = SvgPoint { x, y };
860        let tr = SvgPoint { x: x + w, y };
861        let br = SvgPoint { x: x + w, y: y + h };
862        let bl = SvgPoint { x, y: y + h };
863
864        let elements = vec![
865            SvgPathElement::Line(SvgLine { start: tl, end: tr }),
866            SvgPathElement::Line(SvgLine { start: tr, end: br }),
867            SvgPathElement::Line(SvgLine { start: br, end: bl }),
868            SvgPathElement::Line(SvgLine { start: bl, end: tl }),
869        ];
870
871        return SvgPath {
872            items: SvgPathElementVec::from_vec(elements),
873        };
874    }
875
876    // Rounded rectangle
877    let kx = rx * KAPPA;
878    let ky = ry * KAPPA;
879
880    let mut elements = Vec::with_capacity(8);
881
882    // Top edge (left to right)
883    elements.push(SvgPathElement::Line(SvgLine {
884        start: SvgPoint { x: x + rx, y },
885        end: SvgPoint { x: x + w - rx, y },
886    }));
887    // Top-right corner
888    elements.push(SvgPathElement::CubicCurve(SvgCubicCurve {
889        start: SvgPoint { x: x + w - rx, y },
890        ctrl_1: SvgPoint {
891            x: x + w - rx + kx,
892            y,
893        },
894        ctrl_2: SvgPoint {
895            x: x + w,
896            y: y + ry - ky,
897        },
898        end: SvgPoint {
899            x: x + w,
900            y: y + ry,
901        },
902    }));
903    // Right edge
904    elements.push(SvgPathElement::Line(SvgLine {
905        start: SvgPoint {
906            x: x + w,
907            y: y + ry,
908        },
909        end: SvgPoint {
910            x: x + w,
911            y: y + h - ry,
912        },
913    }));
914    // Bottom-right corner
915    elements.push(SvgPathElement::CubicCurve(SvgCubicCurve {
916        start: SvgPoint {
917            x: x + w,
918            y: y + h - ry,
919        },
920        ctrl_1: SvgPoint {
921            x: x + w,
922            y: y + h - ry + ky,
923        },
924        ctrl_2: SvgPoint {
925            x: x + w - rx + kx,
926            y: y + h,
927        },
928        end: SvgPoint {
929            x: x + w - rx,
930            y: y + h,
931        },
932    }));
933    // Bottom edge (right to left)
934    elements.push(SvgPathElement::Line(SvgLine {
935        start: SvgPoint {
936            x: x + w - rx,
937            y: y + h,
938        },
939        end: SvgPoint {
940            x: x + rx,
941            y: y + h,
942        },
943    }));
944    // Bottom-left corner
945    elements.push(SvgPathElement::CubicCurve(SvgCubicCurve {
946        start: SvgPoint {
947            x: x + rx,
948            y: y + h,
949        },
950        ctrl_1: SvgPoint {
951            x: x + rx - kx,
952            y: y + h,
953        },
954        ctrl_2: SvgPoint {
955            x,
956            y: y + h - ry + ky,
957        },
958        end: SvgPoint { x, y: y + h - ry },
959    }));
960    // Left edge
961    elements.push(SvgPathElement::Line(SvgLine {
962        start: SvgPoint { x, y: y + h - ry },
963        end: SvgPoint { x, y: y + ry },
964    }));
965    // Top-left corner
966    elements.push(SvgPathElement::CubicCurve(SvgCubicCurve {
967        start: SvgPoint { x, y: y + ry },
968        ctrl_1: SvgPoint { x, y: y + ry - ky },
969        ctrl_2: SvgPoint { x: x + rx - kx, y },
970        end: SvgPoint { x: x + rx, y },
971    }));
972
973    SvgPath {
974        items: SvgPathElementVec::from_vec(elements),
975    }
976}
977
978#[cfg(test)]
979mod tests {
980    use super::*;
981
982    /// AUDIT 2026-07-08 regression: `"M0 0Z5"` used to spin at 100% CPU forever
983    /// because the trailing `5` re-derived `cmd = Z` (zero-length consume) and
984    /// the cursor never advanced. It must now terminate with `UnexpectedChar`.
985    #[test]
986    fn m0_0z5_does_not_hang() {
987        let err = parse_svg_path_d("M0 0Z5").unwrap_err();
988        match err {
989            SvgPathParseError::UnexpectedChar { ch, .. } => assert_eq!(ch, '5'),
990            other => panic!("expected UnexpectedChar, got {other:?}"),
991        }
992    }
993
994    /// Any digit or symbol directly after a closepath is rejected, not looped on.
995    #[test]
996    fn stray_byte_after_closepath_rejected() {
997        for s in ["M0 0Z9", "m0 0z-", "M0 0Z."] {
998            assert!(
999                matches!(
1000                    parse_svg_path_d(s),
1001                    Err(SvgPathParseError::UnexpectedChar { .. })
1002                ),
1003                "expected UnexpectedChar for {s:?}"
1004            );
1005        }
1006    }
1007
1008    /// A leading non-command byte reports the real Unicode char, not a Latin-1
1009    /// reinterpretation of a single UTF-8 byte (the old `b as char`).
1010    #[test]
1011    fn error_char_is_unicode_not_byte() {
1012        // 'ü' is two UTF-8 bytes; `b as char` would have yielded a mojibake char.
1013        let err = parse_svg_path_d("ü10 10").unwrap_err();
1014        match err {
1015            SvgPathParseError::UnexpectedChar { ch, pos } => {
1016                assert_eq!(ch, 'ü');
1017                assert_eq!(pos, 0);
1018            }
1019            other => panic!("expected UnexpectedChar, got {other:?}"),
1020        }
1021    }
1022
1023    /// A well-formed closepath followed by a real command still parses.
1024    #[test]
1025    fn valid_closepath_then_command_ok() {
1026        let parsed = parse_svg_path_d("M0 0 L10 0 Z M20 20 L30 20 Z");
1027        assert!(parsed.is_ok(), "valid multi-subpath path should parse");
1028    }
1029}
1030
1031#[cfg(test)]
1032#[path = "path_parser_test.rs"]
1033mod path_parser_test;