1
use agg_rust::{
2
    basics::{FillingRule, PATH_FLAGS_NONE},
3
    color::Rgba8,
4
    conv_stroke::ConvStroke,
5
    conv_transform::ConvTransform,
6
    path_storage::PathStorage,
7
    trans_affine::TransAffine,
8
};
9
use azul_core::resources::ImageRef;
10

            
11
#[allow(clippy::wildcard_imports)]
12
// widget/render module pulls in the css property/value types it builds with
13
use super::*;
14

            
15
/// Render raw SVG bytes to a PNG image ON AN OPAQUE WHITE BACKGROUND.
16
///
17
/// Parses the SVG XML, walks the element tree, extracts path geometry +
18
/// fill/stroke attributes, and rasterizes via agg-rust directly (no CSS
19
/// layout involved).
20
///
21
/// An SVG has no background of its own, so "white" is this function's
22
/// CHOICE - fine for a document preview, wrong for an icon, which has to
23
/// composite over whatever is behind it. Use
24
/// [`render_svg_to_png_over`] to say what the backdrop is.
25
#[cfg(all(feature = "std", feature = "xml"))]
26
/// # Errors
27
///
28
/// Returns an error string if the SVG cannot be parsed or rendered.
29
52
pub fn render_svg_to_png(
30
52
    svg_data: &[u8],
31
52
    target_width: u32,
32
52
    target_height: u32,
33
52
) -> Result<Vec<u8>, String> {
34
52
    render_svg_to_png_over(
35
52
        svg_data,
36
52
        target_width,
37
52
        target_height,
38
52
        Some((255, 255, 255, 255)),
39
    )
40
52
}
41

            
42
/// [`render_svg_to_png`] over an explicit backdrop.
43
///
44
/// `background = None` renders onto TRANSPARENT pixels, which is what an SVG
45
/// actually has and what any icon needs: filled white, a themed window-control
46
/// icon arrives as a white tile sitting in the titlebar instead of a glyph on
47
/// it. `Some((r, g, b, a))` fills first, for the callers that want a page.
48
#[cfg(all(feature = "std", feature = "xml"))]
49
/// # Errors
50
///
51
/// Returns an error string if the SVG cannot be parsed or rendered.
52
55
pub fn render_svg_to_png_over(
53
55
    svg_data: &[u8],
54
55
    target_width: u32,
55
55
    target_height: u32,
56
55
    background: Option<(u8, u8, u8, u8)>,
57
55
) -> Result<Vec<u8>, String> {
58
52
    let svg_str =
59
55
        core::str::from_utf8(svg_data).map_err(|e| format!("SVG is not valid UTF-8: {e}"))?;
60

            
61
49
    let nodes =
62
52
        crate::xml::parse_xml_string(svg_str).map_err(|e| format!("XML parse error: {e}"))?;
63

            
64
    // Find the <svg> root
65
49
    let node_slice: &[azul_core::xml::XmlNodeChild] = nodes.as_ref();
66
49
    let svg_node = node_slice
67
49
        .iter()
68
49
        .find_map(|n| {
69
45
            if let azul_core::xml::XmlNodeChild::Element(e) = n {
70
43
                let tag = e.node_type.as_str().to_lowercase();
71
43
                if tag == "svg" {
72
41
                    Some(e)
73
                } else {
74
2
                    None
75
                }
76
            } else {
77
2
                None
78
            }
79
45
        })
80
49
        .ok_or_else(|| "No <svg> root element found".to_string())?;
81

            
82
    // Parse viewBox for coordinate mapping
83
41
    let vb = parse_viewbox(svg_node);
84
41
    let (vb_x, vb_y, vb_w, vb_h) =
85
41
        vb.unwrap_or_else(|| (0.0, 0.0, f64::from(target_width), f64::from(target_height)));
86

            
87
41
    let sx = f64::from(target_width) / vb_w;
88
41
    let sy = f64::from(target_height) / vb_h;
89
41
    let scale = sx.min(sy);
90

            
91
41
    let root_transform =
92
41
        TransAffine::new_custom(scale, 0.0, 0.0, scale, -vb_x * scale, -vb_y * scale);
93

            
94
41
    let mut pixmap = AzulPixmap::new(target_width, target_height)
95
41
        .ok_or_else(|| "Failed to create pixmap".to_string())?;
96
    // `None` leaves the pixmap at its zeroed (fully transparent) state.
97
38
    if let Some((r, g, b, a)) = background {
98
36
        pixmap.fill(r, g, b, a);
99
36
    }
100

            
101
38
    render_svg_group(svg_node, &mut pixmap, &root_transform);
102

            
103
38
    pixmap
104
38
        .encode_png()
105
38
        .map_err(|e| format!("PNG encode error: {e}"))
106
55
}
107

            
108
/// Like [`render_svg_to_png`] but returns the rendered pixmap as an [`ImageRef`]
109
/// (RGBA8) directly — no PNG round-trip.
110
///
111
/// The `MapWidget` uses this to render each
112
/// decoded tile SVG to a colour image node: `SvgNodeData::Path` in the DOM only
113
/// produces a clip mask (not a filled shape), so reuse the same `render_svg_group`
114
/// rasteriser the tiger uses (which reads SVG fill/stroke attrs) and embed the
115
/// result as an image.
116
/// # Errors
117
///
118
/// Returns an error string if the SVG cannot be parsed or rendered.
119
47
pub fn render_svg_to_imageref(
120
47
    svg_data: &[u8],
121
47
    target_width: u32,
122
47
    target_height: u32,
123
47
) -> Result<ImageRef, String> {
124
46
    let svg_str =
125
47
        core::str::from_utf8(svg_data).map_err(|e| format!("SVG is not valid UTF-8: {e}"))?;
126
34
    let nodes =
127
46
        crate::xml::parse_xml_string(svg_str).map_err(|e| format!("XML parse error: {e}"))?;
128
34
    let node_slice: &[azul_core::xml::XmlNodeChild] = nodes.as_ref();
129
34
    let svg_node = node_slice
130
34
        .iter()
131
34
        .find_map(|n| {
132
28
            if let azul_core::xml::XmlNodeChild::Element(e) = n {
133
24
                if e.node_type.as_str().to_lowercase() == "svg" {
134
22
                    Some(e)
135
                } else {
136
2
                    None
137
                }
138
            } else {
139
4
                None
140
            }
141
28
        })
142
34
        .ok_or_else(|| "No <svg> root element found".to_string())?;
143

            
144
22
    let vb = parse_viewbox(svg_node);
145
22
    let (vb_x, vb_y, vb_w, vb_h) =
146
22
        vb.unwrap_or_else(|| (0.0, 0.0, f64::from(target_width), f64::from(target_height)));
147
22
    let scale = (f64::from(target_width) / vb_w).min(f64::from(target_height) / vb_h);
148
22
    let root_transform =
149
22
        TransAffine::new_custom(scale, 0.0, 0.0, scale, -vb_x * scale, -vb_y * scale);
150

            
151
22
    let mut pixmap = AzulPixmap::new(target_width, target_height)
152
22
        .ok_or_else(|| "Failed to create pixmap".to_string())?;
153
    // Transparent background so the tile container shows through any gaps.
154
19
    pixmap.fill(0, 0, 0, 0);
155
19
    render_svg_group(svg_node, &mut pixmap, &root_transform);
156

            
157
19
    let rgba = pixmap.data().to_vec();
158
19
    let raw = azul_core::resources::RawImage {
159
19
        pixels: azul_core::resources::RawImageData::U8(rgba.into()),
160
19
        width: target_width as usize,
161
19
        height: target_height as usize,
162
19
        premultiplied_alpha: false,
163
19
        data_format: azul_core::resources::RawImageFormat::RGBA8,
164
19
        tag: Vec::new().into(),
165
19
    };
166
19
    ImageRef::new_rawimage(raw).ok_or_else(|| "Failed to build ImageRef from pixmap".to_string())
167
47
}
168

            
169
#[cfg(all(feature = "std", feature = "xml"))]
170
88
fn parse_viewbox(node: &azul_core::xml::XmlNode) -> Option<(f64, f64, f64, f64)> {
171
88
    let vb = node
172
88
        .attributes
173
88
        .get_key("viewbox")
174
88
        .or_else(|| node.attributes.get_key("viewBox"))?;
175
85
    let nums: Vec<f64> = vb
176
85
        .as_str()
177
400891
        .split(|c: char| c == ',' || c.is_ascii_whitespace())
178
200329
        .filter(|s| !s.is_empty())
179
200319
        .filter_map(|s| s.parse().ok())
180
85
        .collect();
181
85
    if nums.len() == 4 {
182
73
        Some((nums[0], nums[1], nums[2], nums[3]))
183
    } else {
184
12
        None
185
    }
186
88
}
187

            
188
/// Inherited SVG style (fill, stroke, stroke-width) that cascades from parent groups.
189
#[cfg(all(feature = "std", feature = "xml"))]
190
#[derive(Clone, Default)]
191
struct SvgInheritedStyle {
192
    fill: Option<String>,   // None = not set (inherit default black)
193
    stroke: Option<String>, // None = not set (inherit default none)
194
    stroke_width: Option<f64>,
195
}
196

            
197
#[cfg(all(feature = "std", feature = "xml"))]
198
86
fn render_svg_group(
199
86
    node: &azul_core::xml::XmlNode,
200
86
    pixmap: &mut AzulPixmap,
201
86
    parent_transform: &TransAffine,
202
86
) {
203
86
    render_svg_group_with_style(
204
86
        node,
205
86
        pixmap,
206
86
        parent_transform,
207
86
        &SvgInheritedStyle::default(),
208
    );
209
86
}
210

            
211
/// One presentation property of an element, `style="..."` first.
212
///
213
/// SVG lets the same property be written two ways - as an XML attribute
214
/// (`fill="#fff"`) or as a declaration inside `style` (`style="fill:#fff"`) -
215
/// and CSS says the `style` one wins. Reading only the attribute is not a
216
/// partial implementation, it is a WRONG one: an element that states its paint
217
/// only in `style` reads as unstated and falls through to the SVG default
218
/// (opaque black). Every Breeze icon is authored that way
219
/// (`style="fill:currentColor;fill-opacity:1;stroke:none"`), so every themed
220
/// icon came out black regardless of the palette it was tinted with.
221
///
222
/// A hand-written scan rather than the CSS parser: this is a semicolon list of
223
/// `name:value` with no selectors, nesting or at-rules, and the rasteriser
224
/// runs before any cascade exists.
225
#[cfg(all(feature = "std", feature = "xml"))]
226
76882
fn style_property(node: &azul_core::xml::XmlNode, name: &str) -> Option<String> {
227
76882
    let style = node.attributes.get_key("style")?;
228
1570
    for decl in style.as_str().split(';') {
229
1570
        let (key, value) = decl.split_once(':')?;
230
1570
        if key.trim() == name {
231
230
            return Some(value.trim().to_string());
232
1340
        }
233
    }
234
120
    None
235
76882
}
236

            
237
/// [`style_property`], falling back to the plain XML attribute.
238
#[cfg(all(feature = "std", feature = "xml"))]
239
76882
fn presentation_property(node: &azul_core::xml::XmlNode, name: &str) -> Option<String> {
240
76882
    style_property(node, name).or_else(|| {
241
76652
        node.attributes
242
76652
            .get_key(name)
243
76652
            .map(|s| s.as_str().to_string())
244
76652
    })
245
76882
}
246

            
247
#[cfg(all(feature = "std", feature = "xml"))]
248
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded graphics/coord/font/fixed-point/debug-marker cast
249
#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine
250
                                 // (one branch per case)
251
12626
fn render_svg_group_with_style(
252
12626
    node: &azul_core::xml::XmlNode,
253
12626
    pixmap: &mut AzulPixmap,
254
12626
    parent_transform: &TransAffine,
255
12626
    parent_style: &SvgInheritedStyle,
256
12626
) {
257
    use agg_rust::math_stroke::{LineCap, LineJoin};
258
    use azul_core::xml::{XmlNode, XmlNodeChild};
259

            
260
12626
    let group_transform = node
261
12626
        .attributes
262
12626
        .get_key("transform")
263
12626
        .map_or(*parent_transform, |t| {
264
21
            let mut tf = parse_svg_transform(t.as_str());
265
21
            tf.premultiply(parent_transform);
266
21
            tf
267
21
        });
268

            
269
    // Inherit style from this group's attributes
270
12626
    let group_style = SvgInheritedStyle {
271
12626
        fill: presentation_property(node, "fill").or_else(|| parent_style.fill.clone()),
272
12626
        stroke: presentation_property(node, "stroke").or_else(|| parent_style.stroke.clone()),
273
12626
        stroke_width: presentation_property(node, "stroke-width")
274
12626
            .and_then(|s| s.parse().ok())
275
12626
            .or(parent_style.stroke_width),
276
    };
277

            
278
27487
    for child in node.children.as_ref() {
279
27487
        let XmlNodeChild::Element(child_node) = child else {
280
7445
            continue;
281
        };
282

            
283
20042
        let tag = child_node.node_type.as_str().to_lowercase();
284

            
285
20042
        match tag.as_str() {
286
20042
            "g" | "svg" => {
287
12423
                render_svg_group_with_style(child_node, pixmap, &group_transform, &group_style);
288
12423
            }
289
7619
            "path" | "circle" | "rect" | "ellipse" | "line" | "polygon" | "polyline" => {
290
7504
                let Some(path_storage) = build_agg_path(child_node) else {
291
4
                    continue;
292
                };
293

            
294
                // Flatten bezier curves into line segments for the rasterizer
295
7500
                let mut curved = agg_rust::conv_curve::ConvCurve::new(path_storage);
296

            
297
                // Per-element transform
298
7500
                let elem_transform =
299
7500
                    child_node
300
7500
                        .attributes
301
7500
                        .get_key("transform")
302
7500
                        .map_or(group_transform, |t| {
303
3
                            let mut tf = parse_svg_transform(t.as_str());
304
3
                            tf.premultiply(&group_transform);
305
3
                            tf
306
3
                        });
307

            
308
                // Fill: element overrides group
309
7500
                let fill_attr =
310
7500
                    presentation_property(child_node, "fill").or_else(|| group_style.fill.clone());
311
7500
                let fill_color = match fill_attr.as_deref() {
312
7483
                    Some("none") => None,
313
7352
                    Some(c) => parse_svg_color(c),
314
17
                    None => Some(Rgba8 {
315
17
                        r: 0,
316
17
                        g: 0,
317
17
                        b: 0,
318
17
                        a: 255,
319
17
                    }), // SVG default
320
                };
321

            
322
7500
                let fill_opacity = presentation_property(child_node, "fill-opacity")
323
7500
                    .and_then(|s| s.parse::<f64>().ok())
324
7500
                    .unwrap_or(1.0);
325

            
326
7500
                let opacity = presentation_property(child_node, "opacity")
327
7500
                    .and_then(|s| s.parse::<f64>().ok())
328
7500
                    .unwrap_or(1.0);
329

            
330
7500
                if let Some(mut color) = fill_color {
331
7368
                    color.a = (f64::from(color.a) * fill_opacity * opacity).min(255.0) as u8;
332

            
333
7368
                    let fill_rule_str = presentation_property(child_node, "fill-rule");
334
7368
                    let rule = match fill_rule_str.as_deref() {
335
                        Some("evenodd") => FillingRule::EvenOdd,
336
7368
                        _ => FillingRule::NonZero,
337
                    };
338

            
339
7368
                    let mut transformed = ConvTransform::new(&mut curved, elem_transform);
340
7368
                    agg_fill_path(pixmap, &mut transformed, &color, rule);
341
132
                }
342

            
343
                // Stroke: element overrides group
344
7500
                let stroke_attr = presentation_property(child_node, "stroke")
345
7500
                    .or_else(|| group_style.stroke.clone());
346
7500
                let stroke_color = match stroke_attr.as_deref() {
347
6682
                    Some("none") | None => None,
348
818
                    Some(c) => parse_svg_color(c),
349
                };
350

            
351
7500
                if let Some(mut color) = stroke_color {
352
818
                    let stroke_opacity = presentation_property(child_node, "stroke-opacity")
353
818
                        .and_then(|s| s.parse::<f64>().ok())
354
818
                        .unwrap_or(1.0);
355
818
                    color.a = (f64::from(color.a) * stroke_opacity * opacity).min(255.0) as u8;
356

            
357
818
                    let stroke_width = presentation_property(child_node, "stroke-width")
358
818
                        .and_then(|s| s.parse::<f64>().ok())
359
818
                        .or(group_style.stroke_width)
360
818
                        .unwrap_or(1.0);
361

            
362
818
                    let mut conv_stroke = ConvStroke::new(&mut curved);
363
818
                    conv_stroke.set_width(stroke_width);
364
818
                    conv_stroke.set_line_cap(LineCap::Round);
365
818
                    conv_stroke.set_line_join(LineJoin::Round);
366

            
367
818
                    let mut transformed = ConvTransform::new(&mut conv_stroke, elem_transform);
368
818
                    agg_fill_path(pixmap, &mut transformed, &color, FillingRule::NonZero);
369
6682
                }
370
            }
371
115
            _ => {
372
115
                // Recurse into unknown containers (defs, symbol, etc.)
373
115
                render_svg_group_with_style(child_node, pixmap, &group_transform, &group_style);
374
115
            }
375
        }
376
    }
377
12626
}
378

            
379
/// Build an agg `PathStorage` from an SVG shape element's attributes.
380
#[cfg(all(feature = "std", feature = "xml"))]
381
#[allow(clippy::cast_possible_truncation)] // bounded graphics/coord/font/fixed-point/debug-marker
382
                                           // cast
383
7559
fn build_agg_path(node: &azul_core::xml::XmlNode) -> Option<PathStorage> {
384
    const KAPPA: f64 = 0.552_284_749_8;
385
7559
    let tag = node.node_type.as_str().to_lowercase();
386
7559
    match tag.as_str() {
387
7559
        "path" => {
388
2435
            let d = node.attributes.get_key("d")?;
389
2434
            let mp = azul_core::path_parser::parse_svg_path_d(d.as_str()).ok()?;
390
2423
            Some(svg_multi_polygon_to_path_storage(&mp))
391
        }
392
5124
        "circle" => {
393
19
            let cx = attr_f64(node, "cx");
394
19
            let cy = attr_f64(node, "cy");
395
19
            let r = attr_f64(node, "r");
396
19
            if r <= 0.0 {
397
5
                return None;
398
14
            }
399
14
            let mp = azul_core::path_parser::svg_circle_to_paths(cx as f32, cy as f32, r as f32);
400
14
            let multi = azul_core::svg::SvgMultiPolygon {
401
14
                rings: azul_core::svg::SvgPathVec::from_vec(vec![mp]),
402
14
            };
403
14
            Some(svg_multi_polygon_to_path_storage(&multi))
404
        }
405
5105
        "rect" => {
406
5074
            let x = attr_f64(node, "x");
407
5074
            let y = attr_f64(node, "y");
408
5074
            let w = attr_f64(node, "width");
409
5074
            let h = attr_f64(node, "height");
410
5074
            let rx = attr_f64(node, "rx");
411
5074
            let ry = node
412
5074
                .attributes
413
5074
                .get_key("ry")
414
5074
                .map_or(rx, |v| v.as_str().parse().unwrap_or(rx));
415
5074
            if w <= 0.0 || h <= 0.0 {
416
10
                return None;
417
5064
            }
418
5064
            let mp = azul_core::path_parser::svg_rect_to_path(
419
5064
                x as f32, y as f32, w as f32, h as f32, rx as f32, ry as f32,
420
            );
421
5064
            let multi = azul_core::svg::SvgMultiPolygon {
422
5064
                rings: azul_core::svg::SvgPathVec::from_vec(vec![mp]),
423
5064
            };
424
5064
            Some(svg_multi_polygon_to_path_storage(&multi))
425
        }
426
31
        "ellipse" => {
427
5
            let cx = attr_f64(node, "cx");
428
5
            let cy = attr_f64(node, "cy");
429
5
            let rx = attr_f64(node, "rx");
430
5
            let ry = attr_f64(node, "ry");
431
5
            if rx <= 0.0 || ry <= 0.0 {
432
4
                return None;
433
1
            }
434
            // Use circle path with scaling
435
1
            let mp = azul_core::path_parser::svg_circle_to_paths(cx as f32, cy as f32, 1.0);
436
1
            let multi = azul_core::svg::SvgMultiPolygon {
437
1
                rings: azul_core::svg::SvgPathVec::from_vec(vec![mp]),
438
1
            };
439
1
            let mut ps = svg_multi_polygon_to_path_storage(&multi);
440
            // Scale ellipse: we'll just build it directly instead
441
1
            let mut path = PathStorage::new();
442
1
            let kx = rx * KAPPA;
443
1
            let ky = ry * KAPPA;
444
1
            path.move_to(cx, cy - ry);
445
1
            path.curve4(cx + kx, cy - ry, cx + rx, cy - ky, cx + rx, cy);
446
1
            path.curve4(cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry);
447
1
            path.curve4(cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy);
448
1
            path.curve4(cx - rx, cy - ky, cx - kx, cy - ry, cx, cy - ry);
449
1
            path.close_polygon(PATH_FLAGS_NONE);
450
1
            Some(path)
451
        }
452
26
        "line" => {
453
12
            let x1 = attr_f64(node, "x1");
454
12
            let y1 = attr_f64(node, "y1");
455
12
            let x2 = attr_f64(node, "x2");
456
12
            let y2 = attr_f64(node, "y2");
457
12
            let mut path = PathStorage::new();
458
12
            path.move_to(x1, y1);
459
12
            path.line_to(x2, y2);
460
12
            Some(path)
461
        }
462
14
        "polygon" | "polyline" => {
463
10
            let pts_str = node.attributes.get_key("points")?;
464
9
            let nums: Vec<f64> = pts_str
465
9
                .as_str()
466
400070
                .split(|c: char| c == ',' || c.is_ascii_whitespace())
467
200032
                .filter(|s| !s.is_empty())
468
200030
                .filter_map(|s| s.parse().ok())
469
9
                .collect();
470
9
            if nums.len() < 4 {
471
4
                return None;
472
5
            }
473
5
            let mut path = PathStorage::new();
474
5
            path.move_to(nums[0], nums[1]);
475
100005
            for chunk in nums[2..].chunks_exact(2) {
476
100005
                path.line_to(chunk[0], chunk[1]);
477
100005
            }
478
5
            if tag == "polygon" {
479
4
                path.close_polygon(PATH_FLAGS_NONE);
480
4
            }
481
5
            Some(path)
482
        }
483
4
        _ => None,
484
    }
485
7559
}
486

            
487
#[cfg(all(feature = "std", feature = "xml"))]
488
25514
fn attr_f64(node: &azul_core::xml::XmlNode, key: &str) -> f64 {
489
25514
    let v: f64 = node
490
25514
        .attributes
491
25514
        .get_key(key)
492
25514
        .and_then(|s| s.as_str().parse().ok())
493
25514
        .unwrap_or(0.0);
494
    // Clamp to a finite range far beyond any real SVG coordinate. A pathological
495
    // attribute (r="1e308", width="1e400", NaN) otherwise flows into geometry as ±inf
496
    // and hangs AGG's adaptive Bézier/arc flattening, which subdivides forever trying to
497
    // meet a flatness tolerance it can never reach. Real coordinates are untouched.
498
25514
    if v.is_nan() {
499
7
        0.0
500
    } else {
501
25507
        v.clamp(-1.0e6, 1.0e6)
502
    }
503
25514
}
504

            
505
/// Convert `SvgMultiPolygon` to agg `PathStorage`.
506
#[cfg(all(feature = "std", feature = "xml"))]
507
7511
fn svg_multi_polygon_to_path_storage(mp: &azul_core::svg::SvgMultiPolygon) -> PathStorage {
508
7511
    let mut path = PathStorage::new();
509
27509
    for ring in mp.rings.as_ref() {
510
27499
        let mut first = true;
511
111284
        for item in ring.items.as_ref() {
512
111284
            match item {
513
92344
                azul_core::svg::SvgPathElement::Line(l) => {
514
92344
                    if first {
515
25181
                        path.move_to(f64::from(l.start.x), f64::from(l.start.y));
516
25181
                        first = false;
517
67163
                    }
518
92344
                    path.line_to(f64::from(l.end.x), f64::from(l.end.y));
519
                }
520
1
                azul_core::svg::SvgPathElement::QuadraticCurve(q) => {
521
1
                    if first {
522
1
                        path.move_to(f64::from(q.start.x), f64::from(q.start.y));
523
1
                        first = false;
524
1
                    }
525
1
                    path.curve3(
526
1
                        f64::from(q.ctrl.x),
527
1
                        f64::from(q.ctrl.y),
528
1
                        f64::from(q.end.x),
529
1
                        f64::from(q.end.y),
530
                    );
531
                }
532
18939
                azul_core::svg::SvgPathElement::CubicCurve(c) => {
533
18939
                    if first {
534
2316
                        path.move_to(f64::from(c.start.x), f64::from(c.start.y));
535
2316
                        first = false;
536
16623
                    }
537
18939
                    path.curve4(
538
18939
                        f64::from(c.ctrl_1.x),
539
18939
                        f64::from(c.ctrl_1.y),
540
18939
                        f64::from(c.ctrl_2.x),
541
18939
                        f64::from(c.ctrl_2.y),
542
18939
                        f64::from(c.end.x),
543
18939
                        f64::from(c.end.y),
544
                    );
545
                }
546
            }
547
        }
548
27499
        path.close_polygon(PATH_FLAGS_NONE);
549
    }
550
7511
    path
551
7511
}
552

            
553
/// Parse SVG transform attribute (supports matrix, translate, scale, rotate).
554
#[cfg(all(feature = "std", feature = "xml"))]
555
72
fn parse_svg_transform(s: &str) -> TransAffine {
556
72
    let s = s.trim();
557

            
558
72
    let parse_nums = |inner: &str| -> Vec<f64> {
559
54
        inner
560
510825
            .split(|c: char| c == ',' || c.is_ascii_whitespace())
561
200160
            .filter(|s| !s.is_empty())
562
200144
            .filter_map(|s| s.parse().ok())
563
54
            .collect()
564
54
    };
565

            
566
72
    if let Some(inner) = s.strip_prefix("matrix(").and_then(|s| s.strip_suffix(')')) {
567
17
        let nums = parse_nums(inner);
568
17
        if nums.len() == 6 {
569
13
            return TransAffine::new_custom(nums[0], nums[1], nums[2], nums[3], nums[4], nums[5]);
570
4
        }
571
55
    } else if let Some(inner) = s
572
55
        .strip_prefix("translate(")
573
55
        .and_then(|s| s.strip_suffix(')'))
574
    {
575
22
        let nums = parse_nums(inner);
576
22
        let tx = nums.first().copied().unwrap_or(0.0);
577
22
        let ty = nums.get(1).copied().unwrap_or(0.0);
578
22
        return TransAffine::new_custom(1.0, 0.0, 0.0, 1.0, tx, ty);
579
33
    } else if let Some(inner) = s.strip_prefix("scale(").and_then(|s| s.strip_suffix(')')) {
580
9
        let nums = parse_nums(inner);
581
9
        let sx = nums.first().copied().unwrap_or(1.0);
582
9
        let sy = nums.get(1).copied().unwrap_or(sx);
583
9
        return TransAffine::new_custom(sx, 0.0, 0.0, sy, 0.0, 0.0);
584
24
    } else if let Some(inner) = s.strip_prefix("rotate(").and_then(|s| s.strip_suffix(')')) {
585
6
        let nums = parse_nums(inner);
586
6
        let angle = nums.first().copied().unwrap_or(0.0).to_radians();
587
6
        let cos_a = angle.cos();
588
6
        let sin_a = angle.sin();
589
6
        return TransAffine::new_custom(cos_a, sin_a, -sin_a, cos_a, 0.0, 0.0);
590
18
    }
591
22
    TransAffine::new()
592
72
}
593

            
594
/// Parse SVG color string (#RRGGBB, #RGB, named colors).
595
#[cfg(all(feature = "std", feature = "xml"))]
596
8228
fn parse_svg_color(s: &str) -> Option<Rgba8> {
597
8228
    let s = s.trim();
598
8228
    if let Some(hex) = s.strip_prefix('#') {
599
        // The arms below index `hex` at fixed BYTE offsets, but `hex.len()` is a byte
600
        // count: a multibyte char (e.g. "#€123", € = 3 bytes) makes the byte length hit
601
        // the 6/3 arm while the slice boundary lands mid-character and panics. Valid hex
602
        // is ASCII, so reject anything else up front.
603
3153
        if !hex.is_ascii() {
604
4
            return None;
605
3149
        }
606
3149
        return match hex.len() {
607
            6 => {
608
663
                let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
609
659
                let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
610
659
                let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
611
658
                Some(Rgba8 { r, g, b, a: 255 })
612
            }
613
            3 => {
614
2476
                let r = u8::from_str_radix(&hex[0..1], 16).ok()? * 17;
615
2474
                let g = u8::from_str_radix(&hex[1..2], 16).ok()? * 17;
616
2474
                let b = u8::from_str_radix(&hex[2..3], 16).ok()? * 17;
617
2474
                Some(Rgba8 { r, g, b, a: 255 })
618
            }
619
10
            _ => None,
620
        };
621
5075
    }
622
5075
    match s.to_lowercase().as_str() {
623
5075
        "black" => Some(Rgba8 {
624
7
            r: 0,
625
7
            g: 0,
626
7
            b: 0,
627
7
            a: 255,
628
7
        }),
629
5068
        "white" => Some(Rgba8 {
630
            r: 255,
631
            g: 255,
632
            b: 255,
633
            a: 255,
634
        }),
635
5068
        "red" => Some(Rgba8 {
636
2545
            r: 255,
637
2545
            g: 0,
638
2545
            b: 0,
639
2545
            a: 255,
640
2545
        }),
641
2523
        "green" => Some(Rgba8 {
642
1
            r: 0,
643
1
            g: 128,
644
1
            b: 0,
645
1
            a: 255,
646
1
        }),
647
2522
        "blue" => Some(Rgba8 {
648
2501
            r: 0,
649
2501
            g: 0,
650
2501
            b: 255,
651
2501
            a: 255,
652
2501
        }),
653
21
        "yellow" => Some(Rgba8 {
654
            r: 255,
655
            g: 255,
656
            b: 0,
657
            a: 255,
658
        }),
659
21
        "orange" => Some(Rgba8 {
660
            r: 255,
661
            g: 165,
662
            b: 0,
663
            a: 255,
664
        }),
665
21
        "gold" => Some(Rgba8 {
666
1
            r: 255,
667
1
            g: 215,
668
1
            b: 0,
669
1
            a: 255,
670
1
        }),
671
20
        _ => None,
672
    }
673
8228
}
674

            
675
#[cfg(all(test, feature = "std", feature = "xml"))]
676
#[allow(clippy::float_cmp)] // exact f64 compares are the point here: they assert parse fidelity, not arithmetic
677
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // pixel-index math in assertions
678
mod autotest_generated {
679
    use azul_core::{
680
        window::{AzStringPair, StringPairVec},
681
        xml::{XmlAttributeMap, XmlNode, XmlNodeChild, XmlNodeChildVec},
682
    };
683

            
684
    use super::*;
685
    use crate::cpurender::AzulPixmap;
686

            
687
    // ------------------------------------------------------------------
688
    // helpers
689
    // ------------------------------------------------------------------
690

            
691
    /// Build an `XmlNode` with the given tag + attributes and no children.
692
    fn el(tag: &str, pairs: &[(&str, &str)]) -> XmlNode {
693
        el_with(tag, pairs, Vec::new())
694
    }
695

            
696
    /// Build an `XmlNode` with the given tag, attributes and element children.
697
    fn el_with(tag: &str, pairs: &[(&str, &str)], children: Vec<XmlNode>) -> XmlNode {
698
        XmlNode {
699
            node_type: tag.into(),
700
            attributes: XmlAttributeMap {
701
                inner: StringPairVec::from_vec(
702
                    pairs
703
                        .iter()
704
                        .map(|(k, v)| AzStringPair {
705
                            key: (*k).into(),
706
                            value: (*v).into(),
707
                        })
708
                        .collect::<Vec<_>>(),
709
                ),
710
            },
711
            children: XmlNodeChildVec::from_vec(
712
                children.into_iter().map(XmlNodeChild::Element).collect(),
713
            ),
714
        }
715
    }
716

            
717
    fn pixmap(w: u32, h: u32) -> AzulPixmap {
718
        AzulPixmap::new(w, h).expect("pixmap alloc")
719
    }
720

            
721
    /// RGBA of pixel (x, y).
722
    fn px(p: &AzulPixmap, x: u32, y: u32) -> [u8; 4] {
723
        let i = ((y * p.width() + x) * 4) as usize;
724
        let d = p.data();
725
        [d[i], d[i + 1], d[i + 2], d[i + 3]]
726
    }
727

            
728
    fn is_all_white(p: &AzulPixmap) -> bool {
729
        p.data().iter().all(|&b| b == 255)
730
    }
731

            
732
    const RED: Rgba8 = Rgba8 {
733
        r: 255,
734
        g: 0,
735
        b: 0,
736
        a: 255,
737
    };
738
    const BLACK: Rgba8 = Rgba8 {
739
        r: 0,
740
        g: 0,
741
        b: 0,
742
        a: 255,
743
    };
744

            
745
    /// 16x16 red square — the positive control for the two public entry points.
746
    const MINIMAL_SVG: &[u8] =
747
        br#"<svg viewBox="0 0 16 16"><rect x="0" y="0" width="16" height="16" fill="red"/></svg>"#;
748

            
749
    // ==================================================================
750
    // parse_svg_color  (parser)
751
    // ==================================================================
752

            
753
    #[test]
754
    fn parse_svg_color_empty_and_whitespace_are_none() {
755
        assert_eq!(parse_svg_color(""), None);
756
        assert_eq!(parse_svg_color("   "), None);
757
        assert_eq!(parse_svg_color("\t\n\r "), None);
758
        assert_eq!(parse_svg_color("#"), None);
759
    }
760

            
761
    #[test]
762
    fn parse_svg_color_valid_minimal_six_digit_hex() {
763
        assert_eq!(
764
            parse_svg_color("#ff0000"),
765
            Some(RED),
766
            "#ff0000 is the positive control"
767
        );
768
        assert_eq!(parse_svg_color("#000000"), Some(BLACK));
769
    }
770

            
771
    #[test]
772
    fn parse_svg_color_six_digit_hex_is_case_insensitive() {
773
        assert_eq!(parse_svg_color("#FF0000"), parse_svg_color("#ff0000"));
774
        assert_eq!(parse_svg_color("#AbCdEf"), parse_svg_color("#abcdef"));
775
    }
776

            
777
    #[test]
778
    fn parse_svg_color_three_digit_hex_expands_by_seventeen() {
779
        // #abc -> a*17=170, b*17=187, c*17=204 (and must not overflow: f*17 == 255)
780
        assert_eq!(
781
            parse_svg_color("#abc"),
782
            Some(Rgba8 {
783
                r: 170,
784
                g: 187,
785
                b: 204,
786
                a: 255
787
            })
788
        );
789
        assert_eq!(
790
            parse_svg_color("#fff"),
791
            Some(Rgba8 {
792
                r: 255,
793
                g: 255,
794
                b: 255,
795
                a: 255
796
            }),
797
            "the *17 expansion must land on exactly 255, not wrap"
798
        );
799
        assert_eq!(parse_svg_color("#000"), Some(BLACK));
800
        assert_eq!(parse_svg_color("#f00"), Some(RED));
801
    }
802

            
803
    #[test]
804
    fn parse_svg_color_wrong_hex_lengths_are_none() {
805
        // note: 8-digit #RRGGBBAA is valid CSS but unsupported here
806
        for s in ["#f", "#ff", "#ffff", "#fffff", "#fffffff", "#ffffffff"] {
807
            assert_eq!(parse_svg_color(s), None, "{s} must be rejected");
808
        }
809
    }
810

            
811
    #[test]
812
    fn parse_svg_color_non_hex_digits_are_none() {
813
        assert_eq!(parse_svg_color("#gggggg"), None);
814
        assert_eq!(parse_svg_color("#00ff0g"), None);
815
        assert_eq!(parse_svg_color("#zzz"), None);
816
        assert_eq!(parse_svg_color("#0x0000"), None);
817
        assert_eq!(parse_svg_color("#-10000"), None); // 7 bytes -> rejected on length
818
        assert_eq!(parse_svg_color("#-1-1-1"), None, "negative hex components");
819
    }
820

            
821
    #[test]
822
    fn parse_svg_color_leading_trailing_whitespace_is_trimmed() {
823
        assert_eq!(parse_svg_color("  #ff0000  "), Some(RED));
824
        assert_eq!(parse_svg_color("\n\tred\r\n"), Some(RED));
825
    }
826

            
827
    #[test]
828
    fn parse_svg_color_named_colors_are_case_insensitive() {
829
        assert_eq!(parse_svg_color("RED"), Some(RED));
830
        assert_eq!(parse_svg_color("Red"), Some(RED));
831
        assert_eq!(parse_svg_color("black"), Some(BLACK));
832
        assert_eq!(
833
            parse_svg_color("green"),
834
            Some(Rgba8 {
835
                r: 0,
836
                g: 128,
837
                b: 0,
838
                a: 255
839
            }),
840
            "SVG `green` is 008000, not 00ff00"
841
        );
842
        assert_eq!(
843
            parse_svg_color("gold"),
844
            Some(Rgba8 {
845
                r: 255,
846
                g: 215,
847
                b: 0,
848
                a: 255
849
            })
850
        );
851
    }
852

            
853
    #[test]
854
    fn parse_svg_color_unsupported_named_colors_are_none() {
855
        // Only 8 names are in the table; every other CSS keyword silently falls
856
        // back to None (the caller then paints nothing).
857
        for s in [
858
            "gray",
859
            "grey",
860
            "cyan",
861
            "magenta",
862
            "transparent",
863
            "currentColor",
864
        ] {
865
            assert_eq!(parse_svg_color(s), None, "{s} is not in the named table");
866
        }
867
    }
868

            
869
    #[test]
870
    fn parse_svg_color_leading_trailing_junk_is_rejected() {
871
        assert_eq!(parse_svg_color("red;garbage"), None);
872
        assert_eq!(parse_svg_color("#ff0000;"), None);
873
        assert_eq!(
874
            parse_svg_color("rgb(255,0,0)"),
875
            None,
876
            "rgb() is unsupported"
877
        );
878
        assert_eq!(parse_svg_color("url(#grad)"), None, "paint servers -> None");
879
    }
880

            
881
    #[test]
882
    fn parse_svg_color_garbage_bytes_never_panic() {
883
        for s in [
884
            "!!!",
885
            "\u{0}\u{1}\u{2}",
886
            "########",
887
            "#\u{0}\u{0}\u{0}",
888
            "%%%",
889
        ] {
890
            let _ = parse_svg_color(s);
891
        }
892
    }
893

            
894
    #[test]
895
    fn parse_svg_color_extremely_long_input_is_none_and_does_not_hang() {
896
        let long_hex = format!("#{}", "f".repeat(1_000_000));
897
        assert_eq!(parse_svg_color(&long_hex), None);
898

            
899
        let long_name = "a".repeat(1_000_000);
900
        assert_eq!(parse_svg_color(&long_name), None);
901
    }
902

            
903
    #[test]
904
    fn parse_svg_color_unicode_is_none_not_panic() {
905
        // Emoji (4-byte) and CJK (3-byte) chars land on byte lengths that never
906
        // reach the 3/6-byte hex arms, so they are safely rejected.
907
        assert_eq!(parse_svg_color("\u{1F600}"), None);
908
        assert_eq!(parse_svg_color("#\u{1F600}"), None); // hex byte-len 4
909
        assert_eq!(parse_svg_color("\u{4F60}\u{597D}"), None);
910
        assert_eq!(parse_svg_color("e\u{301}"), None); // combining acute
911
        assert_eq!(
912
            parse_svg_color("#\u{e9}\u{e9}12"),
913
            None,
914
            "2-byte chars keep bytes 2/4 on char boundaries -> Err -> None"
915
        );
916
    }
917

            
918
    #[test]
919
    fn parse_svg_color_plus_prefixed_hex_components_do_not_panic() {
920
        // `u8::from_str_radix` accepts a leading '+', so "#+1+2+3" is 6 bytes of
921
        // "valid" hex and parses as rgb(1, 2, 3) even though it is not legal
922
        // SVG. Not a safety bug — only assert it is deterministic and total.
923
        let quirk = parse_svg_color("#+1+2+3");
924
        assert!(
925
            quirk.is_none()
926
                || quirk
927
                    == Some(Rgba8 {
928
                        r: 1,
929
                        g: 2,
930
                        b: 3,
931
                        a: 255
932
                    }),
933
            "lenient '+' hex must be rejected or rgb(1,2,3), got {quirk:?}"
934
        );
935
    }
936

            
937
    #[test]
938
    fn red_parse_svg_color_multibyte_hex_must_not_panic() {
939
        // BUG: the 3/6 arms slice `hex` by *byte* index (`&hex[0..2]`,
940
        // `&hex[0..1]`) after only checking `hex.len()`, which is a byte length.
941
        //
942
        // U+20AC EURO SIGN is 3 UTF-8 bytes, so:
943
        //   "#\u{20AC}123" -> hex = "\u{20AC}123", byte len 6 -> `&hex[0..2]`
944
        //   "#\u{20AC}"    -> hex = "\u{20AC}",    byte len 3 -> `&hex[0..1]`
945
        // Both slice *inside* the euro sign -> "byte index N is not a char
946
        // boundary" panic. An attacker-supplied `fill="#<3-byte char>123"`
947
        // crashes the renderer instead of falling back to no-paint.
948
        //
949
        // Correct behaviour: None. Fix = index `hex.as_bytes()` / reject
950
        // non-ASCII before slicing.
951
        assert_eq!(parse_svg_color("#\u{20AC}123"), None);
952
        assert_eq!(parse_svg_color("#\u{20AC}"), None);
953
    }
954

            
955
    // ==================================================================
956
    // parse_svg_transform  (parser)
957
    // ==================================================================
958

            
959
    fn assert_identity(t: &TransAffine) {
960
        assert_eq!(
961
            (t.sx, t.shy, t.shx, t.sy, t.tx, t.ty),
962
            (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
963
        );
964
    }
965

            
966
    #[test]
967
    fn parse_svg_transform_empty_and_whitespace_are_identity() {
968
        assert_identity(&parse_svg_transform(""));
969
        assert_identity(&parse_svg_transform("   "));
970
        assert_identity(&parse_svg_transform("\t\n\r "));
971
    }
972

            
973
    #[test]
974
    fn parse_svg_transform_valid_minimal_matrix() {
975
        let t = parse_svg_transform("matrix(1,2,3,4,5,6)");
976
        assert_eq!(
977
            (t.sx, t.shy, t.shx, t.sy, t.tx, t.ty),
978
            (1.0, 2.0, 3.0, 4.0, 5.0, 6.0)
979
        );
980
    }
981

            
982
    #[test]
983
    fn parse_svg_transform_matrix_accepts_space_separated_numbers() {
984
        let t = parse_svg_transform("matrix( 1 0 0 1 10 20 )");
985
        assert_eq!(
986
            (t.sx, t.shy, t.shx, t.sy, t.tx, t.ty),
987
            (1.0, 0.0, 0.0, 1.0, 10.0, 20.0)
988
        );
989
    }
990

            
991
    #[test]
992
    fn parse_svg_transform_matrix_with_wrong_arity_is_identity() {
993
        assert_identity(&parse_svg_transform("matrix(1,2,3)"));
994
        assert_identity(&parse_svg_transform("matrix(1,2,3,4,5,6,7)"));
995
        assert_identity(&parse_svg_transform("matrix()"));
996
    }
997

            
998
    #[test]
999
    fn parse_svg_transform_translate_defaults_missing_ty_to_zero() {
        let t = parse_svg_transform("translate(10)");
        assert_eq!((t.tx, t.ty), (10.0, 0.0));
        let t = parse_svg_transform("translate(10, 20)");
        assert_eq!((t.tx, t.ty), (10.0, 20.0));
        assert_identity(&parse_svg_transform("translate()"));
    }
    #[test]
    fn parse_svg_transform_scale_defaults_sy_to_sx() {
        let t = parse_svg_transform("scale(2)");
        assert_eq!((t.sx, t.sy), (2.0, 2.0), "uniform scale when sy is omitted");
        let t = parse_svg_transform("scale(2,3)");
        assert_eq!((t.sx, t.sy), (2.0, 3.0));
        assert_identity(&parse_svg_transform("scale()"));
    }
    #[test]
    fn parse_svg_transform_scale_zero_is_degenerate_but_not_a_panic() {
        let t = parse_svg_transform("scale(0)");
        assert_eq!((t.sx, t.sy), (0.0, 0.0));
    }
    #[test]
    fn parse_svg_transform_rotate_90_degrees() {
        let t = parse_svg_transform("rotate(90)");
        assert!((t.sx - 0.0).abs() < 1e-9, "cos(90deg) ~ 0, got {}", t.sx);
        assert!((t.shy - 1.0).abs() < 1e-9, "sin(90deg) == 1, got {}", t.shy);
        assert!(
            (t.shx + 1.0).abs() < 1e-9,
            "-sin(90deg) == -1, got {}",
            t.shx
        );
        assert!((t.sy - 0.0).abs() < 1e-9);
        assert_eq!((t.tx, t.ty), (0.0, 0.0));
        assert_identity(&parse_svg_transform("rotate(0)"));
    }
    #[test]
    fn parse_svg_transform_rotate_with_center_ignores_the_center() {
        // `rotate(angle cx cy)` is legal SVG; the extra args are silently dropped
        // and the rotation happens around the origin instead of (cx, cy).
        let with_center = parse_svg_transform("rotate(90 50 50)");
        let without = parse_svg_transform("rotate(90)");
        assert_eq!((with_center.tx, with_center.ty), (0.0, 0.0));
        assert_eq!(with_center.sx, without.sx);
    }
    #[test]
    fn parse_svg_transform_unclosed_paren_is_identity() {
        assert_identity(&parse_svg_transform("translate(10"));
        assert_identity(&parse_svg_transform("matrix(1,2,3,4,5,6"));
        assert_identity(&parse_svg_transform("scale(2"));
    }
    #[test]
    fn parse_svg_transform_is_case_sensitive() {
        // Uppercase function names are not SVG-legal, so identity is acceptable —
        // pinned so that adding a lowercasing pass is a deliberate change.
        assert_identity(&parse_svg_transform("TRANSLATE(10,20)"));
        assert_identity(&parse_svg_transform("Scale(2)"));
    }
    #[test]
    fn parse_svg_transform_leading_trailing_junk() {
        // Leading/trailing *whitespace* is trimmed...
        let t = parse_svg_transform("  translate(10,20)  ");
        assert_eq!((t.tx, t.ty), (10.0, 20.0));
        // ...but trailing junk breaks the `)` suffix match -> identity.
        assert_identity(&parse_svg_transform("translate(10,20);garbage"));
        assert_identity(&parse_svg_transform("junk translate(10,20)"));
    }
    #[test]
    fn parse_svg_transform_transform_list_keeps_only_the_first_function() {
        // SVG allows a whitespace-separated transform *list*. This parser only
        // understands a single function: for "translate(10,20) scale(2)" the
        // strip_suffix(')') leaves "10,20) scale(2" as the argument text, whose
        // only parseable number is 10 -> ty and the whole scale() are dropped.
        //
        // Characterization, not an endorsement — see the report: a transform
        // list renders *wrong* (ty lost, scale ignored) rather than crashing.
        let t = parse_svg_transform("translate(10,20) scale(2)");
        assert_eq!((t.sx, t.sy), (1.0, 1.0), "scale() silently dropped");
        assert_eq!((t.tx, t.ty), (10.0, 0.0), "translate ty silently dropped");
    }
    #[test]
    fn parse_svg_transform_garbage_is_identity_never_panics() {
        for s in [
            "!!!",
            "()",
            "(((",
            ")))",
            "matrix",
            "translate",
            "\u{0}\u{1}",
            "matrix(,,,,,)",
            "scale(,)",
        ] {
            let t = parse_svg_transform(s);
            assert!(t.sx.is_finite(), "{s} produced a non-finite sx");
        }
    }
    #[test]
    fn parse_svg_transform_boundary_numbers_saturate_not_panic() {
        let t = parse_svg_transform("translate(1e400, -1e400)");
        assert!(t.tx.is_infinite() && t.tx > 0.0, "1e400 -> +inf");
        assert!(t.ty.is_infinite() && t.ty < 0.0, "-1e400 -> -inf");
        let t = parse_svg_transform("scale(NaN)");
        assert!(t.sx.is_nan() && t.sy.is_nan(), "NaN propagates, no panic");
        let t = parse_svg_transform("rotate(NaN)");
        assert!(t.sx.is_nan() && t.shy.is_nan());
        let t = parse_svg_transform("scale(inf)");
        assert!(t.sx.is_infinite());
        let t = parse_svg_transform("translate(-0, 9223372036854775807)");
        assert!(t.tx.is_sign_negative() && t.tx == 0.0, "-0 stays -0.0");
        assert!(t.ty.is_finite(), "i64::MAX fits in f64");
        let t = parse_svg_transform("matrix(1e308,1e308,1e308,1e308,1e308,1e308)");
        assert!(t.sx.is_finite());
    }
    #[test]
    fn parse_svg_transform_unicode_args_are_dropped_not_panic() {
        let t = parse_svg_transform("translate(\u{1F600},\u{1F600})");
        assert_eq!((t.tx, t.ty), (0.0, 0.0));
        assert_identity(&parse_svg_transform("\u{1F600}"));
        // rotate() with an unparseable angle defaults to 0 -> cos 1 / sin 0.
        assert_identity(&parse_svg_transform("rotate(\u{4F60}\u{597D})"));
    }
    #[test]
    fn parse_svg_transform_deeply_nested_input_does_not_stack_overflow() {
        // 10_000 nested "translate(" — the parser is flat (strip_prefix + split),
        // so this must stay linear, never recursive.
        const DEPTH: usize = 10_000;
        let s = format!("{}1{}", "translate(".repeat(DEPTH), ")".repeat(DEPTH));
        let t = parse_svg_transform(&s);
        // The inner text has no separators, so it is one unparseable token.
        assert_eq!((t.tx, t.ty), (0.0, 0.0));
        assert_eq!((t.sx, t.sy), (1.0, 1.0));
    }
    #[test]
    fn parse_svg_transform_extremely_long_arg_list_does_not_hang() {
        let inner = "1,".repeat(200_000);
        let s = format!("translate({inner}1)");
        let t = parse_svg_transform(&s);
        assert_eq!((t.tx, t.ty), (1.0, 1.0), "only the first two args are used");
    }
    // ==================================================================
    // parse_viewbox  (parser)
    // ==================================================================
    #[test]
    fn parse_viewbox_missing_attribute_is_none() {
        assert_eq!(parse_viewbox(&el("svg", &[])), None);
        assert_eq!(parse_viewbox(&el("svg", &[("width", "10")])), None);
    }
    #[test]
    fn parse_viewbox_valid_minimal_both_spellings() {
        // Real SVG uses camelCase `viewBox`; the lowercase key is the fallback
        // for lowercasing XML parsers. Both must work.
        assert_eq!(
            parse_viewbox(&el("svg", &[("viewBox", "0 0 100 50")])),
            Some((0.0, 0.0, 100.0, 50.0))
        );
        assert_eq!(
            parse_viewbox(&el("svg", &[("viewbox", "0 0 100 50")])),
            Some((0.0, 0.0, 100.0, 50.0))
        );
    }
    #[test]
    fn parse_viewbox_accepts_comma_and_mixed_separators() {
        assert_eq!(
            parse_viewbox(&el("svg", &[("viewBox", "0,0,100,50")])),
            Some((0.0, 0.0, 100.0, 50.0))
        );
        assert_eq!(
            parse_viewbox(&el("svg", &[("viewBox", " -1 , -2\t3.5\n4.5 ")])),
            Some((-1.0, -2.0, 3.5, 4.5))
        );
    }
    #[test]
    fn parse_viewbox_wrong_arity_is_none() {
        for v in ["", "   ", "0", "0 0", "0 0 100", "0 0 100 50 25"] {
            assert_eq!(
                parse_viewbox(&el("svg", &[("viewBox", v)])),
                None,
                "viewBox={v:?} must require exactly 4 numbers"
            );
        }
    }
    #[test]
    fn parse_viewbox_garbage_tokens_are_silently_dropped() {
        // filter_map(parse) drops unparseable tokens *before* the len == 4 check,
        // so junk in the middle of an otherwise-valid viewBox is ignored rather
        // than rejected. Characterization: lenient, not unsafe.
        assert_eq!(
            parse_viewbox(&el("svg", &[("viewBox", "0 0 junk 100 50")])),
            Some((0.0, 0.0, 100.0, 50.0))
        );
        assert_eq!(
            parse_viewbox(&el("svg", &[("viewBox", "0 0 100 50 trailing")])),
            Some((0.0, 0.0, 100.0, 50.0))
        );
        // ...but if nothing parses at all it is correctly None.
        assert_eq!(parse_viewbox(&el("svg", &[("viewBox", "a b c d")])), None);
        assert_eq!(parse_viewbox(&el("svg", &[("viewBox", "###")])), None);
    }
    #[test]
    fn parse_viewbox_unit_suffixes_are_rejected() {
        // "0 0 100px 50px" -> the px tokens do not parse -> only 2 numbers -> None.
        assert_eq!(
            parse_viewbox(&el("svg", &[("viewBox", "0 0 100px 50px")])),
            None
        );
    }
    #[test]
    fn parse_viewbox_boundary_numbers() {
        let vb = parse_viewbox(&el("svg", &[("viewBox", "NaN 0 100 50")])).expect("4 numbers");
        assert!(vb.0.is_nan(), "NaN parses and propagates, no panic");
        let vb = parse_viewbox(&el("svg", &[("viewBox", "0 0 1e400 -1e400")])).expect("4 numbers");
        assert!(vb.2.is_infinite() && vb.2 > 0.0);
        assert!(vb.3.is_infinite() && vb.3 < 0.0);
        let vb = parse_viewbox(&el("svg", &[("viewBox", "-0 0 0 0")])).expect("4 numbers");
        assert!(vb.0.is_sign_negative() && vb.0 == 0.0, "-0 stays -0.0");
        assert_eq!((vb.2, vb.3), (0.0, 0.0), "a zero-area viewBox is accepted");
        let vb = parse_viewbox(&el(
            "svg",
            &[("viewBox", "9223372036854775807 -9223372036854775808 1 1")],
        ))
        .expect("4 numbers");
        assert!(
            vb.0.is_finite() && vb.1.is_finite(),
            "i64 bounds fit in f64"
        );
        let vb = parse_viewbox(&el("svg", &[("viewBox", "0 0 inf inf")])).expect("4 numbers");
        assert!(vb.2.is_infinite());
    }
    #[test]
    fn parse_viewbox_unicode_is_none_not_panic() {
        assert_eq!(
            parse_viewbox(&el(
                "svg",
                &[("viewBox", "\u{1F600} \u{1F600} \u{1F600} \u{1F600}")]
            )),
            None
        );
        assert_eq!(
            parse_viewbox(&el("svg", &[("viewBox", "\u{4F60}\u{597D}")])),
            None
        );
    }
    #[test]
    fn parse_viewbox_extremely_long_value_does_not_hang() {
        let huge = "1 ".repeat(200_000);
        assert_eq!(
            parse_viewbox(&el("svg", &[("viewBox", huge.as_str())])),
            None,
            "200k numbers != 4 -> None, and it must not hang getting there"
        );
    }
    // ==================================================================
    // attr_f64
    // ==================================================================
    #[test]
    fn attr_f64_missing_key_is_zero() {
        assert_eq!(attr_f64(&el("rect", &[]), "width"), 0.0);
        assert_eq!(attr_f64(&el("rect", &[("height", "5")]), "width"), 0.0);
    }
    #[test]
    fn attr_f64_valid_minimal() {
        assert_eq!(attr_f64(&el("rect", &[("width", "42.5")]), "width"), 42.5);
        assert_eq!(attr_f64(&el("rect", &[("x", "-3")]), "x"), -3.0);
        assert_eq!(attr_f64(&el("rect", &[("x", "1e3")]), "x"), 1000.0);
    }
    #[test]
    fn attr_f64_key_lookup_is_case_sensitive() {
        assert_eq!(attr_f64(&el("rect", &[("Width", "10")]), "width"), 0.0);
    }
    #[test]
    fn attr_f64_unparseable_values_fall_back_to_zero() {
        // NOTE: `f64::from_str` neither trims whitespace nor accepts CSS units,
        // so both of these collapse to 0.0 — which downstream means "no width"
        // and the shape is skipped entirely. See the report.
        assert_eq!(attr_f64(&el("rect", &[("width", "100px")]), "width"), 0.0);
        assert_eq!(attr_f64(&el("rect", &[("width", " 100 ")]), "width"), 0.0);
        assert_eq!(attr_f64(&el("rect", &[("width", "50%")]), "width"), 0.0);
        assert_eq!(attr_f64(&el("rect", &[("width", "")]), "width"), 0.0);
        assert_eq!(attr_f64(&el("rect", &[("width", "abc")]), "width"), 0.0);
        assert_eq!(
            attr_f64(&el("rect", &[("width", "\u{1F600}")]), "width"),
            0.0
        );
    }
    #[test]
    fn attr_f64_boundary_numbers_are_sanitized_to_finite() {
        // attr_f64 now clamps to a finite range (NaN -> 0, ±inf/huge -> ±1e6) so
        // pathological attributes cannot flow into geometry and hang the flattener.
        assert_eq!(attr_f64(&el("rect", &[("width", "NaN")]), "width"), 0.0);
        assert_eq!(attr_f64(&el("rect", &[("width", "inf")]), "width"), 1.0e6);
        assert_eq!(attr_f64(&el("rect", &[("width", "1e400")]), "width"), 1.0e6);
        assert_eq!(attr_f64(&el("rect", &[("width", "1e-400")]), "width"), 0.0);
        let neg_zero = attr_f64(&el("rect", &[("x", "-0")]), "x");
        assert!(neg_zero.is_sign_negative() && neg_zero == 0.0);
        let big = attr_f64(&el("rect", &[("x", "9223372036854775807")]), "x");
        assert!(big.is_finite() && big == 1.0e6);
    }
    #[test]
    fn attr_f64_extremely_long_value_does_not_hang() {
        let long = "9".repeat(1_000_000);
        let v = attr_f64(&el("rect", &[("width", long.as_str())]), "width");
        // Parses (saturating to +inf) then clamps to the finite ceiling.
        assert_eq!(
            v, 1.0e6,
            "a 1e999999-ish literal is sanitized to the finite ceiling"
        );
    }
    // ==================================================================
    // build_agg_path
    // ==================================================================
    #[test]
    fn build_agg_path_unknown_tag_is_none() {
        assert!(build_agg_path(&el("g", &[])).is_none());
        assert!(build_agg_path(&el("text", &[])).is_none());
        assert!(build_agg_path(&el("", &[])).is_none());
        assert!(build_agg_path(&el("\u{1F600}", &[])).is_none());
    }
    #[test]
    fn build_agg_path_tag_matching_is_case_insensitive() {
        let p = build_agg_path(&el("LINE", &[("x2", "10"), ("y2", "10")]))
            .expect("uppercase <LINE> must be recognised");
        assert_eq!(p.total_vertices(), 2);
    }
    #[test]
    fn build_agg_path_path_valid_minimal() {
        let p = build_agg_path(&el("path", &[("d", "M 0 0 L 10 10")])).expect("valid d");
        assert!(p.total_vertices() >= 2, "move_to + line_to at minimum");
        assert!(
            agg_rust::basics::is_end_poly(p.last_command()),
            "every ring is terminated by close_polygon()"
        );
    }
    #[test]
    fn build_agg_path_path_missing_or_empty_d_is_none() {
        assert!(build_agg_path(&el("path", &[])).is_none(), "no d attribute");
        assert!(build_agg_path(&el("path", &[("d", "")])).is_none());
        assert!(build_agg_path(&el("path", &[("d", "   ")])).is_none());
        assert!(build_agg_path(&el("path", &[("d", "\t\n")])).is_none());
    }
    #[test]
    fn build_agg_path_path_moveto_only_is_an_empty_path_not_a_panic() {
        // "M 0 0" produces zero path *elements*, so the multipolygon has no rings
        // and the PathStorage comes back empty — Some, but with 0 vertices.
        let p = build_agg_path(&el("path", &[("d", "M 0 0")])).expect("parses");
        assert_eq!(p.total_vertices(), 0);
    }
    #[test]
    fn build_agg_path_path_garbage_d_is_handled_never_panics() {
        for d in [
            "garbage",
            "@@@@@",
            "M",
            "L 1",
            "M 0 0 Z 5", // stray arg after closepath (was a 100%-CPU infinite loop)
            "M0 0Z5",    // same, without separators
            "\u{1F600}",
            "M NaN NaN L inf inf",
        ] {
            // Must terminate without panicking; Some/None are both acceptable.
            let _ = build_agg_path(&el("path", &[("d", d)]));
        }
    }
    #[test]
    fn build_agg_path_path_extremely_long_d_does_not_hang() {
        let mut d = String::from("M 0 0");
        for i in 0..50_000 {
            d.push_str(&format!(" L {i} {i}"));
        }
        let p = build_agg_path(&el("path", &[("d", d.as_str())])).expect("parses");
        assert!(p.total_vertices() > 50_000);
    }
    #[test]
    fn build_agg_path_circle_non_positive_radius_is_none() {
        assert!(
            build_agg_path(&el("circle", &[])).is_none(),
            "r defaults to 0"
        );
        assert!(build_agg_path(&el("circle", &[("r", "0")])).is_none());
        assert!(build_agg_path(&el("circle", &[("r", "-5")])).is_none());
        assert!(
            build_agg_path(&el("circle", &[("r", "5px")])).is_none(),
            "a unit suffix makes attr_f64 return 0.0 -> rejected"
        );
    }
    #[test]
    fn build_agg_path_circle_valid_minimal() {
        let p = build_agg_path(&el("circle", &[("cx", "5"), ("cy", "5"), ("r", "5")]))
            .expect("valid circle");
        // 4 cubic segments: move_to(1) + 4*curve4(3) = 13, + close_polygon = 14
        assert_eq!(p.total_vertices(), 14);
    }
    #[test]
    fn build_agg_path_circle_nan_radius_is_rejected() {
        // attr_f64 now sanitizes NaN -> 0, so a NaN radius becomes r == 0 and is caught
        // by the `if r <= 0.0` guard — rejected up front instead of building a path full
        // of NaN coordinates that could hang the flattener.
        assert!(build_agg_path(&el("circle", &[("r", "NaN")])).is_none());
    }
    #[test]
    fn build_agg_path_circle_infinite_radius_does_not_panic() {
        let p = build_agg_path(&el("circle", &[("r", "1e400")])).expect("inf passes the guard");
        assert_eq!(p.total_vertices(), 14);
    }
    #[test]
    fn build_agg_path_rect_non_positive_size_is_none() {
        assert!(build_agg_path(&el("rect", &[])).is_none());
        assert!(
            build_agg_path(&el("rect", &[("width", "10")])).is_none(),
            "height defaults to 0"
        );
        assert!(
            build_agg_path(&el("rect", &[("height", "10")])).is_none(),
            "width defaults to 0"
        );
        assert!(build_agg_path(&el("rect", &[("width", "-1"), ("height", "10")])).is_none());
        assert!(build_agg_path(&el("rect", &[("width", "10"), ("height", "-1")])).is_none());
    }
    #[test]
    fn build_agg_path_rect_valid_minimal_is_four_lines() {
        let p = build_agg_path(&el(
            "rect",
            &[("x", "1"), ("y", "2"), ("width", "10"), ("height", "20")],
        ))
        .expect("valid rect");
        // 4 line elements: move_to(1) + 4*line_to(4) = 5, + close_polygon = 6
        assert_eq!(p.total_vertices(), 6);
        let mut x = 0.0;
        let mut y = 0.0;
        p.vertex_idx(0, &mut x, &mut y);
        assert_eq!((x, y), (1.0, 2.0), "the path starts at (x, y)");
    }
    #[test]
    fn build_agg_path_rect_unparseable_ry_falls_back_to_rx() {
        // `ry` uses `parse().unwrap_or(rx)`, so an unparseable ry inherits rx
        // rather than collapsing to 0. Both variants must build a rounded rect.
        let with_junk_ry = build_agg_path(&el(
            "rect",
            &[
                ("width", "10"),
                ("height", "10"),
                ("rx", "3"),
                ("ry", "junk"),
            ],
        ))
        .expect("rounded rect");
        let rx_only = build_agg_path(&el(
            "rect",
            &[("width", "10"), ("height", "10"), ("rx", "3")],
        ))
        .expect("rounded rect");
        assert_eq!(with_junk_ry.total_vertices(), rx_only.total_vertices());
        assert!(
            with_junk_ry.total_vertices() > 6,
            "rounded corners add curve vertices"
        );
    }
    #[test]
    fn build_agg_path_rect_nan_and_huge_sizes_do_not_panic() {
        // attr_f64 sanitizes NaN -> 0, so a NaN size is caught by `w <= 0.0` -> None.
        assert!(build_agg_path(&el("rect", &[("width", "NaN"), ("height", "NaN")])).is_none());
        // Huge sizes clamp to the finite ceiling and build a valid (bounded) path.
        let p = build_agg_path(&el("rect", &[("width", "1e400"), ("height", "1e400")]))
            .expect("huge size clamps to a finite, buildable rect");
        assert!(p.total_vertices() > 0);
        let p = build_agg_path(&el("rect", &[("width", "1e300"), ("height", "1e300")]))
            .expect("huge size clamps to a finite, buildable rect");
        assert!(p.total_vertices() > 0);
    }
    #[test]
    fn build_agg_path_ellipse_non_positive_radii_are_none() {
        assert!(build_agg_path(&el("ellipse", &[])).is_none());
        assert!(
            build_agg_path(&el("ellipse", &[("rx", "5")])).is_none(),
            "ry defaults to 0"
        );
        assert!(
            build_agg_path(&el("ellipse", &[("ry", "5")])).is_none(),
            "rx defaults to 0"
        );
        assert!(build_agg_path(&el("ellipse", &[("rx", "-1"), ("ry", "5")])).is_none());
    }
    #[test]
    fn build_agg_path_ellipse_valid_minimal() {
        let p = build_agg_path(&el(
            "ellipse",
            &[("cx", "10"), ("cy", "20"), ("rx", "10"), ("ry", "5")],
        ))
        .expect("valid ellipse");
        // move_to(1) + 4*curve4(3) = 13, + close_polygon = 14
        assert_eq!(p.total_vertices(), 14);
        let mut x = 0.0;
        let mut y = 0.0;
        p.vertex_idx(0, &mut x, &mut y);
        assert_eq!((x, y), (10.0, 15.0), "starts at (cx, cy - ry)");
    }
    #[test]
    fn build_agg_path_line_always_builds_two_vertices() {
        // <line> has no validity guard at all: an attribute-less line still
        // produces a 2-vertex path (which the rasterizer then fills to nothing).
        let p = build_agg_path(&el("line", &[])).expect("line with no attrs");
        assert_eq!(p.total_vertices(), 2);
        assert_eq!((p.last_x(), p.last_y()), (0.0, 0.0));
        let p = build_agg_path(&el(
            "line",
            &[("x1", "1"), ("y1", "2"), ("x2", "3"), ("y2", "4")],
        ))
        .expect("valid line");
        assert_eq!(p.total_vertices(), 2);
        assert_eq!((p.last_x(), p.last_y()), (3.0, 4.0));
    }
    #[test]
    fn build_agg_path_polygon_needs_at_least_two_points() {
        assert!(
            build_agg_path(&el("polygon", &[])).is_none(),
            "no points attribute"
        );
        assert!(build_agg_path(&el("polygon", &[("points", "")])).is_none());
        assert!(
            build_agg_path(&el("polygon", &[("points", "0,0")])).is_none(),
            "2 numbers < 4"
        );
        assert!(
            build_agg_path(&el("polygon", &[("points", "0 0 1")])).is_none(),
            "3 numbers < 4"
        );
        assert!(build_agg_path(&el("polygon", &[("points", "a b c d")])).is_none());
    }
    #[test]
    fn build_agg_path_polygon_closes_but_polyline_does_not() {
        let pts = [("points", "0,0 10,0 10,10")];
        let poly = build_agg_path(&el("polygon", &pts)).expect("polygon");
        let line = build_agg_path(&el("polyline", &pts)).expect("polyline");
        // move_to + 2 line_to = 3; polygon adds a close_polygon vertex.
        assert_eq!(line.total_vertices(), 3);
        assert_eq!(poly.total_vertices(), 4);
        assert!(
            agg_rust::basics::is_end_poly(poly.last_command())
                && agg_rust::basics::is_closed(poly.last_command()),
            "<polygon> must be closed"
        );
        assert_eq!(
            line.last_command(),
            agg_rust::basics::PATH_CMD_LINE_TO,
            "<polyline> must stay open"
        );
    }
    #[test]
    fn build_agg_path_polygon_odd_coordinate_count_drops_the_tail() {
        // chunks_exact(2) silently discards the unpaired trailing number.
        let p = build_agg_path(&el("polygon", &[("points", "0 0 10 10 20")])).expect("5 numbers");
        assert_eq!(p.total_vertices(), 3, "move_to + 1 line_to + close");
    }
    #[test]
    fn build_agg_path_polygon_nan_points_do_not_panic() {
        let p = build_agg_path(&el("polygon", &[("points", "NaN NaN inf inf")]))
            .expect("4 numbers parse");
        assert_eq!(p.total_vertices(), 3);
    }
    #[test]
    fn build_agg_path_polygon_extremely_long_points_does_not_hang() {
        let pts = "1 2 ".repeat(100_000);
        let p = build_agg_path(&el("polygon", &[("points", pts.as_str())])).expect("200k numbers");
        assert_eq!(
            p.total_vertices(),
            100_001,
            "move_to + 99_999 line_to + close"
        );
    }
    // ==================================================================
    // svg_multi_polygon_to_path_storage
    // ==================================================================
    fn point(x: f32, y: f32) -> azul_css::props::basic::SvgPoint {
        azul_css::props::basic::SvgPoint { x, y }
    }
    fn ring(items: Vec<azul_core::svg::SvgPathElement>) -> azul_core::svg::SvgPath {
        azul_core::svg::SvgPath {
            items: azul_core::svg::SvgPathElementVec::from_vec(items),
        }
    }
    fn multi(rings: Vec<azul_core::svg::SvgPath>) -> azul_core::svg::SvgMultiPolygon {
        azul_core::svg::SvgMultiPolygon {
            rings: azul_core::svg::SvgPathVec::from_vec(rings),
        }
    }
    fn line_el(x1: f32, y1: f32, x2: f32, y2: f32) -> azul_core::svg::SvgPathElement {
        azul_core::svg::SvgPathElement::Line(azul_core::svg::SvgLine {
            start: point(x1, y1),
            end: point(x2, y2),
        })
    }
    #[test]
    fn svg_multi_polygon_to_path_storage_empty_is_empty() {
        let p = svg_multi_polygon_to_path_storage(&multi(Vec::new()));
        assert_eq!(p.total_vertices(), 0);
    }
    #[test]
    fn svg_multi_polygon_to_path_storage_empty_ring_emits_no_stray_close() {
        // close_polygon() on an empty path is a no-op (last_command is STOP), so
        // an item-less ring must not push a dangling END_POLY vertex.
        let p = svg_multi_polygon_to_path_storage(&multi(vec![ring(Vec::new())]));
        assert_eq!(p.total_vertices(), 0);
    }
    #[test]
    fn svg_multi_polygon_to_path_storage_line_ring() {
        let p = svg_multi_polygon_to_path_storage(&multi(vec![ring(vec![line_el(
            0.0, 0.0, 10.0, 10.0,
        )])]));
        // move_to(start) + line_to(end) + close_polygon
        assert_eq!(p.total_vertices(), 3);
        let mut x = 0.0;
        let mut y = 0.0;
        assert_eq!(
            p.vertex_idx(0, &mut x, &mut y),
            agg_rust::basics::PATH_CMD_MOVE_TO
        );
        assert_eq!((x, y), (0.0, 0.0), "the first vertex is the line start");
        p.vertex_idx(1, &mut x, &mut y);
        assert_eq!((x, y), (10.0, 10.0));
    }
    #[test]
    fn svg_multi_polygon_to_path_storage_quadratic_and_cubic_arity() {
        let quad = azul_core::svg::SvgPathElement::QuadraticCurve(
            azul_css::props::basic::SvgQuadraticCurve {
                start: point(0.0, 0.0),
                ctrl: point(5.0, 5.0),
                end: point(10.0, 0.0),
            },
        );
        let cubic =
            azul_core::svg::SvgPathElement::CubicCurve(azul_css::props::basic::SvgCubicCurve {
                start: point(0.0, 0.0),
                ctrl_1: point(3.0, 3.0),
                ctrl_2: point(7.0, 3.0),
                end: point(10.0, 0.0),
            });
        // move_to + curve3 (2 vertices) + close
        let p = svg_multi_polygon_to_path_storage(&multi(vec![ring(vec![quad])]));
        assert_eq!(p.total_vertices(), 4);
        // move_to + curve4 (3 vertices) + close
        let p = svg_multi_polygon_to_path_storage(&multi(vec![ring(vec![cubic])]));
        assert_eq!(p.total_vertices(), 5);
    }
    #[test]
    fn svg_multi_polygon_to_path_storage_only_the_first_element_emits_a_move_to() {
        let p = svg_multi_polygon_to_path_storage(&multi(vec![ring(vec![
            line_el(0.0, 0.0, 1.0, 0.0),
            line_el(1.0, 0.0, 1.0, 1.0),
            line_el(1.0, 1.0, 0.0, 0.0),
        ])]));
        // 1 move_to + 3 line_to + 1 close
        assert_eq!(p.total_vertices(), 5);
        let mut x = 0.0;
        let mut y = 0.0;
        assert_eq!(
            p.vertex_idx(1, &mut x, &mut y),
            agg_rust::basics::PATH_CMD_LINE_TO,
            "the 2nd element must not restart the subpath"
        );
    }
    #[test]
    fn svg_multi_polygon_to_path_storage_multiple_rings_each_get_a_move_to() {
        let p = svg_multi_polygon_to_path_storage(&multi(vec![
            ring(vec![line_el(0.0, 0.0, 1.0, 1.0)]),
            ring(vec![line_el(5.0, 5.0, 6.0, 6.0)]),
        ]));
        assert_eq!(p.total_vertices(), 6, "3 vertices per ring");
        let mut x = 0.0;
        let mut y = 0.0;
        assert_eq!(
            p.vertex_idx(3, &mut x, &mut y),
            agg_rust::basics::PATH_CMD_MOVE_TO,
            "ring 2 restarts with a move_to"
        );
        assert_eq!((x, y), (5.0, 5.0));
    }
    #[test]
    fn svg_multi_polygon_to_path_storage_nan_and_infinite_coords_do_not_panic() {
        let p = svg_multi_polygon_to_path_storage(&multi(vec![ring(vec![line_el(
            f32::NAN,
            f32::NAN,
            f32::INFINITY,
            f32::NEG_INFINITY,
        )])]));
        // total_vertices() is the real check: the extreme coords are stored, no panic,
        // no data loss. (last_x() is NOT usable here -- close_polygon() appends its own
        // bookkeeping vertex at (0,0), so last_x() reads that marker, not our coord.)
        assert_eq!(p.total_vertices(), 3);
    }
    #[test]
    fn svg_multi_polygon_to_path_storage_many_rings_does_not_hang() {
        let rings: Vec<_> = (0..20_000)
            .map(|i| {
                let f = i as f32;
                ring(vec![line_el(f, f, f + 1.0, f + 1.0)])
            })
            .collect();
        let p = svg_multi_polygon_to_path_storage(&multi(rings));
        assert_eq!(p.total_vertices(), 60_000);
    }
    // ==================================================================
    // render_svg_group / render_svg_group_with_style
    // ==================================================================
    #[test]
    fn render_svg_group_empty_node_paints_nothing() {
        let mut p = pixmap(8, 8);
        p.fill(255, 255, 255, 255);
        render_svg_group(&el("svg", &[]), &mut p, &TransAffine::new());
        assert!(is_all_white(&p), "no children -> untouched pixmap");
    }
    #[test]
    fn render_svg_group_text_children_are_skipped() {
        let mut node = el("svg", &[]);
        node.children = XmlNodeChildVec::from_vec(vec![XmlNodeChild::Text("hello".into())]);
        let mut p = pixmap(8, 8);
        p.fill(255, 255, 255, 255);
        render_svg_group(&node, &mut p, &TransAffine::new());
        assert!(is_all_white(&p));
    }
    #[test]
    fn render_svg_group_default_fill_is_black() {
        // A shape with no fill attribute anywhere inherits the SVG default: black.
        let svg = el_with(
            "svg",
            &[],
            vec![el(
                "rect",
                &[("x", "0"), ("y", "0"), ("width", "8"), ("height", "8")],
            )],
        );
        let mut p = pixmap(8, 8);
        p.fill(255, 255, 255, 255);
        render_svg_group(&svg, &mut p, &TransAffine::new());
        assert_eq!(px(&p, 4, 4), [0, 0, 0, 255]);
    }
    #[test]
    fn render_svg_group_fill_none_paints_nothing() {
        let svg = el_with(
            "svg",
            &[],
            vec![el(
                "rect",
                &[
                    ("x", "0"),
                    ("y", "0"),
                    ("width", "8"),
                    ("height", "8"),
                    ("fill", "none"),
                ],
            )],
        );
        let mut p = pixmap(8, 8);
        p.fill(255, 255, 255, 255);
        render_svg_group(&svg, &mut p, &TransAffine::new());
        assert!(is_all_white(&p), "fill=none must not paint");
    }
    #[test]
    fn render_svg_group_unparseable_fill_paints_nothing() {
        // parse_svg_color returns None for an unknown paint (e.g. url(#grad)),
        // which is treated exactly like fill="none" — silently no fill.
        let svg = el_with(
            "svg",
            &[],
            vec![el(
                "rect",
                &[("width", "8"), ("height", "8"), ("fill", "url(#gradient)")],
            )],
        );
        let mut p = pixmap(8, 8);
        p.fill(255, 255, 255, 255);
        render_svg_group(&svg, &mut p, &TransAffine::new());
        assert!(is_all_white(&p));
    }
    #[test]
    fn render_svg_group_fill_is_inherited_from_the_parent_group() {
        let svg = el_with(
            "svg",
            &[],
            vec![el_with(
                "g",
                &[("fill", "red")],
                vec![el("rect", &[("width", "8"), ("height", "8")])],
            )],
        );
        let mut p = pixmap(8, 8);
        p.fill(255, 255, 255, 255);
        render_svg_group(&svg, &mut p, &TransAffine::new());
        assert_eq!(
            px(&p, 4, 4),
            [255, 0, 0, 255],
            "<g fill> must cascade to <rect>"
        );
    }
    #[test]
    fn render_svg_group_element_fill_overrides_the_group_fill() {
        let svg = el_with(
            "svg",
            &[],
            vec![el_with(
                "g",
                &[("fill", "red")],
                vec![el(
                    "rect",
                    &[("width", "8"), ("height", "8"), ("fill", "blue")],
                )],
            )],
        );
        let mut p = pixmap(8, 8);
        p.fill(255, 255, 255, 255);
        render_svg_group(&svg, &mut p, &TransAffine::new());
        assert_eq!(px(&p, 4, 4), [0, 0, 255, 255]);
    }
    #[test]
    fn render_svg_group_group_transform_composes_with_the_element_transform() {
        // <g translate(4,0)> <rect translate(0,4) w=4 h=4> lands at (4, 4).
        let svg = el_with(
            "svg",
            &[],
            vec![el_with(
                "g",
                &[("transform", "translate(4,0)")],
                vec![el(
                    "rect",
                    &[
                        ("width", "4"),
                        ("height", "4"),
                        ("fill", "red"),
                        ("transform", "translate(0,4)"),
                    ],
                )],
            )],
        );
        let mut p = pixmap(8, 8);
        p.fill(255, 255, 255, 255);
        render_svg_group(&svg, &mut p, &TransAffine::new());
        assert_eq!(
            px(&p, 6, 6),
            [255, 0, 0, 255],
            "the bottom-right quadrant is filled"
        );
        assert_eq!(
            px(&p, 1, 1),
            [255, 255, 255, 255],
            "the top-left stays untouched"
        );
    }
    #[test]
    fn render_svg_group_defs_children_are_painted() {
        // SPEC GAP: the `_ =>` arm recurses into *any* unknown container, so
        // <defs> / <symbol> / <clipPath> content is rasterised even though the
        // SVG spec says those are definitions and must never be painted directly.
        // Characterization — see the report.
        let svg = el_with(
            "svg",
            &[],
            vec![el_with(
                "defs",
                &[],
                vec![el(
                    "rect",
                    &[("width", "8"), ("height", "8"), ("fill", "red")],
                )],
            )],
        );
        let mut p = pixmap(8, 8);
        p.fill(255, 255, 255, 255);
        render_svg_group(&svg, &mut p, &TransAffine::new());
        assert_eq!(
            px(&p, 4, 4),
            [255, 0, 0, 255],
            "<defs> content is painted (the spec says it must not be)"
        );
    }
    #[test]
    fn render_svg_group_opacity_greater_than_one_saturates_to_opaque() {
        let svg = el_with(
            "svg",
            &[],
            vec![el(
                "rect",
                &[
                    ("width", "8"),
                    ("height", "8"),
                    ("fill", "red"),
                    ("fill-opacity", "1000"),
                ],
            )],
        );
        let mut p = pixmap(8, 8);
        p.fill(255, 255, 255, 255);
        render_svg_group(&svg, &mut p, &TransAffine::new());
        assert_eq!(
            px(&p, 4, 4),
            [255, 0, 0, 255],
            "alpha clamps at 255, it must not wrap"
        );
    }
    #[test]
    fn render_svg_group_negative_opacity_becomes_transparent_not_wrapped() {
        // 255 * -1 = -255; `.min(255.0) as u8` is a *saturating* cast in Rust, so
        // it lands on 0 (fully transparent) rather than wrapping to 1.
        let svg = el_with(
            "svg",
            &[],
            vec![el(
                "rect",
                &[
                    ("width", "8"),
                    ("height", "8"),
                    ("fill", "red"),
                    ("fill-opacity", "-1"),
                ],
            )],
        );
        let mut p = pixmap(8, 8);
        p.fill(255, 255, 255, 255);
        render_svg_group(&svg, &mut p, &TransAffine::new());
        assert!(is_all_white(&p), "negative opacity must not paint");
    }
    #[test]
    fn render_svg_group_nan_and_garbage_opacity_do_not_panic() {
        for op in ["NaN", "inf", "-inf", "junk", "", "1e400"] {
            let svg = el_with(
                "svg",
                &[],
                vec![el(
                    "rect",
                    &[
                        ("width", "8"),
                        ("height", "8"),
                        ("fill", "red"),
                        ("fill-opacity", op),
                        ("opacity", op),
                    ],
                )],
            );
            let mut p = pixmap(8, 8);
            p.fill(255, 255, 255, 255);
            render_svg_group(&svg, &mut p, &TransAffine::new());
        }
    }
    #[test]
    fn render_svg_group_missing_stroke_paints_nothing() {
        // Unlike fill, a missing stroke means "no stroke" (not black), so a bare
        // <line> paints nothing at all.
        let svg = el_with(
            "svg",
            &[],
            vec![el(
                "line",
                &[("x1", "0"), ("y1", "4"), ("x2", "8"), ("y2", "4")],
            )],
        );
        let mut p = pixmap(8, 8);
        p.fill(255, 255, 255, 255);
        render_svg_group(&svg, &mut p, &TransAffine::new());
        assert!(is_all_white(&p), "no stroke attribute -> nothing painted");
    }
    #[test]
    fn render_svg_group_stroke_paints_a_line() {
        let svg = el_with(
            "svg",
            &[],
            vec![el(
                "line",
                &[
                    ("x1", "0"),
                    ("y1", "4"),
                    ("x2", "8"),
                    ("y2", "4"),
                    ("stroke", "red"),
                    ("stroke-width", "2"),
                ],
            )],
        );
        let mut p = pixmap(8, 8);
        p.fill(255, 255, 255, 255);
        render_svg_group(&svg, &mut p, &TransAffine::new());
        assert!(
            !is_all_white(&p),
            "a stroked line across the middle must paint something"
        );
    }
    #[test]
    fn render_svg_group_degenerate_stroke_widths_do_not_panic_or_hang() {
        for w in ["0", "-5", "NaN", "inf", "1e400", "junk"] {
            let svg = el_with(
                "svg",
                &[],
                vec![el(
                    "line",
                    &[
                        ("x1", "0"),
                        ("y1", "4"),
                        ("x2", "8"),
                        ("y2", "4"),
                        ("stroke", "black"),
                        ("stroke-width", w),
                    ],
                )],
            );
            let mut p = pixmap(8, 8);
            p.fill(255, 255, 255, 255);
            render_svg_group(&svg, &mut p, &TransAffine::new());
        }
    }
    #[test]
    fn render_svg_group_nan_transform_does_not_panic() {
        // A NaN transform maps every vertex to NaN. The contract asserted here is
        // only that the rasterizer survives it (whatever it chooses to paint) —
        // `f64 as i32` saturates rather than trapping, so this must not panic.
        let svg = el_with(
            "svg",
            &[],
            vec![el(
                "rect",
                &[
                    ("width", "8"),
                    ("height", "8"),
                    ("fill", "red"),
                    ("transform", "scale(NaN)"),
                ],
            )],
        );
        let mut p = pixmap(8, 8);
        p.fill(255, 255, 255, 255);
        render_svg_group(&svg, &mut p, &TransAffine::new());
        assert_eq!(p.data().len(), 8 * 8 * 4, "the pixmap must stay intact");
    }
    #[test]
    fn render_svg_group_infinite_transform_does_not_panic() {
        let svg = el_with(
            "svg",
            &[],
            vec![el(
                "circle",
                &[
                    ("cx", "4"),
                    ("cy", "4"),
                    ("r", "2"),
                    ("fill", "red"),
                    ("transform", "scale(1e400)"),
                ],
            )],
        );
        let mut p = pixmap(8, 8);
        p.fill(255, 255, 255, 255);
        render_svg_group(&svg, &mut p, &TransAffine::new());
    }
    #[test]
    fn render_svg_group_with_style_explicit_parent_style_is_used() {
        let style = SvgInheritedStyle {
            fill: Some("red".to_string()),
            stroke: None,
            stroke_width: None,
        };
        let svg = el_with(
            "svg",
            &[],
            vec![el("rect", &[("width", "8"), ("height", "8")])],
        );
        let mut p = pixmap(8, 8);
        p.fill(255, 255, 255, 255);
        render_svg_group_with_style(&svg, &mut p, &TransAffine::new(), &style);
        assert_eq!(px(&p, 4, 4), [255, 0, 0, 255], "the passed-in fill wins");
    }
    #[test]
    fn render_svg_group_with_style_on_a_1x1_pixmap_does_not_panic() {
        let svg = el_with(
            "svg",
            &[],
            vec![el(
                "rect",
                &[("width", "1000"), ("height", "1000"), ("fill", "red")],
            )],
        );
        let mut p = pixmap(1, 1);
        render_svg_group_with_style(
            &svg,
            &mut p,
            &TransAffine::new(),
            &SvgInheritedStyle::default(),
        );
        assert_eq!(px(&p, 0, 0), [255, 0, 0, 255]);
    }
    #[test]
    fn render_svg_group_deep_nesting_does_not_stack_overflow() {
        // render_svg_group_with_style recurses once per nesting level. Run on a
        // 128 MiB stack so that a genuinely linear-depth recursion is proven
        // safe instead of coin-flipping on the 2 MiB default test stack.
        let child = std::thread::Builder::new()
            .stack_size(128 * 1024 * 1024)
            .spawn(|| {
                const DEPTH: usize = 4_000;
                let mut node = el("rect", &[("width", "8"), ("height", "8"), ("fill", "red")]);
                for _ in 0..DEPTH {
                    node = el_with("g", &[], vec![node]);
                }
                let svg = el_with("svg", &[], vec![node]);
                let mut p = pixmap(8, 8);
                p.fill(255, 255, 255, 255);
                render_svg_group(&svg, &mut p, &TransAffine::new());
                px(&p, 4, 4)
            })
            .expect("spawn");
        assert_eq!(
            child
                .join()
                .expect("4000-deep <g> nesting must not overflow"),
            [255, 0, 0, 255]
        );
    }
    #[test]
    fn render_svg_group_many_siblings_does_not_hang() {
        let children: Vec<_> = (0..5_000)
            .map(|i| {
                el(
                    "rect",
                    &[
                        ("x", "0"),
                        ("y", "0"),
                        ("width", "8"),
                        ("height", "8"),
                        ("fill", if i % 2 == 0 { "red" } else { "blue" }),
                    ],
                )
            })
            .collect();
        let svg = el_with("svg", &[], children);
        let mut p = pixmap(8, 8);
        p.fill(255, 255, 255, 255);
        render_svg_group(&svg, &mut p, &TransAffine::new());
        assert_eq!(px(&p, 4, 4), [0, 0, 255, 255], "the last sibling wins");
    }
    // ==================================================================
    // render_svg_to_png  (parser, public)
    // ==================================================================
    const PNG_MAGIC: &[u8] = &[0x89, b'P', b'N', b'G'];
    #[test]
    fn render_svg_to_png_valid_minimal() {
        let png = render_svg_to_png(MINIMAL_SVG, 16, 16).expect("positive control must render");
        assert!(png.starts_with(PNG_MAGIC), "the output must be a real PNG");
    }
    #[test]
    fn render_svg_to_png_round_trips_through_decode_png() {
        let png = render_svg_to_png(MINIMAL_SVG, 16, 16).expect("render");
        let decoded = AzulPixmap::decode_png(&png).expect("our own PNG must decode");
        assert_eq!((decoded.width(), decoded.height()), (16, 16));
        let [r, g, b, a] = px(&decoded, 8, 8);
        assert!(
            r > 200 && g < 60 && b < 60 && a == 255,
            "the red rect must survive the encode/decode round-trip, got {:?}",
            [r, g, b, a]
        );
    }
    #[test]
    fn render_svg_to_png_empty_and_whitespace_input_is_err() {
        assert!(render_svg_to_png(b"", 16, 16).is_err());
        assert!(render_svg_to_png(b"   ", 16, 16).is_err());
        assert!(render_svg_to_png(b"\t\n\r ", 16, 16).is_err());
    }
    #[test]
    fn render_svg_to_png_invalid_utf8_is_err_not_panic() {
        let err = render_svg_to_png(&[0xFF, 0xFE, 0x00], 16, 16).expect_err("invalid UTF-8");
        assert!(err.contains("UTF-8"), "expected a UTF-8 error, got: {err}");
        assert!(
            render_svg_to_png(&[0x80], 16, 16).is_err(),
            "lone continuation byte"
        );
        assert!(
            render_svg_to_png(&[0xED, 0xA0, 0x80], 16, 16).is_err(),
            "encoded surrogate"
        );
    }
    #[test]
    fn render_svg_to_png_garbage_is_err_never_panics() {
        for data in [
            &b"garbage"[..],
            &b"<<<<<<"[..],
            &b"<svg"[..],
            &b"</svg>"[..],
            &b"\x00\x01\x02\x03"[..],
            &b"{\"json\": true}"[..],
        ] {
            assert!(
                render_svg_to_png(data, 16, 16).is_err(),
                "{data:?} must not render"
            );
        }
    }
    #[test]
    fn render_svg_to_png_without_an_svg_root_is_err() {
        let err = render_svg_to_png(b"<html><body></body></html>", 16, 16).expect_err("no <svg>");
        assert!(err.contains("No <svg> root"), "got: {err}");
    }
    #[test]
    fn render_svg_to_png_root_tag_is_case_insensitive() {
        let png = render_svg_to_png(br#"<SVG viewBox="0 0 8 8"></SVG>"#, 8, 8)
            .expect("<SVG> must be recognised");
        assert!(png.starts_with(PNG_MAGIC));
    }
    #[test]
    fn render_svg_to_png_zero_target_dimensions_are_err_not_panic() {
        let err = render_svg_to_png(MINIMAL_SVG, 0, 16).expect_err("0 width");
        assert!(err.contains("pixmap"), "got: {err}");
        assert!(render_svg_to_png(MINIMAL_SVG, 16, 0).is_err());
        assert!(render_svg_to_png(MINIMAL_SVG, 0, 0).is_err());
    }
    #[test]
    fn render_svg_to_png_one_by_one_target() {
        let png = render_svg_to_png(MINIMAL_SVG, 1, 1).expect("1x1 must render");
        let decoded = AzulPixmap::decode_png(&png).expect("decode");
        assert_eq!((decoded.width(), decoded.height()), (1, 1));
    }
    #[test]
    fn render_svg_to_png_missing_viewbox_falls_back_to_the_target_size() {
        // Without a viewBox the scale is target/target == 1, so the rect maps 1:1.
        let png = render_svg_to_png(
            br#"<svg><rect x="0" y="0" width="16" height="16" fill="red"/></svg>"#,
            16,
            16,
        )
        .expect("render");
        let decoded = AzulPixmap::decode_png(&png).expect("decode");
        let [r, g, b, _] = px(&decoded, 8, 8);
        assert!(r > 200 && g < 60 && b < 60, "got {:?}", [r, g, b]);
    }
    #[test]
    fn render_svg_to_png_zero_area_viewbox_divides_by_zero_without_panicking() {
        // vb_w == 0 -> sx == inf -> scale == inf. Must degrade, not crash.
        let png = render_svg_to_png(
            br#"<svg viewBox="0 0 0 0"><rect width="8" height="8" fill="red"/></svg>"#,
            8,
            8,
        )
        .expect("must still produce a PNG");
        assert!(png.starts_with(PNG_MAGIC));
    }
    #[test]
    fn render_svg_to_png_nan_viewbox_does_not_panic() {
        let png = render_svg_to_png(
            br#"<svg viewBox="NaN NaN NaN NaN"><rect width="8" height="8" fill="red"/></svg>"#,
            8,
            8,
        )
        .expect("must still produce a PNG");
        assert!(png.starts_with(PNG_MAGIC));
    }
    #[test]
    fn render_svg_to_png_boundary_numeric_attributes_do_not_panic() {
        for svg in [
            &br#"<svg viewBox="0 0 8 8"><rect width="1e400" height="1e400" fill="red"/></svg>"#[..],
            &br#"<svg viewBox="0 0 8 8"><rect width="NaN" height="NaN" fill="red"/></svg>"#[..],
            &br#"<svg viewBox="0 0 8 8"><circle cx="0" cy="0" r="1e308" fill="red"/></svg>"#[..],
            &br#"<svg viewBox="0 0 8 8"><rect x="-0" y="-0" width="8" height="8" fill="red"/></svg>"#[..],
            &br#"<svg viewBox="1e-400 0 8 8"><rect width="8" height="8" fill="red"/></svg>"#[..],
            &br#"<svg viewBox="0 0 8 8"><line x1="-1e300" y1="-1e300" x2="1e300" y2="1e300" stroke="red"/></svg>"#[..],
        ] {
            let out = render_svg_to_png(svg, 8, 8);
            assert!(out.is_ok(), "{}", String::from_utf8_lossy(svg));
        }
    }
    #[test]
    fn render_svg_to_png_unicode_content_does_not_panic() {
        let svg = "<svg viewBox=\"0 0 8 8\"><title>\u{1F600} \u{4F60}\u{597D} \
                   e\u{301}</title><rect width=\"8\" height=\"8\" fill=\"red\"/></svg>";
        let png = render_svg_to_png(svg.as_bytes(), 8, 8).expect("unicode text must not break");
        assert!(png.starts_with(PNG_MAGIC));
    }
    #[test]
    fn render_svg_to_png_extremely_long_input_does_not_hang() {
        // ~1 MB of text content inside an element the renderer never draws.
        let svg = format!(
            "<svg viewBox=\"0 0 8 8\"><desc>{}</desc><rect width=\"8\" height=\"8\" \
             fill=\"red\"/></svg>",
            "a".repeat(1_000_000)
        );
        let png = render_svg_to_png(svg.as_bytes(), 8, 8).expect("render");
        assert!(png.starts_with(PNG_MAGIC));
    }
    #[test]
    fn render_svg_to_png_deeply_nested_groups_do_not_stack_overflow() {
        // The XML tokenizer is iterative, but render_svg_group_with_style is not.
        // Give it a 128 MiB stack so a real 4000-deep document is a clean test.
        let out = std::thread::Builder::new()
            .stack_size(128 * 1024 * 1024)
            .spawn(|| {
                const DEPTH: usize = 4_000;
                let svg = format!(
                    "<svg viewBox=\"0 0 8 8\">{}<rect width=\"8\" height=\"8\" \
                     fill=\"red\"/>{}</svg>",
                    "<g>".repeat(DEPTH),
                    "</g>".repeat(DEPTH)
                );
                render_svg_to_png(svg.as_bytes(), 8, 8).is_ok()
            })
            .expect("spawn")
            .join()
            .expect("4000-deep nesting must not overflow the stack");
        assert!(out);
    }
    // ==================================================================
    // render_svg_to_imageref  (parser, public)
    // ==================================================================
    #[test]
    fn render_svg_to_imageref_valid_minimal() {
        let img = render_svg_to_imageref(MINIMAL_SVG, 16, 16).expect("positive control");
        let size = img.get_size();
        assert_eq!((size.width as u32, size.height as u32), (16, 16));
    }
    #[test]
    fn render_svg_to_imageref_empty_and_garbage_input_is_err() {
        assert!(render_svg_to_imageref(b"", 16, 16).is_err());
        assert!(render_svg_to_imageref(b"   ", 16, 16).is_err());
        assert!(render_svg_to_imageref(b"garbage", 16, 16).is_err());
        assert!(render_svg_to_imageref(b"<html></html>", 16, 16).is_err());
    }
    #[test]
    fn render_svg_to_imageref_invalid_utf8_is_err_not_panic() {
        let err = render_svg_to_imageref(&[0xFF, 0xFE, 0x00], 16, 16).expect_err("invalid UTF-8");
        assert!(err.contains("UTF-8"), "got: {err}");
    }
    #[test]
    fn render_svg_to_imageref_zero_target_dimensions_are_err_not_panic() {
        assert!(render_svg_to_imageref(MINIMAL_SVG, 0, 16).is_err());
        assert!(render_svg_to_imageref(MINIMAL_SVG, 16, 0).is_err());
        assert!(render_svg_to_imageref(MINIMAL_SVG, 0, 0).is_err());
    }
    #[test]
    fn render_svg_to_imageref_non_square_target_keeps_the_requested_size() {
        // The scale is min(sx, sy) — an aspect-mismatched target must still
        // produce a pixmap of exactly the requested dimensions.
        let img = render_svg_to_imageref(MINIMAL_SVG, 32, 8).expect("render");
        let size = img.get_size();
        assert_eq!((size.width as u32, size.height as u32), (32, 8));
    }
    #[test]
    fn render_svg_to_imageref_degenerate_viewbox_does_not_panic() {
        let img = render_svg_to_imageref(
            br#"<svg viewBox="0 0 0 0"><rect width="8" height="8" fill="red"/></svg>"#,
            8,
            8,
        )
        .expect("a zero-area viewBox must still build an ImageRef");
        let size = img.get_size();
        assert_eq!((size.width as u32, size.height as u32), (8, 8));
    }
}