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

            
7
use alloc::{string::String, vec::Vec};
8
use azul_css::props::basic::{SvgCubicCurve, SvgPoint, SvgQuadraticCurve};
9

            
10
use crate::svg::{SvgLine, SvgMultiPolygon, SvgPath, SvgPathElement, SvgPathElementVec, SvgPathVec};
11

            
12
/// Bezier approximation constant for quarter-circle arcs.
13
const KAPPA: f32 = 0.552_284_8;
14

            
15
/// Tolerance for treating two points as coincident (used in closepath and arc degeneracy checks).
16
const POINT_EPSILON: f32 = 1e-6;
17

            
18
/// Tolerance for snapping a closepath line (slightly larger to avoid micro-segments).
19
const CLOSEPATH_EPSILON: f32 = 0.001;
20

            
21
/// Tolerance for treating a vector length as zero in angle computation.
22
const ZERO_LENGTH_EPSILON: f32 = 1e-10;
23

            
24
/// Small offset added to PI/2 when splitting arcs to avoid exact-boundary floating-point issues.
25
const 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`).
34
79
fn char_at(input: &[u8], pos: usize) -> char {
35
79
    input
36
79
        .get(pos..)
37
79
        .and_then(|rest| core::str::from_utf8(rest).ok())
38
79
        .and_then(|s| s.chars().next())
39
79
        .unwrap_or(char::REPLACEMENT_CHARACTER)
40
79
}
41

            
42
/// Errors that can occur during SVG path parsing.
43
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44
pub 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.
56
impl core::fmt::Display for SvgPathParseError {
57
10
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
58
10
        match self {
59
1
            Self::EmptyPath => write!(f, "empty path"),
60
5
            Self::UnexpectedChar { pos, ch } => {
61
5
                write!(f, "unexpected char '{ch}' at byte {pos}")
62
            }
63
2
            Self::ExpectedNumber { pos } => write!(f, "expected number at byte {pos}"),
64
2
            Self::InvalidArcFlag { pos } => write!(f, "invalid arc flag at byte {pos}"),
65
        }
66
10
    }
67
}
68

            
69
/// Internal parser state.
70
struct 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

            
79
impl<'a> PathParser<'a> {
80
3309
    const fn new(input: &'a [u8]) -> Self {
81
3309
        Self {
82
3309
            input,
83
3309
            pos: 0,
84
3309
            current: SvgPoint { x: 0.0, y: 0.0 },
85
3309
            subpath_start: SvgPoint { x: 0.0, y: 0.0 },
86
3309
            last_control: None,
87
3309
            last_command: 0,
88
3309
        }
89
3309
    }
90

            
91
1759660
    const fn at_end(&self) -> bool {
92
1759660
        self.pos >= self.input.len()
93
1759660
    }
94

            
95
1169824
    fn peek(&self) -> Option<u8> {
96
1169824
        self.input.get(self.pos).copied()
97
1169824
    }
98

            
99
2423900
    fn skip_whitespace_and_commas(&mut self) {
100
4371946
        while let Some(&b) = self.input.get(self.pos) {
101
4371563
            if b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' || b == b',' {
102
1948046
                self.pos += 1;
103
1948046
            } else {
104
2423517
                break;
105
            }
106
        }
107
2423900
    }
108

            
109
3134
    fn skip_whitespace(&mut self) {
110
3136
        while let Some(&b) = self.input.get(self.pos) {
111
3135
            if b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' {
112
2
                self.pos += 1;
113
2
            } else {
114
3133
                break;
115
            }
116
        }
117
3134
    }
118

            
119
    /// Returns true if the current position looks like the start of a number.
120
13029
    fn has_number(&self) -> bool {
121
13029
        match self.input.get(self.pos) {
122
4185
            Some(b'+' | b'-' | b'.') => true,
123
8841
            Some(b) if b.is_ascii_digit() => true,
124
13
            _ => false,
125
        }
126
13029
    }
127

            
128
1253702
    fn parse_number(&mut self) -> Result<f32, SvgPathParseError> {
129
1253702
        self.skip_whitespace_and_commas();
130
1253702
        let start = self.pos;
131

            
132
        // Optional sign
133
1253702
        if let Some(&b) = self.input.get(self.pos) {
134
1253639
            if b == b'+' || b == b'-' {
135
54490
                self.pos += 1;
136
1199149
            }
137
63
        }
138

            
139
1253702
        let mut has_digits = false;
140

            
141
        // Integer part
142
6766255
        while let Some(&b) = self.input.get(self.pos) {
143
6765890
            if b.is_ascii_digit() {
144
5512553
                self.pos += 1;
145
5512553
                has_digits = true;
146
5512553
            } else {
147
1253337
                break;
148
            }
149
        }
150

            
151
        // Decimal part
152
1253702
        if self.input.get(self.pos) == Some(&b'.') {
153
97783
            self.pos += 1;
154
293576
            while let Some(&b) = self.input.get(self.pos) {
155
293487
                if b.is_ascii_digit() {
156
195793
                    self.pos += 1;
157
195793
                    has_digits = true;
158
195793
                } else {
159
97694
                    break;
160
                }
161
            }
162
1155919
        }
163

            
164
1253702
        if !has_digits {
165
124
            return Err(SvgPathParseError::ExpectedNumber { pos: start });
166
1253578
        }
167

            
168
        // Exponent
169
1253578
        if let Some(&b) = self.input.get(self.pos) {
170
1253194
            if b == b'e' || b == b'E' {
171
31
                self.pos += 1;
172
31
                if let Some(&b) = self.input.get(self.pos) {
173
26
                    if b == b'+' || b == b'-' {
174
7
                        self.pos += 1;
175
19
                    }
176
5
                }
177
88
                while let Some(&b) = self.input.get(self.pos) {
178
68
                    if b.is_ascii_digit() {
179
57
                        self.pos += 1;
180
57
                    } else {
181
11
                        break;
182
                    }
183
                }
184
1253163
            }
185
384
        }
186

            
187
1253578
        let s = core::str::from_utf8(&self.input[start..self.pos])
188
1253578
            .map_err(|_| SvgPathParseError::ExpectedNumber { pos: start })?;
189
1253578
        s.parse::<f32>()
190
1253578
            .map_err(|_| SvgPathParseError::ExpectedNumber { pos: start })
191
1253702
    }
192

            
193
64
    fn parse_flag(&mut self) -> Result<bool, SvgPathParseError> {
194
64
        self.skip_whitespace_and_commas();
195
64
        match self.input.get(self.pos) {
196
            Some(b'0') => {
197
24
                self.pos += 1;
198
24
                Ok(false)
199
            }
200
            Some(b'1') => {
201
25
                self.pos += 1;
202
25
                Ok(true)
203
            }
204
15
            _ => Err(SvgPathParseError::InvalidArcFlag { pos: self.pos }),
205
        }
206
64
    }
207

            
208
626730
    fn parse_coordinate_pair(&mut self) -> Result<(f32, f32), SvgPathParseError> {
209
626730
        let x = self.parse_number()?;
210
626679
        let y = self.parse_number()?;
211
626651
        Ok((x, y))
212
626730
    }
213

            
214
626652
    fn make_absolute(&self, x: f32, y: f32, relative: bool) -> SvgPoint {
215
626652
        if relative {
216
60004
            SvgPoint {
217
60004
                x: self.current.x + x,
218
60004
                y: self.current.y + y,
219
60004
            }
220
        } else {
221
566648
            SvgPoint { x, y }
222
        }
223
626652
    }
224

            
225
562698
    fn handle_line_to(&mut self, relative: bool, elements: &mut Vec<SvgPathElement>) -> Result<(), SvgPathParseError> {
226
562698
        let (x, y) = self.parse_coordinate_pair()?;
227
562674
        let end = self.make_absolute(x, y, relative);
228
562674
        elements.push(SvgPathElement::Line(SvgLine { start: self.current, end }));
229
562674
        self.current = end;
230
562674
        self.last_control = None;
231
562674
        Ok(())
232
562698
    }
233

            
234
35
    fn handle_horizontal_to(&mut self, relative: bool, elements: &mut Vec<SvgPathElement>) -> Result<(), SvgPathParseError> {
235
35
        let x = self.parse_number()?;
236
28
        let abs_x = if relative { self.current.x + x } else { x };
237
28
        let end = SvgPoint { x: abs_x, y: self.current.y };
238
28
        elements.push(SvgPathElement::Line(SvgLine { start: self.current, end }));
239
28
        self.current = end;
240
28
        self.last_control = None;
241
28
        Ok(())
242
35
    }
243

            
244
109
    fn handle_vertical_to(&mut self, relative: bool, elements: &mut Vec<SvgPathElement>) -> Result<(), SvgPathParseError> {
245
109
        let y = self.parse_number()?;
246
103
        let abs_y = if relative { self.current.y + y } else { y };
247
103
        let end = SvgPoint { x: self.current.x, y: abs_y };
248
103
        elements.push(SvgPathElement::Line(SvgLine { start: self.current, end }));
249
103
        self.current = end;
250
103
        self.last_control = None;
251
103
        Ok(())
252
109
    }
253

            
254
    #[allow(clippy::similar_names)] // domain-standard coordinate/control-point names
255
14310
    fn handle_cubic_to(&mut self, relative: bool, elements: &mut Vec<SvgPathElement>) -> Result<(), SvgPathParseError> {
256
14310
        let (c1x, c1y) = self.parse_coordinate_pair()?;
257
14303
        let (c2x, c2y) = self.parse_coordinate_pair()?;
258
14303
        let (ex, ey) = self.parse_coordinate_pair()?;
259
14303
        let ctrl_1 = self.make_absolute(c1x, c1y, relative);
260
14303
        let ctrl_2 = self.make_absolute(c2x, c2y, relative);
261
14303
        let end = self.make_absolute(ex, ey, relative);
262
14303
        elements.push(SvgPathElement::CubicCurve(SvgCubicCurve {
263
14303
            start: self.current, ctrl_1, ctrl_2, end,
264
14303
        }));
265
14303
        self.last_control = Some(ctrl_2);
266
14303
        self.current = end;
267
14303
        Ok(())
268
14310
    }
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
6454
    fn handle_smooth_cubic_to(&mut self, relative: bool, elements: &mut Vec<SvgPathElement>) -> Result<(), SvgPathParseError> {
273
6454
        let ctrl_1 = match self.last_control {
274
3854
            Some(lc) if matches!(self.last_command.to_ascii_uppercase(), b'C' | b'S') => {
275
3853
                SvgPoint {
276
3853
                    x: 2.0 * self.current.x - lc.x,
277
3853
                    y: 2.0 * self.current.y - lc.y,
278
3853
                }
279
            }
280
2601
            _ => self.current,
281
        };
282
6454
        let (c2x, c2y) = self.parse_coordinate_pair()?;
283
6453
        let (ex, ey) = self.parse_coordinate_pair()?;
284
6453
        let ctrl_2 = self.make_absolute(c2x, c2y, relative);
285
6453
        let end = self.make_absolute(ex, ey, relative);
286
6453
        elements.push(SvgPathElement::CubicCurve(SvgCubicCurve {
287
6453
            start: self.current, ctrl_1, ctrl_2, end,
288
6453
        }));
289
6453
        self.last_control = Some(ctrl_2);
290
6453
        self.current = end;
291
6453
        Ok(())
292
6454
    }
293

            
294
31
    fn handle_quadratic_to(&mut self, relative: bool, elements: &mut Vec<SvgPathElement>) -> Result<(), SvgPathParseError> {
295
31
        let (cx, cy) = self.parse_coordinate_pair()?;
296
24
        let (ex, ey) = self.parse_coordinate_pair()?;
297
24
        let ctrl = self.make_absolute(cx, cy, relative);
298
24
        let end = self.make_absolute(ex, ey, relative);
299
24
        elements.push(SvgPathElement::QuadraticCurve(SvgQuadraticCurve {
300
24
            start: self.current, ctrl, end,
301
24
        }));
302
24
        self.last_control = Some(ctrl);
303
24
        self.current = end;
304
24
        Ok(())
305
31
    }
306

            
307
    #[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
308
18
    fn handle_smooth_quadratic_to(&mut self, relative: bool, elements: &mut Vec<SvgPathElement>) -> Result<(), SvgPathParseError> {
309
18
        let ctrl = match self.last_control {
310
15
            Some(lc) if matches!(self.last_command.to_ascii_uppercase(), b'Q' | b'T') => {
311
14
                SvgPoint {
312
14
                    x: 2.0 * self.current.x - lc.x,
313
14
                    y: 2.0 * self.current.y - lc.y,
314
14
                }
315
            }
316
4
            _ => self.current,
317
        };
318
18
        let (ex, ey) = self.parse_coordinate_pair()?;
319
17
        let end = self.make_absolute(ex, ey, relative);
320
17
        elements.push(SvgPathElement::QuadraticCurve(SvgQuadraticCurve {
321
17
            start: self.current, ctrl, end,
322
17
        }));
323
17
        self.last_control = Some(ctrl);
324
17
        self.current = end;
325
17
        Ok(())
326
18
    }
327

            
328
34
    fn handle_arc_to(&mut self, relative: bool, elements: &mut Vec<SvgPathElement>) -> Result<(), SvgPathParseError> {
329
34
        let rx = self.parse_number()?.abs();
330
28
        let ry = self.parse_number()?.abs();
331
26
        let x_rotation = self.parse_number()?;
332
26
        let large_arc = self.parse_flag()?;
333
22
        let sweep = self.parse_flag()?;
334
21
        let (ex, ey) = self.parse_coordinate_pair()?;
335
20
        let end = self.make_absolute(ex, ey, relative);
336
20
        arc_to_cubics(self.current, end, rx, ry, x_rotation, large_arc, sweep, elements);
337
20
        self.current = end;
338
20
        self.last_control = None;
339
20
        Ok(())
340
34
    }
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.
357
3201
pub fn parse_svg_path_d(d: &str) -> Result<SvgMultiPolygon, SvgPathParseError> {
358
3201
    let d = d.trim();
359
3201
    if d.is_empty() {
360
70
        return Err(SvgPathParseError::EmptyPath);
361
3131
    }
362

            
363
3131
    let mut parser = PathParser::new(d.as_bytes());
364
3131
    let mut rings: Vec<SvgPath> = Vec::new();
365
3131
    let mut current_elements: Vec<SvgPathElement> = Vec::new();
366

            
367
3131
    parser.skip_whitespace();
368

            
369
589523
    while !parser.at_end() {
370
586522
        parser.skip_whitespace_and_commas();
371
586522
        if parser.at_end() {
372
1
            break;
373
586521
        }
374

            
375
586521
        let b = parser.peek().unwrap();
376

            
377
        // Determine if this is a command letter or an implicit repeat
378
586521
        let cmd = if b.is_ascii_alphabetic() {
379
586321
            parser.pos += 1;
380
586321
            b
381
200
        } else if parser.last_command != 0 {
382
            // Implicit repeat: after M/m, implicit commands become L/l
383
167
            match parser.last_command {
384
38
                b'M' => b'L',
385
99
                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
28
                    return Err(SvgPathParseError::UnexpectedChar {
394
28
                        pos: parser.pos,
395
28
                        ch: char_at(parser.input, parser.pos),
396
28
                    });
397
                }
398
2
                other => other,
399
            }
400
        } else {
401
33
            return Err(SvgPathParseError::UnexpectedChar {
402
33
                pos: parser.pos,
403
33
                ch: char_at(parser.input, parser.pos),
404
33
            });
405
        };
406

            
407
586460
        let relative = cmd.is_ascii_lowercase();
408
586460
        let cmd_upper = cmd.to_ascii_uppercase();
409

            
410
586460
        match cmd_upper {
411
            b'M' => {
412
                // Flush current subpath
413
8096
                if !current_elements.is_empty() {
414
11
                    rings.push(SvgPath {
415
11
                        items: SvgPathElementVec::from_vec(core::mem::take(&mut current_elements)),
416
11
                    });
417
8085
                }
418
8096
                let (x, y) = parser.parse_coordinate_pair()?;
419
8069
                let pt = parser.make_absolute(x, y, relative);
420
8069
                parser.current = pt;
421
8069
                parser.subpath_start = pt;
422
8069
                parser.last_control = None;
423
8069
                parser.last_command = cmd;
424
            }
425
            b'L' => {
426
562047
                parser.handle_line_to(relative, &mut current_elements)?;
427
562031
                parser.last_command = cmd;
428
            }
429
            b'H' => {
430
28
                parser.handle_horizontal_to(relative, &mut current_elements)?;
431
26
                parser.last_command = cmd;
432
            }
433
            b'V' => {
434
103
                parser.handle_vertical_to(relative, &mut current_elements)?;
435
102
                parser.last_command = cmd;
436
            }
437
            b'C' => {
438
4095
                parser.handle_cubic_to(relative, &mut current_elements)?;
439
4094
                parser.last_command = cmd;
440
            }
441
            b'S' => {
442
4294
                parser.handle_smooth_cubic_to(relative, &mut current_elements)?;
443
4293
                parser.last_command = cmd;
444
            }
445
            b'Q' => {
446
25
                parser.handle_quadratic_to(relative, &mut current_elements)?;
447
24
                parser.last_command = cmd;
448
            }
449
            b'T' => {
450
15
                parser.handle_smooth_quadratic_to(relative, &mut current_elements)?;
451
14
                parser.last_command = cmd;
452
            }
453
            b'A' => {
454
20
                parser.handle_arc_to(relative, &mut current_elements)?;
455
17
                parser.last_command = cmd;
456
            }
457
            b'Z' => {
458
                // Close subpath
459
7724
                let dx = parser.current.x - parser.subpath_start.x;
460
7724
                let dy = parser.current.y - parser.subpath_start.y;
461
7724
                if dx * dx + dy * dy > CLOSEPATH_EPSILON * CLOSEPATH_EPSILON {
462
5229
                    current_elements.push(SvgPathElement::Line(SvgLine {
463
5229
                        start: parser.current,
464
5229
                        end: parser.subpath_start,
465
5229
                    }));
466
7484
                }
467
7724
                parser.current = parser.subpath_start;
468
7724
                parser.last_control = None;
469
7724
                parser.last_command = cmd;
470

            
471
                // Flush current subpath
472
7724
                if !current_elements.is_empty() {
473
7673
                    rings.push(SvgPath {
474
7673
                        items: SvgPathElementVec::from_vec(core::mem::take(&mut current_elements)),
475
7673
                    });
476
7673
                }
477
            }
478
            _ => {
479
13
                return Err(SvgPathParseError::UnexpectedChar {
480
13
                    pos: parser.pos - 1,
481
13
                    ch: cmd as char,
482
13
                });
483
            }
484
        }
485

            
486
        // After processing one argument group, try to consume more
487
        // argument groups for the same command (implicit repeats)
488
586394
        if cmd_upper != b'M' && cmd_upper != b'Z' {
489
            loop {
490
583608
                parser.skip_whitespace_and_commas();
491
583608
                if parser.at_end() {
492
313
                    break;
493
583295
                }
494
583295
                let next = parser.peek().unwrap();
495
583295
                if next.is_ascii_alphabetic() {
496
570284
                    break; // Next command letter
497
13011
                }
498
13011
                if !parser.has_number() {
499
2
                    break;
500
13009
                }
501

            
502
                // Implicit repeat of the same command
503
13009
                match cmd_upper {
504
643
                    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
10208
                    b'C' => parser.handle_cubic_to(relative, &mut current_elements)?,
508
2157
                    b'S' => parser.handle_smooth_cubic_to(relative, &mut current_elements)?,
509
                    b'Q' => parser.handle_quadratic_to(relative, &mut current_elements)?,
510
1
                    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
15793
        }
516
    }
517

            
518
    // Flush any remaining elements
519
3002
    if !current_elements.is_empty() {
520
312
        rings.push(SvgPath {
521
312
            items: SvgPathElementVec::from_vec(current_elements),
522
312
        });
523
2699
    }
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
3002
    if parser.last_command == 0 && rings.is_empty() {
530
1
        return Err(SvgPathParseError::UnexpectedChar {
531
1
            pos: 0,
532
1
            ch: char_at(parser.input, 0),
533
1
        });
534
3001
    }
535

            
536
3001
    Ok(SvgMultiPolygon {
537
3001
        rings: SvgPathVec::from_vec(rings),
538
3001
    })
539
3201
}
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
553
37
fn arc_to_cubics(
554
37
    start: SvgPoint,
555
37
    end: SvgPoint,
556
37
    mut rx: f32,
557
37
    mut ry: f32,
558
37
    x_rotation_deg: f32,
559
37
    large_arc: bool,
560
37
    sweep: bool,
561
37
    out: &mut Vec<SvgPathElement>,
562
37
) {
563
    // Degenerate cases
564
37
    if (start.x - end.x).abs() < POINT_EPSILON && (start.y - end.y).abs() < POINT_EPSILON {
565
3
        return;
566
34
    }
567
34
    if rx < POINT_EPSILON || ry < POINT_EPSILON {
568
4
        out.push(SvgPathElement::Line(SvgLine { start, end }));
569
4
        return;
570
30
    }
571

            
572
30
    let phi = x_rotation_deg.to_radians();
573
30
    let cos_phi = phi.cos();
574
30
    let sin_phi = phi.sin();
575

            
576
    // Step 1: Compute (x1', y1')
577
30
    let dx = (start.x - end.x) / 2.0;
578
30
    let dy = (start.y - end.y) / 2.0;
579
30
    let x1p = cos_phi * dx + sin_phi * dy;
580
30
    let y1p = -sin_phi * dx + cos_phi * dy;
581

            
582
    // Step 2: Compute (cx', cy') - correct radii if too small
583
30
    let x1p2 = x1p * x1p;
584
30
    let y1p2 = y1p * y1p;
585
30
    let mut rx2 = rx * rx;
586
30
    let mut ry2 = ry * ry;
587

            
588
30
    let lambda = x1p2 / rx2 + y1p2 / ry2;
589
30
    if lambda > 1.0 {
590
5
        let sqrt_lambda = lambda.sqrt();
591
5
        rx *= sqrt_lambda;
592
5
        ry *= sqrt_lambda;
593
5
        rx2 = rx * rx;
594
5
        ry2 = ry * ry;
595
25
    }
596

            
597
30
    let num = (rx2 * ry2 - rx2 * y1p2 - ry2 * x1p2).max(0.0);
598
30
    let den = rx2 * y1p2 + ry2 * x1p2;
599
30
    let sq = if den > 0.0 {
600
25
        (num / den).sqrt()
601
    } else {
602
5
        0.0
603
    };
604

            
605
30
    let sign = if large_arc == sweep { -1.0 } else { 1.0 };
606
30
    let cxp = sign * sq * (rx * y1p / ry);
607
30
    let cyp = sign * sq * -(ry * x1p / rx);
608

            
609
    // Step 3: Compute (cx, cy) from (cx', cy')
610
30
    let mx = f32::midpoint(start.x, end.x);
611
30
    let my = f32::midpoint(start.y, end.y);
612
30
    let cx = cos_phi * cxp - sin_phi * cyp + mx;
613
30
    let cy = sin_phi * cxp + cos_phi * cyp + my;
614

            
615
    // Step 4: Compute theta1 and dtheta
616
30
    let theta1 = angle_between(1.0, 0.0, (x1p - cxp) / rx, (y1p - cyp) / ry);
617
30
    let mut dtheta = angle_between(
618
30
        (x1p - cxp) / rx,
619
30
        (y1p - cyp) / ry,
620
30
        (-x1p - cxp) / rx,
621
30
        (-y1p - cyp) / ry,
622
    );
623

            
624
30
    if !sweep && dtheta > 0.0 {
625
2
        dtheta -= core::f32::consts::TAU;
626
28
    } else if sweep && dtheta < 0.0 {
627
1
        dtheta += core::f32::consts::TAU;
628
27
    }
629

            
630
    // Split into segments of at most PI/2
631
30
    let n_segs = (dtheta.abs() / (core::f32::consts::FRAC_PI_2 + ARC_SPLIT_FUDGE)).ceil() as usize;
632
30
    let n_segs = n_segs.max(1);
633
30
    let seg_angle = dtheta / n_segs as f32;
634

            
635
30
    let mut prev = start;
636
54
    for i in 0..n_segs {
637
54
        let t1 = theta1 + seg_angle * i as f32;
638
54
        let t2 = theta1 + seg_angle * (i + 1) as f32;
639

            
640
54
        let (c1, c2, ep) =
641
54
            arc_segment_to_cubic(cx, cy, rx, ry, cos_phi, sin_phi, t1, t2);
642

            
643
54
        let seg_end = if i + 1 == n_segs { end } else { ep };
644
54
        out.push(SvgPathElement::CubicCurve(SvgCubicCurve {
645
54
            start: prev,
646
54
            ctrl_1: c1,
647
54
            ctrl_2: c2,
648
54
            end: seg_end,
649
54
        }));
650
54
        prev = seg_end;
651
    }
652
37
}
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
656
4171
fn angle_between(ux: f32, uy: f32, vx: f32, vy: f32) -> f32 {
657
4171
    let dot = ux * vx + uy * vy;
658
4171
    let len = ((ux * ux + uy * uy) * (vx * vx + vy * vy)).sqrt();
659
4171
    if len < ZERO_LENGTH_EPSILON {
660
276
        return 0.0;
661
3895
    }
662
3895
    let cos_val = (dot / len).clamp(-1.0, 1.0);
663
3895
    let angle = cos_val.acos();
664
3895
    if ux * vy - uy * vx < 0.0 {
665
1604
        -angle
666
    } else {
667
2291
        angle
668
    }
669
4171
}
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
674
64
fn arc_segment_to_cubic(
675
64
    cx: f32,
676
64
    cy: f32,
677
64
    rx: f32,
678
64
    ry: f32,
679
64
    cos_phi: f32,
680
64
    sin_phi: f32,
681
64
    theta1: f32,
682
64
    theta2: f32,
683
64
) -> (SvgPoint, SvgPoint, SvgPoint) {
684
64
    let alpha = 4.0 / 3.0 * ((theta2 - theta1) / 4.0).tan();
685

            
686
64
    let cos1 = theta1.cos();
687
64
    let sin1 = theta1.sin();
688
64
    let cos2 = theta2.cos();
689
64
    let sin2 = theta2.sin();
690

            
691
    // Control point 1 (relative to unit circle)
692
64
    let dx1 = rx * (cos1 - alpha * sin1);
693
64
    let dy1 = ry * (sin1 + alpha * cos1);
694
    // Control point 2
695
64
    let dx2 = rx * (cos2 + alpha * sin2);
696
64
    let dy2 = ry * (sin2 - alpha * cos2);
697
    // End point
698
64
    let dx3 = rx * cos2;
699
64
    let dy3 = ry * sin2;
700

            
701
64
    let c1 = SvgPoint {
702
64
        x: cos_phi * dx1 - sin_phi * dy1 + cx,
703
64
        y: sin_phi * dx1 + cos_phi * dy1 + cy,
704
64
    };
705
64
    let c2 = SvgPoint {
706
64
        x: cos_phi * dx2 - sin_phi * dy2 + cx,
707
64
        y: sin_phi * dx2 + cos_phi * dy2 + cy,
708
64
    };
709
64
    let ep = SvgPoint {
710
64
        x: cos_phi * dx3 - sin_phi * dy3 + cx,
711
64
        y: sin_phi * dx3 + cos_phi * dy3 + cy,
712
64
    };
713

            
714
64
    (c1, c2, ep)
715
64
}
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]
721
141
pub fn svg_circle_to_paths(cx: f32, cy: f32, r: f32) -> SvgPath {
722
141
    let k = r * KAPPA;
723

            
724
141
    let elements = vec![
725
        // Top to right
726
141
        SvgPathElement::CubicCurve(SvgCubicCurve {
727
141
            start: SvgPoint { x: cx, y: cy - r },
728
141
            ctrl_1: SvgPoint {
729
141
                x: cx + k,
730
141
                y: cy - r,
731
141
            },
732
141
            ctrl_2: SvgPoint {
733
141
                x: cx + r,
734
141
                y: cy - k,
735
141
            },
736
141
            end: SvgPoint { x: cx + r, y: cy },
737
141
        }),
738
        // Right to bottom
739
141
        SvgPathElement::CubicCurve(SvgCubicCurve {
740
141
            start: SvgPoint { x: cx + r, y: cy },
741
141
            ctrl_1: SvgPoint {
742
141
                x: cx + r,
743
141
                y: cy + k,
744
141
            },
745
141
            ctrl_2: SvgPoint {
746
141
                x: cx + k,
747
141
                y: cy + r,
748
141
            },
749
141
            end: SvgPoint { x: cx, y: cy + r },
750
141
        }),
751
        // Bottom to left
752
141
        SvgPathElement::CubicCurve(SvgCubicCurve {
753
141
            start: SvgPoint { x: cx, y: cy + r },
754
141
            ctrl_1: SvgPoint {
755
141
                x: cx - k,
756
141
                y: cy + r,
757
141
            },
758
141
            ctrl_2: SvgPoint {
759
141
                x: cx - r,
760
141
                y: cy + k,
761
141
            },
762
141
            end: SvgPoint { x: cx - r, y: cy },
763
141
        }),
764
        // Left to top
765
141
        SvgPathElement::CubicCurve(SvgCubicCurve {
766
141
            start: SvgPoint { x: cx - r, y: cy },
767
141
            ctrl_1: SvgPoint {
768
141
                x: cx - r,
769
141
                y: cy - k,
770
141
            },
771
141
            ctrl_2: SvgPoint {
772
141
                x: cx - k,
773
141
                y: cy - r,
774
141
            },
775
141
            end: SvgPoint { x: cx, y: cy - r },
776
141
        }),
777
    ];
778

            
779
141
    SvgPath {
780
141
        items: SvgPathElementVec::from_vec(elements),
781
141
    }
782
141
}
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)
793
55706
pub fn svg_rect_to_path(x: f32, y: f32, w: f32, h: f32, rx: f32, ry: f32) -> SvgPath {
794
55706
    let rx = rx.min(w / 2.0);
795
55706
    let ry = ry.min(h / 2.0);
796

            
797
55706
    if rx < CLOSEPATH_EPSILON && ry < CLOSEPATH_EPSILON {
798
        // Simple rectangle: 4 lines
799
55633
        let tl = SvgPoint { x, y };
800
55633
        let tr = SvgPoint { x: x + w, y };
801
55633
        let br = SvgPoint { x: x + w, y: y + h };
802
55633
        let bl = SvgPoint { x, y: y + h };
803

            
804
55633
        let elements = vec![
805
55633
            SvgPathElement::Line(SvgLine { start: tl, end: tr }),
806
55633
            SvgPathElement::Line(SvgLine { start: tr, end: br }),
807
55633
            SvgPathElement::Line(SvgLine {
808
55633
                start: br,
809
55633
                end: bl,
810
55633
            }),
811
55633
            SvgPathElement::Line(SvgLine { start: bl, end: tl }),
812
        ];
813

            
814
55633
        return SvgPath {
815
55633
            items: SvgPathElementVec::from_vec(elements),
816
55633
        };
817
73
    }
818

            
819
    // Rounded rectangle
820
73
    let kx = rx * KAPPA;
821
73
    let ky = ry * KAPPA;
822

            
823
73
    let mut elements = Vec::with_capacity(8);
824

            
825
    // Top edge (left to right)
826
73
    elements.push(SvgPathElement::Line(SvgLine {
827
73
        start: SvgPoint { x: x + rx, y },
828
73
        end: SvgPoint { x: x + w - rx, y },
829
73
    }));
830
    // Top-right corner
831
73
    elements.push(SvgPathElement::CubicCurve(SvgCubicCurve {
832
73
        start: SvgPoint { x: x + w - rx, y },
833
73
        ctrl_1: SvgPoint {
834
73
            x: x + w - rx + kx,
835
73
            y,
836
73
        },
837
73
        ctrl_2: SvgPoint {
838
73
            x: x + w,
839
73
            y: y + ry - ky,
840
73
        },
841
73
        end: SvgPoint {
842
73
            x: x + w,
843
73
            y: y + ry,
844
73
        },
845
73
    }));
846
    // Right edge
847
73
    elements.push(SvgPathElement::Line(SvgLine {
848
73
        start: SvgPoint {
849
73
            x: x + w,
850
73
            y: y + ry,
851
73
        },
852
73
        end: SvgPoint {
853
73
            x: x + w,
854
73
            y: y + h - ry,
855
73
        },
856
73
    }));
857
    // Bottom-right corner
858
73
    elements.push(SvgPathElement::CubicCurve(SvgCubicCurve {
859
73
        start: SvgPoint {
860
73
            x: x + w,
861
73
            y: y + h - ry,
862
73
        },
863
73
        ctrl_1: SvgPoint {
864
73
            x: x + w,
865
73
            y: y + h - ry + ky,
866
73
        },
867
73
        ctrl_2: SvgPoint {
868
73
            x: x + w - rx + kx,
869
73
            y: y + h,
870
73
        },
871
73
        end: SvgPoint {
872
73
            x: x + w - rx,
873
73
            y: y + h,
874
73
        },
875
73
    }));
876
    // Bottom edge (right to left)
877
73
    elements.push(SvgPathElement::Line(SvgLine {
878
73
        start: SvgPoint {
879
73
            x: x + w - rx,
880
73
            y: y + h,
881
73
        },
882
73
        end: SvgPoint { x: x + rx, y: y + h },
883
73
    }));
884
    // Bottom-left corner
885
73
    elements.push(SvgPathElement::CubicCurve(SvgCubicCurve {
886
73
        start: SvgPoint { x: x + rx, y: y + h },
887
73
        ctrl_1: SvgPoint {
888
73
            x: x + rx - kx,
889
73
            y: y + h,
890
73
        },
891
73
        ctrl_2: SvgPoint {
892
73
            x,
893
73
            y: y + h - ry + ky,
894
73
        },
895
73
        end: SvgPoint { x, y: y + h - ry },
896
73
    }));
897
    // Left edge
898
73
    elements.push(SvgPathElement::Line(SvgLine {
899
73
        start: SvgPoint { x, y: y + h - ry },
900
73
        end: SvgPoint { x, y: y + ry },
901
73
    }));
902
    // Top-left corner
903
73
    elements.push(SvgPathElement::CubicCurve(SvgCubicCurve {
904
73
        start: SvgPoint { x, y: y + ry },
905
73
        ctrl_1: SvgPoint {
906
73
            x,
907
73
            y: y + ry - ky,
908
73
        },
909
73
        ctrl_2: SvgPoint {
910
73
            x: x + rx - kx,
911
73
            y,
912
73
        },
913
73
        end: SvgPoint { x: x + rx, y },
914
73
    }));
915

            
916
73
    SvgPath {
917
73
        items: SvgPathElementVec::from_vec(elements),
918
73
    }
919
55706
}
920

            
921
#[cfg(test)]
922
mod 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
1
    fn m0_0z5_does_not_hang() {
930
1
        let err = parse_svg_path_d("M0 0Z5").unwrap_err();
931
1
        match err {
932
1
            SvgPathParseError::UnexpectedChar { ch, .. } => assert_eq!(ch, '5'),
933
            other => panic!("expected UnexpectedChar, got {other:?}"),
934
        }
935
1
    }
936

            
937
    /// Any digit or symbol directly after a closepath is rejected, not looped on.
938
    #[test]
939
1
    fn stray_byte_after_closepath_rejected() {
940
3
        for s in ["M0 0Z9", "m0 0z-", "M0 0Z."] {
941
3
            assert!(
942
                matches!(
943
3
                    parse_svg_path_d(s),
944
                    Err(SvgPathParseError::UnexpectedChar { .. })
945
                ),
946
                "expected UnexpectedChar for {s:?}"
947
            );
948
        }
949
1
    }
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
1
    fn error_char_is_unicode_not_byte() {
955
        // 'ü' is two UTF-8 bytes; `b as char` would have yielded a mojibake char.
956
1
        let err = parse_svg_path_d("ü10 10").unwrap_err();
957
1
        match err {
958
1
            SvgPathParseError::UnexpectedChar { ch, pos } => {
959
1
                assert_eq!(ch, 'ü');
960
1
                assert_eq!(pos, 0);
961
            }
962
            other => panic!("expected UnexpectedChar, got {other:?}"),
963
        }
964
1
    }
965

            
966
    /// A well-formed closepath followed by a real command still parses.
967
    #[test]
968
1
    fn valid_closepath_then_command_ok() {
969
1
        let parsed = parse_svg_path_d("M0 0 L10 0 Z M20 20 L30 20 Z");
970
1
        assert!(parsed.is_ok(), "valid multi-subpath path should parse");
971
1
    }
972
}
973

            
974
#[cfg(test)]
975
#[allow(clippy::float_cmp)] // exact float equality is the point: the parser propagates values bit-for-bit
976
mod 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);
                }
                SvgPathElement::CubicCurve(c) => {
                    out.push(c.start);
                    out.push(c.ctrl_1);
                    out.push(c.ctrl_2);
                    out.push(c.end);
                }
            }
        }
        out
    }
    /// Each element's end must be the next element's start (the parser threads
    /// `self.current` through every handler, so this holds bit-for-bit).
    fn assert_contiguous(items: &[SvgPathElement], what: &str) {
        for w in items.windows(2) {
            assert_eq!(
                w[0].get_end(),
                w[1].get_start(),
                "{what}: element chain is not contiguous"
            );
        }
    }
    // ======================================================= char_at (numeric)
    #[test]
    fn char_at_zero_and_ascii() {
        // "M0 0" is ['M', '0', ' ', '0'] -- the space is at index 2, not 3.
        assert_eq!(char_at(b"M0 0", 0), 'M');
        assert_eq!(char_at(b"M0 0", 2), ' ');
        assert_eq!(char_at(b"M0 0", 3), '0');
    }
    #[test]
    fn char_at_empty_input_is_replacement() {
        assert_eq!(char_at(b"", 0), char::REPLACEMENT_CHARACTER);
    }
    #[test]
    fn char_at_past_end_is_replacement_not_panic() {
        assert_eq!(char_at(b"abc", 3), char::REPLACEMENT_CHARACTER);
        assert_eq!(char_at(b"abc", 4), char::REPLACEMENT_CHARACTER);
    }
    /// `pos = usize::MAX` must be a `get(pos..)` miss, not an arithmetic panic.
    #[test]
    fn char_at_usize_max_is_replacement() {
        assert_eq!(char_at(b"abc", usize::MAX), char::REPLACEMENT_CHARACTER);
        assert_eq!(char_at(b"", usize::MAX), char::REPLACEMENT_CHARACTER);
    }
    /// The whole point of `char_at`: report the real char, not one UTF-8 byte.
    #[test]
    fn char_at_decodes_multibyte_not_latin1() {
        assert_eq!(char_at("ü".as_bytes(), 0), 'ü');
        assert_eq!(char_at("€".as_bytes(), 0), '€');
        assert_eq!(char_at("\u{1F600}".as_bytes(), 0), '\u{1F600}');
    }
    /// A corrupt (mid-codepoint) offset falls back rather than panicking.
    #[test]
    fn char_at_mid_codepoint_offset_is_replacement() {
        let bytes = "😀".as_bytes(); // 4 bytes
        for pos in 1..bytes.len() {
            assert_eq!(
                char_at(bytes, pos),
                char::REPLACEMENT_CHARACTER,
                "mid-codepoint offset {pos} must not panic"
            );
        }
    }
    /// Trailing garbage after a valid char makes the *whole rest* invalid UTF-8,
    /// so even a valid leading char decodes to the replacement char. Pinned as
    /// deterministic (never a panic).
    #[test]
    fn char_at_invalid_utf8_tail_is_replacement() {
        assert_eq!(char_at(&[b'A', 0xFF], 0), char::REPLACEMENT_CHARACTER);
        assert_eq!(char_at(&[0xFF], 0), char::REPLACEMENT_CHARACTER);
        // ...but a clean tail after the char still decodes.
        assert_eq!(char_at(b"AB", 0), 'A');
    }
    // ============================================ SvgPathParseError (serializer)
    #[test]
    fn error_display_is_non_empty_for_every_variant() {
        let variants = [
            SvgPathParseError::EmptyPath,
            SvgPathParseError::UnexpectedChar { pos: 0, ch: 'x' },
            SvgPathParseError::ExpectedNumber { pos: 0 },
            SvgPathParseError::InvalidArcFlag { pos: 0 },
        ];
        for v in variants {
            let s = format!("{v}");
            assert!(!s.is_empty(), "Display for {v:?} must not be empty");
            assert!(!format!("{v:?}").is_empty(), "Debug must not be empty");
        }
    }
    #[test]
    fn error_display_edge_values_do_not_panic() {
        let s = format!(
            "{}",
            SvgPathParseError::UnexpectedChar {
                pos: usize::MAX,
                ch: char::REPLACEMENT_CHARACTER,
            }
        );
        assert!(s.contains(&format!("{}", usize::MAX)));
        assert!(s.contains(char::REPLACEMENT_CHARACTER));
        // NUL, an emoji and a combining mark all format without panicking.
        for ch in ['\0', '\u{1F600}', '\u{0301}'] {
            let s = format!("{}", SvgPathParseError::UnexpectedChar { pos: 0, ch });
            assert!(!s.is_empty());
        }
        assert!(!format!("{}", SvgPathParseError::ExpectedNumber { pos: usize::MAX }).is_empty());
        assert!(!format!("{}", SvgPathParseError::InvalidArcFlag { pos: usize::MAX }).is_empty());
    }
    // ================================================= PathParser::new / getters
    #[test]
    fn parser_new_invariants_hold() {
        let p = PathParser::new(b"M0 0");
        assert_eq!(p.pos, 0);
        assert_eq!(p.current, SvgPoint { x: 0.0, y: 0.0 });
        assert_eq!(p.subpath_start, SvgPoint { x: 0.0, y: 0.0 });
        assert!(p.last_control.is_none());
        assert_eq!(p.last_command, 0);
        assert_eq!(p.input.len(), 4);
    }
    #[test]
    fn parser_new_on_empty_input_does_not_panic() {
        let p = PathParser::new(b"");
        assert!(p.at_end(), "empty input is immediately at_end");
        assert_eq!(p.peek(), None);
        assert!(!p.has_number());
    }
    #[test]
    fn at_end_and_peek_agree_across_the_whole_input() {
        let mut p = PathParser::new(b"ab");
        assert!(!p.at_end());
        assert_eq!(p.peek(), Some(b'a'));
        p.pos = 1;
        assert!(!p.at_end());
        assert_eq!(p.peek(), Some(b'b'));
        p.pos = 2;
        assert!(p.at_end());
        assert_eq!(p.peek(), None);
    }
    /// A cursor pushed far past the end must report `at_end` / `None`, never panic.
    #[test]
    fn peek_and_at_end_at_extreme_positions() {
        let mut p = PathParser::new(b"abc");
        p.pos = usize::MAX;
        assert!(p.at_end());
        assert_eq!(p.peek(), None);
        assert!(!p.has_number());
    }
    // ============================================================ skip_* (other)
    #[test]
    fn skip_whitespace_and_commas_consumes_all_separators() {
        let mut p = PathParser::new(b" \t\r\n,,, \tX");
        p.skip_whitespace_and_commas();
        assert_eq!(p.peek(), Some(b'X'));
    }
    /// `skip_whitespace` must *not* eat commas (they are only argument separators).
    #[test]
    fn skip_whitespace_stops_at_comma() {
        let mut p = PathParser::new(b"  ,1");
        p.skip_whitespace();
        assert_eq!(p.peek(), Some(b','));
        assert_eq!(p.pos, 2);
    }
    #[test]
    fn skip_on_empty_and_all_separator_input_terminates() {
        let mut p = PathParser::new(b"");
        p.skip_whitespace();
        p.skip_whitespace_and_commas();
        assert!(p.at_end());
        let all_ws = " \t\r\n,".repeat(20_000);
        let mut p = PathParser::new(all_ws.as_bytes());
        p.skip_whitespace_and_commas();
        assert!(p.at_end(), "a huge run of separators must be fully consumed");
        assert_eq!(p.pos, all_ws.len());
    }
    /// Non-separator input leaves the cursor exactly where it was.
    #[test]
    fn skip_is_a_no_op_on_non_separator() {
        let mut p = PathParser::new("😀".as_bytes());
        p.skip_whitespace_and_commas();
        assert_eq!(p.pos, 0);
        // Form feed / vertical tab are NOT SVG wsp; the parser must leave them.
        let mut p = PathParser::new(b"\x0c\x0b1");
        p.skip_whitespace();
        assert_eq!(p.pos, 0);
    }
    // ======================================================= has_number (predicate)
    #[test]
    fn has_number_true_for_number_starters() {
        for s in ["0", "9", "+", "-", ".", "5.5", "-.5"] {
            assert!(
                PathParser::new(s.as_bytes()).has_number(),
                "{s:?} should look like a number start"
            );
        }
    }
    #[test]
    fn has_number_false_for_non_number_starters() {
        // Note 'e'/'E' only appear *inside* a number, never at its start.
        for s in ["", " ", ",", "M", "z", "e", "E", "😀", "\u{0301}"] {
            assert!(
                !PathParser::new(s.as_bytes()).has_number(),
                "{s:?} should not look like a number start"
            );
        }
    }
    // ========================================================= parse_number (parser)
    fn num(s: &str) -> Result<f32, SvgPathParseError> {
        PathParser::new(s.as_bytes()).parse_number()
    }
    #[test]
    fn parse_number_valid_minimal() {
        assert_eq!(num("0").unwrap(), 0.0);
        assert_eq!(num("5").unwrap(), 5.0);
        assert_eq!(num("+5").unwrap(), 5.0);
        assert_eq!(num("-5").unwrap(), -5.0);
        assert!(approx(num("12.34").unwrap(), 12.34));
        assert!(approx(num(".5").unwrap(), 0.5));
        assert_eq!(num("5.").unwrap(), 5.0);
        assert!(approx(num("1e2").unwrap(), 100.0));
        assert!(approx(num("1E-2").unwrap(), 0.01));
        assert!(approx(num("  ,, 7").unwrap(), 7.0), "leading separators skipped");
    }
    #[test]
    fn parse_number_empty_input_is_err() {
        assert_eq!(num(""), Err(SvgPathParseError::ExpectedNumber { pos: 0 }));
    }
    /// Whitespace-only input reports the position *after* the skipped separators.
    #[test]
    fn parse_number_whitespace_only_is_err() {
        assert_eq!(num("   "), Err(SvgPathParseError::ExpectedNumber { pos: 3 }));
        assert_eq!(num("\t\n"), Err(SvgPathParseError::ExpectedNumber { pos: 2 }));
        assert_eq!(num(" , "), Err(SvgPathParseError::ExpectedNumber { pos: 3 }));
    }
    #[test]
    fn parse_number_garbage_is_err_never_panics() {
        for s in ["abc", "@", "#$%", "-", "+", ".", "-.", "+.", "e5", "NaN", "inf", "-inf"] {
            assert!(
                matches!(num(s), Err(SvgPathParseError::ExpectedNumber { .. })),
                "{s:?} must be rejected, got {:?}",
                num(s)
            );
        }
    }
    /// A dangling exponent is consumed by the tokenizer but rejected by `f32::from_str`.
    #[test]
    fn parse_number_dangling_exponent_is_err() {
        for s in ["1e", "1E", "1e+", "1e-", "1.5e"] {
            assert!(
                matches!(num(s), Err(SvgPathParseError::ExpectedNumber { pos: 0 })),
                "{s:?} must be rejected"
            );
        }
    }
    #[test]
    fn parse_number_unicode_does_not_panic() {
        for s in ["😀", "\u{0301}", "ü", "€1", "1"] {
            assert!(num(s).is_err(), "{s:?} must be rejected");
        }
        // A number immediately followed by a multibyte char stops at the boundary.
        let mut p = PathParser::new("1😀".as_bytes());
        assert_eq!(p.parse_number().unwrap(), 1.0);
        assert_eq!(p.pos, 1);
    }
    /// Boundary numerics: overflow saturates to +/-inf, underflow flushes to zero,
    /// and `-0` keeps its sign. None of these panic.
    #[test]
    fn parse_number_boundary_values_saturate() {
        assert!(num("-0").unwrap().is_sign_negative(), "-0 keeps its sign bit");
        assert_eq!(num("-0").unwrap(), -0.0);
        assert!(num("1e999").unwrap().is_infinite());
        assert!(num("1e999").unwrap().is_sign_positive());
        assert!(num("-1e999").unwrap().is_infinite());
        assert!(num("-1e999").unwrap().is_sign_negative());
        assert_eq!(num("1e-999").unwrap(), 0.0, "underflow flushes to zero");
        // i64::MAX / f32 extremes round to a finite f32.
        assert!(num("9223372036854775807").unwrap().is_finite());
        assert!(num("340282350000000000000000000000000000000").unwrap().is_finite());
        // Just past f32::MAX -> +inf, not a panic.
        assert!(num("1e39").unwrap().is_infinite());
    }
    /// A 20k-digit literal must not hang or panic; it saturates to +inf.
    #[test]
    fn parse_number_extremely_long_input_terminates() {
        let huge = "9".repeat(20_000);
        assert!(num(&huge).unwrap().is_infinite());
        let long_frac = format!("0.{}", "0".repeat(20_000));
        assert_eq!(num(&long_frac).unwrap(), 0.0);
        let long_zeros = format!("{}1", "0".repeat(20_000));
        assert_eq!(num(&long_zeros).unwrap(), 1.0);
    }
    /// Trailing junk is left on the cursor rather than swallowed: `"1.2.3"` yields
    /// `1.2` and stops at the second dot (SVG's own "1.5.5" == two numbers rule).
    #[test]
    fn parse_number_stops_at_trailing_junk() {
        let mut p = PathParser::new(b"1.2.3");
        assert!(approx(p.parse_number().unwrap(), 1.2));
        assert_eq!(p.pos, 3, "second '.' must not be consumed");
        let mut p = PathParser::new(b"5;garbage");
        assert_eq!(p.parse_number().unwrap(), 5.0);
        assert_eq!(p.peek(), Some(b';'));
    }
    /// The cursor is never advanced past the end of the input, whatever happens.
    #[test]
    fn parse_number_never_overruns_the_buffer() {
        for s in ["", "-", ".", "1e", "1e+", "1.", "+.e", "1e-", "999"] {
            let mut p = PathParser::new(s.as_bytes());
            let _ = p.parse_number();
            assert!(p.pos <= s.len(), "{s:?}: pos {} > len {}", p.pos, s.len());
        }
    }
    // =========================================================== parse_flag (parser)
    fn flag(s: &str) -> Result<bool, SvgPathParseError> {
        PathParser::new(s.as_bytes()).parse_flag()
    }
    #[test]
    fn parse_flag_valid_minimal() {
        assert!(!flag("0").unwrap());
        assert!(flag("1").unwrap());
        assert!(flag("  , 1").unwrap(), "separators are skipped first");
    }
    /// A flag is exactly one byte: "11" is two flags, not the number eleven.
    #[test]
    fn parse_flag_consumes_exactly_one_byte() {
        let mut p = PathParser::new(b"10");
        assert!(p.parse_flag().unwrap());
        assert_eq!(p.pos, 1);
        assert!(!p.parse_flag().unwrap());
        assert_eq!(p.pos, 2);
    }
    #[test]
    fn parse_flag_empty_and_whitespace_only_are_err() {
        assert_eq!(flag(""), Err(SvgPathParseError::InvalidArcFlag { pos: 0 }));
        assert_eq!(flag("   "), Err(SvgPathParseError::InvalidArcFlag { pos: 3 }));
    }
    /// Any byte other than `0`/`1` is rejected and the cursor stays put.
    #[test]
    fn parse_flag_garbage_is_err_and_does_not_advance() {
        for s in ["2", "9", "-1", "+1", "x", ".", "😀", "0.5"] {
            let mut p = PathParser::new(s.as_bytes());
            let before = p.pos;
            match p.parse_flag() {
                Err(SvgPathParseError::InvalidArcFlag { pos }) => {
                    assert_eq!(pos, p.pos, "{s:?}: reported pos must be the cursor");
                    assert_eq!(p.pos, before, "{s:?}: rejected flag must not advance");
                }
                // "0.5" legitimately parses its leading '0' as the flag.
                Ok(v) => assert!(s == "0.5" && !v, "{s:?} unexpectedly parsed as {v}"),
                other => panic!("{s:?}: unexpected {other:?}"),
            }
        }
    }
    /// A 100k-byte separator run followed by no flag terminates with an error.
    #[test]
    fn parse_flag_extremely_long_separator_run_terminates() {
        let s = " ".repeat(100_000);
        assert_eq!(
            flag(&s),
            Err(SvgPathParseError::InvalidArcFlag { pos: 100_000 })
        );
    }
    // ================================================ parse_coordinate_pair (parser)
    fn pair(s: &str) -> Result<(f32, f32), SvgPathParseError> {
        PathParser::new(s.as_bytes()).parse_coordinate_pair()
    }
    #[test]
    fn parse_coordinate_pair_valid_minimal() {
        assert_eq!(pair("1 2").unwrap(), (1.0, 2.0));
        assert_eq!(pair("1,2").unwrap(), (1.0, 2.0));
        assert_eq!(pair(" 1 , 2 ").unwrap(), (1.0, 2.0));
        // SVG allows a sign to act as the separator.
        assert_eq!(pair("-1-2").unwrap(), (-1.0, -2.0));
    }
    /// SVG's notorious "1.5.5" == (1.5, 0.5) tokenization.
    #[test]
    fn parse_coordinate_pair_splits_on_second_dot() {
        let (x, y) = pair("1.5.5").unwrap();
        assert!(approx(x, 1.5) && approx(y, 0.5), "got ({x}, {y})");
    }
    #[test]
    fn parse_coordinate_pair_empty_and_partial_are_err() {
        assert!(pair("").is_err());
        assert!(pair("   ").is_err());
        assert!(pair("1").is_err(), "a lone x with no y must be rejected");
        assert!(pair("1 ").is_err());
        assert!(pair("1,").is_err());
    }
    #[test]
    fn parse_coordinate_pair_garbage_and_unicode_are_err() {
        for s in ["abc", "1 abc", "😀 1", "1 😀", ";;", "1;2"] {
            assert!(pair(s).is_err(), "{s:?} must be rejected");
        }
    }
    #[test]
    fn parse_coordinate_pair_boundary_values() {
        let (x, y) = pair("1e999 -1e999").unwrap();
        assert!(x.is_infinite() && x.is_sign_positive());
        assert!(y.is_infinite() && y.is_sign_negative());
        let (x, y) = pair("-0 0").unwrap();
        assert!(x.is_sign_negative() && y.is_sign_positive());
    }
    #[test]
    fn parse_coordinate_pair_extremely_long_input_terminates() {
        let s = format!("{} {}", "9".repeat(10_000), "9".repeat(10_000));
        let (x, y) = pair(&s).unwrap();
        assert!(x.is_infinite() && y.is_infinite());
    }
    // ======================================================= make_absolute (numeric)
    #[test]
    fn make_absolute_zero_and_absolute_mode_is_identity() {
        let mut p = PathParser::new(b"");
        p.current = SvgPoint { x: 7.0, y: -3.0 };
        // Absolute: current is ignored entirely.
        assert_eq!(p.make_absolute(0.0, 0.0, false), SvgPoint { x: 0.0, y: 0.0 });
        assert_eq!(p.make_absolute(1.0, 2.0, false), SvgPoint { x: 1.0, y: 2.0 });
        // Relative: offsets from current.
        assert_eq!(p.make_absolute(0.0, 0.0, true), SvgPoint { x: 7.0, y: -3.0 });
        assert_eq!(p.make_absolute(-7.0, 3.0, true), SvgPoint { x: 0.0, y: 0.0 });
    }
    #[test]
    fn make_absolute_negative_inputs() {
        let mut p = PathParser::new(b"");
        p.current = SvgPoint { x: -10.0, y: -10.0 };
        assert_eq!(p.make_absolute(-5.0, -5.0, true), SvgPoint { x: -15.0, y: -15.0 });
        assert_eq!(p.make_absolute(-5.0, -5.0, false), SvgPoint { x: -5.0, y: -5.0 });
    }
    /// f32 addition saturates to infinity; it never wraps or debug-panics.
    #[test]
    fn make_absolute_overflow_saturates_to_infinity() {
        let mut p = PathParser::new(b"");
        p.current = SvgPoint {
            x: f32::MAX,
            y: f32::MIN,
        };
        let r = p.make_absolute(f32::MAX, f32::MIN, true);
        assert!(r.x.is_infinite() && r.x.is_sign_positive());
        assert!(r.y.is_infinite() && r.y.is_sign_negative());
    }
    #[test]
    fn make_absolute_nan_and_inf_are_defined_not_panics() {
        let mut p = PathParser::new(b"");
        p.current = SvgPoint {
            x: f32::INFINITY,
            y: 0.0,
        };
        // inf + (-inf) is NaN by IEEE-754 -- defined, not a panic.
        let r = p.make_absolute(f32::NEG_INFINITY, f32::NAN, true);
        assert!(r.x.is_nan(), "inf + -inf must be NaN");
        assert!(r.y.is_nan());
        // Absolute mode passes NaN straight through.
        let r = p.make_absolute(f32::NAN, f32::INFINITY, false);
        assert!(r.x.is_nan());
        assert!(r.y.is_infinite());
    }
    // ============================================================ handle_* (other)
    /// Drive one handler over `input` starting from `current`, returning the
    /// pushed elements plus the parser's post-state.
    fn run_handler<F>(
        input: &str,
        current: SvgPoint,
        last_command: u8,
        last_control: Option<SvgPoint>,
        f: F,
    ) -> (Result<(), SvgPathParseError>, Vec<SvgPathElement>, SvgPoint)
    where
        F: FnOnce(&mut PathParser<'_>, &mut Vec<SvgPathElement>) -> Result<(), SvgPathParseError>,
    {
        let mut p = PathParser::new(input.as_bytes());
        p.current = current;
        p.last_command = last_command;
        p.last_control = last_control;
        let mut els = Vec::new();
        let r = f(&mut p, &mut els);
        (r, els, p.current)
    }
    const ORIGIN: SvgPoint = SvgPoint { x: 0.0, y: 0.0 };
    #[test]
    fn handle_line_to_absolute_and_relative() {
        let start = SvgPoint { x: 10.0, y: 10.0 };
        let (r, els, cur) = run_handler("5 5", start, b'L', None, |p, e| p.handle_line_to(false, e));
        assert!(r.is_ok());
        assert_eq!(els.len(), 1);
        assert_eq!(els[0].get_start(), start);
        assert_eq!(els[0].get_end(), SvgPoint { x: 5.0, y: 5.0 });
        assert_eq!(cur, SvgPoint { x: 5.0, y: 5.0 });
        let (r, els, cur) = run_handler("5 5", start, b'l', None, |p, e| p.handle_line_to(true, e));
        assert!(r.is_ok());
        assert_eq!(els[0].get_end(), SvgPoint { x: 15.0, y: 15.0 });
        assert_eq!(cur, SvgPoint { x: 15.0, y: 15.0 });
    }
    /// H keeps y, V keeps x -- including when the incoming coordinate is infinite.
    #[test]
    fn handle_horizontal_and_vertical_preserve_the_other_axis() {
        let start = SvgPoint { x: 3.0, y: 4.0 };
        let (_, els, _) = run_handler("9", start, b'H', None, |p, e| p.handle_horizontal_to(false, e));
        assert_eq!(els[0].get_end(), SvgPoint { x: 9.0, y: 4.0 });
        let (_, els, _) = run_handler("9", start, b'V', None, |p, e| p.handle_vertical_to(false, e));
        assert_eq!(els[0].get_end(), SvgPoint { x: 3.0, y: 9.0 });
        let (_, els, _) = run_handler("1e999", start, b'h', None, |p, e| p.handle_horizontal_to(true, e));
        let end = els[0].get_end();
        assert!(end.x.is_infinite(), "relative H by +inf saturates");
        assert_eq!(end.y, 4.0, "y must be untouched");
    }
    #[test]
    fn handlers_reject_empty_and_garbage_input_without_panicking() {
        for input in ["", "   ", "abc", "😀", ";", "1"] {
            // Each handler needs >= 1 number; "1" is enough only for H/V.
            let (r, _, _) = run_handler(input, ORIGIN, 0, None, |p, e| p.handle_line_to(false, e));
            assert!(r.is_err(), "line_to({input:?}) must be Err");
            let (r, _, _) = run_handler(input, ORIGIN, 0, None, |p, e| p.handle_cubic_to(false, e));
            assert!(r.is_err(), "cubic_to({input:?}) must be Err");
            let (r, _, _) = run_handler(input, ORIGIN, 0, None, |p, e| p.handle_quadratic_to(false, e));
            assert!(r.is_err(), "quadratic_to({input:?}) must be Err");
            let (r, _, _) = run_handler(input, ORIGIN, 0, None, |p, e| p.handle_arc_to(false, e));
            assert!(r.is_err(), "arc_to({input:?}) must be Err");
            if input != "1" {
                let (r, _, _) =
                    run_handler(input, ORIGIN, 0, None, |p, e| p.handle_horizontal_to(false, e));
                assert!(r.is_err(), "horizontal_to({input:?}) must be Err");
                let (r, _, _) =
                    run_handler(input, ORIGIN, 0, None, |p, e| p.handle_vertical_to(false, e));
                assert!(r.is_err(), "vertical_to({input:?}) must be Err");
            }
        }
    }
    #[test]
    fn handle_cubic_to_records_second_control_point() {
        let (r, els, _) = run_handler("1 1 2 2 3 3", ORIGIN, b'C', None, |p, e| {
            p.handle_cubic_to(false, e)
        });
        assert!(r.is_ok());
        match els[0] {
            SvgPathElement::CubicCurve(c) => {
                assert_eq!(c.start, ORIGIN);
                assert_eq!(c.ctrl_1, SvgPoint { x: 1.0, y: 1.0 });
                assert_eq!(c.ctrl_2, SvgPoint { x: 2.0, y: 2.0 });
                assert_eq!(c.end, SvgPoint { x: 3.0, y: 3.0 });
            }
            other => panic!("expected CubicCurve, got {other:?}"),
        }
    }
    /// S reflects the previous control point only when the previous command was
    /// C or S; otherwise ctrl_1 collapses onto the current point.
    #[test]
    fn handle_smooth_cubic_reflects_only_after_c_or_s() {
        let cur = SvgPoint { x: 10.0, y: 10.0 };
        let lc = Some(SvgPoint { x: 8.0, y: 6.0 });
        let (_, els, _) = run_handler("1 1 2 2", cur, b'C', lc, |p, e| p.handle_smooth_cubic_to(false, e));
        match els[0] {
            // reflection of (8,6) about (10,10) == (12,14)
            SvgPathElement::CubicCurve(c) => assert_eq!(c.ctrl_1, SvgPoint { x: 12.0, y: 14.0 }),
            other => panic!("expected CubicCurve, got {other:?}"),
        }
        // Previous command was L: no reflection, ctrl_1 == current.
        let (_, els, _) = run_handler("1 1 2 2", cur, b'L', lc, |p, e| p.handle_smooth_cubic_to(false, e));
        match els[0] {
            SvgPathElement::CubicCurve(c) => assert_eq!(c.ctrl_1, cur),
            other => panic!("expected CubicCurve, got {other:?}"),
        }
        // No stored control point at all: no reflection either.
        let (_, els, _) = run_handler("1 1 2 2", cur, b'S', None, |p, e| p.handle_smooth_cubic_to(false, e));
        match els[0] {
            SvgPathElement::CubicCurve(c) => assert_eq!(c.ctrl_1, cur),
            other => panic!("expected CubicCurve, got {other:?}"),
        }
    }
    #[test]
    fn handle_smooth_quadratic_reflects_only_after_q_or_t() {
        let cur = SvgPoint { x: 10.0, y: 10.0 };
        let lc = Some(SvgPoint { x: 8.0, y: 6.0 });
        let (_, els, _) = run_handler("2 2", cur, b'Q', lc, |p, e| p.handle_smooth_quadratic_to(false, e));
        match els[0] {
            SvgPathElement::QuadraticCurve(q) => assert_eq!(q.ctrl, SvgPoint { x: 12.0, y: 14.0 }),
            other => panic!("expected QuadraticCurve, got {other:?}"),
        }
        let (_, els, _) = run_handler("2 2", cur, b'M', lc, |p, e| p.handle_smooth_quadratic_to(false, e));
        match els[0] {
            SvgPathElement::QuadraticCurve(q) => assert_eq!(q.ctrl, cur),
            other => panic!("expected QuadraticCurve, got {other:?}"),
        }
    }
    /// Arc radii are absolute-valued, so a negative radius still draws an arc.
    #[test]
    fn handle_arc_to_takes_abs_of_radii() {
        let (r, els, cur) = run_handler("-5 -5 0 0 1 10 0", ORIGIN, b'A', None, |p, e| {
            p.handle_arc_to(false, e)
        });
        assert!(r.is_ok());
        assert!(!els.is_empty(), "negative radii must still produce an arc");
        assert!(
            els.iter().all(|e| matches!(e, SvgPathElement::CubicCurve(_))),
            "abs() of the radii keeps this a real arc, not a line fallback"
        );
        assert_eq!(cur, SvgPoint { x: 10.0, y: 0.0 });
    }
    /// A zero radius degenerates to a straight line (SVG spec F.6.6).
    #[test]
    fn handle_arc_to_zero_radius_degenerates_to_line() {
        let (r, els, _) = run_handler("0 0 0 0 1 10 0", ORIGIN, b'A', None, |p, e| {
            p.handle_arc_to(false, e)
        });
        assert!(r.is_ok());
        assert_eq!(els.len(), 1);
        assert!(matches!(els[0], SvgPathElement::Line(_)));
        assert_eq!(els[0].get_end(), SvgPoint { x: 10.0, y: 0.0 });
    }
    #[test]
    fn handle_arc_to_rejects_out_of_range_flags() {
        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"] {
            let (r, _, _) = run_handler(input, ORIGIN, b'A', None, |p, e| p.handle_arc_to(false, e));
            assert!(r.is_err(), "arc flags in {input:?} must be rejected");
        }
        let (r, _, _) = run_handler("5 5 0 2 1 10 0", ORIGIN, b'A', None, |p, e| {
            p.handle_arc_to(false, e)
        });
        assert!(matches!(r, Err(SvgPathParseError::InvalidArcFlag { .. })));
    }
    /// Infinite radii feed NaN through the endpoint parameterization. The
    /// segment count must still be bounded (no runaway loop) and no panic.
    #[test]
    fn handle_arc_to_infinite_radii_is_bounded() {
        let (r, els, _) = run_handler("1e999 1e999 0 0 1 10 10", ORIGIN, b'A', None, |p, e| {
            p.handle_arc_to(false, e)
        });
        assert!(r.is_ok());
        assert!(
            els.len() <= 4,
            "a single arc must never expand past 4 cubics, got {}",
            els.len()
        );
    }
    // ===================================================== parse_svg_path_d (parser)
    #[test]
    fn parse_path_empty_and_whitespace_only_is_empty_path_err() {
        for s in ["", "   ", "\t\n", "\r\n  \t"] {
            assert_eq!(
                parse_svg_path_d(s),
                Err(SvgPathParseError::EmptyPath),
                "{s:?} must be EmptyPath"
            );
        }
    }
    #[test]
    fn parse_path_valid_minimal() {
        let mp = parse_svg_path_d("M10 20 L30 40").unwrap();
        let rings = mp.rings.as_ref();
        assert_eq!(rings.len(), 1);
        let items = rings[0].items.as_ref();
        assert_eq!(items.len(), 1);
        assert_eq!(items[0].get_start(), SvgPoint { x: 10.0, y: 20.0 });
        assert_eq!(items[0].get_end(), SvgPoint { x: 30.0, y: 40.0 });
    }
    /// Relative commands accumulate from the current point.
    #[test]
    fn parse_path_relative_accumulates() {
        let mp = parse_svg_path_d("m10 20 l30 40").unwrap();
        let items_owner = &mp.rings.as_ref()[0];
        let items = items_owner.items.as_ref();
        assert_eq!(items[0].get_start(), SvgPoint { x: 10.0, y: 20.0 });
        assert_eq!(items[0].get_end(), SvgPoint { x: 40.0, y: 60.0 });
    }
    /// A moveto with no drawing commands produces no geometry (but is not an error).
    #[test]
    fn parse_path_moveto_only_yields_no_rings() {
        let mp = parse_svg_path_d("M10 10").unwrap();
        assert_eq!(mp.rings.as_ref().len(), 0);
    }
    /// Extra coordinate pairs after an M are implicit linetos (SVG spec).
    #[test]
    fn parse_path_implicit_lineto_after_moveto() {
        let mp = parse_svg_path_d("M0 0 10 0 20 0").unwrap();
        let items_owner = &mp.rings.as_ref()[0];
        let items = items_owner.items.as_ref();
        assert_eq!(items.len(), 2, "two implicit L commands");
        assert_eq!(items[0].get_end(), SvgPoint { x: 10.0, y: 0.0 });
        assert_eq!(items[1].get_end(), SvgPoint { x: 20.0, y: 0.0 });
    }
    #[test]
    fn parse_path_garbage_is_err_never_panics() {
        for s in ["@#$", "hello", "?", "-", ".", ",", "0 0", "5", ";;;", "\u{0}"] {
            assert!(parse_svg_path_d(s).is_err(), "{s:?} must be rejected");
        }
    }
    /// A leading non-command byte is reported as a real Unicode char at byte 0.
    #[test]
    fn parse_path_unicode_does_not_panic() {
        assert_eq!(
            parse_svg_path_d("\u{1F600}"),
            Err(SvgPathParseError::UnexpectedChar {
                pos: 0,
                ch: '\u{1F600}',
            })
        );
        assert_eq!(
            parse_svg_path_d("\u{0301}M0 0"),
            Err(SvgPathParseError::UnexpectedChar {
                pos: 0,
                ch: '\u{0301}',
            })
        );
        // A multibyte char *after* a valid command falls into the argument
        // parser and is rejected as a missing number, at the right byte offset.
        assert_eq!(
            parse_svg_path_d("M0 0L1 1ü"),
            Err(SvgPathParseError::ExpectedNumber { pos: 8 })
        );
    }
    /// Trailing junk after a valid prefix is rejected deterministically.
    #[test]
    fn parse_path_trailing_junk_is_rejected() {
        assert!(parse_svg_path_d("M0 0 L1 1;garbage").is_err());
        assert!(parse_svg_path_d("M0 0 L").is_err(), "command with no args");
        assert!(parse_svg_path_d("M0 0 L1").is_err(), "half a coordinate pair");
        assert!(parse_svg_path_d("M0 0 X10 10").is_err(), "unknown command letter");
        // Surrounding whitespace is trimmed, not rejected.
        assert!(parse_svg_path_d("  \n M0 0 L1 1 \t ").is_ok());
    }
    /// An unknown ASCII command letter reports its own offset.
    #[test]
    fn parse_path_unknown_command_reports_its_offset() {
        assert_eq!(
            parse_svg_path_d("M0 0 X10 10"),
            Err(SvgPathParseError::UnexpectedChar { pos: 5, ch: 'X' })
        );
    }
    /// Closepath only emits a joining line when the gap exceeds CLOSEPATH_EPSILON.
    #[test]
    fn parse_path_closepath_epsilon_boundary() {
        // Well above the epsilon: a closing line is added.
        let mp = parse_svg_path_d("M0 0 L1 0 Z").unwrap();
        assert_eq!(mp.rings.as_ref()[0].items.as_ref().len(), 2);
        // Exactly at the epsilon (dx*dx == eps*eps, and the test is strictly `>`):
        // no closing line.
        let mp = parse_svg_path_d("M0 0 L0.001 0 Z").unwrap();
        assert_eq!(mp.rings.as_ref()[0].items.as_ref().len(), 1);
        // Below the epsilon: no closing line.
        let mp = parse_svg_path_d("M0 0 L0.0005 0 Z").unwrap();
        assert_eq!(mp.rings.as_ref()[0].items.as_ref().len(), 1);
        // Degenerate closepath on an empty subpath yields no rings at all.
        assert_eq!(parse_svg_path_d("M0 0 Z").unwrap().rings.as_ref().len(), 0);
    }
    /// Every M and every Z flushes a ring.
    #[test]
    fn parse_path_multiple_subpaths_produce_multiple_rings() {
        let mp = parse_svg_path_d("M0 0 L10 0 Z M20 20 L30 20 Z M40 40 L50 40").unwrap();
        assert_eq!(mp.rings.as_ref().len(), 3);
    }
    /// Structural invariant: within a ring, each element's end is the next
    /// element's start -- for every command type, including arcs and closepath.
    #[test]
    fn parse_path_rings_are_contiguous_chains() {
        let d = "M0 0 L10 0 H20 V10 C25 15 30 20 35 20 S45 25 50 20 \
                 Q55 15 60 20 T70 20 A5 5 0 1 1 80 30 Z \
                 m100 100 l10 0 z";
        let mp = parse_svg_path_d(d).unwrap();
        assert!(mp.rings.as_ref().len() >= 2);
        for (i, ring) in mp.rings.as_ref().iter().enumerate() {
            assert_contiguous(ring.items.as_ref(), &format!("ring {i}"));
            assert!(!ring.items.as_ref().is_empty(), "ring {i} must not be empty");
        }
    }
    /// A closed ring ends where it started.
    #[test]
    fn parse_path_closed_ring_returns_to_subpath_start() {
        let mp = parse_svg_path_d("M0 0 L10 0 L10 10 Z").unwrap();
        let ring = &mp.rings.as_ref()[0];
        let items = ring.items.as_ref();
        assert_eq!(items.last().unwrap().get_end(), SvgPoint { x: 0.0, y: 0.0 });
        assert_eq!(items.first().unwrap().get_start(), SvgPoint { x: 0.0, y: 0.0 });
    }
    /// Boundary numerics survive the full parse: coordinates saturate to inf
    /// rather than panicking or wrapping.
    #[test]
    fn parse_path_boundary_numbers_saturate() {
        let mp = parse_svg_path_d("M1e999 -1e999 L1e-999 0").unwrap();
        let items_owner = &mp.rings.as_ref()[0];
        let start = items_owner.items.as_ref()[0].get_start();
        assert!(start.x.is_infinite() && start.x.is_sign_positive());
        assert!(start.y.is_infinite() && start.y.is_sign_negative());
        // f32::MAX-ish coordinates with a *relative* lineto overflow to +inf.
        let mp = parse_svg_path_d("M3.4e38 0 l3.4e38 0").unwrap();
        let items_owner = &mp.rings.as_ref()[0];
        assert!(items_owner.items.as_ref()[0].get_end().x.is_infinite());
        // "NaN" / "inf" are not valid SVG numbers -- they must be rejected,
        // so a NaN coordinate can never enter the geometry via the parser.
        assert!(parse_svg_path_d("M NaN 0").is_err());
        assert!(parse_svg_path_d("M inf 0").is_err());
    }
    /// 5000 implicit repeats: the parser is iterative, so this must neither
    /// hang nor blow the stack.
    #[test]
    fn parse_path_extremely_long_input_terminates() {
        let mut d = String::from("M0 0");
        for _ in 0..5_000 {
            d.push_str(" L1 1");
        }
        let mp = parse_svg_path_d(&d).unwrap();
        let items_owner = &mp.rings.as_ref()[0];
        assert_eq!(items_owner.items.as_ref().len(), 5_000);
        // 5000 subpaths -> 5000 rings, still iterative.
        let mut d = String::new();
        for _ in 0..5_000 {
            d.push_str("M0 0 L1 1 Z ");
        }
        assert_eq!(parse_svg_path_d(&d).unwrap().rings.as_ref().len(), 5_000);
    }
    /// A long run of adversarial fragments: every one must *return* (Ok or Err)
    /// and never spin. The `Z`-followed-by-a-digit case used to loop forever.
    #[test]
    fn parse_path_adversarial_fragments_all_terminate() {
        let fragments = [
            "Z", "z", "ZZZ", "M0 0ZZ", "M0 0Z0", "M0 0zZ5", "M0 0Z Z Z",
            "M", "M0", "M0 0 C", "M0 0 A", "M0 0 A1", "M0 0 A1 1 0 0 0 0",
            "M0 0 S", "M0 0 T", "M0 0 H", "M0 0 V", "M0 0 Q1",
            "M0 0 L1 1 1", "M0 0 L1 1 1 1 1", "M0 0 A0 0 0 0 0 0 0",
            "M0 0 l-.5-.5-.5-.5", "M0 0 t1 1 2 2", "M0 0 s1 1 2 2 3 3 4 4",
            "M0,0,1,1", "M0 0e", "M0 0 1e1e1", "M.5.5.5.5",
            "M0 0 A1 1 0 11 10 10", "M0 0 A1 1 0 1 1 10 10 A1 1 0 0 0 0 0",
            "M0 0 h1e999 v1e999 h-1e999", "M-0-0-0-0",
        ];
        for f in fragments {
            // The assertion is termination itself; the result is merely pinned
            // as "did not panic".
            let r = parse_svg_path_d(f);
            assert!(r.is_ok() || r.is_err(), "{f:?} must return, not panic");
        }
    }
    /// All 14 commands round-trip through the tokenizer into geometry of the
    /// expected kind.
    #[test]
    fn parse_path_every_command_produces_its_element_kind() {
        let cases: [(&str, usize); 10] = [
            ("M0 0 L1 1", 1),
            ("M0 0 l1 1", 1),
            ("M0 0 H1", 1),
            ("M0 0 V1", 1),
            ("M0 0 C1 1 2 2 3 3", 1),
            ("M0 0 S1 1 2 2", 1),
            ("M0 0 Q1 1 2 2", 1),
            ("M0 0 T1 1", 1),
            ("M0 0 L1 0 Z", 2), // the Z adds the closing line
            ("M0 0 A5 5 0 0 1 10 0", 2), // a half-turn arc splits into 2 cubics
        ];
        for (d, expected) in cases {
            let mp = parse_svg_path_d(d).unwrap_or_else(|e| panic!("{d:?} failed: {e:?}"));
            let rings = mp.rings.as_ref();
            assert_eq!(rings.len(), 1, "{d:?}");
            assert_eq!(rings[0].items.as_ref().len(), expected, "{d:?}");
        }
    }
    // ======================================================= arc_to_cubics (numeric)
    #[test]
    fn arc_to_cubics_coincident_endpoints_emit_nothing() {
        let mut out = Vec::new();
        arc_to_cubics(ORIGIN, ORIGIN, 5.0, 5.0, 0.0, true, true, &mut out);
        assert!(out.is_empty(), "a zero-length arc is dropped per SVG F.6.2");
        // Within POINT_EPSILON also counts as coincident.
        let near = SvgPoint { x: 1e-9, y: 1e-9 };
        arc_to_cubics(ORIGIN, near, 5.0, 5.0, 0.0, false, false, &mut out);
        assert!(out.is_empty());
    }
    #[test]
    fn arc_to_cubics_zero_radius_emits_a_line() {
        let end = SvgPoint { x: 10.0, y: 10.0 };
        for (rx, ry) in [(0.0, 5.0), (5.0, 0.0), (0.0, 0.0)] {
            let mut out = Vec::new();
            arc_to_cubics(ORIGIN, end, rx, ry, 0.0, false, true, &mut out);
            assert_eq!(out.len(), 1, "rx={rx} ry={ry}");
            assert!(matches!(out[0], SvgPathElement::Line(_)));
            assert_eq!(out[0].get_start(), ORIGIN);
            assert_eq!(out[0].get_end(), end);
        }
    }
    /// For every flag combination, the arc is 1..=4 cubics that start exactly at
    /// `start` and end exactly at `end` (the endpoint is snapped, not computed).
    #[test]
    fn arc_to_cubics_endpoints_are_exact_for_all_flag_combos() {
        let start = SvgPoint { x: 0.0, y: 0.0 };
        let end = SvgPoint { x: 10.0, y: 10.0 };
        for large_arc in [false, true] {
            for sweep in [false, true] {
                let mut out = Vec::new();
                arc_to_cubics(start, end, 8.0, 6.0, 30.0, large_arc, sweep, &mut out);
                assert!(
                    (1..=4).contains(&out.len()),
                    "large_arc={large_arc} sweep={sweep}: got {} cubics",
                    out.len()
                );
                assert_eq!(out[0].get_start(), start);
                assert_eq!(out.last().unwrap().get_end(), end);
                assert_contiguous(&out, "arc");
                for p in out.iter().flat_map(|e| [e.get_start(), e.get_end()]) {
                    assert!(p.x.is_finite() && p.y.is_finite(), "arc produced {p:?}");
                }
            }
        }
    }
    /// Radii that are too small to span the endpoints are scaled up (F.6.6 step 3),
    /// so the arc still lands exactly on the endpoint instead of NaN-ing out.
    #[test]
    fn arc_to_cubics_undersized_radii_are_scaled_up() {
        let start = ORIGIN;
        let end = SvgPoint { x: 100.0, y: 0.0 };
        let mut out = Vec::new();
        arc_to_cubics(start, end, 1.0, 1.0, 0.0, false, true, &mut out);
        assert!(!out.is_empty());
        assert_eq!(out.last().unwrap().get_end(), end);
        for p in all_points(&SvgPath {
            items: SvgPathElementVec::from_vec(out),
        }) {
            assert!(p.x.is_finite() && p.y.is_finite(), "scaled arc produced {p:?}");
        }
    }
    /// NaN / infinite radii must not spin the segment loop: `n_segs` comes from a
    /// NaN -> usize cast, which saturates to 0 and is then clamped to 1.
    #[test]
    fn arc_to_cubics_nan_and_inf_inputs_are_bounded() {
        let end = SvgPoint { x: 10.0, y: 10.0 };
        let bad = [
            (f32::NAN, 5.0, 0.0),
            (5.0, f32::NAN, 0.0),
            (f32::INFINITY, f32::INFINITY, 0.0),
            (5.0, 5.0, f32::NAN),
            (5.0, 5.0, f32::INFINITY),
            (f32::MAX, f32::MAX, 360.0),
        ];
        for (rx, ry, rot) in bad {
            let mut out = Vec::new();
            arc_to_cubics(ORIGIN, end, rx, ry, rot, true, false, &mut out);
            assert!(
                out.len() <= 4,
                "rx={rx} ry={ry} rot={rot}: {} elements (segment loop ran away)",
                out.len()
            );
        }
    }
    /// Extreme but finite endpoints do not panic and stay bounded.
    #[test]
    fn arc_to_cubics_extreme_endpoints_do_not_panic() {
        let mut out = Vec::new();
        arc_to_cubics(
            SvgPoint { x: f32::MIN, y: f32::MIN },
            SvgPoint { x: f32::MAX, y: f32::MAX },
            f32::MAX,
            f32::MAX,
            0.0,
            true,
            true,
            &mut out,
        );
        assert!(out.len() <= 4);
    }
    // ======================================================= angle_between (numeric)
    #[test]
    fn angle_between_known_angles() {
        assert!(approx(angle_between(1.0, 0.0, 1.0, 0.0), 0.0));
        assert!(approx(
            angle_between(1.0, 0.0, 0.0, 1.0),
            core::f32::consts::FRAC_PI_2
        ));
        assert!(approx(
            angle_between(1.0, 0.0, 0.0, -1.0),
            -core::f32::consts::FRAC_PI_2
        ));
        assert!(approx(
            angle_between(1.0, 0.0, -1.0, 0.0),
            core::f32::consts::PI
        ));
        // Magnitude is irrelevant -- only direction matters.
        assert!(approx(
            angle_between(100.0, 0.0, 0.0, 0.001),
            core::f32::consts::FRAC_PI_2
        ));
    }
    /// A zero-length (or underflowing) vector short-circuits to 0.0.
    #[test]
    fn angle_between_zero_length_vectors_return_zero() {
        assert_eq!(angle_between(0.0, 0.0, 1.0, 0.0), 0.0);
        assert_eq!(angle_between(1.0, 0.0, 0.0, 0.0), 0.0);
        assert_eq!(angle_between(0.0, 0.0, 0.0, 0.0), 0.0);
        // Denormal-scale vectors: the squared length underflows to 0.
        assert_eq!(angle_between(1e-30, 1e-30, 1e-30, 1e-30), 0.0);
    }
    /// Result is always within [-PI, PI] for finite inputs (the acos argument is
    /// clamped, so rounding can never push it out of the domain).
    #[test]
    fn angle_between_is_always_within_pi_for_finite_inputs() {
        let vals = [-1e30_f32, -3.0, -1.0, -0.0, 0.0, 1.0, 3.0, 1e30];
        for ux in vals {
            for uy in vals {
                for vx in vals {
                    for vy in vals {
                        let a = angle_between(ux, uy, vx, vy);
                        assert!(
                            a.is_nan() || a.abs() <= core::f32::consts::PI + 1e-5,
                            "angle_between({ux},{uy},{vx},{vy}) = {a} is out of range"
                        );
                    }
                }
            }
        }
    }
    /// Antiparallel/parallel unit vectors do not fall out of acos's domain even
    /// when the dot product rounds slightly past +/-1.
    #[test]
    fn angle_between_clamps_the_acos_domain() {
        let a = angle_between(0.1, 0.2, 0.1, 0.2);
        assert!(!a.is_nan(), "parallel vectors must not produce NaN, got {a}");
        assert!(approx(a, 0.0));
        let a = angle_between(0.1, 0.2, -0.1, -0.2);
        assert!(!a.is_nan());
        assert!(approx(a.abs(), core::f32::consts::PI));
    }
    /// NaN / inf inputs produce NaN, not a panic.
    #[test]
    fn angle_between_nan_and_inf_do_not_panic() {
        assert!(angle_between(f32::NAN, 0.0, 1.0, 0.0).is_nan());
        assert!(angle_between(1.0, 0.0, f32::NAN, f32::NAN).is_nan());
        assert!(angle_between(f32::INFINITY, 0.0, f32::INFINITY, 0.0).is_nan());
        assert!(angle_between(f32::INFINITY, 0.0, 1.0, 0.0).is_nan());
    }
    // ================================================ arc_segment_to_cubic (numeric)
    #[test]
    fn arc_segment_to_cubic_quarter_circle() {
        // Unit circle, no rotation, 0 -> PI/2: the endpoint must land on (0, 1).
        let (c1, c2, ep) = arc_segment_to_cubic(
            0.0,
            0.0,
            1.0,
            1.0,
            1.0,
            0.0,
            0.0,
            core::f32::consts::FRAC_PI_2,
        );
        assert!(approx(ep.x, 0.0) && approx(ep.y, 1.0), "ep = {ep:?}");
        // Control points bulge outward by kappa.
        assert!(approx(c1.x, 1.0) && approx(c1.y, KAPPA), "c1 = {c1:?}");
        assert!(approx(c2.x, KAPPA) && approx(c2.y, 1.0), "c2 = {c2:?}");
    }
    /// A zero-width segment collapses every point onto the start of the arc.
    #[test]
    fn arc_segment_to_cubic_zero_sweep_collapses() {
        let (c1, c2, ep) = arc_segment_to_cubic(5.0, 5.0, 2.0, 2.0, 1.0, 0.0, 0.7, 0.7);
        assert!(approx(c1.x, c2.x) && approx(c1.y, c2.y));
        assert!(approx(c2.x, ep.x) && approx(c2.y, ep.y));
        // ...and that point is on the circle around (5,5).
        assert!(approx((ep.x - 5.0).hypot(ep.y - 5.0), 2.0));
    }
    /// Zero radii put all three points at the center.
    #[test]
    fn arc_segment_to_cubic_zero_radius_is_the_center() {
        let (c1, c2, ep) = arc_segment_to_cubic(3.0, 4.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0);
        for p in [c1, c2, ep] {
            assert_eq!(p, SvgPoint { x: 3.0, y: 4.0 });
        }
    }
    /// Rotation is applied via the caller-supplied cos/sin pair.
    #[test]
    fn arc_segment_to_cubic_applies_rotation() {
        // 90-degree rotation (cos=0, sin=1) maps the theta=0 point (rx, 0) to (0, rx).
        let (_, _, ep) = arc_segment_to_cubic(0.0, 0.0, 2.0, 1.0, 0.0, 1.0, 0.0, 0.0);
        assert!(approx(ep.x, 0.0) && approx(ep.y, 2.0), "ep = {ep:?}");
    }
    #[test]
    fn arc_segment_to_cubic_nan_and_inf_do_not_panic() {
        let bad = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN];
        for v in bad {
            let (c1, c2, ep) = arc_segment_to_cubic(v, v, v, v, v, v, v, v);
            // The only contract is "returns a defined value without panicking".
            for p in [c1, c2, ep] {
                assert!(p.x.is_nan() || p.x.is_finite() || p.x.is_infinite());
                assert!(p.y.is_nan() || p.y.is_finite() || p.y.is_infinite());
            }
        }
        // A full-circle sweep drives tan(PI/2) to a huge value; still no panic.
        let (c1, _, _) = arc_segment_to_cubic(
            0.0,
            0.0,
            1.0,
            1.0,
            1.0,
            0.0,
            0.0,
            core::f32::consts::TAU,
        );
        assert!(!c1.x.is_nan() || c1.x.is_nan(), "must not panic");
    }
    // =================================================== svg_circle_to_paths (numeric)
    #[test]
    fn circle_has_four_cubics_and_closes_on_itself() {
        let p = svg_circle_to_paths(10.0, 20.0, 5.0);
        let items = p.items.as_ref();
        assert_eq!(items.len(), 4);
        assert!(items.iter().all(|e| matches!(e, SvgPathElement::CubicCurve(_))));
        assert_contiguous(items, "circle");
        assert_eq!(
            items.last().unwrap().get_end(),
            items.first().unwrap().get_start(),
            "the circle must close exactly"
        );
        // The four anchors are the cardinal points.
        assert_eq!(items[0].get_start(), SvgPoint { x: 10.0, y: 15.0 });
        assert_eq!(items[0].get_end(), SvgPoint { x: 15.0, y: 20.0 });
        assert_eq!(items[1].get_end(), SvgPoint { x: 10.0, y: 25.0 });
        assert_eq!(items[2].get_end(), SvgPoint { x: 5.0, y: 20.0 });
    }
    /// r = 0 degenerates to four zero-length curves at the center, not a panic.
    #[test]
    fn circle_zero_radius_collapses_to_the_center() {
        let p = svg_circle_to_paths(3.0, 4.0, 0.0);
        let items = p.items.as_ref();
        assert_eq!(items.len(), 4);
        for pt in all_points(&p) {
            assert_eq!(pt, SvgPoint { x: 3.0, y: 4.0 });
        }
    }
    /// A negative radius mirrors the circle (it is not rejected or abs()'d);
    /// it still yields a closed 4-curve path.
    #[test]
    fn circle_negative_radius_is_mirrored_not_rejected() {
        let p = svg_circle_to_paths(0.0, 0.0, -5.0);
        let items = p.items.as_ref();
        assert_eq!(items.len(), 4);
        assert_contiguous(items, "negative-r circle");
        assert_eq!(items[0].get_start(), SvgPoint { x: 0.0, y: 5.0 });
        for pt in all_points(&p) {
            assert!(pt.x.is_finite() && pt.y.is_finite());
        }
    }
    #[test]
    fn circle_nan_inf_and_max_do_not_panic() {
        for (cx, cy, r) in [
            (f32::NAN, 0.0, 1.0),
            (0.0, 0.0, f32::NAN),
            (0.0, 0.0, f32::INFINITY),
            (f32::MAX, f32::MAX, f32::MAX),
            (f32::MIN, f32::MIN, f32::MIN),
        ] {
            let p = svg_circle_to_paths(cx, cy, r);
            assert_eq!(p.items.as_ref().len(), 4, "cx={cx} cy={cy} r={r}");
        }
        // f32::MAX radius overflows the control points to infinity rather than
        // wrapping.
        let p = svg_circle_to_paths(f32::MAX, 0.0, f32::MAX);
        assert!(all_points(&p).iter().any(|pt| pt.x.is_infinite()));
    }
    // ==================================================== svg_rect_to_path (numeric)
    #[test]
    fn rect_sharp_corners_are_four_lines() {
        let p = svg_rect_to_path(1.0, 2.0, 10.0, 20.0, 0.0, 0.0);
        let items = p.items.as_ref();
        assert_eq!(items.len(), 4);
        assert!(items.iter().all(|e| matches!(e, SvgPathElement::Line(_))));
        assert_contiguous(items, "sharp rect");
        assert_eq!(items[0].get_start(), SvgPoint { x: 1.0, y: 2.0 });
        assert_eq!(items[1].get_start(), SvgPoint { x: 11.0, y: 2.0 });
        assert_eq!(items[2].get_start(), SvgPoint { x: 11.0, y: 22.0 });
        assert_eq!(items[3].get_start(), SvgPoint { x: 1.0, y: 22.0 });
        assert_eq!(
            items.last().unwrap().get_end(),
            items.first().unwrap().get_start(),
            "the rect must close exactly"
        );
    }
    #[test]
    fn rect_rounded_is_eight_alternating_segments_and_closes() {
        let p = svg_rect_to_path(0.0, 0.0, 100.0, 50.0, 10.0, 5.0);
        let items = p.items.as_ref();
        assert_eq!(items.len(), 8);
        for (i, e) in items.iter().enumerate() {
            if i % 2 == 0 {
                assert!(matches!(e, SvgPathElement::Line(_)), "item {i} should be an edge");
            } else {
                assert!(
                    matches!(e, SvgPathElement::CubicCurve(_)),
                    "item {i} should be a corner"
                );
            }
        }
        assert_contiguous(items, "rounded rect");
        assert_eq!(
            items.last().unwrap().get_end(),
            items.first().unwrap().get_start(),
            "the rounded rect must close exactly"
        );
    }
    /// Radii larger than half the rect are clamped to half (SVG spec), so an
    /// over-large rx cannot invert the geometry.
    #[test]
    fn rect_oversized_radii_are_clamped_to_half() {
        let p = svg_rect_to_path(0.0, 0.0, 10.0, 10.0, 1000.0, 1000.0);
        let items = p.items.as_ref();
        assert_eq!(items.len(), 8);
        // rx clamps to 5 => the top edge runs from x+5 to x+w-5, i.e. zero length.
        match items[0] {
            SvgPathElement::Line(l) => {
                assert_eq!(l.start, SvgPoint { x: 5.0, y: 0.0 });
                assert_eq!(l.end, SvgPoint { x: 5.0, y: 0.0 });
            }
            other => panic!("expected Line, got {other:?}"),
        }
        assert_contiguous(items, "clamped rect");
        for pt in all_points(&p) {
            assert!(pt.x.is_finite() && pt.y.is_finite());
            assert!((0.0..=10.0).contains(&pt.x), "x {} escaped the rect", pt.x);
            assert!((0.0..=10.0).contains(&pt.y), "y {} escaped the rect", pt.y);
        }
    }
    /// Only one of rx/ry being zero still takes the rounded path.
    #[test]
    fn rect_single_zero_radius_still_rounds() {
        let p = svg_rect_to_path(0.0, 0.0, 100.0, 100.0, 0.0, 10.0);
        assert_eq!(p.items.as_ref().len(), 8);
        let p = svg_rect_to_path(0.0, 0.0, 100.0, 100.0, 10.0, 0.0);
        assert_eq!(p.items.as_ref().len(), 8);
    }
    /// A negative width drives `rx.min(w / 2.0)` negative, which falls below the
    /// epsilon and takes the sharp-corner branch. Deterministic, no panic.
    #[test]
    fn rect_negative_extent_takes_the_sharp_branch() {
        let p = svg_rect_to_path(0.0, 0.0, -10.0, -10.0, 4.0, 4.0);
        let items = p.items.as_ref();
        assert_eq!(items.len(), 4);
        assert!(items.iter().all(|e| matches!(e, SvgPathElement::Line(_))));
        assert_contiguous(items, "negative rect");
        assert_eq!(items[1].get_start(), SvgPoint { x: -10.0, y: 0.0 });
    }
    /// `f32::min` returns the non-NaN operand, so a NaN radius silently becomes
    /// half the extent -- and every coordinate stays finite.
    #[test]
    fn rect_nan_radius_falls_back_to_half_extent() {
        let p = svg_rect_to_path(0.0, 0.0, 100.0, 100.0, f32::NAN, f32::NAN);
        let items = p.items.as_ref();
        assert_eq!(items.len(), 8, "NaN radii clamp to w/2, h/2 -> rounded path");
        for pt in all_points(&p) {
            assert!(pt.x.is_finite() && pt.y.is_finite(), "NaN leaked into {pt:?}");
        }
        // rx == 50 => the top edge collapses (x+50 .. x+100-50).
        match items[0] {
            SvgPathElement::Line(l) => assert_eq!(l.start, l.end),
            other => panic!("expected Line, got {other:?}"),
        }
    }
    /// A NaN extent cannot be repaired -- but it must still return a well-formed
    /// path rather than panicking.
    #[test]
    fn rect_nan_extent_is_deterministic() {
        let p = svg_rect_to_path(0.0, 0.0, f32::NAN, f32::NAN, 0.0, 0.0);
        // rx = 0.min(NaN) = 0 -> sharp branch.
        assert_eq!(p.items.as_ref().len(), 4);
        let p = svg_rect_to_path(f32::NAN, f32::NAN, 10.0, 10.0, 0.0, 0.0);
        assert_eq!(p.items.as_ref().len(), 4);
    }
    #[test]
    fn rect_inf_and_max_extents_do_not_panic() {
        for (x, y, w, h, rx, ry) in [
            (0.0, 0.0, f32::INFINITY, f32::INFINITY, 0.0, 0.0),
            (0.0, 0.0, f32::MAX, f32::MAX, 0.0, 0.0),
            (f32::MIN, f32::MIN, f32::MAX, f32::MAX, f32::MAX, f32::MAX),
            (0.0, 0.0, f32::INFINITY, f32::INFINITY, f32::INFINITY, f32::INFINITY),
        ] {
            let p = svg_rect_to_path(x, y, w, h, rx, ry);
            let n = p.items.as_ref().len();
            assert!(n == 4 || n == 8, "w={w} h={h} rx={rx} ry={ry}: got {n} items");
        }
    }
}