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::{SvgLine, SvgMultiPolygon, SvgPath, SvgPathElement, SvgPathElementVec, SvgPathVec};
11
12/// Bezier approximation constant for quarter-circle arcs.
13const KAPPA: f32 = 0.552_284_8;
14
15/// Tolerance for treating two points as coincident (used in closepath and arc degeneracy checks).
16const POINT_EPSILON: f32 = 1e-6;
17
18/// Tolerance for snapping a closepath line (slightly larger to avoid micro-segments).
19const CLOSEPATH_EPSILON: f32 = 0.001;
20
21/// Tolerance for treating a vector length as zero in angle computation.
22const ZERO_LENGTH_EPSILON: f32 = 1e-10;
23
24/// Small offset added to PI/2 when splitting arcs to avoid exact-boundary floating-point issues.
25const ARC_SPLIT_FUDGE: f32 = 0.001;
26
27/// Decode the UTF-8 character starting at `pos` in `input`.
28///
29/// `input` is always the byte view of a valid `&str` and `pos` is always at a
30/// char boundary (only whole ASCII tokens are consumed), so the UTF-8 decode
31/// succeeds; a corrupt offset falls back to the replacement character rather
32/// than panicking. Used so error messages report the real Unicode char instead
33/// of a Latin-1 reinterpretation of a single UTF-8 byte (`b as char`).
34fn char_at(input: &[u8], pos: usize) -> char {
35    input
36        .get(pos..)
37        .and_then(|rest| core::str::from_utf8(rest).ok())
38        .and_then(|s| s.chars().next())
39        .unwrap_or(char::REPLACEMENT_CHARACTER)
40}
41
42/// Errors that can occur during SVG path parsing.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum SvgPathParseError {
45    /// The path string is empty.
46    EmptyPath,
47    /// Unexpected character encountered at the given byte offset.
48    UnexpectedChar { pos: usize, ch: char },
49    /// Expected a number but found something else.
50    ExpectedNumber { pos: usize },
51    /// Invalid arc flag (must be 0 or 1).
52    InvalidArcFlag { pos: usize },
53}
54
55/// Human-readable error messages for SVG path parse failures.
56impl core::fmt::Display for SvgPathParseError {
57    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
58        match self {
59            Self::EmptyPath => write!(f, "empty path"),
60            Self::UnexpectedChar { pos, ch } => {
61                write!(f, "unexpected char '{ch}' at byte {pos}")
62            }
63            Self::ExpectedNumber { pos } => write!(f, "expected number at byte {pos}"),
64            Self::InvalidArcFlag { pos } => write!(f, "invalid arc flag at byte {pos}"),
65        }
66    }
67}
68
69/// Internal parser state.
70struct PathParser<'a> {
71    input: &'a [u8],
72    pos: usize,
73    current: SvgPoint,
74    subpath_start: SvgPoint,
75    last_control: Option<SvgPoint>,
76    last_command: u8,
77}
78
79impl<'a> PathParser<'a> {
80    const fn new(input: &'a [u8]) -> Self {
81        Self {
82            input,
83            pos: 0,
84            current: SvgPoint { x: 0.0, y: 0.0 },
85            subpath_start: SvgPoint { x: 0.0, y: 0.0 },
86            last_control: None,
87            last_command: 0,
88        }
89    }
90
91    const fn at_end(&self) -> bool {
92        self.pos >= self.input.len()
93    }
94
95    fn peek(&self) -> Option<u8> {
96        self.input.get(self.pos).copied()
97    }
98
99    fn skip_whitespace_and_commas(&mut self) {
100        while let Some(&b) = self.input.get(self.pos) {
101            if b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' || b == b',' {
102                self.pos += 1;
103            } else {
104                break;
105            }
106        }
107    }
108
109    fn skip_whitespace(&mut self) {
110        while let Some(&b) = self.input.get(self.pos) {
111            if b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' {
112                self.pos += 1;
113            } else {
114                break;
115            }
116        }
117    }
118
119    /// Returns true if the current position looks like the start of a number.
120    fn has_number(&self) -> bool {
121        match self.input.get(self.pos) {
122            Some(b'+' | b'-' | b'.') => true,
123            Some(b) if b.is_ascii_digit() => true,
124            _ => false,
125        }
126    }
127
128    fn parse_number(&mut self) -> Result<f32, SvgPathParseError> {
129        self.skip_whitespace_and_commas();
130        let start = self.pos;
131
132        // Optional sign
133        if let Some(&b) = self.input.get(self.pos) {
134            if b == b'+' || b == b'-' {
135                self.pos += 1;
136            }
137        }
138
139        let mut has_digits = false;
140
141        // Integer part
142        while let Some(&b) = self.input.get(self.pos) {
143            if b.is_ascii_digit() {
144                self.pos += 1;
145                has_digits = true;
146            } else {
147                break;
148            }
149        }
150
151        // Decimal part
152        if self.input.get(self.pos) == Some(&b'.') {
153            self.pos += 1;
154            while let Some(&b) = self.input.get(self.pos) {
155                if b.is_ascii_digit() {
156                    self.pos += 1;
157                    has_digits = true;
158                } else {
159                    break;
160                }
161            }
162        }
163
164        if !has_digits {
165            return Err(SvgPathParseError::ExpectedNumber { pos: start });
166        }
167
168        // Exponent
169        if let Some(&b) = self.input.get(self.pos) {
170            if b == b'e' || b == b'E' {
171                self.pos += 1;
172                if let Some(&b) = self.input.get(self.pos) {
173                    if b == b'+' || b == b'-' {
174                        self.pos += 1;
175                    }
176                }
177                while let Some(&b) = self.input.get(self.pos) {
178                    if b.is_ascii_digit() {
179                        self.pos += 1;
180                    } else {
181                        break;
182                    }
183                }
184            }
185        }
186
187        let s = core::str::from_utf8(&self.input[start..self.pos])
188            .map_err(|_| SvgPathParseError::ExpectedNumber { pos: start })?;
189        s.parse::<f32>()
190            .map_err(|_| SvgPathParseError::ExpectedNumber { pos: start })
191    }
192
193    fn parse_flag(&mut self) -> Result<bool, SvgPathParseError> {
194        self.skip_whitespace_and_commas();
195        match self.input.get(self.pos) {
196            Some(b'0') => {
197                self.pos += 1;
198                Ok(false)
199            }
200            Some(b'1') => {
201                self.pos += 1;
202                Ok(true)
203            }
204            _ => Err(SvgPathParseError::InvalidArcFlag { pos: self.pos }),
205        }
206    }
207
208    fn parse_coordinate_pair(&mut self) -> Result<(f32, f32), SvgPathParseError> {
209        let x = self.parse_number()?;
210        let y = self.parse_number()?;
211        Ok((x, y))
212    }
213
214    fn make_absolute(&self, x: f32, y: f32, relative: bool) -> SvgPoint {
215        if relative {
216            SvgPoint {
217                x: self.current.x + x,
218                y: self.current.y + y,
219            }
220        } else {
221            SvgPoint { x, y }
222        }
223    }
224
225    fn handle_line_to(&mut self, relative: bool, elements: &mut Vec<SvgPathElement>) -> Result<(), SvgPathParseError> {
226        let (x, y) = self.parse_coordinate_pair()?;
227        let end = self.make_absolute(x, y, relative);
228        elements.push(SvgPathElement::Line(SvgLine { start: self.current, end }));
229        self.current = end;
230        self.last_control = None;
231        Ok(())
232    }
233
234    fn handle_horizontal_to(&mut self, relative: bool, elements: &mut Vec<SvgPathElement>) -> Result<(), SvgPathParseError> {
235        let x = self.parse_number()?;
236        let abs_x = if relative { self.current.x + x } else { x };
237        let end = SvgPoint { x: abs_x, y: self.current.y };
238        elements.push(SvgPathElement::Line(SvgLine { start: self.current, end }));
239        self.current = end;
240        self.last_control = None;
241        Ok(())
242    }
243
244    fn handle_vertical_to(&mut self, relative: bool, elements: &mut Vec<SvgPathElement>) -> Result<(), SvgPathParseError> {
245        let y = self.parse_number()?;
246        let abs_y = if relative { self.current.y + y } else { y };
247        let end = SvgPoint { x: self.current.x, y: abs_y };
248        elements.push(SvgPathElement::Line(SvgLine { start: self.current, end }));
249        self.current = end;
250        self.last_control = None;
251        Ok(())
252    }
253
254    #[allow(clippy::similar_names)] // domain-standard coordinate/control-point names
255    fn handle_cubic_to(&mut self, relative: bool, elements: &mut Vec<SvgPathElement>) -> Result<(), SvgPathParseError> {
256        let (c1x, c1y) = self.parse_coordinate_pair()?;
257        let (c2x, c2y) = self.parse_coordinate_pair()?;
258        let (ex, ey) = self.parse_coordinate_pair()?;
259        let ctrl_1 = self.make_absolute(c1x, c1y, relative);
260        let ctrl_2 = self.make_absolute(c2x, c2y, relative);
261        let end = self.make_absolute(ex, ey, relative);
262        elements.push(SvgPathElement::CubicCurve(SvgCubicCurve {
263            start: self.current, ctrl_1, ctrl_2, end,
264        }));
265        self.last_control = Some(ctrl_2);
266        self.current = end;
267        Ok(())
268    }
269
270    #[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
271    #[allow(clippy::similar_names)] // domain-standard coordinate/control-point names
272    fn handle_smooth_cubic_to(&mut self, relative: bool, elements: &mut Vec<SvgPathElement>) -> Result<(), SvgPathParseError> {
273        let ctrl_1 = match self.last_control {
274            Some(lc) if matches!(self.last_command.to_ascii_uppercase(), b'C' | b'S') => {
275                SvgPoint {
276                    x: 2.0 * self.current.x - lc.x,
277                    y: 2.0 * self.current.y - lc.y,
278                }
279            }
280            _ => self.current,
281        };
282        let (c2x, c2y) = self.parse_coordinate_pair()?;
283        let (ex, ey) = self.parse_coordinate_pair()?;
284        let ctrl_2 = self.make_absolute(c2x, c2y, relative);
285        let end = self.make_absolute(ex, ey, relative);
286        elements.push(SvgPathElement::CubicCurve(SvgCubicCurve {
287            start: self.current, ctrl_1, ctrl_2, end,
288        }));
289        self.last_control = Some(ctrl_2);
290        self.current = end;
291        Ok(())
292    }
293
294    fn handle_quadratic_to(&mut self, relative: bool, elements: &mut Vec<SvgPathElement>) -> Result<(), SvgPathParseError> {
295        let (cx, cy) = self.parse_coordinate_pair()?;
296        let (ex, ey) = self.parse_coordinate_pair()?;
297        let ctrl = self.make_absolute(cx, cy, relative);
298        let end = self.make_absolute(ex, ey, relative);
299        elements.push(SvgPathElement::QuadraticCurve(SvgQuadraticCurve {
300            start: self.current, ctrl, end,
301        }));
302        self.last_control = Some(ctrl);
303        self.current = end;
304        Ok(())
305    }
306
307    #[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
308    fn handle_smooth_quadratic_to(&mut self, relative: bool, elements: &mut Vec<SvgPathElement>) -> Result<(), SvgPathParseError> {
309        let ctrl = match self.last_control {
310            Some(lc) if matches!(self.last_command.to_ascii_uppercase(), b'Q' | b'T') => {
311                SvgPoint {
312                    x: 2.0 * self.current.x - lc.x,
313                    y: 2.0 * self.current.y - lc.y,
314                }
315            }
316            _ => self.current,
317        };
318        let (ex, ey) = self.parse_coordinate_pair()?;
319        let end = self.make_absolute(ex, ey, relative);
320        elements.push(SvgPathElement::QuadraticCurve(SvgQuadraticCurve {
321            start: self.current, ctrl, end,
322        }));
323        self.last_control = Some(ctrl);
324        self.current = end;
325        Ok(())
326    }
327
328    fn handle_arc_to(&mut self, relative: bool, elements: &mut Vec<SvgPathElement>) -> Result<(), SvgPathParseError> {
329        let rx = self.parse_number()?.abs();
330        let ry = self.parse_number()?.abs();
331        let x_rotation = self.parse_number()?;
332        let large_arc = self.parse_flag()?;
333        let sweep = self.parse_flag()?;
334        let (ex, ey) = self.parse_coordinate_pair()?;
335        let end = self.make_absolute(ex, ey, relative);
336        arc_to_cubics(self.current, end, rx, ry, x_rotation, large_arc, sweep, elements);
337        self.current = end;
338        self.last_control = None;
339        Ok(())
340    }
341}
342
343/// Parse an SVG path `d` attribute string into a `SvgMultiPolygon`.
344///
345/// Each M/m command starts a new subpath (ring). All 14 SVG path commands are
346/// supported including arcs (converted to cubic beziers).
347///
348/// # Panics
349///
350/// Panics if the path tokenizer signals a command but then yields no token
351/// (an internal parser invariant that should not occur for any input).
352#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
353#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose parser/builder/dispatch (one branch per input variant)
354/// # Errors
355///
356/// Returns an error if `d` is not a valid SVG path-data string.
357pub fn parse_svg_path_d(d: &str) -> Result<SvgMultiPolygon, SvgPathParseError> {
358    let d = d.trim();
359    if d.is_empty() {
360        return Err(SvgPathParseError::EmptyPath);
361    }
362
363    let mut parser = PathParser::new(d.as_bytes());
364    let mut rings: Vec<SvgPath> = Vec::new();
365    let mut current_elements: Vec<SvgPathElement> = Vec::new();
366
367    parser.skip_whitespace();
368
369    while !parser.at_end() {
370        parser.skip_whitespace_and_commas();
371        if parser.at_end() {
372            break;
373        }
374
375        let b = parser.peek().unwrap();
376
377        // Determine if this is a command letter or an implicit repeat
378        let cmd = if b.is_ascii_alphabetic() {
379            parser.pos += 1;
380            b
381        } else if parser.last_command != 0 {
382            // Implicit repeat: after M/m, implicit commands become L/l
383            match parser.last_command {
384                b'M' => b'L',
385                b'm' => b'l',
386                // AUDIT 2026-07-08: a `Z`/`z` closepath takes no arguments, so it
387                // cannot be implicitly repeated. Reaching here means a stray
388                // non-command byte followed a closepath (e.g. the `5` in "M0 0Z5").
389                // The old `other => other` fell through to the `Z` arm, which
390                // consumes zero bytes, so `pos` never advanced -> 100% CPU infinite
391                // loop. Reject it as an unexpected character instead.
392                b'Z' | b'z' => {
393                    return Err(SvgPathParseError::UnexpectedChar {
394                        pos: parser.pos,
395                        ch: char_at(parser.input, parser.pos),
396                    });
397                }
398                other => other,
399            }
400        } else {
401            return Err(SvgPathParseError::UnexpectedChar {
402                pos: parser.pos,
403                ch: char_at(parser.input, parser.pos),
404            });
405        };
406
407        let relative = cmd.is_ascii_lowercase();
408        let cmd_upper = cmd.to_ascii_uppercase();
409
410        match cmd_upper {
411            b'M' => {
412                // Flush current subpath
413                if !current_elements.is_empty() {
414                    rings.push(SvgPath {
415                        items: SvgPathElementVec::from_vec(core::mem::take(&mut current_elements)),
416                    });
417                }
418                let (x, y) = parser.parse_coordinate_pair()?;
419                let pt = parser.make_absolute(x, y, relative);
420                parser.current = pt;
421                parser.subpath_start = pt;
422                parser.last_control = None;
423                parser.last_command = cmd;
424            }
425            b'L' => {
426                parser.handle_line_to(relative, &mut current_elements)?;
427                parser.last_command = cmd;
428            }
429            b'H' => {
430                parser.handle_horizontal_to(relative, &mut current_elements)?;
431                parser.last_command = cmd;
432            }
433            b'V' => {
434                parser.handle_vertical_to(relative, &mut current_elements)?;
435                parser.last_command = cmd;
436            }
437            b'C' => {
438                parser.handle_cubic_to(relative, &mut current_elements)?;
439                parser.last_command = cmd;
440            }
441            b'S' => {
442                parser.handle_smooth_cubic_to(relative, &mut current_elements)?;
443                parser.last_command = cmd;
444            }
445            b'Q' => {
446                parser.handle_quadratic_to(relative, &mut current_elements)?;
447                parser.last_command = cmd;
448            }
449            b'T' => {
450                parser.handle_smooth_quadratic_to(relative, &mut current_elements)?;
451                parser.last_command = cmd;
452            }
453            b'A' => {
454                parser.handle_arc_to(relative, &mut current_elements)?;
455                parser.last_command = cmd;
456            }
457            b'Z' => {
458                // Close subpath
459                let dx = parser.current.x - parser.subpath_start.x;
460                let dy = parser.current.y - parser.subpath_start.y;
461                if dx * dx + dy * dy > CLOSEPATH_EPSILON * CLOSEPATH_EPSILON {
462                    current_elements.push(SvgPathElement::Line(SvgLine {
463                        start: parser.current,
464                        end: parser.subpath_start,
465                    }));
466                }
467                parser.current = parser.subpath_start;
468                parser.last_control = None;
469                parser.last_command = cmd;
470
471                // Flush current subpath
472                if !current_elements.is_empty() {
473                    rings.push(SvgPath {
474                        items: SvgPathElementVec::from_vec(core::mem::take(&mut current_elements)),
475                    });
476                }
477            }
478            _ => {
479                return Err(SvgPathParseError::UnexpectedChar {
480                    pos: parser.pos - 1,
481                    ch: cmd as char,
482                });
483            }
484        }
485
486        // After processing one argument group, try to consume more
487        // argument groups for the same command (implicit repeats)
488        if cmd_upper != b'M' && cmd_upper != b'Z' {
489            loop {
490                parser.skip_whitespace_and_commas();
491                if parser.at_end() {
492                    break;
493                }
494                let next = parser.peek().unwrap();
495                if next.is_ascii_alphabetic() {
496                    break; // Next command letter
497                }
498                if !parser.has_number() {
499                    break;
500                }
501
502                // Implicit repeat of the same command
503                match cmd_upper {
504                    b'L' => parser.handle_line_to(relative, &mut current_elements)?,
505                    b'H' => parser.handle_horizontal_to(relative, &mut current_elements)?,
506                    b'V' => parser.handle_vertical_to(relative, &mut current_elements)?,
507                    b'C' => parser.handle_cubic_to(relative, &mut current_elements)?,
508                    b'S' => parser.handle_smooth_cubic_to(relative, &mut current_elements)?,
509                    b'Q' => parser.handle_quadratic_to(relative, &mut current_elements)?,
510                    b'T' => parser.handle_smooth_quadratic_to(relative, &mut current_elements)?,
511                    b'A' => parser.handle_arc_to(relative, &mut current_elements)?,
512                    _ => break,
513                }
514            }
515        }
516    }
517
518    // Flush any remaining elements
519    if !current_elements.is_empty() {
520        rings.push(SvgPath {
521            items: SvgPathElementVec::from_vec(current_elements),
522        });
523    }
524
525    // A `d` made up solely of comma/whitespace filler (e.g. ",") consumes to EOF
526    // without ever reading a command and used to be accepted as an empty Ok. The SVG
527    // path grammar requires a moveto to start; a bare separator is only valid BETWEEN
528    // commands, never as the whole string.
529    if parser.last_command == 0 && rings.is_empty() {
530        return Err(SvgPathParseError::UnexpectedChar {
531            pos: 0,
532            ch: char_at(parser.input, 0),
533        });
534    }
535
536    Ok(SvgMultiPolygon {
537        rings: SvgPathVec::from_vec(rings),
538    })
539}
540
541/// Convert an SVG arc to 1–4 cubic bezier curves.
542///
543/// Implements the SVG spec arc endpoint-to-center parameterization (Appendix F.6).
544#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
545// n_segs is a tiny arc-quadrant count (<= ~6) and its loop index; the float<->usize
546// casts are exact for these bounded values.
547#[allow(
548    clippy::cast_possible_truncation,
549    clippy::cast_precision_loss,
550    clippy::cast_sign_loss
551)]
552#[allow(clippy::similar_names)] // domain-standard coordinate/control-point names
553fn arc_to_cubics(
554    start: SvgPoint,
555    end: SvgPoint,
556    mut rx: f32,
557    mut ry: f32,
558    x_rotation_deg: f32,
559    large_arc: bool,
560    sweep: bool,
561    out: &mut Vec<SvgPathElement>,
562) {
563    // Degenerate cases
564    if (start.x - end.x).abs() < POINT_EPSILON && (start.y - end.y).abs() < POINT_EPSILON {
565        return;
566    }
567    if rx < POINT_EPSILON || ry < POINT_EPSILON {
568        out.push(SvgPathElement::Line(SvgLine { start, end }));
569        return;
570    }
571
572    let phi = x_rotation_deg.to_radians();
573    let cos_phi = phi.cos();
574    let sin_phi = phi.sin();
575
576    // Step 1: Compute (x1', y1')
577    let dx = (start.x - end.x) / 2.0;
578    let dy = (start.y - end.y) / 2.0;
579    let x1p = cos_phi * dx + sin_phi * dy;
580    let y1p = -sin_phi * dx + cos_phi * dy;
581
582    // Step 2: Compute (cx', cy') - correct radii if too small
583    let x1p2 = x1p * x1p;
584    let y1p2 = y1p * y1p;
585    let mut rx2 = rx * rx;
586    let mut ry2 = ry * ry;
587
588    let lambda = x1p2 / rx2 + y1p2 / ry2;
589    if lambda > 1.0 {
590        let sqrt_lambda = lambda.sqrt();
591        rx *= sqrt_lambda;
592        ry *= sqrt_lambda;
593        rx2 = rx * rx;
594        ry2 = ry * ry;
595    }
596
597    let num = (rx2 * ry2 - rx2 * y1p2 - ry2 * x1p2).max(0.0);
598    let den = rx2 * y1p2 + ry2 * x1p2;
599    let sq = if den > 0.0 {
600        (num / den).sqrt()
601    } else {
602        0.0
603    };
604
605    let sign = if large_arc == sweep { -1.0 } else { 1.0 };
606    let cxp = sign * sq * (rx * y1p / ry);
607    let cyp = sign * sq * -(ry * x1p / rx);
608
609    // Step 3: Compute (cx, cy) from (cx', cy')
610    let mx = f32::midpoint(start.x, end.x);
611    let my = f32::midpoint(start.y, end.y);
612    let cx = cos_phi * cxp - sin_phi * cyp + mx;
613    let cy = sin_phi * cxp + cos_phi * cyp + my;
614
615    // Step 4: Compute theta1 and dtheta
616    let theta1 = angle_between(1.0, 0.0, (x1p - cxp) / rx, (y1p - cyp) / ry);
617    let mut dtheta = angle_between(
618        (x1p - cxp) / rx,
619        (y1p - cyp) / ry,
620        (-x1p - cxp) / rx,
621        (-y1p - cyp) / ry,
622    );
623
624    if !sweep && dtheta > 0.0 {
625        dtheta -= core::f32::consts::TAU;
626    } else if sweep && dtheta < 0.0 {
627        dtheta += core::f32::consts::TAU;
628    }
629
630    // Split into segments of at most PI/2
631    let n_segs = (dtheta.abs() / (core::f32::consts::FRAC_PI_2 + ARC_SPLIT_FUDGE)).ceil() as usize;
632    let n_segs = n_segs.max(1);
633    let seg_angle = dtheta / n_segs as f32;
634
635    let mut prev = start;
636    for i in 0..n_segs {
637        let t1 = theta1 + seg_angle * i as f32;
638        let t2 = theta1 + seg_angle * (i + 1) as f32;
639
640        let (c1, c2, ep) =
641            arc_segment_to_cubic(cx, cy, rx, ry, cos_phi, sin_phi, t1, t2);
642
643        let seg_end = if i + 1 == n_segs { end } else { ep };
644        out.push(SvgPathElement::CubicCurve(SvgCubicCurve {
645            start: prev,
646            ctrl_1: c1,
647            ctrl_2: c2,
648            end: seg_end,
649        }));
650        prev = seg_end;
651    }
652}
653
654/// Compute the angle between two vectors.
655#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
656fn angle_between(ux: f32, uy: f32, vx: f32, vy: f32) -> f32 {
657    let dot = ux * vx + uy * vy;
658    let len = ((ux * ux + uy * uy) * (vx * vx + vy * vy)).sqrt();
659    if len < ZERO_LENGTH_EPSILON {
660        return 0.0;
661    }
662    let cos_val = (dot / len).clamp(-1.0, 1.0);
663    let angle = cos_val.acos();
664    if ux * vy - uy * vx < 0.0 {
665        -angle
666    } else {
667        angle
668    }
669}
670
671/// Convert a single arc segment (<=90 degrees) to a cubic bezier.
672#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
673#[allow(clippy::similar_names)] // domain-standard coordinate/control-point names
674fn arc_segment_to_cubic(
675    cx: f32,
676    cy: f32,
677    rx: f32,
678    ry: f32,
679    cos_phi: f32,
680    sin_phi: f32,
681    theta1: f32,
682    theta2: f32,
683) -> (SvgPoint, SvgPoint, SvgPoint) {
684    let alpha = 4.0 / 3.0 * ((theta2 - theta1) / 4.0).tan();
685
686    let cos1 = theta1.cos();
687    let sin1 = theta1.sin();
688    let cos2 = theta2.cos();
689    let sin2 = theta2.sin();
690
691    // Control point 1 (relative to unit circle)
692    let dx1 = rx * (cos1 - alpha * sin1);
693    let dy1 = ry * (sin1 + alpha * cos1);
694    // Control point 2
695    let dx2 = rx * (cos2 + alpha * sin2);
696    let dy2 = ry * (sin2 - alpha * cos2);
697    // End point
698    let dx3 = rx * cos2;
699    let dy3 = ry * sin2;
700
701    let c1 = SvgPoint {
702        x: cos_phi * dx1 - sin_phi * dy1 + cx,
703        y: sin_phi * dx1 + cos_phi * dy1 + cy,
704    };
705    let c2 = SvgPoint {
706        x: cos_phi * dx2 - sin_phi * dy2 + cx,
707        y: sin_phi * dx2 + cos_phi * dy2 + cy,
708    };
709    let ep = SvgPoint {
710        x: cos_phi * dx3 - sin_phi * dy3 + cx,
711        y: sin_phi * dx3 + cos_phi * dy3 + cy,
712    };
713
714    (c1, c2, ep)
715}
716
717/// Approximate a circle with 4 cubic bezier curves.
718///
719/// Uses the standard kappa constant (0.5522847498) for quarter-arc approximation.
720#[must_use]
721pub fn svg_circle_to_paths(cx: f32, cy: f32, r: f32) -> SvgPath {
722    let k = r * KAPPA;
723
724    let elements = vec![
725        // Top to right
726        SvgPathElement::CubicCurve(SvgCubicCurve {
727            start: SvgPoint { x: cx, y: cy - r },
728            ctrl_1: SvgPoint {
729                x: cx + k,
730                y: cy - r,
731            },
732            ctrl_2: SvgPoint {
733                x: cx + r,
734                y: cy - k,
735            },
736            end: SvgPoint { x: cx + r, y: cy },
737        }),
738        // Right to bottom
739        SvgPathElement::CubicCurve(SvgCubicCurve {
740            start: SvgPoint { x: cx + r, y: cy },
741            ctrl_1: SvgPoint {
742                x: cx + r,
743                y: cy + k,
744            },
745            ctrl_2: SvgPoint {
746                x: cx + k,
747                y: cy + r,
748            },
749            end: SvgPoint { x: cx, y: cy + r },
750        }),
751        // Bottom to left
752        SvgPathElement::CubicCurve(SvgCubicCurve {
753            start: SvgPoint { x: cx, y: cy + r },
754            ctrl_1: SvgPoint {
755                x: cx - k,
756                y: cy + r,
757            },
758            ctrl_2: SvgPoint {
759                x: cx - r,
760                y: cy + k,
761            },
762            end: SvgPoint { x: cx - r, y: cy },
763        }),
764        // Left to top
765        SvgPathElement::CubicCurve(SvgCubicCurve {
766            start: SvgPoint { x: cx - r, y: cy },
767            ctrl_1: SvgPoint {
768                x: cx - r,
769                y: cy - k,
770            },
771            ctrl_2: SvgPoint {
772                x: cx - k,
773                y: cy - r,
774            },
775            end: SvgPoint { x: cx, y: cy - r },
776        }),
777    ];
778
779    SvgPath {
780        items: SvgPathElementVec::from_vec(elements),
781    }
782}
783
784/// Convert an SVG `<rect>` to a path with optional rounded corners.
785///
786/// If `rx` and `ry` are both 0, produces 4 line segments.
787/// Otherwise, produces lines for straight edges and cubic curves for corners.
788#[must_use]
789// builds the rounded-rect path segment-by-segment with a matching capacity hint;
790// a `vec![..]` literal of the 8 multi-line elements would be less readable here.
791#[allow(clippy::vec_init_then_push)]
792#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose parser/builder/dispatch (one branch per input variant)
793pub fn svg_rect_to_path(x: f32, y: f32, w: f32, h: f32, rx: f32, ry: f32) -> SvgPath {
794    let rx = rx.min(w / 2.0);
795    let ry = ry.min(h / 2.0);
796
797    if rx < CLOSEPATH_EPSILON && ry < CLOSEPATH_EPSILON {
798        // Simple rectangle: 4 lines
799        let tl = SvgPoint { x, y };
800        let tr = SvgPoint { x: x + w, y };
801        let br = SvgPoint { x: x + w, y: y + h };
802        let bl = SvgPoint { x, y: y + h };
803
804        let elements = vec![
805            SvgPathElement::Line(SvgLine { start: tl, end: tr }),
806            SvgPathElement::Line(SvgLine { start: tr, end: br }),
807            SvgPathElement::Line(SvgLine {
808                start: br,
809                end: bl,
810            }),
811            SvgPathElement::Line(SvgLine { start: bl, end: tl }),
812        ];
813
814        return SvgPath {
815            items: SvgPathElementVec::from_vec(elements),
816        };
817    }
818
819    // Rounded rectangle
820    let kx = rx * KAPPA;
821    let ky = ry * KAPPA;
822
823    let mut elements = Vec::with_capacity(8);
824
825    // Top edge (left to right)
826    elements.push(SvgPathElement::Line(SvgLine {
827        start: SvgPoint { x: x + rx, y },
828        end: SvgPoint { x: x + w - rx, y },
829    }));
830    // Top-right corner
831    elements.push(SvgPathElement::CubicCurve(SvgCubicCurve {
832        start: SvgPoint { x: x + w - rx, y },
833        ctrl_1: SvgPoint {
834            x: x + w - rx + kx,
835            y,
836        },
837        ctrl_2: SvgPoint {
838            x: x + w,
839            y: y + ry - ky,
840        },
841        end: SvgPoint {
842            x: x + w,
843            y: y + ry,
844        },
845    }));
846    // Right edge
847    elements.push(SvgPathElement::Line(SvgLine {
848        start: SvgPoint {
849            x: x + w,
850            y: y + ry,
851        },
852        end: SvgPoint {
853            x: x + w,
854            y: y + h - ry,
855        },
856    }));
857    // Bottom-right corner
858    elements.push(SvgPathElement::CubicCurve(SvgCubicCurve {
859        start: SvgPoint {
860            x: x + w,
861            y: y + h - ry,
862        },
863        ctrl_1: SvgPoint {
864            x: x + w,
865            y: y + h - ry + ky,
866        },
867        ctrl_2: SvgPoint {
868            x: x + w - rx + kx,
869            y: y + h,
870        },
871        end: SvgPoint {
872            x: x + w - rx,
873            y: y + h,
874        },
875    }));
876    // Bottom edge (right to left)
877    elements.push(SvgPathElement::Line(SvgLine {
878        start: SvgPoint {
879            x: x + w - rx,
880            y: y + h,
881        },
882        end: SvgPoint { x: x + rx, y: y + h },
883    }));
884    // Bottom-left corner
885    elements.push(SvgPathElement::CubicCurve(SvgCubicCurve {
886        start: SvgPoint { x: x + rx, y: y + h },
887        ctrl_1: SvgPoint {
888            x: x + rx - kx,
889            y: y + h,
890        },
891        ctrl_2: SvgPoint {
892            x,
893            y: y + h - ry + ky,
894        },
895        end: SvgPoint { x, y: y + h - ry },
896    }));
897    // Left edge
898    elements.push(SvgPathElement::Line(SvgLine {
899        start: SvgPoint { x, y: y + h - ry },
900        end: SvgPoint { x, y: y + ry },
901    }));
902    // Top-left corner
903    elements.push(SvgPathElement::CubicCurve(SvgCubicCurve {
904        start: SvgPoint { x, y: y + ry },
905        ctrl_1: SvgPoint {
906            x,
907            y: y + ry - ky,
908        },
909        ctrl_2: SvgPoint {
910            x: x + rx - kx,
911            y,
912        },
913        end: SvgPoint { x: x + rx, y },
914    }));
915
916    SvgPath {
917        items: SvgPathElementVec::from_vec(elements),
918    }
919}
920
921#[cfg(test)]
922mod tests {
923    use super::*;
924
925    /// AUDIT 2026-07-08 regression: `"M0 0Z5"` used to spin at 100% CPU forever
926    /// because the trailing `5` re-derived `cmd = Z` (zero-length consume) and
927    /// the cursor never advanced. It must now terminate with `UnexpectedChar`.
928    #[test]
929    fn m0_0z5_does_not_hang() {
930        let err = parse_svg_path_d("M0 0Z5").unwrap_err();
931        match err {
932            SvgPathParseError::UnexpectedChar { ch, .. } => assert_eq!(ch, '5'),
933            other => panic!("expected UnexpectedChar, got {other:?}"),
934        }
935    }
936
937    /// Any digit or symbol directly after a closepath is rejected, not looped on.
938    #[test]
939    fn stray_byte_after_closepath_rejected() {
940        for s in ["M0 0Z9", "m0 0z-", "M0 0Z."] {
941            assert!(
942                matches!(
943                    parse_svg_path_d(s),
944                    Err(SvgPathParseError::UnexpectedChar { .. })
945                ),
946                "expected UnexpectedChar for {s:?}"
947            );
948        }
949    }
950
951    /// A leading non-command byte reports the real Unicode char, not a Latin-1
952    /// reinterpretation of a single UTF-8 byte (the old `b as char`).
953    #[test]
954    fn error_char_is_unicode_not_byte() {
955        // 'ü' is two UTF-8 bytes; `b as char` would have yielded a mojibake char.
956        let err = parse_svg_path_d("ü10 10").unwrap_err();
957        match err {
958            SvgPathParseError::UnexpectedChar { ch, pos } => {
959                assert_eq!(ch, 'ü');
960                assert_eq!(pos, 0);
961            }
962            other => panic!("expected UnexpectedChar, got {other:?}"),
963        }
964    }
965
966    /// A well-formed closepath followed by a real command still parses.
967    #[test]
968    fn valid_closepath_then_command_ok() {
969        let parsed = parse_svg_path_d("M0 0 L10 0 Z M20 20 L30 20 Z");
970        assert!(parsed.is_ok(), "valid multi-subpath path should parse");
971    }
972}
973
974#[cfg(test)]
975#[allow(clippy::float_cmp)] // exact float equality is the point: the parser propagates values bit-for-bit
976mod autotest_generated {
977    use alloc::format;
978
979    use super::*;
980
981    // ---------------------------------------------------------------- helpers
982
983    fn approx(a: f32, b: f32) -> bool {
984        (a - b).abs() < 1e-4
985    }
986
987    /// Every point produced by a path, in element order.
988    fn all_points(path: &SvgPath) -> Vec<SvgPoint> {
989        let mut out = Vec::new();
990        for e in path.items.as_ref() {
991            match e {
992                SvgPathElement::Line(l) => {
993                    out.push(l.start);
994                    out.push(l.end);
995                }
996                SvgPathElement::QuadraticCurve(q) => {
997                    out.push(q.start);
998                    out.push(q.ctrl);
999                    out.push(q.end);
1000                }
1001                SvgPathElement::CubicCurve(c) => {
1002                    out.push(c.start);
1003                    out.push(c.ctrl_1);
1004                    out.push(c.ctrl_2);
1005                    out.push(c.end);
1006                }
1007            }
1008        }
1009        out
1010    }
1011
1012    /// Each element's end must be the next element's start (the parser threads
1013    /// `self.current` through every handler, so this holds bit-for-bit).
1014    fn assert_contiguous(items: &[SvgPathElement], what: &str) {
1015        for w in items.windows(2) {
1016            assert_eq!(
1017                w[0].get_end(),
1018                w[1].get_start(),
1019                "{what}: element chain is not contiguous"
1020            );
1021        }
1022    }
1023
1024    // ======================================================= char_at (numeric)
1025
1026    #[test]
1027    fn char_at_zero_and_ascii() {
1028        // "M0 0" is ['M', '0', ' ', '0'] -- the space is at index 2, not 3.
1029        assert_eq!(char_at(b"M0 0", 0), 'M');
1030        assert_eq!(char_at(b"M0 0", 2), ' ');
1031        assert_eq!(char_at(b"M0 0", 3), '0');
1032    }
1033
1034    #[test]
1035    fn char_at_empty_input_is_replacement() {
1036        assert_eq!(char_at(b"", 0), char::REPLACEMENT_CHARACTER);
1037    }
1038
1039    #[test]
1040    fn char_at_past_end_is_replacement_not_panic() {
1041        assert_eq!(char_at(b"abc", 3), char::REPLACEMENT_CHARACTER);
1042        assert_eq!(char_at(b"abc", 4), char::REPLACEMENT_CHARACTER);
1043    }
1044
1045    /// `pos = usize::MAX` must be a `get(pos..)` miss, not an arithmetic panic.
1046    #[test]
1047    fn char_at_usize_max_is_replacement() {
1048        assert_eq!(char_at(b"abc", usize::MAX), char::REPLACEMENT_CHARACTER);
1049        assert_eq!(char_at(b"", usize::MAX), char::REPLACEMENT_CHARACTER);
1050    }
1051
1052    /// The whole point of `char_at`: report the real char, not one UTF-8 byte.
1053    #[test]
1054    fn char_at_decodes_multibyte_not_latin1() {
1055        assert_eq!(char_at("ü".as_bytes(), 0), 'ü');
1056        assert_eq!(char_at("€".as_bytes(), 0), '€');
1057        assert_eq!(char_at("\u{1F600}".as_bytes(), 0), '\u{1F600}');
1058    }
1059
1060    /// A corrupt (mid-codepoint) offset falls back rather than panicking.
1061    #[test]
1062    fn char_at_mid_codepoint_offset_is_replacement() {
1063        let bytes = "😀".as_bytes(); // 4 bytes
1064        for pos in 1..bytes.len() {
1065            assert_eq!(
1066                char_at(bytes, pos),
1067                char::REPLACEMENT_CHARACTER,
1068                "mid-codepoint offset {pos} must not panic"
1069            );
1070        }
1071    }
1072
1073    /// Trailing garbage after a valid char makes the *whole rest* invalid UTF-8,
1074    /// so even a valid leading char decodes to the replacement char. Pinned as
1075    /// deterministic (never a panic).
1076    #[test]
1077    fn char_at_invalid_utf8_tail_is_replacement() {
1078        assert_eq!(char_at(&[b'A', 0xFF], 0), char::REPLACEMENT_CHARACTER);
1079        assert_eq!(char_at(&[0xFF], 0), char::REPLACEMENT_CHARACTER);
1080        // ...but a clean tail after the char still decodes.
1081        assert_eq!(char_at(b"AB", 0), 'A');
1082    }
1083
1084    // ============================================ SvgPathParseError (serializer)
1085
1086    #[test]
1087    fn error_display_is_non_empty_for_every_variant() {
1088        let variants = [
1089            SvgPathParseError::EmptyPath,
1090            SvgPathParseError::UnexpectedChar { pos: 0, ch: 'x' },
1091            SvgPathParseError::ExpectedNumber { pos: 0 },
1092            SvgPathParseError::InvalidArcFlag { pos: 0 },
1093        ];
1094        for v in variants {
1095            let s = format!("{v}");
1096            assert!(!s.is_empty(), "Display for {v:?} must not be empty");
1097            assert!(!format!("{v:?}").is_empty(), "Debug must not be empty");
1098        }
1099    }
1100
1101    #[test]
1102    fn error_display_edge_values_do_not_panic() {
1103        let s = format!(
1104            "{}",
1105            SvgPathParseError::UnexpectedChar {
1106                pos: usize::MAX,
1107                ch: char::REPLACEMENT_CHARACTER,
1108            }
1109        );
1110        assert!(s.contains(&format!("{}", usize::MAX)));
1111        assert!(s.contains(char::REPLACEMENT_CHARACTER));
1112
1113        // NUL, an emoji and a combining mark all format without panicking.
1114        for ch in ['\0', '\u{1F600}', '\u{0301}'] {
1115            let s = format!("{}", SvgPathParseError::UnexpectedChar { pos: 0, ch });
1116            assert!(!s.is_empty());
1117        }
1118        assert!(!format!("{}", SvgPathParseError::ExpectedNumber { pos: usize::MAX }).is_empty());
1119        assert!(!format!("{}", SvgPathParseError::InvalidArcFlag { pos: usize::MAX }).is_empty());
1120    }
1121
1122    // ================================================= PathParser::new / getters
1123
1124    #[test]
1125    fn parser_new_invariants_hold() {
1126        let p = PathParser::new(b"M0 0");
1127        assert_eq!(p.pos, 0);
1128        assert_eq!(p.current, SvgPoint { x: 0.0, y: 0.0 });
1129        assert_eq!(p.subpath_start, SvgPoint { x: 0.0, y: 0.0 });
1130        assert!(p.last_control.is_none());
1131        assert_eq!(p.last_command, 0);
1132        assert_eq!(p.input.len(), 4);
1133    }
1134
1135    #[test]
1136    fn parser_new_on_empty_input_does_not_panic() {
1137        let p = PathParser::new(b"");
1138        assert!(p.at_end(), "empty input is immediately at_end");
1139        assert_eq!(p.peek(), None);
1140        assert!(!p.has_number());
1141    }
1142
1143    #[test]
1144    fn at_end_and_peek_agree_across_the_whole_input() {
1145        let mut p = PathParser::new(b"ab");
1146        assert!(!p.at_end());
1147        assert_eq!(p.peek(), Some(b'a'));
1148        p.pos = 1;
1149        assert!(!p.at_end());
1150        assert_eq!(p.peek(), Some(b'b'));
1151        p.pos = 2;
1152        assert!(p.at_end());
1153        assert_eq!(p.peek(), None);
1154    }
1155
1156    /// A cursor pushed far past the end must report `at_end` / `None`, never panic.
1157    #[test]
1158    fn peek_and_at_end_at_extreme_positions() {
1159        let mut p = PathParser::new(b"abc");
1160        p.pos = usize::MAX;
1161        assert!(p.at_end());
1162        assert_eq!(p.peek(), None);
1163        assert!(!p.has_number());
1164    }
1165
1166    // ============================================================ skip_* (other)
1167
1168    #[test]
1169    fn skip_whitespace_and_commas_consumes_all_separators() {
1170        let mut p = PathParser::new(b" \t\r\n,,, \tX");
1171        p.skip_whitespace_and_commas();
1172        assert_eq!(p.peek(), Some(b'X'));
1173    }
1174
1175    /// `skip_whitespace` must *not* eat commas (they are only argument separators).
1176    #[test]
1177    fn skip_whitespace_stops_at_comma() {
1178        let mut p = PathParser::new(b"  ,1");
1179        p.skip_whitespace();
1180        assert_eq!(p.peek(), Some(b','));
1181        assert_eq!(p.pos, 2);
1182    }
1183
1184    #[test]
1185    fn skip_on_empty_and_all_separator_input_terminates() {
1186        let mut p = PathParser::new(b"");
1187        p.skip_whitespace();
1188        p.skip_whitespace_and_commas();
1189        assert!(p.at_end());
1190
1191        let all_ws = " \t\r\n,".repeat(20_000);
1192        let mut p = PathParser::new(all_ws.as_bytes());
1193        p.skip_whitespace_and_commas();
1194        assert!(p.at_end(), "a huge run of separators must be fully consumed");
1195        assert_eq!(p.pos, all_ws.len());
1196    }
1197
1198    /// Non-separator input leaves the cursor exactly where it was.
1199    #[test]
1200    fn skip_is_a_no_op_on_non_separator() {
1201        let mut p = PathParser::new("😀".as_bytes());
1202        p.skip_whitespace_and_commas();
1203        assert_eq!(p.pos, 0);
1204        // Form feed / vertical tab are NOT SVG wsp; the parser must leave them.
1205        let mut p = PathParser::new(b"\x0c\x0b1");
1206        p.skip_whitespace();
1207        assert_eq!(p.pos, 0);
1208    }
1209
1210    // ======================================================= has_number (predicate)
1211
1212    #[test]
1213    fn has_number_true_for_number_starters() {
1214        for s in ["0", "9", "+", "-", ".", "5.5", "-.5"] {
1215            assert!(
1216                PathParser::new(s.as_bytes()).has_number(),
1217                "{s:?} should look like a number start"
1218            );
1219        }
1220    }
1221
1222    #[test]
1223    fn has_number_false_for_non_number_starters() {
1224        // Note 'e'/'E' only appear *inside* a number, never at its start.
1225        for s in ["", " ", ",", "M", "z", "e", "E", "😀", "\u{0301}"] {
1226            assert!(
1227                !PathParser::new(s.as_bytes()).has_number(),
1228                "{s:?} should not look like a number start"
1229            );
1230        }
1231    }
1232
1233    // ========================================================= parse_number (parser)
1234
1235    fn num(s: &str) -> Result<f32, SvgPathParseError> {
1236        PathParser::new(s.as_bytes()).parse_number()
1237    }
1238
1239    #[test]
1240    fn parse_number_valid_minimal() {
1241        assert_eq!(num("0").unwrap(), 0.0);
1242        assert_eq!(num("5").unwrap(), 5.0);
1243        assert_eq!(num("+5").unwrap(), 5.0);
1244        assert_eq!(num("-5").unwrap(), -5.0);
1245        assert!(approx(num("12.34").unwrap(), 12.34));
1246        assert!(approx(num(".5").unwrap(), 0.5));
1247        assert_eq!(num("5.").unwrap(), 5.0);
1248        assert!(approx(num("1e2").unwrap(), 100.0));
1249        assert!(approx(num("1E-2").unwrap(), 0.01));
1250        assert!(approx(num("  ,, 7").unwrap(), 7.0), "leading separators skipped");
1251    }
1252
1253    #[test]
1254    fn parse_number_empty_input_is_err() {
1255        assert_eq!(num(""), Err(SvgPathParseError::ExpectedNumber { pos: 0 }));
1256    }
1257
1258    /// Whitespace-only input reports the position *after* the skipped separators.
1259    #[test]
1260    fn parse_number_whitespace_only_is_err() {
1261        assert_eq!(num("   "), Err(SvgPathParseError::ExpectedNumber { pos: 3 }));
1262        assert_eq!(num("\t\n"), Err(SvgPathParseError::ExpectedNumber { pos: 2 }));
1263        assert_eq!(num(" , "), Err(SvgPathParseError::ExpectedNumber { pos: 3 }));
1264    }
1265
1266    #[test]
1267    fn parse_number_garbage_is_err_never_panics() {
1268        for s in ["abc", "@", "#$%", "-", "+", ".", "-.", "+.", "e5", "NaN", "inf", "-inf"] {
1269            assert!(
1270                matches!(num(s), Err(SvgPathParseError::ExpectedNumber { .. })),
1271                "{s:?} must be rejected, got {:?}",
1272                num(s)
1273            );
1274        }
1275    }
1276
1277    /// A dangling exponent is consumed by the tokenizer but rejected by `f32::from_str`.
1278    #[test]
1279    fn parse_number_dangling_exponent_is_err() {
1280        for s in ["1e", "1E", "1e+", "1e-", "1.5e"] {
1281            assert!(
1282                matches!(num(s), Err(SvgPathParseError::ExpectedNumber { pos: 0 })),
1283                "{s:?} must be rejected"
1284            );
1285        }
1286    }
1287
1288    #[test]
1289    fn parse_number_unicode_does_not_panic() {
1290        for s in ["😀", "\u{0301}", "ü", "€1", "1"] {
1291            assert!(num(s).is_err(), "{s:?} must be rejected");
1292        }
1293        // A number immediately followed by a multibyte char stops at the boundary.
1294        let mut p = PathParser::new("1😀".as_bytes());
1295        assert_eq!(p.parse_number().unwrap(), 1.0);
1296        assert_eq!(p.pos, 1);
1297    }
1298
1299    /// Boundary numerics: overflow saturates to +/-inf, underflow flushes to zero,
1300    /// and `-0` keeps its sign. None of these panic.
1301    #[test]
1302    fn parse_number_boundary_values_saturate() {
1303        assert!(num("-0").unwrap().is_sign_negative(), "-0 keeps its sign bit");
1304        assert_eq!(num("-0").unwrap(), -0.0);
1305
1306        assert!(num("1e999").unwrap().is_infinite());
1307        assert!(num("1e999").unwrap().is_sign_positive());
1308        assert!(num("-1e999").unwrap().is_infinite());
1309        assert!(num("-1e999").unwrap().is_sign_negative());
1310
1311        assert_eq!(num("1e-999").unwrap(), 0.0, "underflow flushes to zero");
1312
1313        // i64::MAX / f32 extremes round to a finite f32.
1314        assert!(num("9223372036854775807").unwrap().is_finite());
1315        assert!(num("340282350000000000000000000000000000000").unwrap().is_finite());
1316        // Just past f32::MAX -> +inf, not a panic.
1317        assert!(num("1e39").unwrap().is_infinite());
1318    }
1319
1320    /// A 20k-digit literal must not hang or panic; it saturates to +inf.
1321    #[test]
1322    fn parse_number_extremely_long_input_terminates() {
1323        let huge = "9".repeat(20_000);
1324        assert!(num(&huge).unwrap().is_infinite());
1325
1326        let long_frac = format!("0.{}", "0".repeat(20_000));
1327        assert_eq!(num(&long_frac).unwrap(), 0.0);
1328
1329        let long_zeros = format!("{}1", "0".repeat(20_000));
1330        assert_eq!(num(&long_zeros).unwrap(), 1.0);
1331    }
1332
1333    /// Trailing junk is left on the cursor rather than swallowed: `"1.2.3"` yields
1334    /// `1.2` and stops at the second dot (SVG's own "1.5.5" == two numbers rule).
1335    #[test]
1336    fn parse_number_stops_at_trailing_junk() {
1337        let mut p = PathParser::new(b"1.2.3");
1338        assert!(approx(p.parse_number().unwrap(), 1.2));
1339        assert_eq!(p.pos, 3, "second '.' must not be consumed");
1340
1341        let mut p = PathParser::new(b"5;garbage");
1342        assert_eq!(p.parse_number().unwrap(), 5.0);
1343        assert_eq!(p.peek(), Some(b';'));
1344    }
1345
1346    /// The cursor is never advanced past the end of the input, whatever happens.
1347    #[test]
1348    fn parse_number_never_overruns_the_buffer() {
1349        for s in ["", "-", ".", "1e", "1e+", "1.", "+.e", "1e-", "999"] {
1350            let mut p = PathParser::new(s.as_bytes());
1351            let _ = p.parse_number();
1352            assert!(p.pos <= s.len(), "{s:?}: pos {} > len {}", p.pos, s.len());
1353        }
1354    }
1355
1356    // =========================================================== parse_flag (parser)
1357
1358    fn flag(s: &str) -> Result<bool, SvgPathParseError> {
1359        PathParser::new(s.as_bytes()).parse_flag()
1360    }
1361
1362    #[test]
1363    fn parse_flag_valid_minimal() {
1364        assert!(!flag("0").unwrap());
1365        assert!(flag("1").unwrap());
1366        assert!(flag("  , 1").unwrap(), "separators are skipped first");
1367    }
1368
1369    /// A flag is exactly one byte: "11" is two flags, not the number eleven.
1370    #[test]
1371    fn parse_flag_consumes_exactly_one_byte() {
1372        let mut p = PathParser::new(b"10");
1373        assert!(p.parse_flag().unwrap());
1374        assert_eq!(p.pos, 1);
1375        assert!(!p.parse_flag().unwrap());
1376        assert_eq!(p.pos, 2);
1377    }
1378
1379    #[test]
1380    fn parse_flag_empty_and_whitespace_only_are_err() {
1381        assert_eq!(flag(""), Err(SvgPathParseError::InvalidArcFlag { pos: 0 }));
1382        assert_eq!(flag("   "), Err(SvgPathParseError::InvalidArcFlag { pos: 3 }));
1383    }
1384
1385    /// Any byte other than `0`/`1` is rejected and the cursor stays put.
1386    #[test]
1387    fn parse_flag_garbage_is_err_and_does_not_advance() {
1388        for s in ["2", "9", "-1", "+1", "x", ".", "😀", "0.5"] {
1389            let mut p = PathParser::new(s.as_bytes());
1390            let before = p.pos;
1391            match p.parse_flag() {
1392                Err(SvgPathParseError::InvalidArcFlag { pos }) => {
1393                    assert_eq!(pos, p.pos, "{s:?}: reported pos must be the cursor");
1394                    assert_eq!(p.pos, before, "{s:?}: rejected flag must not advance");
1395                }
1396                // "0.5" legitimately parses its leading '0' as the flag.
1397                Ok(v) => assert!(s == "0.5" && !v, "{s:?} unexpectedly parsed as {v}"),
1398                other => panic!("{s:?}: unexpected {other:?}"),
1399            }
1400        }
1401    }
1402
1403    /// A 100k-byte separator run followed by no flag terminates with an error.
1404    #[test]
1405    fn parse_flag_extremely_long_separator_run_terminates() {
1406        let s = " ".repeat(100_000);
1407        assert_eq!(
1408            flag(&s),
1409            Err(SvgPathParseError::InvalidArcFlag { pos: 100_000 })
1410        );
1411    }
1412
1413    // ================================================ parse_coordinate_pair (parser)
1414
1415    fn pair(s: &str) -> Result<(f32, f32), SvgPathParseError> {
1416        PathParser::new(s.as_bytes()).parse_coordinate_pair()
1417    }
1418
1419    #[test]
1420    fn parse_coordinate_pair_valid_minimal() {
1421        assert_eq!(pair("1 2").unwrap(), (1.0, 2.0));
1422        assert_eq!(pair("1,2").unwrap(), (1.0, 2.0));
1423        assert_eq!(pair(" 1 , 2 ").unwrap(), (1.0, 2.0));
1424        // SVG allows a sign to act as the separator.
1425        assert_eq!(pair("-1-2").unwrap(), (-1.0, -2.0));
1426    }
1427
1428    /// SVG's notorious "1.5.5" == (1.5, 0.5) tokenization.
1429    #[test]
1430    fn parse_coordinate_pair_splits_on_second_dot() {
1431        let (x, y) = pair("1.5.5").unwrap();
1432        assert!(approx(x, 1.5) && approx(y, 0.5), "got ({x}, {y})");
1433    }
1434
1435    #[test]
1436    fn parse_coordinate_pair_empty_and_partial_are_err() {
1437        assert!(pair("").is_err());
1438        assert!(pair("   ").is_err());
1439        assert!(pair("1").is_err(), "a lone x with no y must be rejected");
1440        assert!(pair("1 ").is_err());
1441        assert!(pair("1,").is_err());
1442    }
1443
1444    #[test]
1445    fn parse_coordinate_pair_garbage_and_unicode_are_err() {
1446        for s in ["abc", "1 abc", "😀 1", "1 😀", ";;", "1;2"] {
1447            assert!(pair(s).is_err(), "{s:?} must be rejected");
1448        }
1449    }
1450
1451    #[test]
1452    fn parse_coordinate_pair_boundary_values() {
1453        let (x, y) = pair("1e999 -1e999").unwrap();
1454        assert!(x.is_infinite() && x.is_sign_positive());
1455        assert!(y.is_infinite() && y.is_sign_negative());
1456
1457        let (x, y) = pair("-0 0").unwrap();
1458        assert!(x.is_sign_negative() && y.is_sign_positive());
1459    }
1460
1461    #[test]
1462    fn parse_coordinate_pair_extremely_long_input_terminates() {
1463        let s = format!("{} {}", "9".repeat(10_000), "9".repeat(10_000));
1464        let (x, y) = pair(&s).unwrap();
1465        assert!(x.is_infinite() && y.is_infinite());
1466    }
1467
1468    // ======================================================= make_absolute (numeric)
1469
1470    #[test]
1471    fn make_absolute_zero_and_absolute_mode_is_identity() {
1472        let mut p = PathParser::new(b"");
1473        p.current = SvgPoint { x: 7.0, y: -3.0 };
1474        // Absolute: current is ignored entirely.
1475        assert_eq!(p.make_absolute(0.0, 0.0, false), SvgPoint { x: 0.0, y: 0.0 });
1476        assert_eq!(p.make_absolute(1.0, 2.0, false), SvgPoint { x: 1.0, y: 2.0 });
1477        // Relative: offsets from current.
1478        assert_eq!(p.make_absolute(0.0, 0.0, true), SvgPoint { x: 7.0, y: -3.0 });
1479        assert_eq!(p.make_absolute(-7.0, 3.0, true), SvgPoint { x: 0.0, y: 0.0 });
1480    }
1481
1482    #[test]
1483    fn make_absolute_negative_inputs() {
1484        let mut p = PathParser::new(b"");
1485        p.current = SvgPoint { x: -10.0, y: -10.0 };
1486        assert_eq!(p.make_absolute(-5.0, -5.0, true), SvgPoint { x: -15.0, y: -15.0 });
1487        assert_eq!(p.make_absolute(-5.0, -5.0, false), SvgPoint { x: -5.0, y: -5.0 });
1488    }
1489
1490    /// f32 addition saturates to infinity; it never wraps or debug-panics.
1491    #[test]
1492    fn make_absolute_overflow_saturates_to_infinity() {
1493        let mut p = PathParser::new(b"");
1494        p.current = SvgPoint {
1495            x: f32::MAX,
1496            y: f32::MIN,
1497        };
1498        let r = p.make_absolute(f32::MAX, f32::MIN, true);
1499        assert!(r.x.is_infinite() && r.x.is_sign_positive());
1500        assert!(r.y.is_infinite() && r.y.is_sign_negative());
1501    }
1502
1503    #[test]
1504    fn make_absolute_nan_and_inf_are_defined_not_panics() {
1505        let mut p = PathParser::new(b"");
1506        p.current = SvgPoint {
1507            x: f32::INFINITY,
1508            y: 0.0,
1509        };
1510        // inf + (-inf) is NaN by IEEE-754 -- defined, not a panic.
1511        let r = p.make_absolute(f32::NEG_INFINITY, f32::NAN, true);
1512        assert!(r.x.is_nan(), "inf + -inf must be NaN");
1513        assert!(r.y.is_nan());
1514
1515        // Absolute mode passes NaN straight through.
1516        let r = p.make_absolute(f32::NAN, f32::INFINITY, false);
1517        assert!(r.x.is_nan());
1518        assert!(r.y.is_infinite());
1519    }
1520
1521    // ============================================================ handle_* (other)
1522
1523    /// Drive one handler over `input` starting from `current`, returning the
1524    /// pushed elements plus the parser's post-state.
1525    fn run_handler<F>(
1526        input: &str,
1527        current: SvgPoint,
1528        last_command: u8,
1529        last_control: Option<SvgPoint>,
1530        f: F,
1531    ) -> (Result<(), SvgPathParseError>, Vec<SvgPathElement>, SvgPoint)
1532    where
1533        F: FnOnce(&mut PathParser<'_>, &mut Vec<SvgPathElement>) -> Result<(), SvgPathParseError>,
1534    {
1535        let mut p = PathParser::new(input.as_bytes());
1536        p.current = current;
1537        p.last_command = last_command;
1538        p.last_control = last_control;
1539        let mut els = Vec::new();
1540        let r = f(&mut p, &mut els);
1541        (r, els, p.current)
1542    }
1543
1544    const ORIGIN: SvgPoint = SvgPoint { x: 0.0, y: 0.0 };
1545
1546    #[test]
1547    fn handle_line_to_absolute_and_relative() {
1548        let start = SvgPoint { x: 10.0, y: 10.0 };
1549        let (r, els, cur) = run_handler("5 5", start, b'L', None, |p, e| p.handle_line_to(false, e));
1550        assert!(r.is_ok());
1551        assert_eq!(els.len(), 1);
1552        assert_eq!(els[0].get_start(), start);
1553        assert_eq!(els[0].get_end(), SvgPoint { x: 5.0, y: 5.0 });
1554        assert_eq!(cur, SvgPoint { x: 5.0, y: 5.0 });
1555
1556        let (r, els, cur) = run_handler("5 5", start, b'l', None, |p, e| p.handle_line_to(true, e));
1557        assert!(r.is_ok());
1558        assert_eq!(els[0].get_end(), SvgPoint { x: 15.0, y: 15.0 });
1559        assert_eq!(cur, SvgPoint { x: 15.0, y: 15.0 });
1560    }
1561
1562    /// H keeps y, V keeps x -- including when the incoming coordinate is infinite.
1563    #[test]
1564    fn handle_horizontal_and_vertical_preserve_the_other_axis() {
1565        let start = SvgPoint { x: 3.0, y: 4.0 };
1566        let (_, els, _) = run_handler("9", start, b'H', None, |p, e| p.handle_horizontal_to(false, e));
1567        assert_eq!(els[0].get_end(), SvgPoint { x: 9.0, y: 4.0 });
1568
1569        let (_, els, _) = run_handler("9", start, b'V', None, |p, e| p.handle_vertical_to(false, e));
1570        assert_eq!(els[0].get_end(), SvgPoint { x: 3.0, y: 9.0 });
1571
1572        let (_, els, _) = run_handler("1e999", start, b'h', None, |p, e| p.handle_horizontal_to(true, e));
1573        let end = els[0].get_end();
1574        assert!(end.x.is_infinite(), "relative H by +inf saturates");
1575        assert_eq!(end.y, 4.0, "y must be untouched");
1576    }
1577
1578    #[test]
1579    fn handlers_reject_empty_and_garbage_input_without_panicking() {
1580        for input in ["", "   ", "abc", "😀", ";", "1"] {
1581            // Each handler needs >= 1 number; "1" is enough only for H/V.
1582            let (r, _, _) = run_handler(input, ORIGIN, 0, None, |p, e| p.handle_line_to(false, e));
1583            assert!(r.is_err(), "line_to({input:?}) must be Err");
1584
1585            let (r, _, _) = run_handler(input, ORIGIN, 0, None, |p, e| p.handle_cubic_to(false, e));
1586            assert!(r.is_err(), "cubic_to({input:?}) must be Err");
1587
1588            let (r, _, _) = run_handler(input, ORIGIN, 0, None, |p, e| p.handle_quadratic_to(false, e));
1589            assert!(r.is_err(), "quadratic_to({input:?}) must be Err");
1590
1591            let (r, _, _) = run_handler(input, ORIGIN, 0, None, |p, e| p.handle_arc_to(false, e));
1592            assert!(r.is_err(), "arc_to({input:?}) must be Err");
1593
1594            if input != "1" {
1595                let (r, _, _) =
1596                    run_handler(input, ORIGIN, 0, None, |p, e| p.handle_horizontal_to(false, e));
1597                assert!(r.is_err(), "horizontal_to({input:?}) must be Err");
1598                let (r, _, _) =
1599                    run_handler(input, ORIGIN, 0, None, |p, e| p.handle_vertical_to(false, e));
1600                assert!(r.is_err(), "vertical_to({input:?}) must be Err");
1601            }
1602        }
1603    }
1604
1605    #[test]
1606    fn handle_cubic_to_records_second_control_point() {
1607        let (r, els, _) = run_handler("1 1 2 2 3 3", ORIGIN, b'C', None, |p, e| {
1608            p.handle_cubic_to(false, e)
1609        });
1610        assert!(r.is_ok());
1611        match els[0] {
1612            SvgPathElement::CubicCurve(c) => {
1613                assert_eq!(c.start, ORIGIN);
1614                assert_eq!(c.ctrl_1, SvgPoint { x: 1.0, y: 1.0 });
1615                assert_eq!(c.ctrl_2, SvgPoint { x: 2.0, y: 2.0 });
1616                assert_eq!(c.end, SvgPoint { x: 3.0, y: 3.0 });
1617            }
1618            other => panic!("expected CubicCurve, got {other:?}"),
1619        }
1620    }
1621
1622    /// S reflects the previous control point only when the previous command was
1623    /// C or S; otherwise ctrl_1 collapses onto the current point.
1624    #[test]
1625    fn handle_smooth_cubic_reflects_only_after_c_or_s() {
1626        let cur = SvgPoint { x: 10.0, y: 10.0 };
1627        let lc = Some(SvgPoint { x: 8.0, y: 6.0 });
1628
1629        let (_, els, _) = run_handler("1 1 2 2", cur, b'C', lc, |p, e| p.handle_smooth_cubic_to(false, e));
1630        match els[0] {
1631            // reflection of (8,6) about (10,10) == (12,14)
1632            SvgPathElement::CubicCurve(c) => assert_eq!(c.ctrl_1, SvgPoint { x: 12.0, y: 14.0 }),
1633            other => panic!("expected CubicCurve, got {other:?}"),
1634        }
1635
1636        // Previous command was L: no reflection, ctrl_1 == current.
1637        let (_, els, _) = run_handler("1 1 2 2", cur, b'L', lc, |p, e| p.handle_smooth_cubic_to(false, e));
1638        match els[0] {
1639            SvgPathElement::CubicCurve(c) => assert_eq!(c.ctrl_1, cur),
1640            other => panic!("expected CubicCurve, got {other:?}"),
1641        }
1642
1643        // No stored control point at all: no reflection either.
1644        let (_, els, _) = run_handler("1 1 2 2", cur, b'S', None, |p, e| p.handle_smooth_cubic_to(false, e));
1645        match els[0] {
1646            SvgPathElement::CubicCurve(c) => assert_eq!(c.ctrl_1, cur),
1647            other => panic!("expected CubicCurve, got {other:?}"),
1648        }
1649    }
1650
1651    #[test]
1652    fn handle_smooth_quadratic_reflects_only_after_q_or_t() {
1653        let cur = SvgPoint { x: 10.0, y: 10.0 };
1654        let lc = Some(SvgPoint { x: 8.0, y: 6.0 });
1655
1656        let (_, els, _) = run_handler("2 2", cur, b'Q', lc, |p, e| p.handle_smooth_quadratic_to(false, e));
1657        match els[0] {
1658            SvgPathElement::QuadraticCurve(q) => assert_eq!(q.ctrl, SvgPoint { x: 12.0, y: 14.0 }),
1659            other => panic!("expected QuadraticCurve, got {other:?}"),
1660        }
1661
1662        let (_, els, _) = run_handler("2 2", cur, b'M', lc, |p, e| p.handle_smooth_quadratic_to(false, e));
1663        match els[0] {
1664            SvgPathElement::QuadraticCurve(q) => assert_eq!(q.ctrl, cur),
1665            other => panic!("expected QuadraticCurve, got {other:?}"),
1666        }
1667    }
1668
1669    /// Arc radii are absolute-valued, so a negative radius still draws an arc.
1670    #[test]
1671    fn handle_arc_to_takes_abs_of_radii() {
1672        let (r, els, cur) = run_handler("-5 -5 0 0 1 10 0", ORIGIN, b'A', None, |p, e| {
1673            p.handle_arc_to(false, e)
1674        });
1675        assert!(r.is_ok());
1676        assert!(!els.is_empty(), "negative radii must still produce an arc");
1677        assert!(
1678            els.iter().all(|e| matches!(e, SvgPathElement::CubicCurve(_))),
1679            "abs() of the radii keeps this a real arc, not a line fallback"
1680        );
1681        assert_eq!(cur, SvgPoint { x: 10.0, y: 0.0 });
1682    }
1683
1684    /// A zero radius degenerates to a straight line (SVG spec F.6.6).
1685    #[test]
1686    fn handle_arc_to_zero_radius_degenerates_to_line() {
1687        let (r, els, _) = run_handler("0 0 0 0 1 10 0", ORIGIN, b'A', None, |p, e| {
1688            p.handle_arc_to(false, e)
1689        });
1690        assert!(r.is_ok());
1691        assert_eq!(els.len(), 1);
1692        assert!(matches!(els[0], SvgPathElement::Line(_)));
1693        assert_eq!(els[0].get_end(), SvgPoint { x: 10.0, y: 0.0 });
1694    }
1695
1696    #[test]
1697    fn handle_arc_to_rejects_out_of_range_flags() {
1698        for input in ["5 5 0 2 1 10 0", "5 5 0 1 2 10 0", "5 5 0 x 1 10 0", "5 5 0"] {
1699            let (r, _, _) = run_handler(input, ORIGIN, b'A', None, |p, e| p.handle_arc_to(false, e));
1700            assert!(r.is_err(), "arc flags in {input:?} must be rejected");
1701        }
1702        let (r, _, _) = run_handler("5 5 0 2 1 10 0", ORIGIN, b'A', None, |p, e| {
1703            p.handle_arc_to(false, e)
1704        });
1705        assert!(matches!(r, Err(SvgPathParseError::InvalidArcFlag { .. })));
1706    }
1707
1708    /// Infinite radii feed NaN through the endpoint parameterization. The
1709    /// segment count must still be bounded (no runaway loop) and no panic.
1710    #[test]
1711    fn handle_arc_to_infinite_radii_is_bounded() {
1712        let (r, els, _) = run_handler("1e999 1e999 0 0 1 10 10", ORIGIN, b'A', None, |p, e| {
1713            p.handle_arc_to(false, e)
1714        });
1715        assert!(r.is_ok());
1716        assert!(
1717            els.len() <= 4,
1718            "a single arc must never expand past 4 cubics, got {}",
1719            els.len()
1720        );
1721    }
1722
1723    // ===================================================== parse_svg_path_d (parser)
1724
1725    #[test]
1726    fn parse_path_empty_and_whitespace_only_is_empty_path_err() {
1727        for s in ["", "   ", "\t\n", "\r\n  \t"] {
1728            assert_eq!(
1729                parse_svg_path_d(s),
1730                Err(SvgPathParseError::EmptyPath),
1731                "{s:?} must be EmptyPath"
1732            );
1733        }
1734    }
1735
1736    #[test]
1737    fn parse_path_valid_minimal() {
1738        let mp = parse_svg_path_d("M10 20 L30 40").unwrap();
1739        let rings = mp.rings.as_ref();
1740        assert_eq!(rings.len(), 1);
1741        let items = rings[0].items.as_ref();
1742        assert_eq!(items.len(), 1);
1743        assert_eq!(items[0].get_start(), SvgPoint { x: 10.0, y: 20.0 });
1744        assert_eq!(items[0].get_end(), SvgPoint { x: 30.0, y: 40.0 });
1745    }
1746
1747    /// Relative commands accumulate from the current point.
1748    #[test]
1749    fn parse_path_relative_accumulates() {
1750        let mp = parse_svg_path_d("m10 20 l30 40").unwrap();
1751        let items_owner = &mp.rings.as_ref()[0];
1752        let items = items_owner.items.as_ref();
1753        assert_eq!(items[0].get_start(), SvgPoint { x: 10.0, y: 20.0 });
1754        assert_eq!(items[0].get_end(), SvgPoint { x: 40.0, y: 60.0 });
1755    }
1756
1757    /// A moveto with no drawing commands produces no geometry (but is not an error).
1758    #[test]
1759    fn parse_path_moveto_only_yields_no_rings() {
1760        let mp = parse_svg_path_d("M10 10").unwrap();
1761        assert_eq!(mp.rings.as_ref().len(), 0);
1762    }
1763
1764    /// Extra coordinate pairs after an M are implicit linetos (SVG spec).
1765    #[test]
1766    fn parse_path_implicit_lineto_after_moveto() {
1767        let mp = parse_svg_path_d("M0 0 10 0 20 0").unwrap();
1768        let items_owner = &mp.rings.as_ref()[0];
1769        let items = items_owner.items.as_ref();
1770        assert_eq!(items.len(), 2, "two implicit L commands");
1771        assert_eq!(items[0].get_end(), SvgPoint { x: 10.0, y: 0.0 });
1772        assert_eq!(items[1].get_end(), SvgPoint { x: 20.0, y: 0.0 });
1773    }
1774
1775    #[test]
1776    fn parse_path_garbage_is_err_never_panics() {
1777        for s in ["@#$", "hello", "?", "-", ".", ",", "0 0", "5", ";;;", "\u{0}"] {
1778            assert!(parse_svg_path_d(s).is_err(), "{s:?} must be rejected");
1779        }
1780    }
1781
1782    /// A leading non-command byte is reported as a real Unicode char at byte 0.
1783    #[test]
1784    fn parse_path_unicode_does_not_panic() {
1785        assert_eq!(
1786            parse_svg_path_d("\u{1F600}"),
1787            Err(SvgPathParseError::UnexpectedChar {
1788                pos: 0,
1789                ch: '\u{1F600}',
1790            })
1791        );
1792        assert_eq!(
1793            parse_svg_path_d("\u{0301}M0 0"),
1794            Err(SvgPathParseError::UnexpectedChar {
1795                pos: 0,
1796                ch: '\u{0301}',
1797            })
1798        );
1799        // A multibyte char *after* a valid command falls into the argument
1800        // parser and is rejected as a missing number, at the right byte offset.
1801        assert_eq!(
1802            parse_svg_path_d("M0 0L1 1ü"),
1803            Err(SvgPathParseError::ExpectedNumber { pos: 8 })
1804        );
1805    }
1806
1807    /// Trailing junk after a valid prefix is rejected deterministically.
1808    #[test]
1809    fn parse_path_trailing_junk_is_rejected() {
1810        assert!(parse_svg_path_d("M0 0 L1 1;garbage").is_err());
1811        assert!(parse_svg_path_d("M0 0 L").is_err(), "command with no args");
1812        assert!(parse_svg_path_d("M0 0 L1").is_err(), "half a coordinate pair");
1813        assert!(parse_svg_path_d("M0 0 X10 10").is_err(), "unknown command letter");
1814        // Surrounding whitespace is trimmed, not rejected.
1815        assert!(parse_svg_path_d("  \n M0 0 L1 1 \t ").is_ok());
1816    }
1817
1818    /// An unknown ASCII command letter reports its own offset.
1819    #[test]
1820    fn parse_path_unknown_command_reports_its_offset() {
1821        assert_eq!(
1822            parse_svg_path_d("M0 0 X10 10"),
1823            Err(SvgPathParseError::UnexpectedChar { pos: 5, ch: 'X' })
1824        );
1825    }
1826
1827    /// Closepath only emits a joining line when the gap exceeds CLOSEPATH_EPSILON.
1828    #[test]
1829    fn parse_path_closepath_epsilon_boundary() {
1830        // Well above the epsilon: a closing line is added.
1831        let mp = parse_svg_path_d("M0 0 L1 0 Z").unwrap();
1832        assert_eq!(mp.rings.as_ref()[0].items.as_ref().len(), 2);
1833
1834        // Exactly at the epsilon (dx*dx == eps*eps, and the test is strictly `>`):
1835        // no closing line.
1836        let mp = parse_svg_path_d("M0 0 L0.001 0 Z").unwrap();
1837        assert_eq!(mp.rings.as_ref()[0].items.as_ref().len(), 1);
1838
1839        // Below the epsilon: no closing line.
1840        let mp = parse_svg_path_d("M0 0 L0.0005 0 Z").unwrap();
1841        assert_eq!(mp.rings.as_ref()[0].items.as_ref().len(), 1);
1842
1843        // Degenerate closepath on an empty subpath yields no rings at all.
1844        assert_eq!(parse_svg_path_d("M0 0 Z").unwrap().rings.as_ref().len(), 0);
1845    }
1846
1847    /// Every M and every Z flushes a ring.
1848    #[test]
1849    fn parse_path_multiple_subpaths_produce_multiple_rings() {
1850        let mp = parse_svg_path_d("M0 0 L10 0 Z M20 20 L30 20 Z M40 40 L50 40").unwrap();
1851        assert_eq!(mp.rings.as_ref().len(), 3);
1852    }
1853
1854    /// Structural invariant: within a ring, each element's end is the next
1855    /// element's start -- for every command type, including arcs and closepath.
1856    #[test]
1857    fn parse_path_rings_are_contiguous_chains() {
1858        let d = "M0 0 L10 0 H20 V10 C25 15 30 20 35 20 S45 25 50 20 \
1859                 Q55 15 60 20 T70 20 A5 5 0 1 1 80 30 Z \
1860                 m100 100 l10 0 z";
1861        let mp = parse_svg_path_d(d).unwrap();
1862        assert!(mp.rings.as_ref().len() >= 2);
1863        for (i, ring) in mp.rings.as_ref().iter().enumerate() {
1864            assert_contiguous(ring.items.as_ref(), &format!("ring {i}"));
1865            assert!(!ring.items.as_ref().is_empty(), "ring {i} must not be empty");
1866        }
1867    }
1868
1869    /// A closed ring ends where it started.
1870    #[test]
1871    fn parse_path_closed_ring_returns_to_subpath_start() {
1872        let mp = parse_svg_path_d("M0 0 L10 0 L10 10 Z").unwrap();
1873        let ring = &mp.rings.as_ref()[0];
1874        let items = ring.items.as_ref();
1875        assert_eq!(items.last().unwrap().get_end(), SvgPoint { x: 0.0, y: 0.0 });
1876        assert_eq!(items.first().unwrap().get_start(), SvgPoint { x: 0.0, y: 0.0 });
1877    }
1878
1879    /// Boundary numerics survive the full parse: coordinates saturate to inf
1880    /// rather than panicking or wrapping.
1881    #[test]
1882    fn parse_path_boundary_numbers_saturate() {
1883        let mp = parse_svg_path_d("M1e999 -1e999 L1e-999 0").unwrap();
1884        let items_owner = &mp.rings.as_ref()[0];
1885        let start = items_owner.items.as_ref()[0].get_start();
1886        assert!(start.x.is_infinite() && start.x.is_sign_positive());
1887        assert!(start.y.is_infinite() && start.y.is_sign_negative());
1888
1889        // f32::MAX-ish coordinates with a *relative* lineto overflow to +inf.
1890        let mp = parse_svg_path_d("M3.4e38 0 l3.4e38 0").unwrap();
1891        let items_owner = &mp.rings.as_ref()[0];
1892        assert!(items_owner.items.as_ref()[0].get_end().x.is_infinite());
1893
1894        // "NaN" / "inf" are not valid SVG numbers -- they must be rejected,
1895        // so a NaN coordinate can never enter the geometry via the parser.
1896        assert!(parse_svg_path_d("M NaN 0").is_err());
1897        assert!(parse_svg_path_d("M inf 0").is_err());
1898    }
1899
1900    /// 5000 implicit repeats: the parser is iterative, so this must neither
1901    /// hang nor blow the stack.
1902    #[test]
1903    fn parse_path_extremely_long_input_terminates() {
1904        let mut d = String::from("M0 0");
1905        for _ in 0..5_000 {
1906            d.push_str(" L1 1");
1907        }
1908        let mp = parse_svg_path_d(&d).unwrap();
1909        let items_owner = &mp.rings.as_ref()[0];
1910        assert_eq!(items_owner.items.as_ref().len(), 5_000);
1911
1912        // 5000 subpaths -> 5000 rings, still iterative.
1913        let mut d = String::new();
1914        for _ in 0..5_000 {
1915            d.push_str("M0 0 L1 1 Z ");
1916        }
1917        assert_eq!(parse_svg_path_d(&d).unwrap().rings.as_ref().len(), 5_000);
1918    }
1919
1920    /// A long run of adversarial fragments: every one must *return* (Ok or Err)
1921    /// and never spin. The `Z`-followed-by-a-digit case used to loop forever.
1922    #[test]
1923    fn parse_path_adversarial_fragments_all_terminate() {
1924        let fragments = [
1925            "Z", "z", "ZZZ", "M0 0ZZ", "M0 0Z0", "M0 0zZ5", "M0 0Z Z Z",
1926            "M", "M0", "M0 0 C", "M0 0 A", "M0 0 A1", "M0 0 A1 1 0 0 0 0",
1927            "M0 0 S", "M0 0 T", "M0 0 H", "M0 0 V", "M0 0 Q1",
1928            "M0 0 L1 1 1", "M0 0 L1 1 1 1 1", "M0 0 A0 0 0 0 0 0 0",
1929            "M0 0 l-.5-.5-.5-.5", "M0 0 t1 1 2 2", "M0 0 s1 1 2 2 3 3 4 4",
1930            "M0,0,1,1", "M0 0e", "M0 0 1e1e1", "M.5.5.5.5",
1931            "M0 0 A1 1 0 11 10 10", "M0 0 A1 1 0 1 1 10 10 A1 1 0 0 0 0 0",
1932            "M0 0 h1e999 v1e999 h-1e999", "M-0-0-0-0",
1933        ];
1934        for f in fragments {
1935            // The assertion is termination itself; the result is merely pinned
1936            // as "did not panic".
1937            let r = parse_svg_path_d(f);
1938            assert!(r.is_ok() || r.is_err(), "{f:?} must return, not panic");
1939        }
1940    }
1941
1942    /// All 14 commands round-trip through the tokenizer into geometry of the
1943    /// expected kind.
1944    #[test]
1945    fn parse_path_every_command_produces_its_element_kind() {
1946        let cases: [(&str, usize); 10] = [
1947            ("M0 0 L1 1", 1),
1948            ("M0 0 l1 1", 1),
1949            ("M0 0 H1", 1),
1950            ("M0 0 V1", 1),
1951            ("M0 0 C1 1 2 2 3 3", 1),
1952            ("M0 0 S1 1 2 2", 1),
1953            ("M0 0 Q1 1 2 2", 1),
1954            ("M0 0 T1 1", 1),
1955            ("M0 0 L1 0 Z", 2), // the Z adds the closing line
1956            ("M0 0 A5 5 0 0 1 10 0", 2), // a half-turn arc splits into 2 cubics
1957        ];
1958        for (d, expected) in cases {
1959            let mp = parse_svg_path_d(d).unwrap_or_else(|e| panic!("{d:?} failed: {e:?}"));
1960            let rings = mp.rings.as_ref();
1961            assert_eq!(rings.len(), 1, "{d:?}");
1962            assert_eq!(rings[0].items.as_ref().len(), expected, "{d:?}");
1963        }
1964    }
1965
1966    // ======================================================= arc_to_cubics (numeric)
1967
1968    #[test]
1969    fn arc_to_cubics_coincident_endpoints_emit_nothing() {
1970        let mut out = Vec::new();
1971        arc_to_cubics(ORIGIN, ORIGIN, 5.0, 5.0, 0.0, true, true, &mut out);
1972        assert!(out.is_empty(), "a zero-length arc is dropped per SVG F.6.2");
1973
1974        // Within POINT_EPSILON also counts as coincident.
1975        let near = SvgPoint { x: 1e-9, y: 1e-9 };
1976        arc_to_cubics(ORIGIN, near, 5.0, 5.0, 0.0, false, false, &mut out);
1977        assert!(out.is_empty());
1978    }
1979
1980    #[test]
1981    fn arc_to_cubics_zero_radius_emits_a_line() {
1982        let end = SvgPoint { x: 10.0, y: 10.0 };
1983        for (rx, ry) in [(0.0, 5.0), (5.0, 0.0), (0.0, 0.0)] {
1984            let mut out = Vec::new();
1985            arc_to_cubics(ORIGIN, end, rx, ry, 0.0, false, true, &mut out);
1986            assert_eq!(out.len(), 1, "rx={rx} ry={ry}");
1987            assert!(matches!(out[0], SvgPathElement::Line(_)));
1988            assert_eq!(out[0].get_start(), ORIGIN);
1989            assert_eq!(out[0].get_end(), end);
1990        }
1991    }
1992
1993    /// For every flag combination, the arc is 1..=4 cubics that start exactly at
1994    /// `start` and end exactly at `end` (the endpoint is snapped, not computed).
1995    #[test]
1996    fn arc_to_cubics_endpoints_are_exact_for_all_flag_combos() {
1997        let start = SvgPoint { x: 0.0, y: 0.0 };
1998        let end = SvgPoint { x: 10.0, y: 10.0 };
1999        for large_arc in [false, true] {
2000            for sweep in [false, true] {
2001                let mut out = Vec::new();
2002                arc_to_cubics(start, end, 8.0, 6.0, 30.0, large_arc, sweep, &mut out);
2003                assert!(
2004                    (1..=4).contains(&out.len()),
2005                    "large_arc={large_arc} sweep={sweep}: got {} cubics",
2006                    out.len()
2007                );
2008                assert_eq!(out[0].get_start(), start);
2009                assert_eq!(out.last().unwrap().get_end(), end);
2010                assert_contiguous(&out, "arc");
2011                for p in out.iter().flat_map(|e| [e.get_start(), e.get_end()]) {
2012                    assert!(p.x.is_finite() && p.y.is_finite(), "arc produced {p:?}");
2013                }
2014            }
2015        }
2016    }
2017
2018    /// Radii that are too small to span the endpoints are scaled up (F.6.6 step 3),
2019    /// so the arc still lands exactly on the endpoint instead of NaN-ing out.
2020    #[test]
2021    fn arc_to_cubics_undersized_radii_are_scaled_up() {
2022        let start = ORIGIN;
2023        let end = SvgPoint { x: 100.0, y: 0.0 };
2024        let mut out = Vec::new();
2025        arc_to_cubics(start, end, 1.0, 1.0, 0.0, false, true, &mut out);
2026        assert!(!out.is_empty());
2027        assert_eq!(out.last().unwrap().get_end(), end);
2028        for p in all_points(&SvgPath {
2029            items: SvgPathElementVec::from_vec(out),
2030        }) {
2031            assert!(p.x.is_finite() && p.y.is_finite(), "scaled arc produced {p:?}");
2032        }
2033    }
2034
2035    /// NaN / infinite radii must not spin the segment loop: `n_segs` comes from a
2036    /// NaN -> usize cast, which saturates to 0 and is then clamped to 1.
2037    #[test]
2038    fn arc_to_cubics_nan_and_inf_inputs_are_bounded() {
2039        let end = SvgPoint { x: 10.0, y: 10.0 };
2040        let bad = [
2041            (f32::NAN, 5.0, 0.0),
2042            (5.0, f32::NAN, 0.0),
2043            (f32::INFINITY, f32::INFINITY, 0.0),
2044            (5.0, 5.0, f32::NAN),
2045            (5.0, 5.0, f32::INFINITY),
2046            (f32::MAX, f32::MAX, 360.0),
2047        ];
2048        for (rx, ry, rot) in bad {
2049            let mut out = Vec::new();
2050            arc_to_cubics(ORIGIN, end, rx, ry, rot, true, false, &mut out);
2051            assert!(
2052                out.len() <= 4,
2053                "rx={rx} ry={ry} rot={rot}: {} elements (segment loop ran away)",
2054                out.len()
2055            );
2056        }
2057    }
2058
2059    /// Extreme but finite endpoints do not panic and stay bounded.
2060    #[test]
2061    fn arc_to_cubics_extreme_endpoints_do_not_panic() {
2062        let mut out = Vec::new();
2063        arc_to_cubics(
2064            SvgPoint { x: f32::MIN, y: f32::MIN },
2065            SvgPoint { x: f32::MAX, y: f32::MAX },
2066            f32::MAX,
2067            f32::MAX,
2068            0.0,
2069            true,
2070            true,
2071            &mut out,
2072        );
2073        assert!(out.len() <= 4);
2074    }
2075
2076    // ======================================================= angle_between (numeric)
2077
2078    #[test]
2079    fn angle_between_known_angles() {
2080        assert!(approx(angle_between(1.0, 0.0, 1.0, 0.0), 0.0));
2081        assert!(approx(
2082            angle_between(1.0, 0.0, 0.0, 1.0),
2083            core::f32::consts::FRAC_PI_2
2084        ));
2085        assert!(approx(
2086            angle_between(1.0, 0.0, 0.0, -1.0),
2087            -core::f32::consts::FRAC_PI_2
2088        ));
2089        assert!(approx(
2090            angle_between(1.0, 0.0, -1.0, 0.0),
2091            core::f32::consts::PI
2092        ));
2093        // Magnitude is irrelevant -- only direction matters.
2094        assert!(approx(
2095            angle_between(100.0, 0.0, 0.0, 0.001),
2096            core::f32::consts::FRAC_PI_2
2097        ));
2098    }
2099
2100    /// A zero-length (or underflowing) vector short-circuits to 0.0.
2101    #[test]
2102    fn angle_between_zero_length_vectors_return_zero() {
2103        assert_eq!(angle_between(0.0, 0.0, 1.0, 0.0), 0.0);
2104        assert_eq!(angle_between(1.0, 0.0, 0.0, 0.0), 0.0);
2105        assert_eq!(angle_between(0.0, 0.0, 0.0, 0.0), 0.0);
2106        // Denormal-scale vectors: the squared length underflows to 0.
2107        assert_eq!(angle_between(1e-30, 1e-30, 1e-30, 1e-30), 0.0);
2108    }
2109
2110    /// Result is always within [-PI, PI] for finite inputs (the acos argument is
2111    /// clamped, so rounding can never push it out of the domain).
2112    #[test]
2113    fn angle_between_is_always_within_pi_for_finite_inputs() {
2114        let vals = [-1e30_f32, -3.0, -1.0, -0.0, 0.0, 1.0, 3.0, 1e30];
2115        for ux in vals {
2116            for uy in vals {
2117                for vx in vals {
2118                    for vy in vals {
2119                        let a = angle_between(ux, uy, vx, vy);
2120                        assert!(
2121                            a.is_nan() || a.abs() <= core::f32::consts::PI + 1e-5,
2122                            "angle_between({ux},{uy},{vx},{vy}) = {a} is out of range"
2123                        );
2124                    }
2125                }
2126            }
2127        }
2128    }
2129
2130    /// Antiparallel/parallel unit vectors do not fall out of acos's domain even
2131    /// when the dot product rounds slightly past +/-1.
2132    #[test]
2133    fn angle_between_clamps_the_acos_domain() {
2134        let a = angle_between(0.1, 0.2, 0.1, 0.2);
2135        assert!(!a.is_nan(), "parallel vectors must not produce NaN, got {a}");
2136        assert!(approx(a, 0.0));
2137        let a = angle_between(0.1, 0.2, -0.1, -0.2);
2138        assert!(!a.is_nan());
2139        assert!(approx(a.abs(), core::f32::consts::PI));
2140    }
2141
2142    /// NaN / inf inputs produce NaN, not a panic.
2143    #[test]
2144    fn angle_between_nan_and_inf_do_not_panic() {
2145        assert!(angle_between(f32::NAN, 0.0, 1.0, 0.0).is_nan());
2146        assert!(angle_between(1.0, 0.0, f32::NAN, f32::NAN).is_nan());
2147        assert!(angle_between(f32::INFINITY, 0.0, f32::INFINITY, 0.0).is_nan());
2148        assert!(angle_between(f32::INFINITY, 0.0, 1.0, 0.0).is_nan());
2149    }
2150
2151    // ================================================ arc_segment_to_cubic (numeric)
2152
2153    #[test]
2154    fn arc_segment_to_cubic_quarter_circle() {
2155        // Unit circle, no rotation, 0 -> PI/2: the endpoint must land on (0, 1).
2156        let (c1, c2, ep) = arc_segment_to_cubic(
2157            0.0,
2158            0.0,
2159            1.0,
2160            1.0,
2161            1.0,
2162            0.0,
2163            0.0,
2164            core::f32::consts::FRAC_PI_2,
2165        );
2166        assert!(approx(ep.x, 0.0) && approx(ep.y, 1.0), "ep = {ep:?}");
2167        // Control points bulge outward by kappa.
2168        assert!(approx(c1.x, 1.0) && approx(c1.y, KAPPA), "c1 = {c1:?}");
2169        assert!(approx(c2.x, KAPPA) && approx(c2.y, 1.0), "c2 = {c2:?}");
2170    }
2171
2172    /// A zero-width segment collapses every point onto the start of the arc.
2173    #[test]
2174    fn arc_segment_to_cubic_zero_sweep_collapses() {
2175        let (c1, c2, ep) = arc_segment_to_cubic(5.0, 5.0, 2.0, 2.0, 1.0, 0.0, 0.7, 0.7);
2176        assert!(approx(c1.x, c2.x) && approx(c1.y, c2.y));
2177        assert!(approx(c2.x, ep.x) && approx(c2.y, ep.y));
2178        // ...and that point is on the circle around (5,5).
2179        assert!(approx((ep.x - 5.0).hypot(ep.y - 5.0), 2.0));
2180    }
2181
2182    /// Zero radii put all three points at the center.
2183    #[test]
2184    fn arc_segment_to_cubic_zero_radius_is_the_center() {
2185        let (c1, c2, ep) = arc_segment_to_cubic(3.0, 4.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0);
2186        for p in [c1, c2, ep] {
2187            assert_eq!(p, SvgPoint { x: 3.0, y: 4.0 });
2188        }
2189    }
2190
2191    /// Rotation is applied via the caller-supplied cos/sin pair.
2192    #[test]
2193    fn arc_segment_to_cubic_applies_rotation() {
2194        // 90-degree rotation (cos=0, sin=1) maps the theta=0 point (rx, 0) to (0, rx).
2195        let (_, _, ep) = arc_segment_to_cubic(0.0, 0.0, 2.0, 1.0, 0.0, 1.0, 0.0, 0.0);
2196        assert!(approx(ep.x, 0.0) && approx(ep.y, 2.0), "ep = {ep:?}");
2197    }
2198
2199    #[test]
2200    fn arc_segment_to_cubic_nan_and_inf_do_not_panic() {
2201        let bad = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN];
2202        for v in bad {
2203            let (c1, c2, ep) = arc_segment_to_cubic(v, v, v, v, v, v, v, v);
2204            // The only contract is "returns a defined value without panicking".
2205            for p in [c1, c2, ep] {
2206                assert!(p.x.is_nan() || p.x.is_finite() || p.x.is_infinite());
2207                assert!(p.y.is_nan() || p.y.is_finite() || p.y.is_infinite());
2208            }
2209        }
2210        // A full-circle sweep drives tan(PI/2) to a huge value; still no panic.
2211        let (c1, _, _) = arc_segment_to_cubic(
2212            0.0,
2213            0.0,
2214            1.0,
2215            1.0,
2216            1.0,
2217            0.0,
2218            0.0,
2219            core::f32::consts::TAU,
2220        );
2221        assert!(!c1.x.is_nan() || c1.x.is_nan(), "must not panic");
2222    }
2223
2224    // =================================================== svg_circle_to_paths (numeric)
2225
2226    #[test]
2227    fn circle_has_four_cubics_and_closes_on_itself() {
2228        let p = svg_circle_to_paths(10.0, 20.0, 5.0);
2229        let items = p.items.as_ref();
2230        assert_eq!(items.len(), 4);
2231        assert!(items.iter().all(|e| matches!(e, SvgPathElement::CubicCurve(_))));
2232        assert_contiguous(items, "circle");
2233        assert_eq!(
2234            items.last().unwrap().get_end(),
2235            items.first().unwrap().get_start(),
2236            "the circle must close exactly"
2237        );
2238        // The four anchors are the cardinal points.
2239        assert_eq!(items[0].get_start(), SvgPoint { x: 10.0, y: 15.0 });
2240        assert_eq!(items[0].get_end(), SvgPoint { x: 15.0, y: 20.0 });
2241        assert_eq!(items[1].get_end(), SvgPoint { x: 10.0, y: 25.0 });
2242        assert_eq!(items[2].get_end(), SvgPoint { x: 5.0, y: 20.0 });
2243    }
2244
2245    /// r = 0 degenerates to four zero-length curves at the center, not a panic.
2246    #[test]
2247    fn circle_zero_radius_collapses_to_the_center() {
2248        let p = svg_circle_to_paths(3.0, 4.0, 0.0);
2249        let items = p.items.as_ref();
2250        assert_eq!(items.len(), 4);
2251        for pt in all_points(&p) {
2252            assert_eq!(pt, SvgPoint { x: 3.0, y: 4.0 });
2253        }
2254    }
2255
2256    /// A negative radius mirrors the circle (it is not rejected or abs()'d);
2257    /// it still yields a closed 4-curve path.
2258    #[test]
2259    fn circle_negative_radius_is_mirrored_not_rejected() {
2260        let p = svg_circle_to_paths(0.0, 0.0, -5.0);
2261        let items = p.items.as_ref();
2262        assert_eq!(items.len(), 4);
2263        assert_contiguous(items, "negative-r circle");
2264        assert_eq!(items[0].get_start(), SvgPoint { x: 0.0, y: 5.0 });
2265        for pt in all_points(&p) {
2266            assert!(pt.x.is_finite() && pt.y.is_finite());
2267        }
2268    }
2269
2270    #[test]
2271    fn circle_nan_inf_and_max_do_not_panic() {
2272        for (cx, cy, r) in [
2273            (f32::NAN, 0.0, 1.0),
2274            (0.0, 0.0, f32::NAN),
2275            (0.0, 0.0, f32::INFINITY),
2276            (f32::MAX, f32::MAX, f32::MAX),
2277            (f32::MIN, f32::MIN, f32::MIN),
2278        ] {
2279            let p = svg_circle_to_paths(cx, cy, r);
2280            assert_eq!(p.items.as_ref().len(), 4, "cx={cx} cy={cy} r={r}");
2281        }
2282        // f32::MAX radius overflows the control points to infinity rather than
2283        // wrapping.
2284        let p = svg_circle_to_paths(f32::MAX, 0.0, f32::MAX);
2285        assert!(all_points(&p).iter().any(|pt| pt.x.is_infinite()));
2286    }
2287
2288    // ==================================================== svg_rect_to_path (numeric)
2289
2290    #[test]
2291    fn rect_sharp_corners_are_four_lines() {
2292        let p = svg_rect_to_path(1.0, 2.0, 10.0, 20.0, 0.0, 0.0);
2293        let items = p.items.as_ref();
2294        assert_eq!(items.len(), 4);
2295        assert!(items.iter().all(|e| matches!(e, SvgPathElement::Line(_))));
2296        assert_contiguous(items, "sharp rect");
2297        assert_eq!(items[0].get_start(), SvgPoint { x: 1.0, y: 2.0 });
2298        assert_eq!(items[1].get_start(), SvgPoint { x: 11.0, y: 2.0 });
2299        assert_eq!(items[2].get_start(), SvgPoint { x: 11.0, y: 22.0 });
2300        assert_eq!(items[3].get_start(), SvgPoint { x: 1.0, y: 22.0 });
2301        assert_eq!(
2302            items.last().unwrap().get_end(),
2303            items.first().unwrap().get_start(),
2304            "the rect must close exactly"
2305        );
2306    }
2307
2308    #[test]
2309    fn rect_rounded_is_eight_alternating_segments_and_closes() {
2310        let p = svg_rect_to_path(0.0, 0.0, 100.0, 50.0, 10.0, 5.0);
2311        let items = p.items.as_ref();
2312        assert_eq!(items.len(), 8);
2313        for (i, e) in items.iter().enumerate() {
2314            if i % 2 == 0 {
2315                assert!(matches!(e, SvgPathElement::Line(_)), "item {i} should be an edge");
2316            } else {
2317                assert!(
2318                    matches!(e, SvgPathElement::CubicCurve(_)),
2319                    "item {i} should be a corner"
2320                );
2321            }
2322        }
2323        assert_contiguous(items, "rounded rect");
2324        assert_eq!(
2325            items.last().unwrap().get_end(),
2326            items.first().unwrap().get_start(),
2327            "the rounded rect must close exactly"
2328        );
2329    }
2330
2331    /// Radii larger than half the rect are clamped to half (SVG spec), so an
2332    /// over-large rx cannot invert the geometry.
2333    #[test]
2334    fn rect_oversized_radii_are_clamped_to_half() {
2335        let p = svg_rect_to_path(0.0, 0.0, 10.0, 10.0, 1000.0, 1000.0);
2336        let items = p.items.as_ref();
2337        assert_eq!(items.len(), 8);
2338        // rx clamps to 5 => the top edge runs from x+5 to x+w-5, i.e. zero length.
2339        match items[0] {
2340            SvgPathElement::Line(l) => {
2341                assert_eq!(l.start, SvgPoint { x: 5.0, y: 0.0 });
2342                assert_eq!(l.end, SvgPoint { x: 5.0, y: 0.0 });
2343            }
2344            other => panic!("expected Line, got {other:?}"),
2345        }
2346        assert_contiguous(items, "clamped rect");
2347        for pt in all_points(&p) {
2348            assert!(pt.x.is_finite() && pt.y.is_finite());
2349            assert!((0.0..=10.0).contains(&pt.x), "x {} escaped the rect", pt.x);
2350            assert!((0.0..=10.0).contains(&pt.y), "y {} escaped the rect", pt.y);
2351        }
2352    }
2353
2354    /// Only one of rx/ry being zero still takes the rounded path.
2355    #[test]
2356    fn rect_single_zero_radius_still_rounds() {
2357        let p = svg_rect_to_path(0.0, 0.0, 100.0, 100.0, 0.0, 10.0);
2358        assert_eq!(p.items.as_ref().len(), 8);
2359        let p = svg_rect_to_path(0.0, 0.0, 100.0, 100.0, 10.0, 0.0);
2360        assert_eq!(p.items.as_ref().len(), 8);
2361    }
2362
2363    /// A negative width drives `rx.min(w / 2.0)` negative, which falls below the
2364    /// epsilon and takes the sharp-corner branch. Deterministic, no panic.
2365    #[test]
2366    fn rect_negative_extent_takes_the_sharp_branch() {
2367        let p = svg_rect_to_path(0.0, 0.0, -10.0, -10.0, 4.0, 4.0);
2368        let items = p.items.as_ref();
2369        assert_eq!(items.len(), 4);
2370        assert!(items.iter().all(|e| matches!(e, SvgPathElement::Line(_))));
2371        assert_contiguous(items, "negative rect");
2372        assert_eq!(items[1].get_start(), SvgPoint { x: -10.0, y: 0.0 });
2373    }
2374
2375    /// `f32::min` returns the non-NaN operand, so a NaN radius silently becomes
2376    /// half the extent -- and every coordinate stays finite.
2377    #[test]
2378    fn rect_nan_radius_falls_back_to_half_extent() {
2379        let p = svg_rect_to_path(0.0, 0.0, 100.0, 100.0, f32::NAN, f32::NAN);
2380        let items = p.items.as_ref();
2381        assert_eq!(items.len(), 8, "NaN radii clamp to w/2, h/2 -> rounded path");
2382        for pt in all_points(&p) {
2383            assert!(pt.x.is_finite() && pt.y.is_finite(), "NaN leaked into {pt:?}");
2384        }
2385        // rx == 50 => the top edge collapses (x+50 .. x+100-50).
2386        match items[0] {
2387            SvgPathElement::Line(l) => assert_eq!(l.start, l.end),
2388            other => panic!("expected Line, got {other:?}"),
2389        }
2390    }
2391
2392    /// A NaN extent cannot be repaired -- but it must still return a well-formed
2393    /// path rather than panicking.
2394    #[test]
2395    fn rect_nan_extent_is_deterministic() {
2396        let p = svg_rect_to_path(0.0, 0.0, f32::NAN, f32::NAN, 0.0, 0.0);
2397        // rx = 0.min(NaN) = 0 -> sharp branch.
2398        assert_eq!(p.items.as_ref().len(), 4);
2399        let p = svg_rect_to_path(f32::NAN, f32::NAN, 10.0, 10.0, 0.0, 0.0);
2400        assert_eq!(p.items.as_ref().len(), 4);
2401    }
2402
2403    #[test]
2404    fn rect_inf_and_max_extents_do_not_panic() {
2405        for (x, y, w, h, rx, ry) in [
2406            (0.0, 0.0, f32::INFINITY, f32::INFINITY, 0.0, 0.0),
2407            (0.0, 0.0, f32::MAX, f32::MAX, 0.0, 0.0),
2408            (f32::MIN, f32::MIN, f32::MAX, f32::MAX, f32::MAX, f32::MAX),
2409            (0.0, 0.0, f32::INFINITY, f32::INFINITY, f32::INFINITY, f32::INFINITY),
2410        ] {
2411            let p = svg_rect_to_path(x, y, w, h, rx, ry);
2412            let n = p.items.as_ref().len();
2413            assert!(n == 4 || n == 8, "w={w} h={h} rx={rx} ry={ry}: got {n} items");
2414        }
2415    }
2416}